阅读 443

Slate 源码解析(二)

前言

在第一篇文章中 Slate 源码分析(一)我已经从源码角度分析了 Slate 核心的包 slate 的实现,也就是 ControllerModel 设计,那么本篇文章中会带大家学习 View 层的实现 ,并且总结 Slate 总比较优秀的一些设计,让我感觉眼前一亮,最后通过官网的几个例子来学习怎么使用 Slate 进行编辑器开发。 ​

slate-react

slate 之所以选择 React 的做渲染层,实际上它们之间的数据设计理念是完全匹配的,我们知道 React 数据流的核心理念就是 immutable。而且 React 作为前端目前最受欢迎的三大框架之一,也有不错的生态,实际上从这几年的数据来看,React 的市场占有率更好,受更多的开发者青睐。 ​

所以要看懂 slate-react,你需要对 React 的基本 API 有一些了解,如果你有一定的 React 项目经验,那么看这里的源码应该毫无压力。而且我的建议是,如果你看 slate core 比较吃力,那么对于 slate-react 源码一定是要认真地看。为什么了?因为 slate-react 做的功能,就是我们编辑器的 core 实际上要实现的。

让我们直接看源码吧!

先看目录结构: ​

image.png 目录结构也比较简单,比较核心的就是 hooks、components、plugin 目录,其它的目录就是一些 utils 和 ts 的类型定义,可以在讲到具体源码的时候用到的时候专门去看。 ​

首先看 components 目录吧。 ​

components

在前面的内容中,我们提到在 Slate Document 中,Slate 抽象了 Node、Element、Text 等数据模型,那如果我们在使用 React 去对应这些模型,那么肯定就是通过组件的形式去对应。所以 components 目录下的文件就是做这件事,为了更好的抽象这些模型,components 目录封装了如下组件:

  • Children 组件,就是 Element 和 Text 组件的集合,对应 React 父组件传给子组件的 children(通过 props.children 拿到),它是一个 JSX.Eelment 数组;
  • Editable 组件,编辑器组件,也就是可编辑部分的组件抽象,是最核心的一个组件,基本上编辑器的核心事件监听和操作就集中在这个组件上了;
  • Element 组件,基本对应的就是 Slate Element 节点的抽象了;
  • Leaf 组件,Text 组件下一层的组件,在 Slate 模块是没有这一层的抽象的,我理解加这一层为了更加方便扩展 Text;
  • Slate 组件,实际上就是 slate-react 几个 Context Provider 的一层抽象,其中包括 SlateContext、EditorContext、FocusedContext等,为了更加方便共享一些通用数据加了这一层;
  • String 组件,slate-react 最底层的组件,也就是渲染最后的 text 内容的组件,使用 span 包裹,如果有加粗等功能,实际使用加粗标签例如 strong 包裹的就是 String 组件;
  • Text 组件,基本对应的就是 Slate Text 节点的抽象了。

从 slate-react 封装的组件,我们大概知道了 View 层中的编辑器文档树的结构,因为使用 React,我们这里叫组件树结构吧: ​

slate-react components tree.png

那么为什么要加 Leaf 和 String 这两层抽象了 ?其实在我们老大在另一篇文章中有说到,如果大家感兴趣可以点击下面链接去细品:富文本编辑器 L1 能力调研记录。里面有提到 Slate 对于 Model 设计的扁平化,那么何为扁平化?下面举个例子,对于一个 text 文本,如果同时它的内容同时包含多种效果:加粗、斜体、颜色等,如果扁平化的数据是这样的:

[
  {
    type: 'p',
    attrs: {},
    children: [
      {
        type: 'text',
        text: 'editable'
      },
      {
        type: 'text',
        text: 'rich',
        marks: { bold: true }
      },
      {
        type: 'text',
        text: 'rich',
        marks: { bold: true, em: true },
      },
    ]
  }
] 
复制代码

直接看两个真实的 dom 节点案例: image.png 多个效果就是这样: image.png 加粗和斜体的组件。这样扁平化的好处就是不管里面多少层,操作的时候我们关注外层就行,它们的这种结构是固定的,不会因为如果叠加更多效果,导致 dom 层级不可控而很难管理。理解好这一点比较重要,实际上我们 v5 设计的思路应该也是朝这个靠齐的。 ​

理解了组件层级的关系和扁平化的设计,我们具体来看下 Editable 组件,这是实现编辑器的核心组件,其它的组件相对比较简单,大家可以自行去看。 ​

先看 Editable 中几个核心的类型定义:

interface RenderElementProps {
  children: any
  element: Element
  attributes: {
    'data-slate-node': 'element'
    'data-slate-inline'?: true
    'data-slate-void'?: true
    dir?: 'rtl'
    ref: any
  }
}

interface RenderLeafProps {
  children: any
  leaf: Text
  text: Text
  attributes: {
    'data-slate-leaf': true
  }
}
// Editable props 类型
type EditableProps = {
  decorate?: (entry: NodeEntry) => Range[]
  onDOMBeforeInput?: (event: Event) => void
  placeholder?: string
  readOnly?: boolean
  role?: string
  style?: React.CSSProperties
  renderElement?: (props: RenderElementProps) => JSX.Element
  renderLeaf?: (props: RenderLeafProps) => JSX.Element
  as?: React.ElementType
} & React.TextareaHTMLAttributes<HTMLDivElement>
复制代码

