Slate源码解析(一)

3,865 阅读20分钟

背景

首先明确一个点,我们的 wangEditor 编辑器 5.0 版本已确定使用 Slate 作为核心依赖开发,但是为了不受框架限制,我们不直接使用 Slate 自己封装的 “View” 也就是 Slate React,而是基于 Slate Core 去开发我们自己的编辑器,也就是我们自己去实现 View 层。这部分内容在我们老大之前的文章中:基于 slate.js(不依赖 React)设计富文本编辑器 已经做了一些介绍。 ​

既然要基于 Slate 进行深度定制开发,那么就要熟悉 Slate 的 API 和设计,甚至阅读源码都是必不可少的。首先建议,如果对 Slate 不是很了解的小伙伴,先去阅读一到两遍 Slate官方文档,之前我也写过从 Slate 宏观的角度设计分析的文章,大家可以结合一起来看:从 MVC 架构的角度看 Slate。然后如果感兴趣,可以把官网的 example 都去实现一遍。熟悉了 API,了解了其宏观设计,也写了一定的 demo,那么就可以开始从源码角度进行更深度的学习。插句题外话,其实学习其他的框架或者库,也可以参照这个学习思路。先知道怎么用,然后了解宏观设计,写 demo 甚至在项目中实践起来,最后想要更加深入了解,再去阅读源码。

源码分析内容

  • slate core 源码概览
  • 细聊 slate-react
  • 略读 slate-history 和 slate-hyperscript,在其它模块会略带过这部分内容
  • 零散地介绍其它一些 slate 设计上可以给我们启发的点
  • 几个官网的 examples 实现解读

因为考虑到篇幅问题,源码解析内容分为两篇文章,第一篇也就是本文主要介绍 Slate 的核心包slate,上面列的其它内容在第二篇文章中详细介绍

首先要知道 package 目录下的 slate 包和 slate-react 是我们重点要关注的包。slate 包作为核心不用说了,而 slate-react 包作为基于 slate-core 核心包封装的 “View”(在第二篇文章中会分析 slate-react 的源码),实际上我们要基于 slate 开发我们自己的编辑器,也同样要实现这一层。而且还是比较核心的部分,也就是我们老大之前文章中的整体设计图:

image.png

上图来自我们老大的文章,这张图的中的 core 实际上相当于 slate-react 这个包的实现。所以如果你读懂了 slate-react 的源码,也基本知道我们的 core 需要做那些设计,包含哪些内容。 ​

源码分析内容主要围绕 slate 和 slate-react 这两个包重点分析,其它部分基本是略讲。如果要更好地理解本文的内容,你需要做以下知识储备:

  • 至少读过一遍 slate 的文档,对 slate api 有基本了解;
  • 写过一些官网提供的例子;

附加:

最后强调一点,考虑到篇幅问题,很多源码没有太多深入到具体的 API 实现,我只是挑了一部分重要的设计源码进行分析,有一些我觉得重要的点会深入讲解。另外,阅读过程中的源码和设计上的理解都是个人的理解,也不一定完全对,如果大家有一些更好的理解,可以及时提出,欢迎评论区和我一起讨论。

让我们进入主题吧! ​

slate core概览

首先要明确的是 slate 核心包主要有两部分内容:Model 、Controller ,这一点我也之前的文章中介绍过,我们可以把整个 Slate 设计看成是一个 MVC 架构,而 core 包含了Model、Controller 两层内容,slate-react 即为 View 层,用一张图表示如下: ​

slate目录架构.png 我们首先来看 Model 层。 ​

Model

Model 层主要分为两部分内容,一部分是 Slate Document 的抽象,一部分是 Location 的抽象。

Slate Document

先说 Slate Document,Slate Document 可以类比 DOM 树里面的 Document,它的设计是一个树的结构,为了更加清晰地了解,我们看一张图: ​

Slate Document.png 在文档树中,最高层的是 Editor Node,虽然 Slate 在设计上,Element 和 Text 也都算是 Node,但是在编辑器的结构中,我们知道 Editor 、Element、Text 不是同一层级,Element 和 Text 在其之下。 ​

我们看下每个层级的接口和类型设计吧:

// Node
type BaseNode = Editor | Element | Text
type Node = ExtendedType<'Node', BaseNode>
type Descendant = Element | Text

