从 MVC 架构的角度看 Slate

1,511 阅读15分钟

前言

Slate 是一个完全可定制的、用来构建富文本编辑器的框架,它受 Draft.jsProsemirrorQuill 等的启发,你可以使用 Slate 构建像 Medium, Dropbox Paper 甚至 Google Docs 这样的编辑器应用,早期语雀的编辑器也是基于 Slate 开发。

Slate 编辑区域还是基于原生的 HTML 元素属性: contenteditable,但是对于所有的富文本编辑器功能,例如加粗、斜体、列表、表格等功能则抛弃了 document.execCommand API ,渲染层更是脱离了原生的 DOM 操作,拥抱了 React。其强大的插件机制,因为灵活的对象组合的设计,可以直接使用高阶函数的方式实现。对于像 Slate 这种编辑区域还是依赖 contenteditable属性,但是具体功能实现还是脱离了 exeCommand API而全部通过自定义插入完成的设计,我们称之为 L1 级富文本编辑器架构。

因为 Slate 的设计比较抽象,阅读其源码也有一定的难度,本文主要是从 MVC 架构的角度带领大家了解 Slate

基本使用

在前面已经介绍过 Slate 渲染层依赖 React,所以如果你选择了 Slate,那么你的项目框架同样也选择了 React。首先安装依赖:

yarn add slate slate-react react react-dom

引入依赖:

// Import React dependencies.
import React, { useEffect, useMemo, useState } from 'react'
// Import the Slate editor factory.
import { createEditor } from 'slate'

// Import the Slate components and React plugin.
import { Slate, Editable, withReact } from 'slate-react'

创建一个编辑器:

const App = () => {
  const editor = useMemo(() => withReact(createEditor()), [])
  // Add the initial value when setting up our state.
  const [value, setValue] = useState([
    {
      type: 'paragraph',
      children: [{ text: 'This is editable plain text, just like a <textarea>!' }],
    },
  ])

  return (
    <Slate
      editor={editor}
      value={value}
      onChange={newValue => setValue(newValue)}
    >
      <Editable />
    </Slate>
  )
}

写过 React 的小伙伴对于这段代码应该非常熟悉,不就是我们在使用 React 做组件开发的代码样例。下面我们看下最终的效果:

这时候使用过富文本编辑器的小伙伴可能就有点懵逼了,编辑器的菜单了?标准的富文本编辑器打开方式应该是这样的:

刚开始我们就提到过, Slate 只是一个让你用来构建自己的富文本编辑器的框架,它不会像 CKEEditorQuill等提供开箱即用的菜单功能,如果你要构建一个功能完善的编辑器,你得通过其暴露的抽象 API 一步步实现自己的编辑器。

对于 Slate 的基本使用就介绍到这里,感兴趣可以自行去 Slate 官网了解更多:Slate

在前面的代码示例中,我们知道了如果需要使用 Slate,我们需要分别从 slateslate-react 包中引入不同的核心依赖,slate 包是 Slate 框架的核心设计部分,而 slate-react 则是其渲染部分的封装,主要用来提供创建编辑器的 React 组件。当然除了这两个包,其仓库里还有 slate-history,用于实现编辑器的撤销和重做功能。salte-hyperscript, 提供 Slate 创建编辑器的 jsx 帮助函数。

在本文中,我们主要关注 slateslate-react

面向接口编程

在开始看 Slate 设计之前,我们先来了解下传统的富文本编辑器架构是怎么实现比如像插入图片这样的功能。思路很简单,首先当用户上传图片后,创建一个图片元素,然后依赖 Selection API确定选区的位置,最后调用 execCommand API 将图片的 HTML 字符串插入到 contenteditable 元素区域。除了多了个 selection 对象需要让我们操心之外,实际整个功能的完成过程有点类似于我们操作 DOM API,但是与 DOM 操作不同的是,execCommand API 除了跨浏览器的差异之外,对于越复杂的功能例如表格、多级列表等,该 API 已经无法满足了,而且很容易出现我们无法控制的怪异问题。

这也是近几年像 Slate 这样优秀的框架抛弃了 execCommand API的原因之一,把所有 DOM 插入通过高可用的 API 抽象出来,实现不同的例如 TextNode 的插入功能。而对于插入的位置,则通过一定的抽象成更易读易用的数据结构,比如Range 在 Slate 抽象成如下的数据结构:

interface Range {
  anchor: Point
  focus: Point
}

