MMM 4

2019/04/22

React 与富文本编辑

这是第一篇纯技术的 MMM。

富文本编辑一直以来被誉为前端开发的天坑。原因有三:

  • 文本编辑本身就是天坑,尤其当涉及到奇怪的书写系统时。
  • 很多人的设计本身就有问题。本该限制支持的标记,做出一个 domain specific 的 Markdown,结果非要存储为 HTML,必须考虑任意复杂的输入,不得不在各个阶段做数据清洗。
  • 在浏览器里和 contenteditable 交互很麻烦,各个浏览器对于常见的插入、删除、格式化、换行、复制粘贴均有差异。

其中,第一点和第二点与其说是坑(繁琐),倒不如说是陌生,通过良好的设计都可以避免。只有第三点才是真正麻烦的地方,也是本文的重点。

本文的「数据结构」,指的是内存里的对文档的抽象描述,与渲染出的 HTML 以及 DOM 树相对应。

本文提出的解决方案只支持 Safari 与 Chrome。Edge 由于更换 Chromium 内核,很快也能使用。至于 Mozilla 的忠实粉丝,只能自求多福了。

Input Events Level 2

借着函数式响应式编程(FRP)的东风,大家都可以看到,富文本编辑的最好解决方案就是只对数据结构做更新,由纯渲染函数将数据结构同步到网页上。不过,目前主流的 contenteditable 处理方法依赖诸如 Mutation Observer 或者 oninput 事件,由浏览器先执行编辑操作,再将 DOM 变化翻译成逻辑操作并修改数据结构,最后再同步数据结构与 DOM。这一组操作每一步都很容易出问题。比如说最初 Litchi 使用的高度可定制的富文本编辑库 Slate JS,在 Safari 下完全输入不了中文,相关 GitHub issue 拖了很多个月都难以处理。我们可以下一个很粗暴的结论:这条路走不通。

当然,W3C 成员早就意识到了这个问题。于是 2016 年的时候,Editing Taskforce 提出了一个新的标准,Input Events。其核心亮点是:

  • 所有的用户输入都会触发两个事件,beforeinputinput,会详细描述输入类型,比如说是插入还是删除,是否新建自然段,是否涉及复制粘贴。与此同时,事件还会涉及受影响的文档区间。
  • 触发 beforeinput 时,浏览器尚未修改 DOM,用户可以在此时通过 preventDefault() 取消事件,并执行自己的处理操作。
  • 当修改完成后,将立即触发 input 事件,以便完成后续操作。

可惜的是,出于一些 Android 平台上的技术问题,Chromium 团队无法实现完整的标准,于是组委会被迫将标准拆分成 Level 1 与 Level 2,其中 Level 1 不支持取消文本的插入与删除。Chrome 支持 Level 1,而人类希望 Apple 不出意料地又一次吊打全体友商,Safari 成为截至目前唯一完整实现 Level 2 的浏览器。

如果你的 React 代码需要同时支持 Chrome 和 Safari,那么问题来了:为了避免 React 在更新 DOM 时出现问题,必须确保 DOM 恢复到输入发生前的样子。对于 Safari 这很好处理:只需要 preventDefault() 避免浏览器主动修改 DOM,对于 Chrome,则相对比较麻烦。

熟悉编辑的人可能知道,这里还有一个坑:如果用户通过输入法输入,那么我们是没有办法提前取消输入法事件的。Input Event Level 2 里提供了多种针对输入法的输入类型。其中,Safari 实现了:

  • insertCompositionText,在使用输入法的时候触发,比如输入「饿了」的时候敲击 el,会触发该类型事件两次,第一次插入文本「e」,第二次将文本更新为「e l」,注意空格。
  • deleteCompositionText,在用户完成输入的时候触发,将中间文字删除。
  • insertFromComposition,在前者的 input 事件触发后触发,插入最终输入文本。

因此,对于 Safari 我们只需要取消最后一个输入类型对应的 beforeinput 事件,再手工插入目标文本即可。至于 Chrome,则将全部输入法事件表示为 insertCompositionText,必须手工回滚 DOM。

处理 Chrome 方案一:避免回滚 DOM

如果我们可以在浏览器触发 beforeinput 之前就拦截输入事件,我们可以避免 Chrome 修改 DOM。有两类情况需要考虑,分别是插入和删除。

插入里有四种情况:

  • 英文字母插入。
  • 输入法插入。
  • 区间选择删除。
  • 光标删除。

字母插入非常好解决,有一个 keypress 事件,直接拦截并对输入进行处理就可以避免触发 beforeinputinput 触发。有一个小细节:回车键也会触发 keypress(但其它功能键不会),需要过滤,或者直接处理回车的行为(如新增段落)。

