slate 源码设计分析

1. slate 是什么

slate 是一个 完全 可定制的用于构建富文本编辑器的框架

在富文本编辑器领域,对其具体的实现方法可以大致分为三种:

  • L0 编辑器:依赖 DOM 的 contenteditable 属性,基于原生 execCommand 或者自定义扩展的 execCommand 去操作 DOM 实现富文内容的修改。比如 CKEditor1-4UEditor 、低版本的 wangEditor
  • L1 编辑器:对 DOM Tree 已经数据的修改操作进行了抽象,使开发者在大部分情况下,不是直接操作的 DOM,使用 L1 框架构建的模型 API 完成。比如 QuillProseMirrorDraft.jsSlate
  • L2 编辑器:不依赖浏览器的编辑能力,独立实现光标和排版。比如使用 canvas 实现的 Goole Doc 和 svg 实现的 WPS web,实现难度极高

slate 作为 L1 编辑器,提供了 Web 富文本编辑器的底层能力,基于 react 进行渲染可以免去富文本视图层实现和 react 框架之间的 diff

由于 react 的异步渲染机制,实现起来较为复杂,后续代码示例使用 slate-vue3 的实现

1.1 一个简单的例子

可以从富文本案例开始,先设置一个初始值

typescript 复制代码
const initialValue: Descendant[] = [
  {
    type: 'paragraph',
    children: [
      { text: 'This is editable ' },
      { text: 'rich', bold: true },
      { text: ' text, ' },
      { text: 'much', italic: true },
      { text: ' better than a ' },
      { text: '<textarea>', code: true },
      { text: '!' },
    ],
  },
  {
    type: 'paragraph',
    children: [
      {text: "Since it's rich text, you can do things like turn a selection of text "},
      {text: 'bold', bold: true },
      {text: ', or add a semantically rendered block quote in the middle of the page, like this:'},
    ],
  }
]

渲染定制化富文本必要的参数:renderElement, renderLeaf

typescript 复制代码
const renderElement = (props: RenderElementProps) => {
  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 renderLeaf = (props: RenderLeafProps) => {
 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>
})

有了这两个要素,就可以使用了

html 复制代码
<script>
const editor = withHistory(withDOM(createEditor()))
editor.children = initialValue
</script>
<template>
  <Slate :editor="editor" :render-element="renderElement" :render-leaf="renderLeaf"
    :render-placeholder="defaultRenderPlaceHolder">
    <Toolbar>
      <MarkButton format="bold" icon="format_bold" />
      .......
      <BlockButton format="justify" icon="format_align_justify" />
    </Toolbar>
    <Editable placeholder="Enter some rich text..." spellcheck />
  </Slate>
</template>

代码省略了一部分,按照官方案例最终渲染出来就是这个效果

可以发现 slate 并不是开箱即用的,需要自己二次开发许多内容,也正是这个特点,使得它的扩展性特别好,许多想要定制开发编辑器的,都会选择基于 slate.js 进行二次开发 (wangEditor5, 在线知识库文档...)

1.2 一个 slate 组件实例

上述的 createEditor 方法返回一个 slate 实例,可以通过控制台打印的方式,将 editor 打印出来

展开后,大多数都是可以直接调用的方法,有两个 key 值为对象 children 和 selection,分别控制子节点和光标

这两个变量储存了 slate 实例的状态,slate 的所有操作,也都是通过修改这两个对象实现的

2. 控制器和接口

2.1 Transform 和 Operation

slate-react 的数据结构是 immutable 的,slate 提供了 TransformOperation 接口用来修改现有的数据模型

Transform 是已经定制好的一连串操作,每次执行之后会调用一个或多个 OperationOperation 为操作 slate 状态的基本方法

共有 8 种节点操作:

插入节点 移除节点 插入文本 移除文本 合并节点 切分节点 移动节点 设置节点
insert_node remove_node insert_text remove_text merge_node split_node move_node set_node

共有 3 种光标操作:

  1. 设置光标位置
  2. 取消光标选择
  3. 修改光标位置