// Editor
export interface BaseEditor {
  children: Descendant[]
  ...
}
type Editor = ExtendedType<'Editor', BaseEditor>

  
// Element
interface BaseElement {
  children: Descendant[]
}
type Element = ExtendedType<'Element', BaseElement>
  
// Text 
interface BaseText {
  text: string
}
type Text = ExtendedType<'Text', BaseText>

看完接口设计,我们就很清晰知道了每个层级的数据类型、包含哪些重要的属性。 ​

所以当我们看这部分源码的时候,可以从 Text 开始,一步步往上看,直到最高层级的 EditorNode,这样由低到高,从简单到复杂,就比较容易理解。

这里提一下,在每个类型的定义时,总是先定义一个 BaseXXX,然后再通过 ExtendedType 去扩展,为什么要这样做了?首先我们看一下 ExtendedType 是干嘛的:

interface CustomTypes {
  [key: string]: unknown
}

type ExtendedType<K extends string, B> = unknown extends CustomTypes[K]
  ? B
  : B & CustomTypes[K]

其实就是为了能方便通过覆盖 CustomTypes 类型定义扩展类型的属性,我们知道,在 Typescript 中,我们一旦给一个数据声明了特定的类型,那么这数据就只能添加该类型上的数据,例如:

interface User {
	id: string;
  name: string;
}

const user: User = {
	id: '1',
  name: 'kitty'
}

user.age = 16 // Error 类型“User”上不存在属性“age”

当然也有其它的方式解决这个问题,但是我觉得 Slate 这种方式更加优雅,在我们以后的编辑器开发中,也可以参考这种方式。下面看一个例子:

// 全局声明类型文件中添加 

declare module 'slate' {
  interface CustomTypes {
    Text: {
      placeholder: string
    }
  }
}

// 代码中使用
const text: Text = {
	text: 'text',
  placeholder: 'placeholder' // ts 编译通过
} 

这种方式就可以交给用户灵活去扩展类型的属性。

Location

接下来就是 Location 部分的抽象,Slate 中的 Location 其实就是对原生的 Selection 更加定制化、细腻度的一些抽象,使得我们在使用 Slate 的 API 的时候能更加方便地通过 Range 去对编辑器内容进行修改。 ​

首先来看它包括哪些类型:

  • Path,Location 最低层次的抽象;
  • Point,比 Path 更加具体的一种抽象;
  • Range,两个点之间范围的内容抽象;
  • Selection,是一种特殊的 Range 的抽象。

光说有点抽象,我们还是看具体的类型定义吧:

// Path 
type Path = number[]

// Point
interface BasePoint {
  path: Path
  offset: number
}
type Point = ExtendedType<'Point', BasePoint>

// Range
interface BaseRange {
  anchor: Point
  focus: Point
}
type Range = ExtendedType<'Range', BaseRange>

// Selection
type BaseSelection = Range | null
type Selection = ExtendedType<'Selection', BaseSelection>

// Location
type Location = Path | Point | Range

使用这些数据类型,我们就可以在 Slate 编辑器中定位内容的具体位置,这就是 Location 的由来,有了位置,我们就能调用 API 进行插入、更新、删除操作。 ​

下面看几个例子,具体是怎么定位内容的,我们假设编辑器里面的内容如下:

const editor = {
  children: [
    {
      type: 'paragraph',
      children: [
        {
          text: 'A line of text!',
        },
      ],
    },
  ],
}

在上面的编辑器内容中,我们通过 [0, 0] 这个 Path 数据就能定位到 text 叶子节点的位置,其实 Path 定位的是某个具体的节点的位置。 ​

类似,我们可以通过:

const start = {
  path: [0, 0],
  offset: 0,
}

定位到上面内容的叶子节点的具体到某一个 “word” 的位置,这就是 “Point” 的由来,因为它是对编辑内容的具体某个点的抽象。 ​

而使用:

const range = {
	anchor: {
  	path: [0, 0],
  	offset: 0,
	},
  focus: {
    path: [0, 0],
  	offset: 15,
  }
}

这个 Range 代表的就是叶子节点从开始到结束的一个范围,也就是一个点到另一个点的区域,而 Range 中的 anchorfocus 属性实际上对应的就是原生 Selection 中的 anchorNodefocusNode ,它们还代表着选区的方向,从左到右还是从右到左。 ​

通过这些数据类型,我们就可以轻松地定位到编辑器中的任意一个 Node、一个点或者一个范围的位置,然后对内容进行操作。 ​

小结