首先要知道的是,虽然 slate-react 内置了默认的渲染 Element 和 Leaf 组件的方式,但是同时也暴露了留给用户自定义渲染的方式,你可以自己通过 renderElement、renderLeaf 两个 prop 属性自定义渲染。 ​

除了上面讲到的 renderElementrenderLeaf属性,其它的就是一些常规的,比如 readOnly、placeholder、style等属性,除了这些有两个属性需要特别提一下,一个是 as 属性,就是你可以自定义编辑器区域想要渲染的标签,默认是 div,你可以改成 textarea 这样的元素。 ​

另一个属性是 decorate,这个属性是干嘛用的了?首先先了解它的类型,它的类型是一个函数,接受的是 NodeEntry 数据,返回的是一个 Range 数组。这个 NodeEntry 实际上是 Slate Core 定义的一个类型,在 src/interfaces/node.ts 中:

type NodeEntry<T extends Node = Node> = [T, Path]
复制代码

就是 Node 节点和 Path 的一个数组。 ​

前面我们介绍过,slate 对于 leaf 节点的渲染是扁平的,无论有多少效果,通过“分词”,或者说拆分 String 的方式去渲染。对于一般的效果,比如加粗、斜体这些场景,我们通过自定义 Leaf 的渲染很容易做。参看官网提供的例子:

const Leaf = ({ attributes, children, leaf }) => {
  if (leaf.bold) {
    children = <strong>{children}</strong>
  }

  if (leaf.code) {
    children = <code>{children}</code>
  }

  if (leaf.italic) {
    children = <em>{children}</em>
  }

  if (leaf.underline) {
    children = <u>{children}</u>
  }

  return <span {...attributes}>{children}</span>
}
复制代码

但是如果在一些特殊场景下,比如代码高亮、markdown 这种特殊的场景下,怎么去拆分、做到扁平的效果了?答案是 Slate 也不管,你这属于业务领域的东西,Slate 也没法知道在特定的场景下怎么去分,所以暴露了这样一个函数给你,你自己去告诉 Slate 你想要怎么去分。说了这么多,有点抽象,我们看个具体的例子吧:

const MarkdownPreviewExample = () => {
  const [value, setValue] = useState<Node[]>(initialValue)
  const renderLeaf = useCallback(props => <Leaf {...props} />, [])
  const editor = useMemo(() => withHistory(withReact(createEditor())), [])
  const decorate = useCallback(([node, path]) => {
    const ranges = []

    if (!Text.isText(node)) {
      return ranges
    }

    const getLength = token => {
      if (typeof token === 'string') {
        return token.length
      } else if (typeof token.content === 'string') {
        return token.content.length
      } else {
        return token.content.reduce((l, t) => l + getLength(t), 0)
      }
    }

    const tokens = Prism.tokenize(node.text, Prism.languages.markdown)
    let start = 0

    for (const token of tokens) {
      const length = getLength(token)
      const end = start + length

      if (typeof token !== 'string') {
        ranges.push({
          [token.type]: true,
          anchor: { path, offset: start },
          focus: { path, offset: end },
        })
      }

      start = end
    }

    return ranges
  }, [])

  return (
    <Slate editor={editor} value={value} onChange={value => setValue(value)}>
      <Editable
        decorate={decorate}
        renderLeaf={renderLeaf}
        placeholder="Write some markdown..."
      />
    </Slate>
  )
}
复制代码

上面的例子来自官方的一个 markdown 实例,在该例子中,使用了 Prism 去对 markdown 语法下的字符串进行分词,然后遍历产生的 tokens,生成最后的 Range 数组返回。那么在 slate 里面怎么去用生成的这个 decorate了,实际上最后通过层层传递,最后真正使用的地方是在 Text 组件中:

// 就是这一行了 
const leaves = SlateText.decorations(text, decorations)
 
 const key = ReactEditor.findKey(editor, text)
 const children = []

 for (let i = 0; i < leaves.length; i++) {
   const leaf = leaves[i]

   children.push(
      <Leaf
        isLast={isLast && i === leaves.length - 1}
        key={`${key.id}-${i}`}
        leaf={leaf}
        text={text}
        parent={parent}
        renderLeaf={renderLeaf}
      />
    )
  }
复制代码

最后调用了 SlateText.decorations 方法,这里的 SlateText 实际上就是 Slate Core 里面定义的 Text 接口中,在 slate 包下的 src/interfaces/text.ts 文件中:

/**
   * Get the leaves for a text node given decorations.
  */

  decorations(node: Text, decorations: Range[]): Text[] {
    let leaves: Text[] = [{ ...node }]

    for (const dec of decorations) {
      const { anchor, focus, ...rest } = dec
      const [start, end] = Range.edges(dec)
      const next = []
      let o = 0

      for (const leaf of leaves) {
        const { length } = leaf.text
        const offset = o
        o += length

        // If the range encompases the entire leaf, add the range.
        if (start.offset <= offset && end.offset >= offset + length) {
          Object.assign(leaf, rest)
          next.push(leaf)
          continue
        }

        // If the range starts after the leaf, or ends before it, continue.
        if (
          start.offset > offset + length ||
          end.offset < offset ||
          (end.offset === offset && offset !== 0)
        ) {
          next.push(leaf)
          continue
        }

        // Otherwise we need to split the leaf, at the start, end, or both,
        // and add the range to the middle intersecting section. Do the end
        // split first since we don't need to update the offset that way.
        let middle = leaf
        let before
        let after

        if (end.offset < offset + length) {
          const off = end.offset - offset
          after = { ...middle, text: middle.text.slice(off) }
          middle = { ...middle, text: middle.text.slice(0, off) }
        }

        if (start.offset > offset) {
          const off = start.offset - offset
          before = { ...middle, text: middle.text.slice(0, off) }
          middle = { ...middle, text: middle.text.slice(off) }
        }

        Object.assign(middle, rest)

        if (before) {
          next.push(before)
        }

        next.push(middle)

        if (after) {
          next.push(after)
        }
      }

      leaves = next
    }

    return leaves
  },
