【长文】Web 富文本编辑器框架 slate.js - 从基本使用到核心概念

8,171 阅读13分钟

(内容较多,请大家耐心阅读)

介绍

slate.js 提供了 Web 富文本编辑器的底层能力,并不是开箱即用的,需要自己二次开发许多内容。

也正是这个特点,使得它的扩展性特别好,许多想要定制开发编辑器的,都会选择基于 slate.js 进行二次开发。

slate.js 能满足全世界用户进行定制开发、扩展功能,说明它本身底层能力强大且完善,能将编辑器的常用 API 高度抽象。这也正是我要使用它、解读它、学习它的部分。

可以直接看一些 demo 和源码,从这里可以体会到,使用 slate.js 需要大量的二次开发工作。

PS:从实现原理上,slate.js 是 L1 级编辑器(具体可参考语雀编辑器的 ppt 分享)。如果仅仅是使用者,则不用关心这个。

(原文链接 juejin.cn/post/691712… 未经允许禁止转载)

基本使用

slate.js 是基于 React 的渲染机制,用于其他框架需要自己二次开发。

最简单的编辑器

npm 安装 slate slate-react ,编写如下代码,即可生成一个简单的编辑器。

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

function BasicEditor() {
    // Create a Slate editor object that won't change across renders.
    // editor 即该编辑器的对象实例
    const editor = useMemo(() => withReact(createEditor()) ,[])

    // 初始化 value ,即编辑器的内容。其数据格式类似于 vnode ,下文会详细结实。
    const initialValue = [
        {
            type: 'paragraph',
            children: [ { text: '我是一行文字' } ]
        }
    ]
    const [value, setValue] = useState(initialValue)

    return (
        <div style={{ border: '1px solid #ccc', padding: '10px' }}>
            <Slate
                editor={editor}
                onChange={newValue => setValue(newValue)}
            >
                <Editable/>
            </Slate>
        </div>
    )
}

但这个编辑器什么都没有,你可以输入文字,然后通过 onchange 可以获取内容。

PS:以上代码中的 editor 变量比较重要,它是编辑器的对象实例,可以使用它的 API 或者继续扩展其他插件。

renderElement

上面的 demo ,输入几行文字,看一下 DOM 结构,会发现每一行都是 div 展示的。

但文字内容最好使用 p 标签来展示。语义化标准一些,这样也好扩展其他类型,例如 ul ol table quote image 等。

slate.js 提供了 renderElement 让我们来自定义渲染逻辑,不过先别着急。富文本编辑器嘛,肯定不仅仅只有文字,还有很多数据类型,这些都是需要渲染的,所以都要依赖于这个 renderElement

例如,需要渲染文本和代码块。此时的 initialValue 也应该包含代码块的数据。代码如下,内有注释。

import React, { useState, useMemo, useCallback } from 'react'
import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'

// 第一,定义两个基础组件,分别用来渲染文本和代码块
// 默认文本段落
function DefaultElement(props) {
    return <p {...props.attributes}>{props.children}</p>
}
// 渲染代码块
function CodeElement(props) {
    return <pre {...props.attributes}>
        <code>{props.children}</code>
    </pre>
}

function BasicEditor() {
    // Create a Slate editor object that won't change across renders.
    const editor = useMemo(() => withReact(createEditor()), [])

    // 初始化 value
    const initialValue = [
        {
            type: 'paragraph',
            children: [{ text: '我是一行文字' }]
        },
        // 第二,数据中包含代码块
        {
            type: 'code',
            children: [{ text: 'hello world' }]
        }
    ]
    const [value, setValue] = useState(initialValue)

    // 第三,定义一个函数,用来判断如何渲染
    // Define a rendering function based on the element passed to `props`. We use
    // `useCallback` here to memoize the function for subsequent renders.
    const renderElement = useCallback(props => {
        switch(props.element.type) {
            case 'code':
                return <CodeElement {...props}/>
            default:
                return <DefaultElement {...props}/>
        }
    }, [])

    return (
        <div style={{ border: '1px solid #ccc', padding: '10px' }}>
            <Slate
                editor={editor}
                value={value}
                onChange={newValue => setValue(newValue)}
            >
                <Editable renderElement={renderElement}/> {/* 第四,使用 renderElement */}
            </Slate>
        </div>
    )
}

