cover

Slate 介绍分析与实践

介绍

Slate 是一个使用 TypeScript 开发富文本编辑器开发框架,诞生于 2016 年,作者是 Ian Storm Taylor。它吸收了 QuillProsemirrorDraft.js 的优点,核心数据模型十分精简,具有高度的可扩展性,最新版本为 v0.60.1

特点

  • 插件作为一等公民,能够完全修改编辑器行为
  • 数据层和渲染层分离,更新数据触发渲染
  • 文档数据类似于 DOM 树,可嵌套
  • 具有原子化操作 API,理论上支持协同编辑
  • 使用 React 作为渲染层
  • 不可变数据结构 Immer

架构图

slate.jpg

源码分析

Slate 使用 monorepo 方式管理仓库,packages 目录中有 4 个源码包。

slate

slate 核心仓库,包含抽象数据模型 interfaces,操作节点的方法 transforms,创建实例的方法等。

Interfaces

intefaces 目录下是 Slate 定义的数据模型。

Node 表示 Slate 文档树中不同类型的节点。

1
2
3
4
5
export type BaseNode = Editor | Element | Text

export type Descendant = Element | Text

export type Ancestor = Editor | Element

Editor 对象用于存储编辑器的所有状态,可以通过插件添加辅助函数或实现新行为。

1
2
3
4
5
6
7
8
export interface BaseEditor {
children: Descendant[]
selection: Selection
operations: Operation[]
marks: Omit<Text, 'text'> | null

/// ...
}

Element 对象是 Slate 文档树中包含其他 ElementText 的一种节点,取决于编辑器配置它可以是块级 block 或内联 inline 的。

1
2
3
4
5
6
7
export interface ElementInterface {
isAncestor: (value: any) => value is Ancestor
isElement: (value: any) => value is Element
isElementList: (value: any) => value is Element[]
isElementProps: (props: any) => props is Partial<Element>
matches: (element: Element, props: Partial<Element>) => boolean
}

Text 对象表示文档树中的叶子节点,是实际包含文本和格式的节点,它们不能包含其他节点。

1
2
3
4
5
6
7
8
export interface TextInterface {
equals: (text: Text, another: Text, options?: { loose?: boolean }) => boolean
isText: (value: any) => value is Text
isTextList: (value: any) => value is Text[]
isTextProps: (props: any) => props is Partial<Text>
matches: (text: Text, props: Partial<Text>) => boolean
decorations: (node: Text, decorations: Range[]) => Text[]
}

Path 是一个描述节点在文档树中的具体位置的索引列表,一般相对于 Editor 节点,但也可以是其他 Node 节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export interface PathInterface {
ancestors: (path: Path, options?: { reverse?: boolean }) => Path[]
common: (path: Path, another: Path) => Path
compare: (path: Path, another: Path) => -1 | 0 | 1
endsAfter: (path: Path, another: Path) => boolean
endsAt: (path: Path, another: Path) => boolean
endsBefore: (path: Path, another: Path) => boolean
equals: (path: Path, another: Path) => boolean
hasPrevious: (path: Path) => boolean
isAfter: (path: Path, another: Path) => boolean
isAncestor: (path: Path, another: Path) => boolean
isBefore: (path: Path, another: Path) => boolean
isChild: (path: Path, another: Path) => boolean
isCommon: (path: Path, another: Path) => boolean
isDescendant: (path: Path, another: Path) => boolean
isParent: (path: Path, another: Path) => boolean
isPath: (value: any) => value is Path
isSibling: (path: Path, another: Path) => boolean
levels: (
path: Path,
options?: {
reverse?: boolean
}
) => Path[]
next: (path: Path) => Path
parent: (path: Path) => Path
previous: (path: Path) => Path
relative: (path: Path, ancestor: Path) => Path
transform: (
path: Path,
operation: Operation,
options?: { affinity?: 'forward' | 'backward' | null }
) => Path | null
}

Point 对象表示文本节点在文档树中的一个特定位置。

1
2
3
4
5
6
7
8
9
10
11
12
export interface PointInterface {
compare: (point: Point, another: Point) => -1 | 0 | 1
isAfter: (point: Point, another: Point) => boolean
isBefore: (point: Point, another: Point) => boolean
equals: (point: Point, another: Point) => boolean
isPoint: (value: any) => value is Point
transform: (
point: Point,
op: Operation,
options?: { affinity?: 'forward' | 'backward' | null }
) => Point | null
}