第二简单的是区间选择删除。解决方案是监听 keydown 事件,专门处理 Backspace、Delete 和 Command + D。需要注意的是,如果输入的时候,没有光标而是区间选择了文字,也需要将被选择文字删除。

输入法插入属于无解的情况。除非你打算用 JavaScript 内置输入法(还要支持很多很多奇怪的书写系统),你必须等完整的输入过程完成并进行回滚。但是输入法插入的回滚很简单,只有两种情况:

  • 当前 Selection 的锚点处于一个文本节点内部,直接在输入完成后还原对应的文本节点。
  • 当前 Selection 的锚点处于一个空元素内部,需要删除新增的文本节点。

光标下的删除,看似简单,但却是非常复杂。这需要科普一些 Unicode 的知识。JavaScript 内部使用 UTF-16 编码,于是就有了第一个概念,code unit,指代一个 16 位数值。完整的 Unicode 编码无法用 16 位描述,于是有了第二个概念,code point,指代一个 Unicode scalar value。比如说 Emoji 里的「😊」,在 JavaScript 字符串类型里的长度是 2。值得注意的是,使用迭代器或者 for ... of 语法,会按照 code point 来逐个访问字符串。

前两个概念还很简单,第三个就复杂了。字体里的一个字形可能由多个 code point 组成。其中有一种情况是连字(ligature),比如某些英文字体会把 fi 连起来,由一个字形表示。这种情况先不说。但还有一类字符,它们逻辑上就由一组字符组合而成:

  • 比如说 Emoji 里的国旗,由两个 Regional Indicator 构成,比如「🇨🇳」,使用 for ... of 打印会得到「🇨」与「🇳」。
  • 有一些书写系统会天生支持组合。比如说韩语,「ᄀ ᄀ ᄀ ᅡ ᆨ ᆨ ᆨ 」去掉中间的空格会得到「ᄀᄀ각ᆨᆨ」,正确实现的浏览器里,这段文本会显示成一个字符。
  • 类似的还有音调符号。音调符号分两种,一种是独立的符号,另一种可以跟在字母与其合并为一个字符,比如说「a ́」去掉空格就变成了「á」。

这类情况被称为 grapheme cluster。这里有以下问题:

  • 按下 Backspace 的时候,是删除 code point 还是 grapheme cluster。
  • 按下 Delete 的时候,是删除 code point 还是 grapheme cluster。

这里的 Backspace/Delete 是 PC 键盘上的定义,对应 Mac 上的 Delete/Command + D。对于前者,一般是一个部件一个部件删除,也就刚好对应 code point。后者一般是删除整个 cluster。但这套逻辑对付 Emoji 却又行不通,比如说「🇨🇳」或者「👪」这种 grapheme cluster,一般来说是整体删除的。一般来说,这个行为是根据不同语言定制的,系统内会自带规则。

所幸的是,当按下删除键时,Chrome 和 Safari 都会触发 beforeinput,输入类型为 deleteContentBackward/Forward。事件会自带 getTargetRanges() 函数,帮你计算要删除的范围。但如果我们决定拦截 keydown 事件,直接处理 Backspace、Delete 和 Command + D,就很麻烦。你可以选择花很大精力学习 Unicode 标准、研究各个平台的实现,但还是回滚 DOM 比较现实。

处理 Chrome 方案二:回滚 DOM

回滚在所难免。当然,如果你的目标用户只有中文用户,你可以直接跳到下一节。

我一开始试图推导基于 DOM 的回滚方案,后来发现太繁琐了。好在我们有一神器,即 Mutation Observer。我们可以在 beforeinput 的时候将其绑定到想要的元素上,在 input 事件的时候断开绑定,并倒过来反着应用所有的变化。我写了一个简单的版本:

  1. for (const record of reversedRecords) {
  2. if (record.type === ‘characterData’) {
  3. record.target.nodeValue = record.oldValue
  4. }
  5. if (record.type === ‘childList’) {
  6. const addedNodes = Array.from(record.addedNodes)
  7. for (const node of addedNodes) {
  8. record.target.removeChild(node)
  9. }
  10. const removedNodes = Array.from(record.removedNodes)
  11. for (const node of removedNodes) {
  12. record.target.insertBefore(node, record.nextSibling)
  13. }
  14. }
  15. }

这里我不保证正确处理了全部的情况,但确实处理了两种情况:

  • 浏览器删除完空文本节点的时候,会把对应的 <span> 之流给去掉。
  • 在无文本节点的情况下插入,会创建新的文本节点。

根据我的测试,Chrome 下这套回滚没有可感知的性能问题。有一些很简单的优化:如果相邻多个 record 是对同一个文本节点进行更新,只需要应用最早的那一次更新的 oldValue 即可。

