本文使用 Zhihu On VSCode 创作并发布
在 2019 年 11月 27日,Slate 发布了新版。底层从 immutable.js 迁移到 immer,基于 TypeScript 重构。
在之前的版本推荐阅读 编辑器初体验
长佑:编辑器初体验zhuanlan.zhihu.com
笔者没有开发过编辑器,但对 Slate 的整体设计理念非常感兴趣。这里不会阐述太多编辑器相关的特定内容,而是分享从整体看 Slate 的重构后,那些令人耳目一新的设计以及思考。
在 Slate.js 里,我们几乎看不到 Class 了。所有的文件和暴露的模块基本都是最纯粹的对象。
我们来看,它的文件结构。
文件结构
Slate 是框架无关的 Model,定义了所有的数据操作,提供 Command, Plugin 等机制。
Slate-React 是用于渲染编辑器的视图层处理,包括 Model 层和 React 中的绑定,以及一些基础编辑器文本内容渲染的组件。本文重点介绍的 Slate 和 Slate-history 这部分。
先从 createEditor 入口文件开始
import { Editor, Node, Transforms, Operation } from 'slate'
const createEditor = () => {
const editor = {
children: [], // 初始化数据结构
onChange: () => {},
apply:(op: Operation) => {
Editor.transform(editor, op)
editor.onChange()
}
insertNode(node:Node) {
Transforms.insertNodes(editor, node)
}
}
return editor;
}
const editor = createEditor();
这里涵盖了非常多的信息。该函数 return 了一个纯对象,并不是某个类的实例。该对象的属性上挂载了常用的编辑器调用方法。在处理的时候,以 insertNode 为例,由于传入的是当前的 editor,就能拿到当前 editor 的 children 属性。然后交由 Transforms 处理,其内部采用 immer 来进行 immutable 操作。
command 触发数据变更流程
Command (Transform) 设计
在 Slate.js 中,一个 Command 可能包含多个 Operation( 新版的 Slate 已称为 Transform,为便于描述,之后统称为 Command)。每个 Operation 都是原子操作,对数据的更改是最小化的。
比如,我们想要插入一个节点,那么除了给这个编辑器数据添加一个节点数据外,可能还需要更新光标、或者是选区等操作。
我们假设每个最小化的更改设计为原子操作。简单起见,我们把当前的 command 操作理解为 2个 operation。
const insertNode =() => {
//insertnodedata (operation1)
//updateSelection (operation2)
}
这么做的好处是,在设计编辑器的 undo,redo 时,我们很容易根据这些 operations 回溯历史。
此外,也为开发调试带来便利。而更令人惊喜的是这种方式对未来文档协同编辑的支持将非常友好。
当Slate 内部调用 editor.apply 时,实际上就是对原子 Operation进行处理。
当 Operation 处理完之后,触发 editor 对象的 onChange。
值得一提的是:当我们把一个 Command 转换成多个 Operation 时,由于每次 Operation 都会触发onChange,那么比如插入一个节点就会收到多次数据的onChange 回调,这在 React 层面会导致多次 re-render。假设,我们希望一个 Command 执行一次,也只通知 onChange 一次,该怎么去实现呢?
有一种做法是为每个 Command 新建一个 Operation 调用栈,当所有原子命令操作执行完毕之后,清空栈,通过判断栈的长度,然后再决定是否触发 onChange。
而在 Slate 中,利用 microTask 优雅地解决该问题。
import { DIRTY_PATHS, FLUSHING } from './utils/weak-maps'
editor.apply = (op: Opeation) {
if (!FLUSHING.get(editor)) {
FLUSHING.set(editor, true)
Promise.resolve().then(() => {
FLUSHING.set(editor, false)
editor.onChange()
editor.operations = []
})
}
}
虽然一个 Command 执行的时候会调用 apply 多次,但是当 Command 执行完毕后,我们使用 Promise 把回调放到 MicroTask 中,当 JavaScript 引擎没有其他任务运行时,就开始执行 MicroTask 中的任务。
CustomCommand
Slate 中编辑器所有操作都汇总到一个对象上,这是我们通过 createEditor 新建出来的。由于返回的只是普通的对象,我们可以非常简单得复写原有的 Command。比如,还是刚才的插入节点,我们想要在插入节点后,打印 log。
const nativeInsertNode = editor.insertNode;
editor.insertNode = (node: Node) => {
console.log('log insertNode');
nativeInsertNode(editor, node);
}
Plugin
Editor 对象是我们通过 createEditor 新建出来的。由于返回的只是普通的对象。 插件机制在新版的 Slate 中变得非常得简单。
插件也只是一个简单的函数,Slate 提供 withHistory library 来记录 undo,redo。
import { createEditor } from 'slate'
import { withHistory } from 'slate-history'
const editor = withHistory(createEditor())
这一切看起来更加得函数式了。
而 withHistory 本质上也只是在复写 editor.apply 函数,当 Slate 触发 apply 来执行 Operation 时, withHistory 内部保存了 apply 中 Operation 属性,然后继续执行 editor 原有的 apply 操作。
const withHistory = editor => {
const e = editor;
const { apply } = e
// 新增 history 属性
e.history = {undos:[], redos:[]}
// 新增 undo 方法
e.undo = () => {
const { history } = e
const { undos } = history
const op = undos[undos.length - 1];
e.apply(op);
history.undos.pop();
history.redos.push(op);
}
// 新增 redo 方法;
e.redo = () => {
const { history } = e
const { redos } = history
const op = undos[redos.length - 1];
e.apply(op);
history.undos.push(op);
history.redos.pop();
}
e.apply = (op: Operation) => {
e.history.undos.push(op); // 增加 undo
apply(op)
}
return e;
}
一切变得更加得函数式,我们不需要构建类,更不需要 Decoration。只需一个纯对象,就可以利用函数组合来实现同等效果。
得益于强大的插件机制,Slate 保证内核非常纯粹,而定制化的处理则通过自定义插件来实现。
Normalize
在新版 Slate 中移除了 Schema。Schema 原先是用于校验文档数据类型。实际在 Slate 里面通过 normalizeNode 来实现校验数据的功能,当传入的数据和预期接受的类型不一致的时候,我们可以借助它来修正数据。
在 Slate 内部提供了许多内置对数据结构的约束。具体可以参考 https://docs.slatejs.org/concepts/10-normalizing
开发者也可以通过插件的形式增加自己的约束。
Slate 在 React 中的数据共享
是一个类似 Provider 的 Wrapper ,用来处理 onChange
事件。其内部包裹了一层 Context 实现 Slate 的数据共享。使得其余子组件可以通过 useSlate
来获得 Slate。
export const SlateContext = createContext<[ReactEditor] | null>(null)
export const useSlate = () => {
const context = useContext(SlateContext)
if (!context) {
throw new Error(
`The `useSlate` hook must be used inside the <SlateProvider> component's context.`
)
}
const [editor] = context
return editor
}
const Slate = (props: {
editor: ReactEditor
value: Node[]
children: React.ReactNode
onChange: (value: Node[]) => void) => {
return <SlateContext.Provider value={context}>
<EditorContext.Provider value={editor}>
<FocusedContext.Provider value={ReactEditor.isFocused(editor)}>
{children}
</FocusedContext.Provider>
</EditorContext.Provider>
</SlateContext.Provider>
}
React 自定义渲染
const App = () => {
const editor = useMemo(() => withReact(createEditor()), [])
const [value, setValue] = useState([
{
type: 'paragraph',
children: [{ text: 'A line of text in a paragraph.' }],
},
])
// 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 (
<Slate editor={editor} value={value} onChange={value => setValue(value)}>
<Editable
// Pass in the `renderElement` function.
renderElement={renderElement}
onKeyDown={event => {
if (event.key === '&') {
event.preventDefault()
editor.insertText("and")
}
}}
/>
</Slate>
)
}
Slate-React 提供自定义渲染机制,即在组件渲染时检测是否有注入 renderElement 的 props,如果存在,则执行该方法。不过前提也需要一个字段来表明是哪种类型的组件,来决定最终的渲染。
const Element = (props) = {
const {
element,
renderElement = (p: RenderElementProps) => <DefaultElement {...p} />,
} = props
return <SelectedContext.Provider value={!!selection}>
{renderElement({ attributes, children, element })}
</SelectedContext.Provider>
}
总结
很多时候,对于复杂的应用我们可能第一时间考虑会用类来组织各个模块,这是个成熟的选择,业界也总结了一套非常完善的设计模式。与此类型的应用,我还专门研究过百度脑图的源码,它整体通过类来构建应用。
对比而言,我更喜欢 Slate ,Slate 的 Model 层面整体设计非常优雅,对于复杂应用而言,测试覆盖率是非常关键的,而越是纯函数,对编写单元测试就越友好。整体非常灵活,代码阅读上去感官很好。而使用类的方式则显得较为笨重。
但要强调的是这种给人简单的感觉往往背后是非常精心的设计。否则的话,很容易写成面向过程的代码。