sql 复制代码
    { type: 'set_selection' properties: null newProperties: Range }
    { type: 'set_selection' properties: Partial<Range> newProperties: Partial<Range> }
    { type: 'set_selection' properties: Range newProperties: null }

具体实现可查看:

Slate Operation 如何修改 model

上述操作覆盖了所有用户行为,在 Transforms,对于上述行为进行组装,以 NodeTransforms.setNodes 为例

typescript 复制代码
    export const setNodes: NodeTransforms['setNodes'] = (
      editor,
      props: Partial<Node>,
      options = {}
    ) => {
    for (const [node, path] of Editor.nodes(editor, {
      at, match, mode, voids,
    })) {
        const properties: Partial<Node> = {}
        const newProperties: Partial<Node> & { [key: string]: unknown } = {}
          .......
          .......
        editor.apply({
          type: 'set_node',
          path,
          properties,
          newProperties,
        })
      }
    }

经过一系列操作,最终调用 editor 的 apply 方法,设置节点信息

Transform 的操作封装在 slate package 下的 transform 目录下,主要包含了四部分内容:

  1. GeneralTransforms:通过 Operation 修改编辑器内容的封装,实际上就是对 9 个 Operations 调用的封装;
  2. NodeTransforms:对操作 Node 高层次的封装;
  3. TextTransforms: 专门针对 TextNode 操作的封装;
  4. SelectionTransforms: 专门针对选区修改的封装。

2.2 Plugin

可以通过开发插件的方式,对现有编辑器的默认行为进行修改,或者添加新的方法,下面有两个例子

  1. 修改默认行为,将图片元素设置为不可编辑
typescript 复制代码
const withImages = editor => {
  const { isVoid } = editor
  editor.isVoid = element => {
    return element.type === 'image' ? true : isVoid(element)
  }
  return editor
}
  1. slate-history 库对于现有的编辑器新增了一些方法
typescript 复制代码
export const withHistory = <T extends Editor>(editor: T) => {
  const e = editor as T & HistoryEditor
  ....
  ....
  e.writeHistory = (stack: 'undos' | 'redos', batch: any) => {
    e.history[stack].push(batch)
  }
  return e
}

这些方法本质就是一个普通函数,传入当前的编辑器实例,返回一个处理后的实例

typescript 复制代码
const editor = withHistory(withShortcuts(withDOM(createEditor(initialValue))))

基于这种方法,能将插件的功能更加细化,便于组合和拆分,可以对于多个插件进行嵌套使用,以上为 markdown 快捷指令官方示例的部分代码

3. 树结构渲染

3.1 节点大致结构

slate 编辑器实例的 children 如下图所示,实际项目中层级有可能多一些,从最上层实例根节点开始,到 Element 节点,然后到 Text 节点

  1. Element 子节点可能含有 Element 节点或 Text 节点
  2. Text 节点再往下只能为 Leaf 节点,有一个或多个(自定义分词语法的情况)
  3. 每个 Leaf 节点都只含有一个 String 节点,这部分渲染不由用户控制,由 slate 框架实现

所以一般在使用的时候,只关心到 Text 层级即可:

  • renderElement 函数控制当前 Element 节点的分叉
  • decorate 函数控制 Text 节点的分叉
  • renderLeaf 函数控制 Text 节点分叉出来的 Leaf 节点渲染
  • String 节点由 slate 框架控制

3.2 修改数据模型

用户在编辑的时候,整个树结构就会跟着发生改变,下图问删除节点示例,其他操作也大致相同

由于每次修改都是从顶层开始的,会造成大量重复渲染更新,slate-react 使用 immer.js 进行了优化

可参考:juejin.cn/post/720250...

大概原理就是从最小单元去进行拷贝,没改变的对象数据则进行复用,如果传入节点的指针不发生变化,则组件不会重新渲染