Operation 对象是 Slate 用来更改内部状态的低级指令,Slate 将所有变化表示为 Operation

1
2
3
4
5
6
7
8
export interface OperationInterface {
isNodeOperation: (value: any) => value is NodeOperation
isOperation: (value: any) => value is Operation
isOperationList: (value: any) => value is Operation[]
isSelectionOperation: (value: any) => value is SelectionOperation
isTextOperation: (value: any) => value is TextOperation
inverse: (op: Operation) => Operation
}

Transforms

Transforms 是对文档进行操作的辅助函数,包括选区转换,节点转换,文本转换和通用转换。

1
2
3
4
5
6
export const Transforms = {
...GeneralTransforms, // 操作 Operation 命令
...NodeTransforms, // 操作节点
...SelectionTransforms, // 操作选区
...TextTransforms, // 操作文本
}

createEditor

创建编辑器实例的方法,返回一个实现了 Editor 接口的编辑器实例对象。

1
2
3
4
5
6
7
/// create-editor.ts

export const createEditor = (): Editor => {
const editor: Editor = {}
/// ...
return editor
}

slate-react

slate-react 编辑器的 React 组件,渲染文档数据。

Slate

组件上下文的包装器,处理 onChange 事件,接受文档数据 value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// Slate.tsx

export const Slate = () => {
/// ...
return (
<SlateContext.Provider value={context}>
<EditorContext.Provider value={editor}>
<FocusedContext.Provider value={ReactEditor.isFocused(editor)}>
{children}
</FocusedContext.Provider>
</EditorContext.Provider>
</SlateContext.Provider>
)
}

Editable

编辑器的主要区域,设置标签属性,处理 DOM 事件。

1
2
3
4
5
6
7
8
9
10
11
12
/// Editable.tsx

export const Editable = (props: EditableProps) => {
/// ...
return (
<ReadOnlyContext.Provider value={readOnly}>
<Component>
<Children />
</Component>
</ReadOnlyContext.Provider>
)
}

Children

根据编辑器文档数据生成渲染组件。

1
2
3
4
5
6
7
/// Children.tsx

const Children = () => {
const children = []
/// ...
return <React.Fragment>{children}</React.Fragment>
}

Element

渲染 Elment 的组件,使用 renderElement 方法渲染元素,使用 Children 组件生成子元素。

1
2
3
4
5
6
7
8
9
10
/// Element.tsx

const Element = () => {
/// ...
return (
<SelectedContext.Provider value={!!selection}>
{renderElement({ attributes, children, element })}
</SelectedContext.Provider>
)
}

Text

渲染文本节点组件。

1
2
3
4
5
6
7
8
9
10
/// Text.tsx

const Text = () => {
/// ...
return (
<span data-slate-node="text" ref={ref}>
{children}
</span>
)
}

withReact

Slate 插件,添加/重写了编辑器实例的一些方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// with-react.ts

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

e.apply = () => {
/// ...
}
e.setFragmentData = () => {
/// ...
}
e.insertData = () => {
/// ...
}
e.onChange = () => {
/// ...
}

return e
}

slate-history

slate-history Slate 插件,为编辑器提供 撤销 **和 重做**功能。

History

使用 redosundos 数组存储编辑器所有底层 Operation 命令的对象。

1
2
3
4
5
/// History.ts
export interface History {
redos: Operation[][]
undos: Operation[][]
}

HistoryEditor

带有历史记录功能的编辑器对象,具有操作历史记录的方法。

1
2
3
4
/// HistoryEditor.ts
export const HistoryEditor = {
/// ...
}

withHistory

Slate 编辑器插件,使用 undosredos 栈追踪编辑器操作,实现编辑器的 redoundo 方法,重写了apply 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// with-history.ts

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

e.redo = () => {
/// ...
}

e.undo = () => {
/// ...
}

e.apply = (op: Operation) => {
/// ...
}

return e
}

slate-hyperscript

slate-hyperscript 是一个使用 JSX 编写 Slate 文档的 hyperscript 工具

插件机制

Slate 的插件只是一个返回 editor 实例的函数,在这个函数中通过重写编辑器实例方法,修改编辑器行为。
在创建编辑器实例的时候调用插件函数即可。