实际上看完 Model 层的类型定义,你基本对数据层的设计有了比较清楚的认识,这相对来说还是很容易看懂的。有了数据,怎么去用,下面就是要看 Controller 的实现了。 ​

Controller

Slate的数据结构是 immutable 的,因此我们不能直接修改或删除节点。因此,Slate提供了一组“Transform”函数,可以让你修改编辑器的内容。 ​

提到不可变(immutable),我们很容易想到 React ,因为 React 框架的状态处理就是要保证数据的不可变性,每次修改必须产生新的 state。所以,这时候我们也能想明白为什么 Slate 官方提供了 slate-react,因为 Slate 设计天生与 React 契合。**当然,不可变数据的设计也是为了能支持协同编辑,每次操作产生的数据必须是新的,这样才能存储数据,去做协同编辑的 diff **。当然,对于协同编辑本身,我也没做太多研究,就不展开了,让我们继续回到主题当中。 ​

在讲 Transform 之前,我们首先要看下 Operation。 ​

Operations

Operations 是在调用 Transform 函数时发生的细粒度的、低层次的操作。单个 Transform 可能导致对编辑器应用许多 Operation 操作。Operations 的定义在 slate package 下的 interfaces 中的 operation.ts文件中,一共定义了 9 个 Operations:

type BaseInsertNodeOperation = {
  type: 'insert_node'
  path: Path
  node: Node
}

type BaseRemoveNodeOperation = {
  type: 'remove_node'
  path: Path
  node: Node
}

type BaseInsertTextOperation = {
  type: 'insert_text'
  path: Path
  offset: number
  text: string
}

type BaseRemoveTextOperation = {
  type: 'remove_text'
  path: Path
  offset: number
  text: string
}

type BaseMergeNodeOperation = {
  type: 'merge_node'
  path: Path
  position: number
  properties: Partial<Node>
}

type BaseSplitNodeOperation = {
  type: 'split_node'
  path: Path
  position: number
  properties: Partial<Node>
}

type BaseMoveNodeOperation = {
  type: 'move_node'
  path: Path
  newPath: Path
}

type BaseSetNodeOperation = {
  type: 'set_node'
  path: Path
  properties: Partial<Node>
  newProperties: Partial<Node>
}

type BaseSetSelectionOperation =
  | {
      type: 'set_selection'
      properties: null
      newProperties: Range
    }
  | {
      type: 'set_selection'
      properties: Partial<Range>
      newProperties: Partial<Range>
    }
  | {
      type: 'set_selection'
      properties: Range
      newProperties: null
    }

注意前6个操作,我特地把它们放在一起,方便对比,实际上它们是两两对应的:

insert_node <-> remove_node
insert_text <-> remove_text
merge_node <-> split_node

这样设计的另一个好处就是方便做 History 功能,实际上在 operation.ts 文件中就封装了 inverse 函数,用来做 undo 功能:

/**
   * Invert an operation, returning a new operation that will exactly undo the
   * original when applied.
   */

  inverse(op: Operation): Operation {
    switch (op.type) {
      case 'insert_node': {
        return { ...op, type: 'remove_node' }
      }

      case 'insert_text': {
        return { ...op, type: 'remove_text' }
      }

      case 'merge_node': {
        return { ...op, type: 'split_node', path: Path.previous(op.path) }
      }

      // 省略部分代码...
      
      case 'remove_node': {
        return { ...op, type: 'insert_node' }
      }

      case 'remove_text': {
        return { ...op, type: 'insert_text' }
      }

      case 'set_node': {
        const { properties, newProperties } = op
        return { ...op, properties: newProperties, newProperties: properties }
      }

    	// 省略部分代码...
     
      case 'split_node': {
        return { ...op, type: 'merge_node', path: Path.next(op.path) }
      }
    }
  }

为了更加方便大家看,这里我省略了部分代码,这部分逻辑还是非常容易理解。 ​

这里要提下的是其实 Operation 中有8个操作是用来对编辑器内容就行修改,只有 set_selection 是作用在 Range (这里指Slate 中定义的 Range,非原生的 Range)上的,实际上就是用来对我们理解上的“选区”操作。

有了最低层次的 Operation,我们就可以使用这些 Operation 对编辑器内容进行任何修改,虽然我们可以用来做任何我们想要的操作。但是有一个问题,就是低层次的 API 调用起来相对来说费劲,每个操作基本上需要明确地传 Path 参数,相当于每次使用 API 我们就需要自己操心 Range。所以,我们需要 Transform 这样的函数。 ​