typescript 复制代码
    const renderElement = ({ attributes, children, element }) => {
        switch (element.type) {
          case 'quote':
            return <blockquote {...attributes}>{children}</blockquote>
          case 'link':
            return <a {...attributes} href={element.url}>
                {children}
              </a>
          default:
            return <p {...attributes}>{children}</p>
        }
      }
      
    const renderLeaf = ({ attributes, children, leaf }) => {
      return <span
          {...attributes}
          style={{
            fontWeight: leaf.bold ? 'bold' : 'normal',
            fontStyle: leaf.italic ? 'italic' : 'normal',
          }}
        >
          {children}
        </span>
    }

回顾上面提到的渲染函数,都是从 element 中获取当前节点信息,最终返回 JSX.Element,有了 jsx 就可以渲染出真实节点了

3.3 decorate

这部分控制 Text 节点的分叉,如果为函数返回空数组的话,则默认一个 Leaf 节点(没有装饰器拆分区间)

以光标左右不同颜色为例: guan-erjia.github.io/slate-vue3/...

typescript 复制代码
const decorate = ([node, path]: NodeEntry): DecoratedRange[] => {
  const ranges: DecoratedRange[] = []
  if (node.children.every(Text.isText) && editor.selection) {
    const offset = node.children[0].text.length
    if (Path.isCommon(path, editor.selection.focus.path)) {
      ranges.push({
        anchor: { path, offset: editor.selection.focus.offset },
        focus: { path, offset },
        highlight: true,
      })
    }
    if (Path.isAfter(path, editor.selection.focus.path)) {
      ranges.push({
        anchor: { path, offset: 0 },
        focus: { path, offset },
        highlight: true,
      })
    }
  }
  return ranges
}

与上面的 renderElementrenderLeaf 不同的是,这部分返回的是个区间范围和该范围的描述 ,配合 renderLeaf 获取到这部分描述,即可实现最终的渲染效果

typescript 复制代码
const renderLeaf = ({ attributes, children, leaf }: RenderLeafProps) => 
    createElement('span', {
      ...attributes,
      style: { backgroundColor: 'highlight' in leaf ? '#ffeeba' : undefined }
}, children)
  • 此部分虽然重要,但是跟数据模型关系不大,区间拆分是在 Text 节点渲染时实现的,并不会影响原始数据模型
  • 所以数据模型的终点就是 Text 节点,可将其看作一个无状态纯函数
  • 用户每次更新节点数据时,Text 节点一定会重新计算 decoration,并根据结果更新子节点
  • 下层的 Leaf 节点和 String 节点同理 slate-vue3 实现源码如下:github.com/Guan-Erjia/...
typescript 复制代码
const decorate = useDecorate();
const leaves = computed(() => {
  const elemPath = DOMEditor.findPath(editor, element);
  const textPath = DOMEditor.findPath(editor, text);
  const textDs = decorate([text, textPath]);
  const elemDs = decorate([element, elemPath]);
  const range = Editor.range(editor, textPath);
  elemDs.forEach((dec) => {
    textDs.push(Range.intersection(dec, range)!);
  });
  if (markPlaceholder.value) {
    textDs.unshift(markPlaceholder.value);
  }
  const filterDs = textDs.filter(Boolean);
  return Text.decorations(text, filterDs.length ? filterDs : []);
});

由于 leaves 中复杂的计算过程, Text 组件下层的子节点数据,和数据模型代理基本脱钩,所以每次修改节点时,Text 组件必更新,可以使用函数直接返回 VNode,已经没有使用状态组件的必要了

4. 光标和 selection

无论是普通输入,还是选中一片区域之后进行剪切复制等,用户所有的操作必须有作用位置,就是 selection

可以先了解一下 DOM 的 getSelection 方法 developer.mozilla.org/zh-CN/docs/...

4.1 没有选择的时候

没什么好说的,什么信息都没有,这个时候就是空

4.2 光标选中一个点

当选中一个点时,获取到的 Selection 有了变化:

  • anchorNode:起始节点
  • anchorOffset:起始偏移
  • focusNode:聚焦节点
  • focusOffset:聚焦偏移
  • isCollapsed:光标是否重合(很明显目前是的)

通过 getSelection 可以获取到当前选中的节点,和光标相对偏移位置,为 slate 获取虚拟 Selection 创造了可能性

