如何不借助 contentEditable 实现富文本编辑器?
13 个回答
能提出这个问题,说明题主有过很仔细的思考。我尝试借此澄清一个大家入门 Web 富文本编辑时可能的误解吧:
在浏览器里, 打开了 contentEditable 不等于 借助了 contentEditable。这是什么意思呢?
- 打开了 contentEditable,意味着页面这个区域内 具备了进行富文本编辑的能力 。
- 借助了 contentEditable,意味着 依赖了浏览器默认的富文本编辑行为 。
粗略看来,一个 DOM 元素加上 contentEditable 属性后,就大致具备了这些能力:
- 选区的拖选编辑和光标闪动支持
- 方向键控制时的默认行为
- 输入文字时的默认行为(牵扯到输入法)
- 复制粘贴时的默认行为(牵扯到富文本剪贴)
主流浏览器都会给你一套开箱即用的方案,让你用一行代码即可实现富文本编辑器。但天下没有免费的午餐,像这种最简单的方案就有传说中的多份快乐,啊不兼容的问题。比如同样一个加粗操作,没人规定浏览器是应该给文本套个
<b>
标签还是给
<span>
加个
font-weight:bold;
的 CSS 样式来实现(印象里 Chrome 是直接套 span 的,说好的语义化呢)。于是最后在 Chrome 和 Firefox 里互相编辑的产物,就必然是不可控、不可预期的了。很多人说富文本做起来很脏,这就是原因之一。
所以还是那句老话, 完全依赖别人现成的技术是会被卡脖子的 。相应地,Facebook 的 DraftJS 虽然不太好用,但代表了一种 React 思潮引领下的富文本新思路(大概率不是它首创,但这么说起来方便前端同学们理解),简单来说是这样的:
- 同样用 contentEditable,但只关心它的选区 (Selection) 状态。
- 输入文本时,以 nextState = f(selection, input) 理念计算出新状态。
这样看来,整个编辑器就像是一个 React 中的受控组件了。关键的受控行为大概包括这些:
- 按键输入被拦截,基于 f(selection, input) 计算出新状态
- 复制粘贴被拦截,基于 f(selection, input) 计算出新状态
- DOM 更改被拦截,基于 nextState 单向地渲染出 DOM 状态
那么,还有哪些地方需要依赖 contentEditable 呢?其实就是和 Selection 强相关的东西:
- 选区高亮状态 依赖 contentEditable,否则你需要自己渲染那个「拖蓝」区域。
- 点击后的选中状态 依赖 contentEditable,否则你需要自己计算某个坐标下对应了哪个文字,意味着要自己去解析字体参数做文本排版,这是天坑。
- 方向键操作后的状态 依赖 contentEditable,理由同上。
我相信看到这里,有经验的前端同学已经知道怎样开始尝试「不借助 contentEditable 的默认行为来实现富文本编辑器」了吧。当然,我已经挺久没做富文本了,上面一些概念可能与具体细节有些出入,欢迎大家指正。
注意,许多实际工程做起来,绝不是知道个原理就够了。富文本在细节层面有非常多麻烦的地方, 除非实力雄厚如 Google,否则不推荐大家在商业项目里完全从头做起 。具体可参考我的这个回答:
有多大比例的前端工程师,能在合理时间内独立开发出一个足以商用的富文本编辑器?
具体到实际项目,ProseMirror 和 Slate 的框架化设计都很不错。我相信它们是开发「自主可控」的富文本编辑器时的正解。许多历史经验已经告诉我们, 自主可控未必意味着完全自己从头写,而是要掌控对满足自己未来需求的控制权 。在这小小的富文本编辑器里,不也是这样吗?
可以看看 Draft.js 项目和代码。
简单地说就是拦截各种操作事件,然后转换成对 immutable 对象的操作,然后再渲染反映出来。
比如光标和选择,就是有一个内部的 SelectionState ( https:// draftjs.org/docs/api-re ference-selection-state )来存储当前状态,你可以找到 offset 等信息。那么可想而知,当鼠标点击编辑器的时候,你就需要拦截然后看是单击还是拖拽,然后再计算 offset 等,再拦截键盘事件,把接受到的字符在 ContentState 里面插入到对应的 offset 位置(单击)或者替换内容(拖拽)。之后再利用 React 等渲染出来,看到的就是替换掉了。
优势很明显,内容清晰不会混入乱七八糟的 HTML,可以对元素额外添加很多描述信息,完全控制交互体验,支持跨端。
劣势也很明显,体验比较差,因为所有操作都需要妥善处理,但这正是最棘手的地方,之前基于这个开发,只能魔改源代码强加判断逻辑。知乎也用的 draft js,选择一段文本然后删除这种基本操作也时常失效。其次就是长文章粘贴性能较差,改动太多。
所以语雀最早是基于类似技术的组件实现的(好像是 Slate js)后来重构基于 contentEditable 实现,编辑体验我个人感觉提升了一层。
不过 contentEditable 的问题也有很多,浏览器的行为不一致以及 HTML 内容的不可控等等。总之两种方案给开发者的感觉都是吃屎,都是要处理各种编辑器和 edge cases。
总之一些建议(如果你们真要这么搞):
- 如无特别的必要,还是用 contentEditable。浏览器的行为还算一致,HTML parse 搞一搞也可以解决内容不可控的问题,体验好省事,比如 1k 代码量就可以搞一个富文本编辑器了( https:// github.com/jaredreich/p ell )。这取决于你们产品是否跨端、是否富内容类型以及你们投入人力的决心。
- 引入 immutable。为了性能和回退、前进的实现。
- 设计好 底层内容描述协议 。Draft.js 的有创新性也有论文但感觉比较 tricky,最初的时候文本中间 inline 的插入表情都无法实现,后来用 emoji 来实现的。。。后来者 Slate 就好多了。这将是最核心的部分,直接关系到这个产品的能力限制和后期维护性。