然后,就可以看到渲染效果。当然了,此时还是一个比较基础的编辑器,除了能输入文字,啥功能没有。

renderLeaf

富文本编辑器最常见的文本操作就是:加粗、斜体、下划线、删除线。slate.js 如何实现这些呢?我们先不管怎么是操作,先看看如何去渲染。

上文的 renderElement 是渲染 Element ,但是它关不了更底层的 Text (Editor ELement Text 的关系,后面会详细结实,此处先实践起来),所以 slate.js 提供了 renderLeaf ,用来控制文本格式。

import React, { useState, useMemo, useCallback } from 'react'
import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'

// 第一,定义一个组件,用于渲染文本样式
function Leaf(props) {
    return (
        <span
            {...props.attributes}
            style={{
                fontWeight: props.leaf.bold ? 'bold' : 'normal',
                textDecoration: props.leaf.underline ? 'underline': null
                /* 其他样式可继续补充…… */
            }}
        >{props.children}</span>
    )
}

function BasicEditor() {
    // Create a Slate editor object that won't change across renders.
    const editor = useMemo(() => withReact(createEditor()), [])

    // 初始化 value
    const initialValue = [
        {
            type: 'paragraph',
            // 第二,这里存储文本的样式
            children: [ { text: '我是' }, { text: '一行', bold: true }, { text: '文本', underline: true } ]
        }
    ]
    const [value, setValue] = useState(initialValue)

    const renderLeaf = useCallback(props => {
        return <Leaf {...props}/>
    }, [])

    return (
        <div style={{ border: '1px solid #ccc', padding: '10px' }}>
            <Slate
                editor={editor}
                value={value}
                onChange={newValue => setValue(newValue)}
            >
                <Editable renderLeaf={renderLeaf}/> {/* 第三,使用 reader */}
            </Slate>
        </div>
    )
}

PS:renderElement 和 renderLeaf 并不冲突,可以一起用,而且一般要一起用。这里为了演示简洁,没有用 renderElement 。

富文本操作

【预警】这一部分比较麻烦,涉及到 slate.js 的很多 API 。本文只能演示一两个,剩下的自己去看文档和 demo 。

上述只介绍了如何渲染,还未介绍如何设置样式。以加粗和代码块为例,自定义一个命令集合,代码如下。
这其中会设计到 EditorTransforms 的一些 API ,其实光看名字,就能猜出什么意思。

import { Transforms, Text, Editor } from 'slate'

// Define our own custom set of helpers.
const CustomCommand = {
    // 当前光标的文字,是否加粗?
    isBoldMarkActive(editor) {
        const [ match ] = Editor.nodes(editor, {
            match: n => n.bold === true,
            universal: true
        })
        return !!match
    },
    // 当前光标的文字,是否是代码块?
    isCodeBlockActive(editor) {
        const [ match ] = Editor.nodes(editor, {
            match: n => n.type === 'code'
        })
        return !!match
    },
    // 设置/取消 加粗
    toggleBoldMark(editor) {
        const isActive = CustomCommand.isBoldMarkActive(editor)
        Transforms.setNodes(
            editor,
            { bold: isActive ? null : true },
            {
                match: n => Text.isText(n),
                split: true
            }
        )
    },
    // 设置/取消 代码块
    toggleCodeBlock(editor) {
        const isActive = CustomCommand.isCodeBlockActive(editor)
        Transforms.setNodes(
            editor,
            { type: isActive ? null : 'code' },
            { match: n => Editor.isBlock(editor, n) }
        )
    }
}