通过打印 editor.selection,可以看出 slate.selection 描述的内容,与 DOM 的 Selection 大致相同,通过 path 可以判断当前节点,offset 与 DOM selection 一致,可以通过某些方式,将 slate 的 selection 映射到真实 DOM 结构上

选中范围的过程与选中单个点类似,再次不赘述

4.3 DOM selection 映射到 slate 路径

用户直接点击某个位置,或使用键盘上下左右移动光标的时候,就需要获取当前光标的 slate 路径

offset 可以从 DOM selection 中获取,目前需要获取 path 数组,path 即当前的节点是父节点的第几个子节点

4.3.1 获取 slate 路径

slate 获取当前节点 path 的方法如下:

  1. 在渲染时将节点索引储存到 NODE_TO_INDEX Weakmap 中
  2. 子节点挂载之后,将真实节点的 ref 储存到 ELEMENT_TO_NODE Weakmap 中
  3. 通过事件的中的真实节点指针,获取到当前 slate 节点指针
  4. 通过 slate 节点指针,获取到当前索引
  5. 通过 NODE_TO_PARENT 逐层向上递归,依次找到父节点的索引,拼接一起就是当前的 slate 路径

遍历数组时,将节点和索引通过 NODE_TO_INDEX 绑定 github.com/Guan-Erjia/...

typescript 复制代码
export const ChildrenFC = (element: Element, editor: DOMEditor) =>
  renderList(element.children, (child, i): VNode => {
    // 这些逻辑不会触发多余渲染
    const key = DOMEditor.findKey(editor, child);
    // 组件直接传入索引将不会动态更新,必须通过 NODE_TO_INDEX 手动获取索引
    NODE_TO_INDEX.set(child, i);
    NODE_TO_PARENT.set(child, element);

    return Element.isElement(child)
      ? h(ElementComp, { element: child, key: key.id })
      : h(TextComp, { text: child, element, key: key.id });
  });

4.3.2 绑定真实节点和 slate 节点

使用回调函数的方式传入 ref,获取真实 DOM 指针并存入 ELEMENT_TO_NODEgithub.com/Guan-Erjia/...

typescript 复制代码
const elementRef = ref<HTMLElement | null>(null);
onMounted(() => {
  const key = DOMEditor.findKey(editor, element);
  const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor);
  if (elementRef.value) {
    KEY_TO_ELEMENT?.set(key, elementRef.value);
    NODE_TO_ELEMENT.set(element, elementRef.value);
    ELEMENT_TO_NODE.set(elementRef.value, element);
  }
});
onUnmounted(() => {
  const key = DOMEditor.findKey(editor, element);
  const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor);
  KEY_TO_ELEMENT?.delete(key);
  NODE_TO_ELEMENT.delete(element);
});

4.3.3 寻找路径

可以通过 DOM 节点指针获取到 slate 路径过程如下: github.com/Guan-Erjia/...

typescript 复制代码
findPath: (editor, node) => {
    const path: Path = []
    let child = node

    while (true) {
      const parent = NODE_TO_PARENT.get(child)
      if (parent == null) {
        if (Editor.isEditor(child)) {
          return path
        } else {
          break
        }
      }

      const i = NODE_TO_INDEX.get(child)
      if (i == null) {
        break
      }
      path.unshift(i)
      child = parent
    }
  }

toSlateNode: (editor, domNode) => {
    return domEl ? ELEMENT_TO_NODE.get(domEl as HTMLElement) : null
}

toSlatePoint: (editor: DOMEditor,domPoint: DOMPoint) => {
    const slateNode = DOMEditor.toSlateNode(editor, textNode!)
    const path = DOMEditor.findPath(editor, slateNode)
    return { path, offset }
}

4.4 slate 路径映射到 DOM Selection

这种行为一般发生在 Operation 之后(例如插入文字), slate 路径与 DOM selection 路径不一致,需要移动 DOM 光标位置,可使用浏览器提供的 setBaseAndExtent 方法