下面一个问题是:如何在同一套代码里共同处理 Safari 和 Chrome。我们可以在 beforeinput 事件里直接检测 cancelable,如果可以的话,自然是 preventDefault() 并对输入进行自定义处理如修改 React 状态等。如果不可以的话,可以开启 Mutation Observer,并在 input 事件里进行回滚。需要注意的是,input 是在 DOM 修改完后立即触发,所以可能需要调用 Mutation Observer 上的 takeRecords 获取完成的更改历史。

输入法输入略复杂。输入法输入无法取消,但是当 Safari 下会触发 beforeinput 中的 insertCompositionText 时,浏览器已经帮你完成 DOM 回滚,此时可以 preventDefault() 并开始自定义处理。至于 Chrome,则只能在 compositionend 里手工回滚并进行处理。至于说用户使用输入法输入的具体内容,可通过两个事件的 data 属性获得。

配合 React Hooks

如果到 2019 年 4 月你还没有开始把玩 React Hooks,那你需要抓紧了。Hooks 下处理事件回调有个小坑。考虑如下代码:

  1. function Component(props) {
  2. const [counter, setCounter] = useState(1)
  3. const onClick = event => {
  4. setCounter(counter + 1)
  5. }
  6. // …
  7. }

每一次重新渲染组件的时候,不管 counter 有没有发生变化,onClick 都会对应一个新的函数,这会导致子组件重新渲染,可能会有性能问题。官方解决方案是 useCallback 钩子:

  1. function Component(props) {
  2. const [counter, setCounter] = useState(1)
  3. const onClick = useCallback(
  4. event => {
  5. setCounter(counter + 1)
  6. },
  7. [counter]
  8. )
  9. // …
  10. }

到目前为止一切都很完美,可是,如果我需要监听浏览器事件怎么办?

避免有的读者不知道,React 给你提供的事件回调都是包装好的,事件类型是 SyntheticEvent。出于种种原因,React 并不会给你真正的 beforeinputinput 事件,preventDefault() 也无法正常使用。此外,selectionchange 是一个全局事件,亦需要在 document 元素上直接监听。而浏览器事件配合 Hooks 会有问题:

  1. function Component(props) {
  2. const [counter, setCounter] = useState(1)
  3. // …
  4. useEffect(() => {
  5. const el = elRef.current
  6. const fn = event => setCounter(counter + 1)
  7. el.addEventListener(‘click’, fn)
  8. return () => el.removeEventListener(fn)
  9. })
  10. // …
  11. }

这一段代码的问题在于,addEventListener 只会调用一次,所以回调函数里的 counter 永远是组件刚加载的时候的值。一个解决方案是给 useEffect 增加依赖,反复的调用 removeEventListeneraddEventListener。应该没有太严重的性能问题,但总觉得有些不爽。另外一个办法是使用 useRefcounter 做个包装。这也是 Litchi 目前使用的方法。但这样耦合度过高。于是我仔细思考了一下,发觉这篇文章里的内容其实很适合作为一个独立组件。它应该有如下功能:

  • 使用传统的 class 方式实现。
  • 正确的处理各类 input 事件。对于 Safari,拦截 beforeinput;对于 Chrome,回滚 DOM 修改;对于 Firefox 和 Edge,显示错误信息——Edge 很快就不需要显示了。
  • 当 Selection 发生变化的时候,通知客户代码。这比客户代码里监听 selectionchange 方便多了。
  • 当客户代码完成数据结构更新的同时,要求客户代码提供新的光标位置。在 React 完成渲染之后,自动修正光标位置。

对于最后一个问题,大部分人可能有点印象:如果 <input> 或者 contenteditable 元素的内容被 React 修正,光标会跳到当前文本节点的起始处。一般的思路是在浏览器内更新后,React 侧通过 shouldComponentUpdate 阻止更新。此处则是主动修正光标。经过测试,我没有感知任何输入迟钝。

有一点需要注意,使用 Hooks 时,useEffect 的调用可能有延时,像诸如修正光标、更新 Ref 的操作,应该使用 useLayoutEffect

我目前正在做一个,有兴趣的朋友可以关注一下。

这一篇文章其实写于 4 月 18 日星期四,要不然又要拖延。最近一段时间发现毕业之后反而心更累,尤其是搬家,需要往国内寄行李变卖家具。明明都是些琐事,但总是抑郁。

最近更倒霉的是,口腔内长了一颗多余的牙齿。和医生讨论了一下,明天把它以及四颗智齿一起拔掉。这个手术说大不大,但是也是要全麻的,想想还是有点刺激。

回国的机票是下下周一,4 月 29 日。如果下一周又拖延的话,这大概是最后一篇二本营的文章了。回国创业的话,新的猪窝就是三本营了。莫名有些惆怅。

— 2019 年 4 月 18 日于二本营。