export default CustomCommand

然后自己写一个菜单栏,定义加粗和代码块的按钮。

return (<div>
  <div style={{ background: '#f1f1f1', padding: '3px 5px' }}>
      <button
          onMouseDown={event => {
              event.preventDefault()
              CustomCommand.toggleBoldMark(editor)
          }}
      >B</button>
      <button
          onMouseDown={event => {
              event.preventDefault()
              CustomCommand.toggleCodeBlock(editor)
          }}
      >code</button>
  </div>
  <Slate editor={editor} value={value} onChange={changeHandler}>
      <Editable
          renderElement={renderElement}
          renderLeaf={renderLeaf}
      />
  </Slate>
</div>)

折腾了这么半天,就只能实现一个非常简单的 demo ,确实很急人。但它就是这样的,没办法。

自定义事件监听

比较简单,直接在 <Editable> 组件监听 DOM 事件即可。一些快捷键,可以通过这种方式来设置。

    <Slate editor={editor} value={value} onChange={value => setValue(value)}>
      <Editable
        onKeyDown={event => {
          // `ctrl + b` 加粗的快捷键
          if (!event.ctrlKey) return
          if (event.key === 'b') {
            event.preventDefault()
            CustomCommand.toggleBoldMark(editor)
          }
        }}
      />
    </Slate>

插入图片

富文本编辑器,最基本的就是图文编辑,图片是最基本的内容。但图片确实和文本完全不一样的东西。

文本是可编辑、可选中、可输入的、非常灵活的编辑方式,而图片我们不期望它可以像文本一样灵活,最好能按照我们既定的方式来操作。

不仅仅是图片,还有代码块(虽然上面也是当作文字处理的,但现实场景不是这样)、表格、视频等。这些我们一般都称之为“卡片”。如果将编辑器比作海洋,那么文本就是海水,卡片就是一个一个的小岛。水是灵活的,而小岛是封闭的,不和水掺和在一起。

插入图片可以直接参考 demo ,可以看出,图片插入之后是不可像文本一样编辑的。从 源码 中可以看出,渲染图片时设置了 contentEditable={false}

const ImageElement = ({ attributes, children, element }) => {
  const selected = useSelected()
  const focused = useFocused()
  return (
    <div {...attributes}>
      <div contentEditable={false}>
        <img
          src={element.url}
          className={css`
            display: block;
            max-width: 100%;
            max-height: 20em;
            box-shadow: ${selected && focused ? '0 0 0 3px #B4D5FF' : 'none'};
          `}
        />
      </div>
      {children}
    </div>
  )
}

slate.js 还专门做了一个 embeds demo ,它是拿视频作为例子来做的,比图片的 demo 更加复杂一点。

插件

slate.js 提供的是编辑器的基本能力,如果不能满足使用,它提供了插件机制供用户去自行扩展。
另外,有了规范的插件机制,还可以形成自己的社区,可以直接下载使用第三方插件。
我们研发的开源富文本编辑器 wangEditor 也会尽快扩展插件机制,扩展更多功能。

开发插件

插件开发其实很简单,就是对 editor 的扩展和装饰。你想要做什么,可以充分返回自己的想象力。slate.js 提供的 demo 也都是用插件实现的,功能很强大。

const withImages = editor => {
  const { isVoid } = editor
  editor.isVoid = element => {
    return element.type === 'image' ? true : isVoid(element)
  }
  return editor
}

单个插件使用非常方便,代码如下。但如果插件多了,想一起叠加使用,那就有点难看了,如 a(b(c(d(e(editor)))))

import { createEditor } from 'slate'

const editor = withImages(createEditor())

可用的第三方插件

可以 github 或者搜索引擎上搜一下 “slate plugins” 可以得到一些结果。例如

核心概念

数据模型

回顾一下上面代码中的初始化数据。