说明参考:developer.mozilla.org/zh-CN/docs/...

  • anchorNode:锚节点 - 选中内容的开始节点
  • anchorOffset:选中范围内起点位置在锚节点下第几个子节点的位置
  • focusNode:焦点节点 - 选中内容的结尾节点
  • focusOffset:选中范围内结束位置在焦点节点下第几个子节点的位置

目前起始 offset 和焦点 offset 已经有了,只需要起始节点和聚焦节点,需要通过 slate 路径获取真实 DOM 节点

首先通过 Path 获取 slate 节点,遍历当前实例下的 children 树结构即可 github.com/Guan-Erjia/...

typescript 复制代码
getIf(root: Node, path: Path): Node | undefined {
    let node = root
    for (let i = 0; i < path.length; i++) {
        const p = path[i]
        if (Text.isText(node) || !node.children[p]) {
            return
        }
        node = node.children[p]
    }
    return node
},

获取到节点之后,通过 SlateNode 指针,通过 NODE_TO_ELEMENT 获取绑定过的真实 DOM 节点,见 4.3.2

github.com/Guan-Erjia/...

typescript 复制代码
onMounted(() => {
  const key = DOMEditor.findKey(editor, element);
  const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor);
  if (elementRef.value) {
    KEY_TO_ELEMENT?.set(key, elementRef.value);
    NODE_TO_ELEMENT.set(element, elementRef.value);
    ELEMENT_TO_NODE.set(elementRef.value, element);
  }
});
onUnmounted(() => {
  const key = DOMEditor.findKey(editor, element);
  const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor);
  KEY_TO_ELEMENT?.delete(key);
  NODE_TO_ELEMENT.delete(element);
    });
const attributes = computed(() => {
  const attr: ElementAttributes = {
    "data-slate-node": "element",
    ref: elementRef,
  };
  ......
  return attr;
});

const renderElement = useRenderElement();
return () => renderElement({
    attributes: attributes.value,
    children: children.value,
    element,
  });

4.5 同步 selection

MVVM 渲染机制一样,slate 编辑器的光标也是单向受控的,每次 editor 实例的 selection 改变,都会通知编辑器更新光标位置

用户行为收集

5.1 输入、删除行为

通过拦截 beforeInput 事件,调用 Operation 修改数据模型并进行重新渲染

  • 使用 event 上的 *preventDefault 方法可以阻止用户输入的默认行为
  • 根据 inputType 调用不同的方法修改数据模型,除了安卓浏览器基本可以覆盖全部交互场景

github.com/Guan-Erjia/...

typescript 复制代码
const onDOMBeforeInput = (event: InputEvent) => {
  event.preventDefault()
  const { inputType: type } = event
  ......
  switch (type) {
    case 'deleteByComposition':
    case 'deleteByCut':
    case 'deleteByDrag': {
      Editor.deleteFragment(editor)
      break
    }
    ........
  }
  .......
}

剪切、粘贴,拖拽等操作:

同理,需要阻止默认行为,调用 slate 内部方法对模型进行修改,然后借助框架进行渲染更新 github.com/Guan-Erjia/...

typescript 复制代码
const onCopy = (event: ClipboardEvent) => {
  if (
    DOMEditor.hasSelectableTarget(editor, event.target) &&
    !isEventHandled(event, attributes.onCopy) &&
    !isDOMEventTargetInput(event)
  ) {
    event.preventDefault();
    event.clipboardData &&
      DOMEditor.setFragmentData(editor, event.clipboardData, "copy");
  }
};

const onCut = (event: ClipboardEvent) => {
  if (
    !readOnly &&
    DOMEditor.hasSelectableTarget(editor, event.target) &&
    !isEventHandled(event, attributes.onCut) &&
    !isDOMEventTargetInput(event)
  ) {
    event.preventDefault();
    event.clipboardData &&
      DOMEditor.setFragmentData(editor, event.clipboardData, "cut");
    const selection = editor.selection;

    if (selection) {
      if (Range.isExpanded(selection)) {
        Editor.deleteFragment(editor);
      } else {
        const node = Node.parent(editor, selection.anchor.path);
        if (Editor.isVoid(editor, node)) {
          Transforms.delete(editor);
        }
      }
    }
  }
};