复制代码

函数的作用就是通过 Text 内容和对应的 decorations 生成最后渲染 Leaf 的 String 节点,这里的 decoration 可以理解一种 format,也就是加粗、斜体、code 等这样的格式,只不过在 markdown 这样的特殊场景下,它的体现形式有点特殊。 ​

举个例子,如果在 markdown 编辑器中输入的内容是:Slate is flexible enough to add decorations that can format text based on its content. ​

那么最后对应的 leaf 渲染展示效果就是: image.png image.png 对于加粗和 code 效果的 text,会单独拆分成自己的 leaf 节点,然后加粗和 code 的效果再根据定制的样式去显示出效果。

介绍完 Editable props 的定义,我们关注下其内部实现的几个比较核心的方法吧。

第一个就是监听 beforeinput方法:

// Listen on the native `beforeinput` event to get real "Level 2" events. This
  // is required because React's `beforeinput` is fake and never really attaches
  // to the real event sadly. (2019/11/01)
  // https://github.com/facebook/react/issues/11211
  const onDOMBeforeInput = useCallback(
    (
      event: Event & {
        data: string | null
        dataTransfer: DataTransfer | null
        getTargetRanges(): DOMStaticRange[]
        inputType: string
        isComposing: boolean
      }
    ) => {
      if (
        !readOnly &&
        hasEditableTarget(editor, event.target) &&
        !isDOMEventHandled(event, propsOnDOMBeforeInput)
      ) {
        const { selection } = editor
        const { inputType: type } = event
        const data = event.dataTransfer || event.data || undefined

        // 省略部分代码

        switch (type) {
          case 'deleteByComposition':
          case 'deleteByCut':
          case 'deleteByDrag': {
            Editor.deleteFragment(editor)
            break
          }

          case 'deleteContent':
          case 'deleteContentForward': {
            Editor.deleteForward(editor)
            break
          }

          case 'deleteContentBackward': {
            Editor.deleteBackward(editor)
            break
          }

          case 'deleteEntireSoftLine': {
            Editor.deleteBackward(editor, { unit: 'line' })
            Editor.deleteForward(editor, { unit: 'line' })
            break
          }

          case 'deleteHardLineBackward': {
            Editor.deleteBackward(editor, { unit: 'block' })
            break
          }

          case 'deleteSoftLineBackward': {
            Editor.deleteBackward(editor, { unit: 'line' })
            break
          }

          case 'deleteHardLineForward': {
            Editor.deleteForward(editor, { unit: 'block' })
            break
          }

          case 'deleteSoftLineForward': {
            Editor.deleteForward(editor, { unit: 'line' })
            break
          }

          case 'deleteWordBackward': {
            Editor.deleteBackward(editor, { unit: 'word' })
            break
          }

          case 'deleteWordForward': {
            Editor.deleteForward(editor, { unit: 'word' })
            break
          }

          case 'insertLineBreak':
          case 'insertParagraph': {
            Editor.insertBreak(editor)
            break
          }

          case 'insertFromComposition':
          case 'insertFromDrop':
          case 'insertFromPaste':
          case 'insertFromYank':
          case 'insertReplacementText':
          case 'insertText': {
            if (data instanceof DataTransfer) {
              ReactEditor.insertData(editor, data)
            } else if (typeof data === 'string') {
              Editor.insertText(editor, data)
            }

            break
          }
        }
      }
    },
    [readOnly, propsOnDOMBeforeInput]
  )

  // Attach a native DOM event handler for `beforeinput` events, because React's
  // built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose
  // real `beforeinput` events sadly... (2019/11/04)
  // https://github.com/facebook/react/issues/11211
  useIsomorphicLayoutEffect(() => {
    if (ref.current && HAS_BEFORE_INPUT_SUPPORT) {
      // @ts-ignore The `beforeinput` event isn't recognized.
      ref.current.addEventListener('beforeinput', onDOMBeforeInput)
    }

    return () => {
      if (ref.current && HAS_BEFORE_INPUT_SUPPORT) {
        // @ts-ignore The `beforeinput` event isn't recognized.
        ref.current.removeEventListener('beforeinput', onDOMBeforeInput)
      }
    }
  }, [onDOMBeforeInput])
复制代码

beforeinput 这个事件会在 <input>, <select><textarea> 或者 contenteditable 的值即将被修改前触发,这样我们可以获取到输入框更新之前的值,实际上对于编辑器内部的一些编辑内容的操作是通过这个劫持这个事件,然后再把用户的一系列操作转化成调用 slate api 去更新编辑器内容。对于为什么直接使用 addEventListener而不用 React 自己的合成事件在方法定义的时候,上面的注释也详细得做了介绍,这里不做过多的展开了。 ​