const initialValue = [
    {
        type: 'paragraph',
        children: [{ text: '我是', bold: true }, { text: '一行', underline: true }, {text: '文字'}]
    },
    {
        type: 'code',
        children: [{ text: 'hello world' }]
    },
    {
    	type: 'image',
        children: [],
        url: 'xxx.png'
    }
    // 其他的继续扩展
]

数据类型和关系

slate.js 的数据模型模拟 DOM 的构造函数,结构非常简单,也很好理解。

整个编辑区域是 Editor 实例,下面就是一个单层的 Element 实例列表。Element 里的子元素是 Text 实例列表。Element 可以通过上述的 renderElement 自定义渲染,Text 可以通过上述的 renderLeaf 渲染样式。

PS:Element 也不是必须单层的,Element 实例下,还可以继续扩展 Element 。

block 和 inline

Element 默认都是 block 的,Text 是 inline 的。不过,有些时候 Element 需要是 inline 的,例如文本链接。

参考 demo 源码 能看到,可以通过扩展插件,将 link 变为 inline Element。

const withLinks = editor => {
  const { isInline } = editor

  editor.isInline = element => {
    return element.type === 'link' ? true : isInline(element)
  }
  
  // 其他代码省略了……
}

然后,渲染的时候直接输出 <a> 标签。

const renderElement = ({ attributes, children, element }) => {
  switch (element.type) {
    case 'link':
      return (
        <a {...attributes} href={element.url}>
          {children}
        </a>
      )
    default:
      return <p {...attributes}>{children}</p>
  }
}

此处你可能会有一个疑问:渲染是 <a> 标签本来就是 inline 的,这是浏览器的默认渲染逻辑,那为何还要重写 editor.isInline 方法呢?
答案其实很简单,slate.js 是 model view 分离的,<a> 在浏览器默认是 inline 这是 view 的,然后还要将其同步到 model ,所以要修改 editor.isInline

Selection 和 Range

SelectionRange 是 L1 级 Web 富文本编辑器的核心 API 。用于找到选区范围,都包含哪些 DOM 节点,开始在哪里,结束在哪里。

slate.js 封装了原生 API ,提供了自己的 API ,供用户二次开发使用。

slate.js 在原生 API 的基础上进行了更细节的拆分,分成了 Path Point Range Selection ,文档在这里。 它们层层依赖,又简洁易懂,设计的非常的巧妙合理。

Path 是一个数组,用于在组件树中找到某个具体的节点。例如下图的树中,找到红色的节点,就可以表示为 [0, 1, 0]

Point 在 Path 的基础上,进一步确定选区对应的 offset 偏移量,即具体选中了哪个文本。offset 在原生 API 也有,概念是一样的。

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

Range 即表示某一段范围,它和选区还不一样,仅仅表示一段范围,无它功能意义。既然是范围,用两个 Point 表示即可,一个开始,一个结束。

interface Range {
  anchor: Point
  focus: Point
}

最后,Selection 其实就是一个 Range 对象,用 Range 表示选区,代码如下。

原生 API 中 Selection 可包含多个 Range ,而 slate.js 不支持,仅是一对一的关系。
其实大部分情况下 Selection 和 Range 一对一没问题,除了特殊场景,例如 vscode 中使用 ctrl+d 多选。

const editor = {
  selection: {
    anchor: { path: [0, 0], offset: 0 },
    focus: { path: [0, 0], offset: 15 },
  },
  // 其他属性……
}

slate.js 作为一个富文本编辑器的底层能力提供者,Selection 和 Range 非常重要的 API ,它也提供了详细的 API 文档 供用户参考。

commands 和 operations

  • commands high-level 可扩展,内部使用 Transforms API 实现
  • operations low-level 原子 不可扩展
  • 一个 command 可包含多个 operation

commands

commands 就是对原生 execCommand API 的重写。因为原生 API 在 MDN 中已宣布过时,而且这个 API 确实不太友好,具体可以看一篇老博客《Why ContentEditable is Terrible》。