5.2 光标移动行为

用户移动光标时,监听 selectionchange 事件,检测当前实际光标位置与 editor.selection 是否已对应,并同步位置

typescript 复制代码
  const setDomSelection = () => {
      const root = DOMEditor.findDocumentOrShadowRoot(editor);
      const domSelection = getSelection(root);
      if (!domSelection) {
        return;
      }
      const hasDomSelection = domSelection.type !== "None";

      // If the DOM selection is properly unset, we're done.
      if (!editor.selection && !hasDomSelection) {
        return;
      }

      // Get anchorNode and focusNode
      const focusNode = domSelection.focusNode;
      let anchorNode;

      // COMPAT: In firefox the normal selection way does not work
      // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223)
      if (IS_FIREFOX && domSelection.rangeCount > 1) {
        const firstRange = domSelection.getRangeAt(0);
        const lastRange = domSelection.getRangeAt(domSelection.rangeCount - 1);

        // Right to left
        if (firstRange.startContainer === focusNode) {
          anchorNode = lastRange.endContainer;
        } else {
          // Left to right
          anchorNode = firstRange.startContainer;
        }
      } else {
        anchorNode = domSelection.anchorNode;
      }
      // verify that the dom selection is in the editor
      const editorElement = EDITOR_TO_ELEMENT.get(editor)!;
      let hasDomSelectionInEditor = false;
      if (
        editorElement.contains(anchorNode) &&
        editorElement.contains(focusNode)
      ) {
        hasDomSelectionInEditor = true;
      }

      // If the DOM selection is in the editor and the editor selection is already correct, we're done.
      if (hasDomSelection && hasDomSelectionInEditor && editor.selection) {
        const slateRange = DOMEditor.toSlateRange(editor, domSelection, {
          exactMatch: true,

          // domSelection is not necessarily a valid Slate range
          // (e.g. when clicking on contenteditable:false element)
          suppressThrow: true,
        });

        if (slateRange && Range.equals(slateRange, editor.selection)) {
          if (!hasMarkPlaceholder.value) {
            return;
          }

          // Ensure selection is inside the mark placeholder
          if (
            anchorNode?.parentElement?.hasAttribute(
              "data-slate-mark-placeholder"
            )
          ) {
            return;
          }
        }
      }

      // when <Editable/> is being controlled through external value
      // then its children might just change - DOM responds to it on its own
      // but Slate's value is not being updated through any operation
      // and thus it doesn't transform selection on its own
      if (editor.selection && !DOMEditor.hasRange(editor, editor.selection)) {
        editor.selection = DOMEditor.toSlateRange(editor, domSelection, {
          exactMatch: false,
          suppressThrow: true,
        });
        return;
      }

      let newDomRange: globalThis.Range | null = null;

      try {
        newDomRange =
          editor.selection && DOMEditor.toDOMRange(editor, editor.selection);
      } catch (e) {
        // Ignore, dom and state might be out of sync
      }

      if (newDomRange) {
        if (DOMEditor.isComposing(editor) && !IS_ANDROID) {
          domSelection.collapseToEnd();
        } else if (Range.isBackward(editor.selection!)) {
          domSelection.setBaseAndExtent(
            newDomRange.endContainer,
            newDomRange.endOffset,
            newDomRange.startContainer,
            newDomRange.startOffset
          );
        } else {
          domSelection.setBaseAndExtent(
            newDomRange.startContainer,
            newDomRange.startOffset,
            newDomRange.endContainer,
            newDomRange.endOffset
          );
        }
        scrollSelectionIntoView(editor, newDomRange);
      } else {
        domSelection.removeAllRanges();
      }

      return newDomRange;
    };

5.3 安卓英文输入兼容

对于 proseMirror 作者专门为此写了一篇文章

discuss.prosemirror.net/t/contented...