Transforms

Transform 实际上低层封装了 Operation 操作,并且内部消化了对 Range 的管理,我们在调用 Tranform 修改编辑器内容的时候,一般的操作,只需要关注修改操作,而不需要关注选区。除非是一些复杂的场景,我们也可以自己管理选区。 ​

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

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

首先我们来看下 **GeneralTransforms **的定义:

interface GeneralTransforms {
  transform: (editor: Editor, op: Operation) => void
}

const GeneralTransforms: GeneralTransforms = {
	 /**
   * Transform the editor by an operation.
   */

  transform(editor: Editor, op: Operation): void {
    // 使用 immer api 创建一个副本
    editor.children = createDraft(editor.children)
    // 直接通过编辑器实例获取到选区
    let selection = editor.selection && createDraft(editor.selection)
    
    switch (op.type) {
      case 'insert_node': {
        const { path, node } = op
        const parent = Node.parent(editor, path)
        const index = path[path.length - 1]
        // 修改选区 Parent Node 节点内容,把新的 Node 插入到 Path 位置
        parent.children.splice(index, 0, node)

        // 更新选区
        if (selection) {
          for (const [point, key] of Range.points(selection)) {
            selection[key] = Point.transform(point, op)!
          }
        }

        break
      }
      
       case 'insert_text': {
        const { path, offset, text } = op
        const node = Node.leaf(editor, path)
        // 通过 offset 把 text node 做拆分,然后把新的 text 拼接到传入的位置
        const before = node.text.slice(0, offset)
        const after = node.text.slice(offset)
        node.text = before + text + after

        // 更新选区 
        if (selection) {
          for (const [point, key] of Range.points(selection)) {
            selection[key] = Point.transform(point, op)!
          }
        }

        break
      }
      // ... 省略部分代码  
    }   
	} 
}

前面我就提到过,Transform 的封装一定是内部消化了对编辑器选区的处理,这里的封装也正好证明了这一点。 ​

这里的 insert_node、insert_text 比较简单,代码很容易看懂,这里不就过多描述了。其它剩余的几个 Operation, 大家可以找到 packages/late/transforms/general.ts 细看。 ​

我们继续看其它的 Transform 的封装,为了让车速慢点,先从最简单的 TextTransforms 开始吧。 ​

先看其类型定义:

interface TextTransforms {
  delete: (
    editor: Editor,
    options?: {
      at?: Location
      distance?: number
      unit?: 'character' | 'word' | 'line' | 'block'
      reverse?: boolean
      hanging?: boolean
      voids?: boolean
    }
  ) => void
  insertFragment: (
    editor: Editor,
    fragment: Node[],
    options?: {
      at?: Location
      hanging?: boolean
      voids?: boolean
    }
  ) => void
  insertText: (
    editor: Editor,
    text: string,
    options?: {
      at?: Location
      voids?: boolean
    }
  ) => void
}

TextTransforms 目前只有三个 API,看起来还是相对容易的,看下具体实现吧:

export const TextTransforms: TextTransforms = {
  // ...这里省略了很多代码
  
  /**
   * Insert a string of text in the Editor.
   */

  insertText(
    editor: Editor,
    text: string,
    options: {
      at?: Location
      voids?: boolean
    } = {}
  ): void {
    Editor.withoutNormalizing(editor, () => {
      const { voids = false } = options
      let { at = editor.selection } = options

      if (!at) {
        return
      }

      if (Path.isPath(at)) {
        at = Editor.range(editor, at)
      }

      if (Range.isRange(at)) {
        if (Range.isCollapsed(at)) {
          at = at.anchor
        } else {
          const end = Range.end(at)

          if (!voids && Editor.void(editor, { at: end })) {
            return
          }

          const pointRef = Editor.pointRef(editor, end)
          Transforms.delete(editor, { at, voids })
          at = pointRef.unref()!
          Transforms.setSelection(editor, { anchor: at, focus: at })
        }
      }

      if (!voids && Editor.void(editor, { at })) {
        return
      }

      const { path, offset } = at
      editor.apply({ type: 'insert_text', path, offset, text })
    })
  },
}

我们挑一个稍微简单一点的 insertText 看吧。 ​

首先整个函数的逻辑都是被包在一个 Editor.withoutNormalizing 函数中,那我们首先要了解下这个函数是干嘛用的。它定义在 slate/src/interfaces/editor.ts 下: ​