第二个就是监听 selectionchange方法:


  // Listen on the native `selectionchange` event to be able to update any time
  // the selection changes. This is required because React's `onSelect` is leaky
  // and non-standard so it doesn't fire until after a selection has been
  // released. This causes issues in situations where another change happens
  // while a selection is being dragged.
  const onDOMSelectionChange = useCallback(
    throttle(() => {
      if (!readOnly && !state.isComposing && !state.isUpdatingSelection) {
        const { activeElement } = window.document
        const el = ReactEditor.toDOMNode(editor, editor)
        const domSelection = window.getSelection()

        if (activeElement === el) {
          state.latestElement = activeElement
          IS_FOCUSED.set(editor, true)
        } else {
          IS_FOCUSED.delete(editor)
        }

        if (!domSelection) {
          return Transforms.deselect(editor)
        }

        const { anchorNode, focusNode } = domSelection

        const anchorNodeSelectable =
          hasEditableTarget(editor, anchorNode) ||
          isTargetInsideVoid(editor, anchorNode)

        const focusNodeSelectable =
          hasEditableTarget(editor, focusNode) ||
          isTargetInsideVoid(editor, focusNode)

        if (anchorNodeSelectable && focusNodeSelectable) {
          const range = ReactEditor.toSlateRange(editor, domSelection)
          Transforms.select(editor, range)
        } else {
          Transforms.deselect(editor)
        }
      }
    }, 100),
    [readOnly]
  )

  // Attach a native DOM event handler for `selectionchange`, because React's
  // built-in `onSelect` handler doesn't fire for all selection changes. It's a
  // leaky polyfill that only fires on keypresses or clicks. Instead, we want to
  // fire for any change to the selection inside the editor. (2019/11/04)
  // https://github.com/facebook/react/issues/5785
  useIsomorphicLayoutEffect(() => {
    window.document.addEventListener('selectionchange', onDOMSelectionChange)

    return () => {
      window.document.removeEventListener(
        'selectionchange',
        onDOMSelectionChange
      )
    }
  }, [onDOMSelectionChange])
复制代码

对于在编辑区域内的一些选区操作,就是通过这个方法去劫持选区的变动,首先获取到原生的 Selection,然后调用 toSlateRange方法将原生的选区转化成 slate 自定义的 Range 格式,然后调用相应方法更新 slate 自己定义的选区。 ​

这两个方法比较核心,大家可以去仔细阅读这部分的源码。当然还有一些其它的方法监听,例如 copypasteclickblurfocuskeydown等,这些就是给编辑器添加更加完善的功能,比如快捷键、中文输入方法的兼容处理等等。 ​

组件的部分介绍就先到这里了,其实严格来讲,这部分的源码还是比较容易读懂的,就算不看具体实现的代码,只看里面的方法就大概能知道一些核心的设计,我们新版编辑器的开发,无论是监听 beforinput事件还是 selectionchange事件,那么 slate-react 的封装实际上带给了我们一些启发。 ​

hooks

hooks 的部分没有太多可以说的东西,因为它的设计很简单,实际上就是几个 Context 的定义和使用 Context 的hooks的封装。 ​

简单介绍的每个hook的作用:

  • useFocused,看名字就知道,这是获取编辑器是否处于 focused 状态的一个 hook;
  • useSelected,这个 hook 用于判断一个 Element 是够处于选中状态,通过这个状态,在一些例如 mention 这样的场景下,给选择的 mention 进行高亮效果,官网就有一个这样的例子;
  • useReadOnly,获取编辑器是否当前状态是 reabOnly 的 hook;
  • useIsomorphicLayoutEffect,用于兼容 ssr 情况下,从使用 useLayoutEffecct fallback 到 useEffect 的一个hook,两个 hook 的区别就是调用时机不一样,useLayoutEffect 会更加快地调用,这里不展开,对 React 感兴趣可以自行去深入了解;
  • useEditor、useSlate、useSlateStatic,这个三个 hook 返回的都是编辑器的实例,那么有什么区别了?

plugin

先看下 Slate 中插件的定位吧,实际上 Slate 编辑器实例上的方法是可以被覆盖或者新增的,我们在最开始介绍 Slate 的数据模型定义的时候,知道 Editor 定义类型如下:


export type Editor = ExtendedType<'Editor', BaseEditor>
复制代码

通过 ExtendedType 的包装,我们可以去扩展 Editor 的类型。实际上 Slate 插件设计是一种特殊的“高阶函数”,我们知道高阶函数是通过接收一个函数参数然后返回一个新的函数,而 Slate 的插件是接收一个编辑实例参数,然后返回一个编辑实例对象,可以说是一种另类的“高阶函数”。

下面看下 slate-history 封装的 withHistory