与其他浏览器不同,在安卓浏览器中,beforeinput 事件行为怪异,表现如下:

  1. keyCode 无法从事件的 inputType 获取,keyCode 一直是 229
  2. 事件无法通过 preventDefault 拦截
  3. 事件获取到的游标位置是错的
  4. 合成文本行为不确定,event.data 会粘连上一次输入的文本
  5. 原子节点删除时会收起键盘
  6. 不同机型、同一机型不同输入法的行为都不一致

对于 slate 这种数据模型驱动的富文本来说,这种怪异行为是致命的

slate-react 团队和 slate-angular 团队提供了一个大致的解决思路

原文链接:zhuanlan.zhihu.com/p/635801047

  1. 安卓环境下代理原有的beforeinput 逻辑
  2. 通过 MutationObserver 监控 beforeinput 到 DOM 更新之间的节点信息变化
  3. 根据当前输入事件,判断需要进行的下一步操作,并推入调用栈中
  4. 节点变更说明用户已经进行了输入,回退 DOM 节点到输入前的状态,并立即执行调用栈的方法
  5. 数据模型驱动 DOM 更新后,编辑器重新稳定,等待用户下一次输入 github.com/Guan-Erjia/...
typescript 复制代码
onMounted(() => {
    mutationObserver.value = new MutationObserver((mutations) => {
      mutationObserver.value?.disconnect();
      mutationObserver.value?.takeRecords();
      mutations.reverse().forEach((mutation) => {
        if (mutation.type === "characterData") {
          // We don't want to restore the DOM for characterData mutations
          // because this interrupts the composition.
          return;
        }
        mutation.removedNodes.forEach((node) => {
          mutation.target.insertBefore(node, mutation.nextSibling);
        });
        mutation.addedNodes.forEach((node) => {
          mutation.target.removeChild(node);
        });
      });

      schedule.value?.();
      schedule.value = undefined;
    });
});
const handleDOMBeforeInput = (event: InputEvent) => {
    mutationObserver.value?.observe(
      editableRef.value!,
      MUTATION_OBSERVER_CONFIG
    );

    const { inputType: type } = event;
    let targetRange: Range | null = null;
    const data: DataTransfer | string | undefined =
      (event as any).dataTransfer || event.data || undefined;
    .......
    switch(inputType):
        ....... //根据当前状态缓存不同的方法,在 mutationObserver 监听到 dom 更新后执行
        case "deleteEntireSoftLine": {
            return scheduleAction(() => {
              Editor.deleteBackward(editor, { unit: "line" });
              Editor.deleteForward(editor, { unit: "line" });
            }, targetRange);
              }

整个 DOM 回退的过程是在 beforeinput 完成,更新数据模型之前,中间的这段时间进行的,不会影响用户输入外的正常渲染,因为非用户输入行为不会触发 beforeinput,按非安卓的正常流程进行即可

组合输入事件时的文本 diff

在输入 read 的时候,会发生下面这种情况,第二次输入 'e' 的时候,会把第一次的 'r' 带上,选择 'read' 单词的时候,行为并不是补充后面的 'ad ',而是整个 'read'

当前路径时已知的,输入前的节点信息可以通过 getNodeIf 的方式获取得到,见 4.4

整个问题变得简单了:

  1. 通过节点信息的计算出哪些内容是节点已经渲染出来的,移除掉这部分
  2. 在当前光标节点插入剩下的文本,即可实现效果

6. 使用 slate 的注意事项

至此,所有 slate 的基本功能实现介绍完毕,但是在实际项目中使用中,还是需要注意以下问题

6.1 谨慎评估使用难度

虽然官方给出的案例看起来都比较简单,但是实际在项目中使用起来完全不一样,并且在技术选型阶段很难察觉,比如官方给出的 markdown 案例

typescript 复制代码
    const handleDOMBeforeInput = useCallback(
        (e: InputEvent) => {
          queueMicrotask(() => {
            const pendingDiffs = ReactEditor.androidPendingDiffs(editor)
            ......
            ......
          })
        },
        [editor]
      )
       return (
        <Slate editor={editor} initialValue={initialValue}>
          <Editable onDOMBeforeInput={handleDOMBeforeInput} renderElement={renderElement} />
        </Slate>
      )
    }