/**
   * Call a function, deferring normalization until after it completes.
   * 调用一个函数,推迟 editor 标准化,直到这个函数完成
*/

 withoutNormalizing(editor: Editor, fn: () => void): void {
    const value = Editor.isNormalizing(editor)
    NORMALIZING.set(editor, false)
    fn()
    NORMALIZING.set(editor, value)
    Editor.normalize(editor)
  },

从函数的注释我们基本知道了,就是在调用 editor.normalize 函数标准化之前的前置操作。关键还是要了解下 normalize 函数做了什么:

  /**
   * Normalize any dirty objects in the editor.
   * 标准化 editor 中任何”脏“对象,实际这些对象就是 Path
   */

  normalize(
    editor: Editor,
    options: {
      force?: boolean
    } = {}
  ): void {
    const { force = false } = options
    const getDirtyPaths = (editor: Editor) => {
      return DIRTY_PATHS.get(editor) || []
    }

    if (!Editor.isNormalizing(editor)) {
      return
    }

    if (force) {
      const allPaths = Array.from(Node.nodes(editor), ([, p]) => p)
      DIRTY_PATHS.set(editor, allPaths)
    }

    if (getDirtyPaths(editor).length === 0) {
      return
    }

		// 套娃,递归引用
    Editor.withoutNormalizing(editor, () => {
      // 设置最大递归树,超过就直接抛错
      const max = getDirtyPaths(editor).length * 42 // HACK: better way?
      let m = 0

      循环对 DIRTY_PATHS 里面的对象进行 normalize
      while (getDirtyPaths(editor).length !== 0) {
        if (m > max) {
          throw new Error(`
            Could not completely normalize the editor after ${max} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.
          `)
        }

        const dirtyPath = getDirtyPaths(editor).pop()!

        // If the node doesn't exist in the tree, it does not need to be normalized.
        if (Node.has(editor, dirtyPath)) {
          // 通过 Path 找到 Node
          const entry = Editor.node(editor, dirtyPath)
          // 最后对 NodeEntry 进行标准化
          editor.normalizeNode(entry)
        }
        m++
      }
    })
  },

看注释的意思就是 Editor 里面会有一个 WeakMap 存储一些不标准的节点对象,然后看时机对这些不标准的对象进行标准化。一般标准化的时机就是对编辑器 Node 进行修改操作后,比如这里就是插入新的 Text 节点后。

这里最终调用的是 normalizeNode,我们看下这个函数的实现,它定义在 slate/src/create-editor.ts 中:

normalizeNode: (entry: NodeEntry) => {
      const [node, path] = entry

      // There are no core normalizations for text nodes.
      if (Text.isText(node)) {
        return
      }

      // Ensure that block and inline nodes have at least one text child.
  		// 确保 Element 至少有一个 child 
      if (Element.isElement(node) && node.children.length === 0) {
        const child = { text: '' }
        Transforms.insertNodes(editor, child, {
          at: path.concat(0),
          voids: true,
        })
        return
      }

      // Determine whether the node should have block or inline children.
      const shouldHaveInlines = Editor.isEditor(node)
        ? false
        : Element.isElement(node) &&
          (editor.isInline(node) ||
            node.children.length === 0 ||
            Text.isText(node.children[0]) ||
            editor.isInline(node.children[0]))

      // Since we'll be applying operations while iterating, keep track of an
      // index that accounts for any added/removed nodes.
      let n = 0

      for (let i = 0; i < node.children.length; i++, n++) {
        const child = node.children[i] as Descendant
        const prev = node.children[i - 1] as Descendant
        const isLast = i === node.children.length - 1
        const isInlineOrText =
          Text.isText(child) ||
          (Element.isElement(child) && editor.isInline(child))

        // Only allow block nodes in the top-level children and parent blocks
        // that only contain block nodes. Similarly, only allow inline nodes in
        // other inline nodes, or parent blocks that only contain inlines and
        // text.
        // 确保 Element Node 是顶级元素(可以理解为 Editor Node)的 children,同理,确保
        // Element Node children 只包含 inline 节点或者 text 节点
        if (isInlineOrText !== shouldHaveInlines) {
          Transforms.removeNodes(editor, { at: path.concat(n), voids: true })
          n--
        } else if (Element.isElement(child)) {
          // Ensure that inline nodes are surrounded by text nodes.
          // 确保 inline 节点被 text 节点包裹
          if (editor.isInline(child)) {
            if (prev == null || !Text.isText(prev)) {
              const newChild = { text: '' }
              Transforms.insertNodes(editor, newChild, {
                at: path.concat(n),
                voids: true,
              })
              n++
            } else if (isLast) {
              const newChild = { text: '' }
              Transforms.insertNodes(editor, newChild, {
                at: path.concat(n + 1),
                voids: true,
              })
              n++
            }
          }
        } else {
          // Merge adjacent text nodes that are empty or match.
          // 如果 text 节点旁边有空的 text 节点或者它们是匹配的,合并或者移除节点
          if (prev != null && Text.isText(prev)) {
            if (Text.equals(child, prev, { loose: true })) {
              Transforms.mergeNodes(editor, { at: path.concat(n), voids: true })
              n--
            } else if (prev.text === '') {
              Transforms.removeNodes(editor, {
                at: path.concat(n - 1),
                voids: true,
              })
              n--
            } else if (isLast && child.text === '') {
              Transforms.removeNodes(editor, {
                at: path.concat(n),
                voids: true,
              })
              n--
            }
          }
        }
      }
    }