commands 即对富文本操作的命令,例如插入文本、删除文本等。slate.js 内置了一些常用 command ,可参考 Editor API

Editor.insertText(editor, 'A new string of text to be inserted.')
Editor.deleteBackward(editor, { unit: 'word' })
Editor.insertBreak(editor)

slate.js 非常推荐用户自己分装 command ,其实就是对 Editor 扩展自己的 helper API ,内部可以使用强大的 Transforms API 来实现。

const MyEditor = {
  ...Editor,

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

operations

要理解 Operations 存在的意义,还需要配合理解 OT 算法,以实现多人协同编辑。

执行 command 会生成 operations ,然后通过 editor.apply(operation) 来生效。

operation 是不可扩展的,所有的类型参考这里,而且是原子的。有了 operation 可方便支持撤销、多人协同编辑。

editor.apply({
  type: 'insert_text',
  path: [0, 0],
  offset: 15,
  text: 'A new string of text to be inserted.',
})

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 },
  },
})

operation 的 type 和 Quill 编辑器 Delt 基本一致,都符合 OT 算法的基本类型。但这里的 operation 更适合富文本树结构。

PS:如果你不考虑多人协同编辑,那这部分不用关心,都是 slate.js 内部封装的。

Normalizing 数据校验

富文本编辑器,内容是复杂的、嵌套的、不可枚举的。所以,需要有一些规则来保证数据格式的规范,这就是数据校验。

可能会引发数据格式混乱的情况有很多,常见的有

  • 复杂的、重复、连续的的文本格式操作,例如加粗、斜体、设置颜色、设置链接、换行等……会让数据格式变的非常复杂
  • 粘贴。从各种网页拷贝、从 word 拷贝、从 excel 拷贝、从微信 qq 拷贝…… 即,粘贴的数据来源无法确定,所以粘贴过来的数据也就无法保证格式统一,混乱是很正常的。

slate.js 内置了一些校验规则,来确保最基本的数据格式

  • 每个 Element 必须包含至少一个子孙 Text 节点。即,如果一个 Element 是空的,则默认给一个空 Text 子节点。
  • 两个连续的 Text 如果有相同属性,则合并为一个 Text 。
  • block 节点只能包含另一个 block 节点,或者 inline 节点和 Text 。即一个 block 节点不能同时包含一个 block 节点外加一个 inline 节点。
  • Inline nodes cannot be the first or last child of a parent block, nor can it be next to another inline node in the children array. If this is the case, an empty text node will be added to correct this to be in complience with the constraint.(笔者:这一个没看明白,就直接贴过来,就不翻译了)
  • 最顶级的 Element 只能是 block 类型

slate.js 也允许用户自定义校验规则,例如写一个插件,来校验:paragraph 的自元素只能是 Text 或者 inline Element 。

import { Transforms, Element, Node } from 'slate'

const withParagraphs = editor => {
  const { normalizeNode } = editor

  editor.normalizeNode = entry => {
    const [node, path] = entry

    // If the element is a paragraph, ensure its children are valid.
    if (Element.isElement(node) && node.type === 'paragraph') {
      for (const [child, childPath] of Node.children(editor, path)) {
        if (Element.isElement(child) && !editor.isInline(child)) {
          Transforms.unwrapNodes(editor, { at: childPath })
          return
        }
      }
    }

    // Fall back to the original `normalizeNode` to enforce other constraints.
    normalizeNode(entry)
  }

  return editor
}

总结

到此应该能体会到,slate.js 是一个功能强大的富文本编辑器框架,需要大量的二次开发。

关于 Web 富文本编辑器和 slate.js 的内容还有很多。包括本文提到未深入的,如历史记录、协同编辑;也包括未提到的,如内部实现过程、不可变数据等。有广度和有深度。以后我还会写文章分享。

曾经有人戏称 “Web 富文本编辑器是前端技术的天花板” ,有些绝对,但也是有一定的道理。