这种转变就像是 Web 领域对于页面开发方式的演进,传统 Web 时代,我们是使用 Ajax 获取到数据后,使用 jQuery 甚至原生的方式将数据通过 DOM 操作更新到页面上。而到了现代的开发方式,我们则更多的是使用最开始的基于 MVC 架构的 Angular.js 到现在的 MVVM 框架 Vue.jsReact.js 开发方式,我们只需要关心的是数据和状态。

而 L0 级富文本编辑器到 L1 级编辑器的架构演进也是类似,我们可以将 L1 型编辑器架构看做是 MVC 的设计架构。 Slate 通过接口的方式抽象了构建编辑器所需要的所有基本数据类型,并通过对象组合构造出最后的编辑器对象,这一层可以看做是 Model 层。而对于这些数据操作 API 的抽象,实际上就是用来操作 Model 层的数据,我们可以看做是 Controller 层,而最后的 View 层就是依赖 React 框架的能力。这也是让我比较惊叹 Slate 的设计的地方之一,全部设计中没有一个类,所有核心的数据抽象都是基于接口,最后通过对象组合拼接起来。无论对于扩展,还是开发者的使用都带来了一定的便利性,也避免了类继承带来的冗余 API 的问题。

下面我们来看一下具体的接口设计。

Data Model

Nodes

先看其类型定义:

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

在 Slate 中,有三类比较重要的 Node:

  • 包含整个文档内容的根级编辑器节点;
  • 在你自己的编辑器使用场景下具有语义意义的容器元素节点;
  • 叶级文本节点,其中包含文档的文本。

我们具体看一些 EditorElementText 的例子:

interface Editor {
  children: Node[]
  ...
}
  
const editor: Editor = {
  children: [
    {
      type: 'paragraph',
      children: [
        {
          text: 'A line of text!',
        },
      ],
    },
  ],
  // ...the editor has other properties too.
}

interface Element {
  children: Node[]
}

const paragraph: Element = {
  type: 'paragraph',
  children: [...],
}

const quote: Element = {
  type: 'quote',
  children: [...],
}

interface Text {
  text: string
}

const text = {
  text: 'A string of bold text',
  bold: true,
}

Node 接口抽象基本就代表了 Slate编辑器文档树的所有节点,跟原生DOM 中的 Node 接口有点类似,只不过在 Slate 中它会更加简单,没有那么多属性,当然每一种 Node的属性你都可以根据自己的需要进行扩展,Slate 只提供最基本的属性。

Locations

看字面的意思就知道是位置,在我们用 Slate API 进行插入、删除、更新编辑器文档内容的时候,编辑器需要有一种数据抽象当前操作的位置,就类比于原生的 Selection,当然 Slate 会抽象得更细腻度。等下我们具体看定义的时候,我们可以看到一些熟悉的字段。

Path

路径是引用位置的最低层次的抽象,每个路径都是一个简单的数字数组,通过文档树中每个祖先节点的索引来引用文档树中的一个节点。它的类型定义为:

type Path = number[]

我们看个例子:

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

在这里,Text叶子节点的路径就是:[0, 0]

Point

一个点比路径更具体一些,它还包含到某个文本节点的偏移量,其类型定义为:

interface Point {
  path: Path
  offset: number
}

例如,对于一个编辑器文档,如果您想要表示光标的第一个位置,那么它就是:

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

而对于前面例子的 Text 节点结尾,则表示为:

const end = {
  path: [0, 0],
  offset: 15,
}

在 Slate 编辑器中,我们可以把点看做是光标的位置。

Range

范围代表的不仅是指文档中的一个点,而且是指两个点之间范围的内容,我们在编辑器中,选择一段内容的时候,就相当于一个 Range,其接口的定义为:

interface Range {
  anchor: Point
  focus: Point
}

在这里我们就看到了熟悉的两个属性,anchorfocus,我们称为锚点和焦点,实际上这就是 DOM 原生的 Selection 对象中的两个属性。当我们选中一段文字的时候可能是从左到右,也可能是从右到左,anchor代表就是选区开始的点,而 focus 表示的时候选区结束的点。

Slate 的 Range 与原生一个重要的区别是,范围的锚点和焦点总是引用文档中的叶级文本节点,而从不引用元素。这种行为与DOM不同,但它简化了范围的处理,因为需要处理的边界情况更少。

Editor

在使用 Slate 在构建一个编辑器的过程中,我们实现一个富文本编辑器的功能,基本上操作的就是 NodeLocation 这两类数据结构,有那么一点数据驱动视图的味道。最后我们看 Editor 的接口定义:

export interface BaseEditor {
  children: Descendant[]
  selection: Selection
  operations: Operation[]
  marks: Omit<Text, 'text'> | null

  // Schema-specific node behaviors.
  isInline: (element: Element) => boolean
  isVoid: (element: Element) => boolean
  normalizeNode: (entry: NodeEntry) => void
  onChange: () => void

  // Overrideable core actions.
  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
  getFragment: () => Descendant[]
  insertBreak: () => void
  insertFragment: (fragment: Node[]) => void
  insertNode: (node: Node) => void
  insertText: (text: string) => void
  removeMark: (key: string) => void
}

对于上面的 DescendantSelectionNodeEntry 都是对于 NodeLocation 的更高层次的抽象,所以最后所有的操作都是围绕着 NodeLocation 来回折腾。看懂了这两类数据类型,就基本看懂了 Slate 整个设计核心。

那么 Slate 是怎么操作这些数据的,下面我们从 API 的角度看下 Slate 的 Controller 层。

Transform VS Operations VS Commands

这节介绍的是 Slate 的 “Controller” 层,这一层封装了所有对基本数据操作的 API。

Transforms

既然 Slate 拥抱了 React,我们知道 React 状态更新的核心思想是 immutable ,所以你不能直接修改或者删除节点。因此,Slate 提供了一组转换函数,让你能直接修改编辑器的节点,这部分 API 在 Slate 中封装在 Transforms 对象中。

Selection Transforms

selection相关的转换是一些比较简单的转换。例如,下面是如何选区设置为一个新的范围例子:

Transforms.select(editor, {
  anchor: { path: [0, 0], offset: 0 },
  focus: { path: [1, 0], offset: 2 },
})

第二个参数的数据结构看着眼熟,在上一部分我们讲过,实际上就是一个 Range

当然也有更加复杂的使用方式,例如,常需要按字符、字或行向前或向后移动光标。下面是如何将光标向后移动三个单词的例子:

Transforms.move(editor, {
  distance: 3,
  unit: 'word',
  reverse: true,
})

倒吸了一口凉气,事情变得越来越复杂,可以看出其 API 设计非常灵活,所以能够提供强大的功能。

Text Transforms

文本转换作用于编辑器的文本内容。例如,下面是如何将文本字符串作为一个特定的点插入的例子:

Transforms.insertText(editor, 'some words', {
  at: { path: [0, 0], offset: 3 },
})

第二个参数中的 at 属性是一个 Point ,这个好理解,你想要往某个位置插入文本,肯定要指定点的位置。

删除一段文本:

Transforms.delete(editor, {
  at: {
    anchor: { path: [0, 0], offset: 0 },
    focus: { path: [1, 0], offset: 2 },
  },
})

在这个例子中,我们发现,at 的参数又变成了一个 Range,确实,如果你要删除一段文本,那么代表的肯定是一个范围。

Node Transforms

节点转换作用于编辑器单个元素和文本节点或者它们的组合。例如,您可以在特定路径上插入一个新的文本节点:

Transforms.insertNodes(
  editor,
  {
    text: 'A new string of text.',
  },
  {
    at: [0, 1],
  }
)

在操作 Node 的时候,我们发现 at 的属性变成了一个 Path,这是最低层次的 Location抽象。仔细思索,合理,当你搞清楚 Node 在 Slate 编辑器文档树中的层级,就可以知道这里确实应该是通过 Path 来定位 Node

你还可以移动一个 Node 节点:

Transforms.moveNodes(editor, {
  at: [0, 0],
  to: [0, 1],
})

好理解吧,从某个 Path 移动到目标 Path

Operations

前面我们介绍了 Transforms,刚开始我也很懵,既然 Transform 已经封装了对于所有 Slate 数据类型的操作 API ,那么这个 Operation 又是干嘛用的。带着这样的疑问,我们看下 Operations 的作用。

看了文档的解释,知道了 OperationsTransforms的区别:Operations是在调用 Transforms API 时发生的细粒度、低级操作。单个 Transform可能导致许多低级 Operations 被应用到编辑器。意思就是 Operations是更低层次的操作数据的 API,而 TransformAPI 的调用,底层实际上调用的是一个或者多个 Operations

下面我们看几个例子:

// 在文本节点某个位置插入一个新的 text 节点
editor.apply({
  type: 'insert_text',
  path: [0, 0],
  offset: 15,
  text: 'A new string of text to be inserted.',
})