export const withHistory = <T extends Editor>(editor: T) => {
  const e = editor as T & HistoryEditor
  const { apply } = e
  e.history = { undos: [], redos: [] }

  e.redo = () => {
    const { history } = e
    const { redos } = history

    if (redos.length > 0) {
      const batch = redos[redos.length - 1]

      HistoryEditor.withoutSaving(e, () => {
        Editor.withoutNormalizing(e, () => {
          for (const op of batch) {
            e.apply(op)
          }
        })
      })

      history.redos.pop()
      history.undos.push(batch)
    }
  }

  e.undo = () => {
    const { history } = e
    const { undos } = history

    if (undos.length > 0) {
      const batch = undos[undos.length - 1]

      HistoryEditor.withoutSaving(e, () => {
        Editor.withoutNormalizing(e, () => {
          const inverseOps = batch.map(Operation.inverse).reverse()

          for (const op of inverseOps) {
            // If the final operation is deselecting the editor, skip it. This is
            if (
              op === inverseOps[inverseOps.length - 1] &&
              op.type === 'set_selection' &&
              op.newProperties == null
            ) {
              continue
            } else {
              e.apply(op)
            }
          }
        })
      })

      history.redos.push(batch)
      history.undos.pop()
    }
  }

  e.apply = (op: Operation) => {
    const { operations, history } = e
    const { undos } = history
    const lastBatch = undos[undos.length - 1]
    const lastOp = lastBatch && lastBatch[lastBatch.length - 1]
    const overwrite = shouldOverwrite(op, lastOp)
    let save = HistoryEditor.isSaving(e)
    let merge = HistoryEditor.isMerging(e)

    if (save == null) {
      save = shouldSave(op, lastOp)
    }

    if (save) {
      if (merge == null) {
        if (lastBatch == null) {
          merge = false
        } else if (operations.length !== 0) {
          merge = true
        } else {
          merge = shouldMerge(op, lastOp) || overwrite
        }
      }

      if (lastBatch && merge) {
        if (overwrite) {
          lastBatch.pop()
        }

        lastBatch.push(op)
      } else {
        const batch = [op]
        undos.push(batch)
      }

      while (undos.length > 100) {
        undos.shift()
      }

      if (shouldClear(op)) {
        history.redos = []
      }
    }

    apply(op)
  }

  return e
}
复制代码

其实就是给编辑器实例增加了 redoundo 的方法,最后重写了 apply 方法,添加额外保存 history 数据的逻辑。 ​

通过这个例子的启发,我们就可以把一些复杂的功能通过插件的形式单独封装,在用户需要的时候再引入对应的插件,从而可以保证编辑器的体积足够小。同时用户也可以通过这种方式,开发自己的插件,去定制特殊的编辑器功能。

在 slate-react 中就封装了 withReact 这样的插件:

export const withReact = <T extends Editor>(editor: T) => {
  const e = editor as T & ReactEditor
  const { apply, onChange } = e

  e.apply = (op: Operation) => {
    const matches: [Path, Key][] = []

    switch (op.type) {
      case 'insert_text':
      case 'remove_text':
      case 'set_node': {
        for (const [node, path] of Editor.levels(e, { at: op.path })) {
          const key = ReactEditor.findKey(e, node)
          matches.push([path, key])
        }

        break
      }

      case 'insert_node':
      case 'remove_node':
      case 'merge_node':
      case 'split_node': {
        for (const [node, path] of Editor.levels(e, {
          at: Path.parent(op.path),
        })) {
          const key = ReactEditor.findKey(e, node)
          matches.push([path, key])
        }

        break
      }

      case 'move_node': {
        // TODO
        break
      }
    }

    apply(op)

    for (const [path, key] of matches) {
      const [node] = Editor.node(e, path)
      NODE_TO_KEY.set(node, key)
    }
  }

  // 设置从节点中复制的内容到粘贴板
  e.setFragmentData = (data: DataTransfer) => {
		// 省略核心代码
  }

  // 用于从粘贴板里获取内容插入到编辑器中
  e.insertData = (data: DataTransfer) => {
    const fragment = data.getData('application/x-slate-fragment')

    if (fragment) {
      const decoded = decodeURIComponent(window.atob(fragment))
      const parsed = JSON.parse(decoded) as Node[]
      e.insertFragment(parsed)
      return
    }

    const text = data.getData('text/plain')

    if (text) {
      const lines = text.split(/\r\n|\r|\n/)
      let split = false

      for (const line of lines) {
        if (split) {
          Transforms.splitNodes(e, { always: true })
        }

        e.insertText(line)
        split = true
      }
    }
  }

  // 重新 onChange,通过 react 的一些方法做一些 batch setState 操作
  e.onChange = () => {
    // COMPAT: React doesn't batch `setState` hook calls, which means that the
    // children and selection can get out of sync for one render pass. So we
    // have to use this unstable API to ensure it batches them. (2019/12/03)
    // https://github.com/facebook/react/issues/14259#issuecomment-439702367
    ReactDOM.unstable_batchedUpdates(() => {
      const onContextChange = EDITOR_TO_ON_CHANGE.get(e)

      if (onContextChange) {
        onContextChange()
      }

      onChange()
    })
  }

  return e
}
复制代码

简单粗暴,写一个插件也不需要太大的成本。 ​

小结

到这里,slate-react 部分也介绍得差不多了。这部分的源码相对来说还是比较容易理解易读的,而且我觉得对于我们学习 Slate 和基于 Slate 开发编辑器有很大的帮助。下面我们做个简单的总结:

  • 既然是基于 React 的开发,那对于 Slate 所有文档树的层级抽象就可以通过组件的形式封装,除了 Element、Text 这样的组件,通过更细腻度的 Leaf、String 抽象,可以使得 Slate 整个数据模型设计和渲染变得扁平化
  • 而核心的编辑区域也就是 Editable 组件,通过监听关键的 beforeinputselecctionchange 事件进行内容和选区的处理,然后通过调用 Slate 的API 进行最终的处理
  • 借用 React Context API 和 hook ,使得对于编辑器的状态管理更加简单和方便;
  • Slate 的插件依赖函数和编辑器实例的可拓展性进行开发,降低了开发成本,同时插件化的机制的设计使得我们可以根据自己的需求进行复杂功能的抽离和提取,保证核心包的体积不至于太大。

其它

除了核心的源码,Slate 还有其它的一些设计让给我觉得眼前一亮,这里单独列出来。 ​