1
2
3
4
5
6
7
8
9
import { Editor } from 'slate'

const myPlugin = (editor: Editor) => {
// 这里对 editor 的一些方法进行重写, 返回编辑器实例
editor.apply = () => {}
return editor
}

export default myPlugin
1
2
3
4
import { createEditor } from 'slate'
import myPlugin from './myPlugin'

const editor = myPlugin(createEditor())

如此以来插件就能完全控制编辑器行为,正如 Slate 的官方介绍所说

Slate 是一个 完全 可定制的富文本编辑器框架。

渲染机制

渲染原理

Slate 的文档数据是一颗类似 DOM 的节点树,slate-react 通过递归这颗树生成 children 数组,这个数组有两种类型的组件 ElementText, 最终 raect 将 children 数组中的组件渲染到页面上,步骤如下。

  1. 设置编辑器实例的 children 属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// Slate.tsx

export const Slate = (props: {
/// ...
}) => {
const { editor, children, onChange, value, ...rest } = props

const context: [ReactEditor] = useMemo(() => {
// 设置 editor 实例的 children 属性为 value
editor.children = value
/// ...
}, [])

/// ...
}
  1. Editable 组件传递 editor 实例给 Children
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// Editable.tsx

export const Editable = (props: EditableProps) => {
// 获取 editor 实例
const editor = useSlate()
/// ...
return (
<ReadOnlyContext.Provider value={readOnly}>
<Component>
<Children
// 将 editor 传递给 Children 组件
node={editor}
/>
</Component>
</ReadOnlyContext.Provider>
)
}
  1. Children 生成渲染数组,交给 React 渲染组件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// Children.tsx

const Children = (props: {
/// ...
}) => {
/// ...
const children = []
// 遍历 editor 实例上的 children 数组
for (let i = 0; i < node.children.length; i++) {
// 判断数据为 Element 或 Text
if (Element.isElement(n)) {
children.push(<ElementComponent />)
} else {
children.push(<TextComponent />)
}
}

return <React.Fragment>{children}</React.Fragment>
}

render.jpg

假设有以下数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
;[
{
type: 'paragraph',
children: [
{
text: 'A line of text in a paragraph.',
},
],
},
{
type: 'paragraph',
children: [
{
text: 'Another line of text in a paragraph.',
},
],
},
]

页面显示为
2line-text.jpg

自定义渲染

传递渲染函数 renderElementrenderLeafEditable 组件,对元素和叶子节点进行自定义渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const Leaf = (props) => {
let { attributes, children, leaf } = props
// 根据属性值设置 HTML 标签
if (leaf.bold) {
children = <strong>{children}</strong>
}

return <span {...attributes}>{children}</span>
}

const Element = (props) => {
const { element } = props
// 根据类型返回组件
switch (element.type) {
case 'custom-type':
return <CustomElement {...props} />
default:
return <DefaultElement {...props} />
}
}

const renderLeaf = props => <Leaf {...props} />
const renderElement = props => <Element {...props} />

<Slate>
<Editable
// 传递自定义渲染函数
renderLeaf={renderLeaf}
renderElement={renderElement}
/>
</Slate>

触发渲染

slate-react 的 withReact 插件会重写编辑器的 onChange 方法,在每次文档数据更新时,调用 onContextChange 函数,执行 setKey(key + 1) 触发 React 重新渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// slate.tsx