那么有哪些不规范的场景需要标准化了?我在注释中基本列出来了,这里总结下:

  • 确保 Element Node 至少有一个 child ,哪怕 child text 是空的;
  • 确保 Element Node 是顶级元素(可以理解为 Editor Node)的 children,同理,确保 Element Node children 只包含 inline 节点或者 text 节点;
  • 确保 inline 节点被 text 节点包裹;
  • 如果 text 节点旁边有空的 text 节点或者它们是匹配的,合并匹配的或者移除空的 text 节点。

上面的代码基本就处理了这些场景,使得 Slate Document 更加标准化,避免一些冗余的节点产生或者缺少必要的节点。 ​

捏一把汗,终于大概看懂了 normalize 整个过程,因为这个函数在源码中还比较常用,所以这里还是详细介绍下。 ​

我们继续回到 insertText 函数中:

const { voids = false } = options
let { at = editor.selection } = options

// 如果 Location 为空,直接返回
if (!at) {
  return
}

// 如果是 Path,则需要先找到对应的Range
if (Path.isPath(at)) {
  at = Editor.range(editor, at)
}

if (Range.isRange(at)) {
  // 判断选区是否折叠,如果是折叠状态,anchor 等于 focus,所以取一个就行
  if (Range.isCollapsed(at)) {
    at = at.anchor
  } else {
    // 非折叠状态
    const end = Range.end(at)

    // 如果是选区是 void 元素,并且传入的 voids 参数为 false,则直接返回
    if (!voids && Editor.void(editor, { at: end })) {
      return
    }

    const pointRef = Editor.pointRef(editor, end)
    // 删除选中的部分
    Transforms.delete(editor, { at, voids })
    at = pointRef.unref()!
    // 更新选区
    Transforms.setSelection(editor, { anchor: at, focus: at })
  }
}

if (!voids && Editor.void(editor, { at })) {
  return
}

const { path, offset } = at
// 最后将新的 text 文本插入到对应的位置
editor.apply({ type: 'insert_text', path, offset, text })

insertText 函数相对来说比较容易阅读,其实最关键的部分,要找到对的 Range,也就是位置。对于其它的删除操作原理是差不多的,就是对位置参数的处理需要考虑更多情况,限于篇幅,这里就不详细介绍 delete 实现逻辑了,大家可以自己去看。 ​

还要提的一个点,Tranforms 第二个参数,option 参数中的 at 属性,代表的就是选区。也就说,我们就算用了高层次的 Transform 函数,其实你还是可以根据需求去自己控制选区,如果不传,那么默认取的就是编辑器当前的选区。

继续看 **NodeTransforms,**首先看下其类型定义:

interface NodeTransforms {
  /**
   * Insert nodes at a specific location in the Editor.
   * 插入节点到编辑器具体的位置
   */
  insertNodes: (
    editor: Editor,
    nodes: Node | Node[],
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'highest' | 'lowest'
      hanging?: boolean
      select?: boolean
      voids?: boolean
    }
  ) => void
  /**
   * Lift nodes at a specific location upwards in the document tree, splitting
   * their parent in two if necessary.
   * 向上提升某个节点到具体的问的文档树位置,如果有必要,拆分他们的父节点为两个
   */
  liftNodes: (
    editor: Editor,
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'all' | 'highest' | 'lowest'
      voids?: boolean
    }
  ) => void
  /**
   * Merge a node at a location with the previous node of the same depth,
   * removing any empty containing nodes after the merge if necessary.
   * 合并两个层级深度一样的节点,如果有必须要,合并后需要移除合并节点后包含的空的节点
   */
  mergeNodes: (
    editor: Editor,
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'highest' | 'lowest'
      hanging?: boolean
      voids?: boolean
    }
  ) => void
   /**
   * Move the nodes at a location to a new location.
   * 移动一个节点到新的位置
   */
  moveNodes: (
    editor: Editor,
    options: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'all' | 'highest' | 'lowest'
      to: Path
      voids?: boolean
    }
  ) => void
   /**
   * Remove the nodes at a specific location in the document.
   * 从某个具体的位置移除节点
   */
  removeNodes: (
    editor: Editor,
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'highest' | 'lowest'
      hanging?: boolean
      voids?: boolean
    }
  ) => void
  /**
   * Set new properties on the nodes at a location.
   * 给某个位置的节点设置新的属性
   */
  setNodes: (
    editor: Editor,
    props: Partial<Node>,
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'all' | 'highest' | 'lowest'
      hanging?: boolean
      split?: boolean
      voids?: boolean
    }
  ) => void
   /**
   * Split the nodes at a specific location.
   * 拆分节点
   */
  splitNodes: (
    editor: Editor,
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'highest' | 'lowest'
      always?: boolean
      height?: number
      voids?: boolean
    }
  ) => void
  /**
   * Unset properties on the nodes at a location.
   * 移除某个节点的属性
   */
  unsetNodes: (
    editor: Editor,
    props: string | string[],
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'all' | 'highest' | 'lowest'
      split?: boolean
      voids?: boolean
    }
  ) => void
   /**
   * Unwrap the nodes at a location from a parent node, splitting the parent if
   * necessary to ensure that only the content in the range is unwrapped.
   * 从父节点分离出某个节点,如果有必要需要拆分父节点,以确保拆分范围内的内容
   */
  unwrapNodes: (
    editor: Editor,
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'all' | 'highest' | 'lowest'
      split?: boolean
      voids?: boolean
    }
  ) => void
   /**
   * Wrap the nodes at a location in a new container node, splitting the edges
   * of the range first to ensure that only the content in the range is wrapped.
   * 使用新的节点包裹具体的某个节点,首先拆分选区范围的边缘,以确保只有范围内的内容被包装
   */
  wrapNodes: (
    editor: Editor,
    element: Element,
    options?: {
      at?: Location
      match?: (node: Node) => boolean
      mode?: 'all' | 'highest' | 'lowest'
      split?: boolean
      voids?: boolean
    }
  ) => void
}

这 10 个方法几乎涵盖了对所有节点的操作,注意这里的节点包含了 Editor、Element 和 Text 等 Slate Document 里面所有的节点类型。限于篇幅,我们不看每个方法的具体实现,大家可以自己找时间慢慢品,这里简单做个总结:

  • 与前面介绍的 TextTransforms 类似,每个方法第二个 options 参数都有 at 属性,用来指定想要操作的节点的位置,如果不传,默认编辑器当前选区的位置;
  • 每个 Transforms 工具函数,都会使用 editor.withoutNormalizing 进行包装,处理完逻辑后,进行编辑器文档树节点就行标准化;
  • 每个 Transforms 工具函数,最终实际上执行的是一个或者多个 Operations 操作,所以实际上 Transforms工具是一种比 Operation 更加高层的操作封装。

最后我们再稍微过一下** SelectionTransforms。**先看选区操作有哪些方法:

SelectionTransforms {
  collapse: (
    editor: Editor,
    options?: {
      edge?: 'anchor' | 'focus' | 'start' | 'end'
    }
  ) => void
  deselect: (editor: Editor) => void
  move: (
    editor: Editor,
    options?: {
      distance?: number
      unit?: 'offset' | 'character' | 'word' | 'line'
      reverse?: boolean
      edge?: 'anchor' | 'focus' | 'start' | 'end'
    }
  ) => void
  select: (editor: Editor, target: Location) => void
  setPoint: (
    editor: Editor,
    props: Partial<Point>,
    options?: {
      edge?: 'anchor' | 'focus' | 'start' | 'end'
    }
  ) => void
  setSelection: (editor: Editor, props: Partial<Range>) => void
}