测试

Slate 测试挺别具一格,反正我第一次看的时候,反应就是原来测试还可以这样写。 ​

我们随便看几个例子,slate/test/interfaces/Text/decorations/* ​

// end.tsx
import { Text } from 'slate'

export const input = [
  {
    anchor: {
      path: [0],
      offset: 2,
    },
    focus: {
      path: [0],
      offset: 3,
    },
    decoration: 'decoration',
  },
]
export const test = decorations => {
  return Text.decorations({ text: 'abc', mark: 'mark' }, decorations)
}
export const output = [
  {
    text: 'ab',
    mark: 'mark',
  },
  {
    text: 'c',
    mark: 'mark',
    decoration: 'decoration',
  },
]

// middle.tsx
import { Text } from 'slate'

export const input = [
  {
    anchor: {
      path: [0],
      offset: 1,
    },
    focus: {
      path: [0],
      offset: 2,
    },
    decoration: 'decoration',
  },
]
export const test = decorations => {
  return Text.decorations({ text: 'abc', mark: 'mark' }, decorations)
}
export const output = [
  {
    text: 'a',
    mark: 'mark',
  },
  {
    text: 'b',
    mark: 'mark',
    decoration: 'decoration',
  },
  {
    text: 'c',
    mark: 'mark',
  },
]

复制代码

注意,这些文件里没有任何跟测试相关的断言之类的调用。其实,所有 test 目录下的只是 fixtures那么什么是 fixtures?在测试领域里面,测试fixture的目的是确保有一个众所周知的、固定的环境来运行测试,以便结果是可重复的,也有的人称 fixtures 为测试上下文。以下是 fixtures 的一些例子:

  • 用特定的已知数据集加载数据库;
  • 复制特定已知的一组文件;
  • 编写输入数据和假设或模拟对象的设置/创建。

Slate 这里的场景就属于复制特定已知的一组文件,那么怎么用了?在 Slate 整个目录有个 support 文件夹下有一个 fixtures.js:

import fs from 'fs'
import { basename, extname, resolve } from 'path'

export const fixtures = (...args) => {
  let fn = args.pop()
  let options = { skip: false }

  if (typeof fn !== 'function') {
    options = fn
    fn = args.pop()
  }

  const path = resolve(...args)
  const files = fs.readdirSync(path)
  const dir = basename(path)
  const d = options.skip ? describe.skip : describe

  d(dir, () => {
    for (const file of files) {
      const p = resolve(path, file)
      const stat = fs.statSync(p)

      if (stat.isDirectory()) {
        fixtures(path, file, fn)
      }
      if (
        stat.isFile() &&
        (file.endsWith('.js') ||
          file.endsWith('.tsx') ||
          file.endsWith('.ts')) &&
        !file.endsWith('custom-types.ts') &&
        !file.endsWith('type-guards.ts') &&
        !file.startsWith('.') &&
        // Ignoring `index.js` files allows us to use the fixtures directly
        // from the top-level directory itself, instead of only children.
        file !== 'index.js'
      ) {
        const name = basename(file, extname(file))

        // This needs to be a non-arrow function to use `this.skip()`.
        it(`${name} `, function() {
          const module = require(p)

          if (module.skip) {
            this.skip()
            return
          }

          fn({ name, path, module })
        })
      }
    }
  })
}

fixtures.skip = (...args) => {
  fixtures(...args, { skip: true })
}

复制代码

它这个文件就封装了读取某个特定模块下的 fixtures 文件函数,然后在每个包下的 test 目录下的 index.js 文件中进行调用,以 slate pkg 为例:

import assert from 'assert'
import { fixtures } from '../../../support/fixtures'
import { Editor } from 'slate'
import { createHyperscript } from 'slate-hyperscript'

describe('slate', () => {
  fixtures(__dirname, 'interfaces', ({ module }) => {
    let { input, test, output } = module
    if (Editor.isEditor(input)) {
      input = withTest(input)
    }
    const result = test(input)
    assert.deepEqual(result, output)
  })
  fixtures(__dirname, 'operations', ({ module }) => {
    const { input, operations, output } = module
    const editor = withTest(input)
    Editor.withoutNormalizing(editor, () => {
      for (const op of operations) {
        editor.apply(op)
      }
    })
    assert.deepEqual(editor.children, output.children)
    assert.deepEqual(editor.selection, output.selection)
  })
  fixtures(__dirname, 'normalization', ({ module }) => {
    const { input, output } = module
    const editor = withTest(input)
    Editor.normalize(editor, { force: true })
    assert.deepEqual(editor.children, output.children)
    assert.deepEqual(editor.selection, output.selection)
  })
  fixtures(__dirname, 'transforms', ({ module }) => {
    const { input, run, output } = module
    const editor = withTest(input)
    run(editor)
    assert.deepEqual(editor.children, output.children)
    assert.deepEqual(editor.selection, output.selection)
  })
})
const withTest = editor => {
  const { isInline, isVoid } = editor
  editor.isInline = element => {
    return element.inline === true ? true : isInline(element)
  }
  editor.isVoid = element => {
    return element.void === true ? true : isVoid(element)
  }
  return editor
}
export const jsx = createHyperscript({
  elements: {
    block: {},
    inline: { inline: true },
  },
})

复制代码

这里才是正式真正写测试的地方,短短五十几行代码就搞定了 slate 这个 package 下的所有测试,我称之为”数据驱动测试“。 ​

WeakMap

还有另一个值得提一下的就是 slate-react 大量 WeakMap 数据结构的使用,如果看过 Vue3.0 源码的 reactivity 的源码,应该对这种数据结构不陌生。使用 WeakMap 的好处是它的键值对象是一种弱引用,所以它的 key 是不能枚举的,能一定程度上节约内存,防止内存泄漏。 ​

看下 slate-react 怎么使用 WeakMap 的,在 slate-react/src/utils/weak-maps.ts 下定义了一系列 WeakMap:

/**
 * Two weak maps that allow us rebuild a path given a node. They are populated
 * at render time such that after a render occurs we can always backtrack.
 * 存储 Node 节点与索引、Node 节点与父节点之间的映射
 */