export const Slate = () => {
const [key, setKey] = useState(0)

const onContextChange = useCallback(() => {
onChange(editor.children)
// 设置 key + 1 触发 React 重新渲染
setKey(key + 1)
}, [key, onChange])

// 设置 onContextChange 函数
EDITOR_TO_ON_CHANGE.set(editor, onContextChange)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// with.react.ts

export const withReact = <T extends Editor>(editor: T) => {
// 重写 onChange 方法
e.onChange = () => {
ReactDOM.unstable_batchedUpdates(() => {
const onContextChange = EDITOR_TO_ON_CHANGE.get(e)

if (onContextChange) {
// 执行 onContextChange 进行 key + 1
onContextChange()
}

onChange()
})
}

return e
}

实践示例

一个基础的富文本编辑器

  1. 导入依赖,创建 <MyEditor /> 组件
1
2
3
4
5
6
7
8
9
import { createEditor } from 'slate'
import React, { useMemo, useState } from 'react'
import { Slate, Editable, withReact } from 'slate-react'

const MyEditor = () => {
return null
}

export default MyEditor
  1. 创建编辑器对象 editor 和文档数据 value,传递给 <Slate />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ...

const MyEditor = () => {
const [value, setValue] = useState([])
const editor = useMemo(() => withReact(createEditor()), [])

return (
// Slate 组件保存编辑器的状态,目的是共享状态,使得其他组件比如工具栏也能获取到编辑器状态。
<Slate
editor={editor}
value={value}
onChange={(value) => setValue(value)}
></Slate>
)
}
  1. 使用 <Editable /> 渲染编辑器主要区域。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...

const MyEditor = () => {
const [value, setValue] = useState([])
const editor = useMemo(() => withReact(createEditor()), [])

return (
<Slate editor={editor} value={value} onChange={(value) => setValue(value)}>
// Editable 组件是编辑器实际的渲染区域,用户在这里进行交互
<Editable
style={{
width: 500,
height: 300,
padding: 20,
border: '1px solid grey',
}}
placeholder="This is placeholder..."
/>
</Slate>
)
}
  1. 添加编辑器的默认值,此时页面上会出现这行文本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/// ...

// 编辑器的值是一个对象数组,slate 会根据它来生成数据模型,交给 slate-react 渲染
const initialValue = [
{
type: 'paragraph',
children: [
{
text: 'A line of text in a paragraph.',
},
],
},
]

const MyEditor = () => {
// 初始化编辑器 value 为 initialValue
const [value, setValue] = useState(initialValue)
const editor = useMemo(() => withReact(createEditor()), [])

return (
<Slate editor={editor} value={value} onChange={(value) => setValue(value)}>
<Editable
style={{
width: 500,
height: 300,
padding: 20,
border: '1px solid grey',
}}
placeholder="This is placeholder..."
/>
</Slate>
)
}

basic-editor.jpg

  1. 创建工具栏组件,添加加粗,斜体,下划线按钮。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
const MyToolbar = () => {
return (
<div
style={{
width: 500,
display: 'flex',
padding: '10px 20px',
alignItems: 'center',
margin: '0 auto',
marginTop: 50,
border: '1px solid grey',
}}
>
<button
style={{
marginRight: 20,
}}
onMouseDown={(event) => {
event.preventDefault()
}}
>
B
</button>

<button
style={{
marginRight: 20,
}}
onMouseDown={(event) => {
event.preventDefault()
}}
>
I
</button>

<button
style={{
marginRight: 20,
}}
onMouseDown={(event) => {
event.preventDefault()
}}
>
U
</button>
</div>
)
}

// ...
<Slate editor={editor} value={value} onChange={(value) => setValue(value)}>
// 在此处使用
<MyToolbar />
<Editable
style={{
width: 500,
height: 300,
padding: 20,
margin: '0 auto',
border: '1px solid grey',
borderTopWidth: 0,
}}
placeholder="This is placeholder..."
/>
</Slate>

toolbar.jpg

  1. 设置加粗,斜体,下划线渲染样式,传递 renderLeaf 函数给 Editable
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
///  MyEditor.jsx

// 定义具体样式如何渲染
const Leaf = (props) => {
let { attributes, children, leaf } = props

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

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

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

return <span {...attributes}>{children}</span>
}

const MyEditor = () => {
/// ...

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

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

<Editable
//
renderLeaf={renderLeaf}
/>
</Slate>
)
}
  1. 在工具栏上添加转换节点属性的方法,点击时调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/// MyToolbar.jsx

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

// 判断节点的属性值是否为真
const isFormatActive = (editor, format) => {
const [match] = Editor.nodes(editor, {
match: (n) => n[format] === true,
universal: true,
})

return !!match
}

// 根据样式切换属性值
const toggleFormat = (event, editor, format) => {
event.preventDefault()
const isActive = isFormatActive(editor, format)

Transforms.setNodes(
editor,
{ [format]: isActive ? false : true },
{ match: (n) => Text.isText(n), split: true }
)
}