实际上对于选区的底层的 Operations 只有一种,统一叫做:set_selection。也对,毕竟对于选区的任何操作,无论是修改、更新、移除选区、添加编辑器选区,实际都可以做是叫做一种 set 操作。不过,在 SelectionTransforms 中就会帮我们封装一些更加场景化命名的方法对选区进行操作。例如对标原生选区的方法:collapse 用来折叠选区,还有选中、取消选中、移动选区等方法。 ​

Transforms 模块先到这里,里面还有很多细节,限于篇幅,我们就不仔细介绍了。如果在使用中遇到问题时,我们可以再根据具体需要进行更加详细的了解。 ​

Commands

前面介绍了 Tansforms,那么 Commands 又是什么了?先看官网的介绍:

While editing richtext content, your users will be doing things like inserting text, deleting text, splitting paragraphs, adding formatting, etc. Under the cover these edits are expressed using transforms and operations. But at a high level we talk about them as "commands".

翻译过来,大概的意思是:当编辑富文本内容时,你的用户会做一些操作,比如插入文本、删除文本、分割段落、添加格式等等。实际上,这些在编辑器内部是使用 TransformsOperations 来表示的。但在高层次上,我们把它们称为“命令”。

我的理解 Commands 是更加面向用户的一个概念,如果用我们在公司中的项目场景类比,那么 TransformsOperations 是更加偏向基础层的封装,而 Commands 更像是偏向业务的封装。 ​

它是一种更加高级的封装,我们来看编辑器内部的几个命令函数就更加清晰了:

Editor.insertText(editor, 'A new string of text to be inserted.')

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

Editor.insertBreak(editor)

insertTextdeleteBackwardinsertBreak 这样的 “工具函数” 更加业务化,代表的是更加具体的业务场景。另一个方面,其实这一层也是 Slate 和使用 Slate 开发者之间的桥梁,什么意思了?就是用户可以根据自己的编辑器具体场景,定义自己的命令,也就是自定义编辑器命令,例如:

const MyEditor = {
  ...Editor,

  insertParagraph(editor) {
    // ...
  },
}

自定义命令的时候,还是需要调用 Transforms 中的方法来进行具体的操作处理。

而且因为原生的对于富文本操作,实际也是有 document.execCommand方法,Slate 的这一层 Commands 抽象可以说是也对上了,减少开发人员理解成本。 ​

目前编辑器内置了如下命令:

	addMark: (key: string, value: any) => void
  apply: (operation: Operation) => void
  deleteBackward: (unit: 'character' | 'word' | 'line' | 'block') => void
  deleteForward: (unit: 'character' | 'word' | 'line' | 'block') => void
  deleteFragment: () => void
  insertBreak: () => void
  insertFragment: (fragment: Node[]) => void
  insertNode: (node: Node) => void
  insertText: (text: string) => void
  removeMark: (key: string) => void

总结

到这里,slate 核心模块就已经分析完了,整个 Slate 源码最复杂的就是这部分了。对于大概的设计我们已经有了比较清楚的认识,但是具体到某个 Operations、Transforms 等的具体源码实现我们只是了解到凤毛麟角。其实看源码就是这样,不要着急去抠具体的细节,在我们有需要或者碰到具体的场景,再去看,那么就会比较容易看懂。毕竟编辑器场景比较复杂,Slate 框架需要考虑到兼容性、各种场景、可扩展性等,所以代码复杂也是正常的。 ​

最后我们总结下:

  • Slate 最低层次的操作是用 Operations 抽象,一共有9个操作,其中6个操作两两对应,用于做 undo 操作,每个操作返回的数据遵循 immuable 数据流思想,也是方便支持协同编辑;
  • 为了减少操作编辑器选区的心智负担,使用 Transforms 封装了所有对编辑器的文档树的操作,Transforms 包含四类,分别为:GeneralTransforms、NodeTransforms、TextTransforms、SelectionTransforms,每个 Transforms 操作实际上调用的是一个或者多个 Operations 操作;
  • Commands 抽象是为了减少用户理解成本,通过这一层也提供了用户定制自己编辑器场景下的“命令”的桥梁,同时编辑内置了10个命令方便用户使用,比如 apply 这个命令,就会经常用到。

本文是 Slate 源码分析的第一篇,感兴趣的可以去阅读我的第二篇文章:Slate 源码分析(二)