export const NODE_TO_INDEX: WeakMap<Node, number> = new WeakMap()
export const NODE_TO_PARENT: WeakMap<Node, Ancestor> = new WeakMap()

/**
 * Weak maps that allow us to go between Slate nodes and DOM nodes. These
 * are used to resolve DOM event-related logic into Slate actions.
 *  存储 slate node 节点和 真实 dom节点的映射
 */

export const EDITOR_TO_ELEMENT: WeakMap<Editor, HTMLElement> = new WeakMap()
export const EDITOR_TO_PLACEHOLDER: WeakMap<Editor, string> = new WeakMap()
export const ELEMENT_TO_NODE: WeakMap<HTMLElement, Node> = new WeakMap()
export const KEY_TO_ELEMENT: WeakMap<Key, HTMLElement> = new WeakMap()
export const NODE_TO_ELEMENT: WeakMap<Node, HTMLElement> = new WeakMap()
export const NODE_TO_KEY: WeakMap<Node, Key> = new WeakMap()

/**
 * Weak maps for storing editor-related state.
 * 存储编辑器的一些状态,比如 readOnly
 */

export const IS_READ_ONLY: WeakMap<Editor, boolean> = new WeakMap()
export const IS_FOCUSED: WeakMap<Editor, boolean> = new WeakMap()
export const IS_DRAGGING: WeakMap<Editor, boolean> = new WeakMap()
export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap()

/**
 * Weak map for associating the context `onChange` context with the plugin.
 * 存储 Context 中 onChange 函数
 */

export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () => void>()
复制代码

主要有四类 WeakMap,每一类我加了大概的注释,大家可以自己看一下。想了解更多 WeakMap 的知识,可以点击如下链接:WeakMap。 ​

官网的几个例子

看完了源码,我们可以通过官网的几个例子来看下怎么使用 slate 创建一个编辑器。

richtext

比较经典的一个例子就是使用 slate 创建一个简单的富文本编辑,如果我们要使用 slate 创建一个富文本,那么最重要的就是你需要自定义 renderElementrenderLeaf,因为 slate-react只提供了默认的 Element(div)、Leaf(span) 的渲染,如果你要实现列表、加粗、标题等功能,你就得自己去自定义特殊效果的渲染层:

const Element = ({ attributes, children, element }) => {
  switch (element.type) {
    case 'block-quote':
      return <blockquote {...attributes}>{children}</blockquote>
    case 'bulleted-list':
      return <ul {...attributes}>{children}</ul>
    case 'heading-one':
      return <h1 {...attributes}>{children}</h1>
    case 'heading-two':
      return <h2 {...attributes}>{children}</h2>
    case 'list-item':
      return <li {...attributes}>{children}</li>
    case 'numbered-list':
      return <ol {...attributes}>{children}</ol>
    default:
      return <p {...attributes}>{children}</p>
  }
}

const Leaf = ({ attributes, children, leaf }) => {
  if (leaf.bold) {
    children = <strong>{children}</strong>
  }

  if (leaf.code) {
    children = <code>{children}</code>
  }

  if (leaf.italic) {
    children = <em>{children}</em>
  }

  if (leaf.underline) {
    children = <u>{children}</u>
  }

  return <span {...attributes}>{children}</span>
}
复制代码

其它的功能就根据你自己的需要使用 slate 的 API 进行集成。比如官网的例子还实现了例如切换加粗效果的函数:

const toggleMark = (editor, format) => {
  const isActive = isMarkActive(editor, format)

  if (isActive) {
    Editor.removeMark(editor, format)
  } else {
    Editor.addMark(editor, format, true)
  }
}
复制代码

直接调用 slate 封装的 removeMarkaddMark 方法非常的方便。官网的这个例子大概使用了200行代码左右就实现了富文本的加粗、斜体、代码、下划线、列表、引用、标题等功能,所以,总体来说还是非常简单的。 ​

markdown 编辑器

另一个值得说的例子就是 markdown 编辑器,这个我们在前面的分析中也有提到,其实最核心的就是要使用 Editabledecorate prop 对 markdown 的语法进行分词,我们看代码:

const decorate = useCallback(([node, path]) => {
    const ranges = []

    if (!Text.isText(node)) {
      return ranges
    }

    const getLength = token => {
      if (typeof token === 'string') {
        return token.length
      } else if (typeof token.content === 'string') {
        return token.content.length
      } else {
        return token.content.reduce((l, t) => l + getLength(t), 0)
      }
    }

    const tokens = Prism.tokenize(node.text, Prism.languages.markdown)
    let start = 0

    for (const token of tokens) {
      const length = getLength(token)
      const end = start + length

      if (typeof token !== 'string') {
        ranges.push({
          [token.type]: true,
          anchor: { path, offset: start },
          focus: { path, offset: end },
        })
      }

      start = end
    }

    return ranges
  }, [])