不翻源码基本不可能知道,重写 beforeInput 方法的原因是为了兼容安卓移动端

如果在项目中考虑移动端的话,则必须考虑 5.3 中提到的各种兼容问题,显然不是几天时间可以解决的

关于操作节点、路径、光标的接口非常多,但是都偏于底层且粒度比较细很多场景下需要混合使用接口

了解这些这些对于构建一个稳定的编辑器是非常必要的,这需要花大量时间

6.2 富文本协议和序列化

如果考虑序列化场景,问题将会变得更复杂,尽管已经由成熟的方案 unified.js 去解析和序列化,但是了解其中的概念依然是一件麻烦事,需要查找一系列插件

6.2.1 序列化案例

以下为一个最基本的 GFM markdown 语法解析和序列化代码

typescript 复制代码
import { remarkToSlate, slateToRemark } from "remark-slate-transformer";
import { unified } from "unified";
import remarkStringify from "remark-stringify";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";

const resolveMarkdown = () => {
    const processor = unified().use(remarkParse).use(remarkGfm).use(remarkToSlate).use(remarkListItem);
    const slateDescendant = processor.processSync(text).result;
    return slateDescendant
}

const stringify = (descendant) => {
    const result = slateToRemark(descendant);
    const remarkString = unified().use(remarkGfm).use(remarkStringify).stringify(result);
    return remarkString
}

除非必要,尽量避免用户操作期间,进行序列化或解析的流程,这不仅会导致性能开销,在编译重新解析后的结果,也不能保证百分百相同

尽量直接保存 children 的数结构信息,避开这种序列化的问题

6.2.2 定制化解析

如果需要添加定制语法,则需要编写 unfied.js 插件,自行解析现有的 ast,短期开发的插件可靠性是有待考量的

以最简单的将列表节点直接暴露首个子节点为例:

typescript 复制代码
import { visit } from "unist-util-visit";
const remarkListItem = () => (tree: Node) => {
  visit(tree, "listItem", function (node: any) {
    node.children = node.children[0].children;
  });
};

6.2.3 Descendant 结构的前期设计

在前期设计的时候,必须有一个成熟并且可拓展的富文本协议

不同于直接修改 html 字符串那么简单,slate 是基于数结构描述富文本内容的,所以当开发过一段时间之后,发现前期设计有问题,并且要修改已有的树结构数据,这将会是个惊天噩耗

6.3 版本尚未稳定

虽然迭代了 4 年多,但是没有一个稳定的 1.0 版本,后续 api 可能还会变化

应该不用过度担心,目前的产品已经相当多了,包括目前用的知识库在线文档,wangEditor5github books......

docs.slatejs.org/general/res...

结尾

以上就是我对 slate 框架实现和使用的一个基本总结,后续我还会继续分享 slate 相关内容,更多集中在正在开发的 slate-vue3,slate 现有的单元测试和集成测试已全部通过,希望大佬们能多提提优化意见
github.com/Guan-Erjia/...

相关推荐
齐尹秦8 分钟前
CSS 轮廓(Outline)属性学习笔记
前端
齐尹秦10 分钟前
CSS 字体学习笔记
前端
入门级前端开发12 分钟前
css实现一键换肤
前端·css
xixixin_23 分钟前
css一些注意事项
前端·css
坊钰1 小时前
【MySQL 数据库】增删查改操作CRUD(下)
java·前端·数据库·学习·mysql·html
excel1 小时前
webpack 模块 第 六 节
前端
Watermelo6171 小时前
Vue3+Vite前端项目部署后部分图片资源无法获取、动态路径图片资源报404错误的原因及解决方案
前端·vue.js·数据挖掘·前端框架·vue·运维开发·持续部署
好_快1 小时前
Lodash源码阅读-flattenDepth
前端·javascript·源码阅读
好_快1 小时前
Lodash源码阅读-baseWhile
前端·javascript·源码阅读
好_快1 小时前
Lodash源码阅读-flatten
前端·javascript·源码阅读