// 删除 Path 为 [0, 0] ,text内容 为 A line of text ! 的 Node 节点
editor.apply({
  type: 'remove_node',
  path: [0, 0],
  node: {
    text: 'A line of text!',
  },
})

// 将选区的锚点设置到新的锚点位置
editor.apply({
  type: 'set_selection',
  properties: {
    anchor: { path: [0, 0], offset: 0 },
  },
  newProperties: {
    anchor: { path: [0, 0], offset: 15 },
  },
})

从这几个例子中,我们可以看到相比于 Transforms,我们可以设置更多的属性,更细粒度的操作 Node节点,修改 Location

Commands

What???命令又是什么鬼。还是直接看官方的介绍:在编辑 richtext 内容时,编辑器用户将进行插入文本、删除文本、分割段落、添加格式等操作。在 Slate 设计的幕后,这些编辑使用TransformsOperations表示。但在高层次上,我们把它们称为“命令”。

仔细思索,当你想到 execCommand API 的时候,你就能理解为什么还需要抽象这样一个概念,实际上就是类比原生的浏览器 API。先看 Slate 提供的几个 Command API

// 插入文本
Editor.insertText(editor, 'A new string of text to be inserted.')

// 删除文字
Editor.deleteBackward(editor, { unit: 'word' })

// 插入一个换行
Editor.insertBreak(editor)

发现这样的命令 API 确实是更高层次的抽象,它们不需要关心 Location,命令的功能也更加具体,比如插入一个文本命令,那么就是插入一个文本,没有其它附加功能。

在 Slate 中,你还可以自定义命令,就很简单:

const MyEditor = {
  ...Editor,

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

从这个例子中,我们也可以看出 Slate 的基于对象的设计带来了扩展的便利性,真面向“对象”编程。如果编辑器的基类是一个类,你想扩展它就得基于这个类通过继承的方式实现,使用起来就会相对来说麻烦一些。

View

刚开始我们就介绍过 Slate 的渲染层是基于 React,从现代 Web 开发的角度来说,这确实也合理。依赖框架的好处是,Slate 只需要将更多精力放在其数据层和 API 的设计上,而其节点扩展的能力,也能通过 React 组件化能力更容易实现,下面我们看一个例子:

import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'

const MyEditor = () => {
  const editor = useMemo(() => withReact(createEditor()), [])
  const renderElement = useCallback(({ 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>
    }
  }, [])

  return (
    <Slate editor={editor}>
      <Editable renderElement={renderElement} />
    </Slate>
  )
}

const renderElement = useCallback(props => {
  switch (props.element.type) {
    case 'quote':
      return <QuoteElement {...props} />
    case 'link':
      return <LinkElement {...props} />
    default:
      return <DefaultElement {...props} />
  }
}, [])

这样的方式更加现代,符合当前的开发模式。

当然弊端也很明显,对于使用者来说选择了 Slate ,也意味着选择了 React,对于其它的框架开发者来说就不友好了,甚至可以说是放弃了其它框架的使用者。

总结

本文从 MVC 架构的角度分别介绍了 Slate 的设计,从中我们了解到:

  • Slate 数据层抽象了 Node 和 Location 两种核心的数据模型,Node 下包括了 Editor、Element、Text 等基本节点,这些节点组合在一起构造出完整的编辑器文档树。而对于节点的位置,Slate 抽象了 Path、Point、Range 等这样的数据结构,用来确定在操作不同的 Node 节点时的位置
  • Slate ”Controller“ 层则抽象了 Transforms、Operations、Commands 三种操作,这三种对象代表了不同粒度的对于 Node 和 Location 数据的更新操作,从而使得开发者可以根据需求更灵活地定制自己的编辑器功能;
  • Slate 视图层选择了 React 这样的框架,能提高一定的开发效率,也使得编辑器的开发更加现代化。编辑器的复杂度不亚于一个 Web 应用,所以这种方式也给我们带来新的一些启发。

从 Slate 的设计当中,我们也可以获得一些开发 L1 型富文本编辑器的启发,新的架构设计应该更加符合现代的开发的模式,它应该是跟着 Web 发展一起演进。

如果你对富文本编辑器开发感兴趣,可以了解我们的开源团队:Wangeditor ,在这里有一堆热爱编辑器开发的伙伴,我们通过分享和技术交流提高自己的技术,同时也一起做好 wangeditor 编辑器这样一个开源项目。