复制代码

这是实现 markdown 编辑器的核心,因为前面介绍过了,这里就不多提了。然后再根据分词出来的效果例如标题、加粗、代码块实现指定的样式:

const Leaf = ({ attributes, children, leaf }) => {
  return (
    <span
      {...attributes}
      className={css`
        font-weight: ${leaf.bold && 'bold'};
        font-style: ${leaf.italic && 'italic'};
        text-decoration: ${leaf.underlined && 'underline'};
        ${leaf.title &&
          css`
            display: inline-block;
            font-weight: bold;
            font-size: 20px;
            margin: 20px 0 10px 0;
          `}
        ${leaf.list &&
          css`
            padding-left: 10px;
            font-size: 20px;
            line-height: 10px;
          `}
        ${leaf.hr &&
          css`
            display: block;
            text-align: center;
            border-bottom: 2px solid #ddd;
          `}
        ${leaf.blockquote &&
          css`
            display: inline-block;
            border-left: 2px solid #ddd;
            padding-left: 10px;
            color: #aaa;
            font-style: italic;
          `}
        ${leaf.code &&
          css`
            font-family: monospace;
            background-color: #eee;
            padding: 3px;
          `}
      `}
    >
      {children}
    </span>
  )
}
复制代码

也就是去我们自己实现 Leaf 这一层,其实去实现代码高亮的效果同理也是这样,关键的步骤在于分词。 ​

image 编辑器

提到内容编辑器,肯定少不了图片的场景,官网也提供了一个插入图片的 demo。对于图片的功能,有两点需要的地方,第一就是图片是一个 void 元素,简单概括就是内容区域不可编辑,所以我们要重写 editor 实例的 isVoid方法,第二个就是复制图片内容的时候需要处理粘贴的情况,前面我们看 slate-react 源码的时候知道 ReactEditor扩展了一个方法 insertData,这里就是专门劫持处理粘贴内容的情况,所以该例子写了一个 withImage插件来扩展这两个方法:

const withImages = editor => {
  const { insertData, isVoid } = editor

  // 图片、视频这类的 Node 都是 void 元素,即它的区域是不可编辑的,我们可以把这样的元素看成一个黑盒
  editor.isVoid = element => {
    return element.type === 'image' ? true : isVoid(element)
  }

  // 这里是处理复制的内容里面包含图片文件的情况,需要单独处理
  editor.insertData = data => {
    const text = data.getData('text/plain')
    const { files } = data

    if (files && files.length > 0) {
      for (const file of files) {
        const reader = new FileReader()
        const [mime] = file.type.split('/')

        if (mime === 'image') {
          reader.addEventListener('load', () => {
            const url = reader.result
            insertImage(editor, url)
          })

          reader.readAsDataURL(file)
        }
      }
    } else if (isImageUrl(text)) {
      insertImage(editor, text)
    } else {
      insertData(data)
    }
  }

  return editor
}
复制代码

其它的就是自定义渲染 ElementinsertImage方法的实现,可以简单看一下:

const insertImage = (editor, url) => {
  const text = { text: '' }
  // image 是被看做为一个 element,所以必须要有 children,并且 children 里面必须有一个子元素
  const image = { type: 'image', url, children: [text] }
  Transforms.insertNodes(editor, image)
}

const Element = props => {
  const { attributes, children, element } = props

  switch (element.type) {
    case 'image':
      return <ImageElement {...props} />
    default:
      return <p {...attributes}>{children}</p>
  }
}
复制代码

是不是很简单!看懂官网这些例子,实际去实现我们 v5 要做的菜单功能就清晰很多了。

结尾

到这里,本系列的 Slate 的源码解析基本结束了。当然本文没有很详细的介绍太多源码细节,还是从宏观地角度去挑选一些重要的设计进行分析,也是希望能给大家去学习 Slate 源码和理解 Slate 设计有些帮助。 ​

最后做一个总结,从本文的内容中,我们可以得到以下的结论和启发:

  • Slate 整个大的设计架构可以看做是 MVC 架构,在其核心的包 slate 中封装了 Model 和 Controller ,其 View 层借助 React 封装了 slate-react;
  • 在其数据模型的定义中,包含了 Editor、Element、Text 等核心的 Node节点,这三种节点共同组成 Slate Document 树;通过 Path、Point、Range 等数据结构组成了编辑器内部选区的抽象,使得我们可以更加方便地根据选区进行 Operation 操作
  • Operations 定义了 Slate 修改编辑器内容的最底层操作,它一共有9种类型,其中6种类型两两对应,方便进行 undo 操作;为了方便使用,基于 Operations 封装了 Transforms、Commands 等更加高级的操作
  • View 层使用了 React 作为渲染层,依赖 React 的组件化、强大的API,使得我们更加方便地去定制业务领域的编辑器;
  • Slate 强大的插件化机制,使得我们很容易去扩展编辑器的功能,亦或者根据具体场景去重写一些实例方法,定制我们自己的编辑器,官方提供的 slate-histroy 就是通过插件机制拆分出来的功能;
  • 除了功能设计比较强大之外,其测试的编写是一种数据驱动的思路,通过给每个测试文件定义输入和输出以及要执行的测试逻辑,最后通过统一的 runnner 运行,极大提供了编写测试的效率 ,让人耳目一新。WeakMap 的引入也一定程度上防止一些意外的内存泄漏的问题。
文章分类
前端