const MyToolbar = ({ editor }) => {
return (
<div
style={{
width: 500,
display: 'flex',
padding: '10px 20px',
alignItems: 'center',
margin: '0 auto',
marginTop: 50,
border: '1px solid grey',
}}
>
<button
style={{
marginRight: 20,
}}
// 在点击事件上调用
onClick={(event) => {
toggleFormat(event, editor, 'bold')
}}
>
B
</button>
/// ...
</div>
)
}

toggle-format.gif

创建一个自定义树型元素

Slate 的强大之处在于它的可扩展性,以下展示如何自定义一个树形元素。

  1. 定义树形元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/// TreeElement.jsx

import React, { useState } from 'react'

const TreeElement = ({ attributes, children, element }) => {
const { checked, label } = element
const [isChecked, setIsChecked] = useState(checked)

const onChange = () => {
setIsChecked(!isChecked)
}

return (
<div {...attributes}>
<p
style={{
display: 'flex',
alignItems: 'center',
}}
contentEditable={false}
>
<input
type="checkbox"
style={{
width: 20,
}}
checked={isChecked}
onChange={onChange}
/>
<label>{label}</label>
</p>
{isChecked ? <div style={{ paddingLeft: 20 }}>{children}</div> : null}
</div>
)
}
  1. renderElement 方法传递给 <Editable />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// ...
const Element = (props) => {
const { element } = props

switch (element.type) {
case 'tree-item':
return <TreeElement {...props} />
default:
return <DefaultElement {...props} />
}
}

/// ...
const renderElement = useCallback((props) => <Element {...props} />, [])

/// ...
<Editable
renderElement={renderElement}
/>
  1. 添加树形元素数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const initialValue = [
/// ...
{
type: 'tree-item',
checked: true,
label: 'first level',
children: [
{
type: 'tree-item',
checked: false,
label: 'second level',
children: [
{
type: 'tree-item',
label: 'third level',
checked: false,
children: [
{
type: 'paragraph',
children: [
{
text: 'This is a tree item',
},
],
},
],
},
],
},
],
},
]

tree-element.gif

创建一个控制输入的插件

以下展示如何定义一个 Slate 插件

  1. 创建一个 withEmojis 插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/// with-emojis.ts

import { ReactEditor } from 'slate-react'

const letterEmojis = {
a: '🐜',
b: '🐻',
c: '🐱',
d: '🐶',
e: '🐘',
f: '🦊',
g: '🐦',
h: '🐵',
i: '🦄',
j: '🦋',
k: '🦀',
l: '🦁',
m: '🐭',
n: '🐮',
o: '🐋',
p: '🐼',
q: '🐧',
r: '🐰',
s: '🕷',
t: '🐯',
u: '🐍',
v: '🦖',
w: '🦕',
x: '🦛',
y: '🐳',
z: '🦓',
}
const withEmojis = (editor: ReactEditor) => {
const { insertText } = editor

// 重写 editor 的 insertText 方法
editor.insertText = (text: string) => {
if (letterEmojis[text.toLowerCase()]) {
text = letterEmojis[text]
}
// 执行原有的 insertText 方法
insertText(text)
}

return editor
}

export default withEmojis
  1. 在新建编辑器对象时使用插件
1
2
3
/// MyEditor.tsx

const editor = useMemo(() => withEmojis(withReact(createEditor())), [])

with-emojis.gif

不足之处

  • 还没有发布正式版,处于 Beta 阶段,API 可能会有变化
  • 渲染层目前只有 React,要在其他框架中使用需要自行实现
  • 数据渲染分离,需要完全控制用户输入行为,否则可能导致数据和渲染不同步
  • 基于 contenteditable 无法突破浏览器的排版效果
  • 对中文输入支持不足,详见此 链接
  • 社区驱动开发,问题可能得不到及时修复

总结

Slate 是一个设计优秀的富文本编辑器开发框架,具有很高的可扩展性。
如果需要一个能迅速接入并使用的富文本编辑器,那么可以使用 ckeditor4, tinymce, ueditor 这些提供开箱即用功能的编辑器。如果是要开发一款功能丰富,需要定制化的编辑器那么 Slate 将是你的第一选择。

参考

开源富文本编辑器技术的演进(2020 1024)
slate 架构设计分析
编辑器初体验
Slate 中文文档

Buy Me A Coffee
← 画一颗圣诞树🎄 认识 Range 和 Selection 对象 →