为什么web富文本编辑器是天坑?
2017年3月15日
知乎看到一个问题,《为什么都说富文本编辑器是天坑》。里面的答案挺有意思的,各位做web前端的大牛们都有自己的切肤之痛。有兴趣的可以手工复制以下网址查看:
https://www.zhihu.com/question/38699645/answer/108376836
但是这些答案大部分还是停留在碰到的问题上面,而没有去深入总结和思考富文本编辑器背后的需求和实现思路。正好前两年在medium上看到一篇文章,叫《Why ContentEditable is Terrible》,推荐给大家。原文可点击左下角链接查看。
https://medium.engineering/why-contenteditable-is-terrible-122d8a40e480
以下为简略意译版本:
【一段引子,大意就是表示作者在富文本编辑器的问题上已经思考了一年了。】
从数学证明的角度来说,ContentEditable在富文本的编辑这件事上是有问题的(broken,也许应该翻为烯烂?)。
首先,什么叫作“所见即所得”(WYSIWYG)?当你选中一段文本按下回车的时候会发生什么?这些问题的定义并不是很清楚,而从数学证明的角度可以首先弄明白我们要研究的问题是什么。
什么叫所见即所得?一个好的所见即所得编辑器应该遵守以下三条公理:
- DOM内容和可见的内容的映射应该是工作良好的(well-behaved)
- DOM选区和可见的选区的映射应该是工作良好的(well-behaved)
- 所有可见的编辑操作应该映射到对可见内容的一个代数闭域和完整集合
我将会解释这三条分别是什么意思,以及富文本编辑器为什么应该遵守这三条规则。但是首先我们要记住,公理是不需要证明的,所以它们的正确性先不作置疑。
接下来,我们会看到ContentEditable无法遵守上面的任意一条公理。
最后我们来看一看一些解决方案,以及Medium的富文本编辑器如何解决这个问题。
。。。敷衍的分隔线。。。
DOM空间是指页面的内在结构,而可视空间(WYSIWYG)是页面的渲染结果。当我们说两个页面一样时,是指他们在可视空间看起来一样。
浏览器的渲染引擎做的工作就是将DOM空间映射到可视空间。即对DOM x来说,可视空间 = 渲染函数Render(x)。
当我们说一个映射工作良好(well-behaved)时,是指所有的编辑操作都能在映射中反映出来。如果用E表示编辑操作,x和y表示DOM,则有
- 如果Render(x) = Render(y)
- 那么Render(E(x)) = Render(E(y))
这其实是在处理“所见”之后的“所得”部分。即如果两个页面看起来一样,我们对它们做相同的编辑操作,那么操作之后的结果应该仍然看起来一样。
事实上无数的富文本编辑器无法满足这个假设。看例子
The hobbit was a very well-to-do hobbit, and his name was Baggins.
对应的DOM大概如下
The <a href=”http://en.wikipedia.org/wiki/The_Hobbit">hobbit</a> was a very well-to-do hobbit, and his name was <strong><em>Baggins</em></strong>.
The <a href=”http://en.wikipedia.org/wiki/The_Hobbit">hobbit</a> was a very well-to-do hobbit, and his name was <strong><em>Baggins</em></strong>.
对于粗体和任何的部分,有非常多的编码方法
<strong><em>Baggins</em></strong>
<em><strong>Baggins</strong></em>
<em><strong>Bagg</strong><strong>ins</strong></em>
<em><strong>Bagg</strong></em><strong><em>ins</em></strong>
<strong><em>Baggins</em></strong>
<em><strong>Baggins</strong></em>
<em><strong>Bagg</strong><strong>ins</strong></em>
<em><strong>Bagg</strong></em><strong><em>ins</em></strong>
从编辑器的角度来说,它们应该是一样的,但是从DOM的角度很难识别它们是同样的东西。
很多编辑器在这方面会有问题,比如有一个空的span
可能会导致两个ContentEditable元素看起来一样,但编辑行为完全不一样,让用户觉得很难编辑,工程师也很难调试。
理想情况下,我们应该可以对DOM使用可视化编辑的指令,这些指令会保证对可视空间相同的页面进行相同编辑操作后可视空间仍然是完全相同的。
。。。分隔线。。。
DOM空间和可视空间的映射很抓狂,但至少是多对一的,一个DOM只会有一种可视表现。
选区就更糟糕了,它是多对多映射的。
his name was <strong><em>Baggins</em></strong>
his name was <strong><em>Baggins</em></strong>
假设这样一段内容,光标在Baggins前面,那么光标到底是在<strong>
前后还是<em>
前后?你打字的时候希望是粗体的还是斜体的还是没有任何样式的?
更麻烦的是,一个DOM选区会有多种可视表示。比如“well-to-do”在“to-”后面折行了,那么在光标在第一行最后和在第二行开始对应着相同的DOM位置,但可视位置却不一样。
我们设计编辑器时希望选区看起来和表现起来都是一样的,但是这个映射完全乱七八糟。
。。。分隔线。。。
【这一段举了webkit的例子,省略,只说结论】
富文本编辑器可以产出非常简洁易懂的代码,但是一旦碰到来自外部的内容就蒙逼了,比如粘贴自网页或者word的内容。
一个好的富文本编辑器的内容应该是对自己的编辑行为闭域的,即内容应该只来自于正常的输入和编辑,而不应该介入来自外部的内容。
。。。分隔线。。。
好的富文本要怎么做?Medium的思路:
- 为文档生成数据模型,可以根据模型判断是否在可视空间相同
- 建立从DOM到模型的映射关系
- 在模型上建立编辑操作的定义
- 将所有的键盘和鼠标操作映射到上述编辑操作
1 模型
Medium编辑器的模型包含两部分:一个段落列表,一个区块列表。
每个段落包括:纯文本,标记(如第1个字到第5个字是粗体),图片等数据,布局数据(位置等)
区域则是包含段落的一片区域。
选区使用两个点来表示,每个点由段落和索引和文本的偏移量来表示。
这个模型的好处是当且仅当两个模型相同的时候,可视空间才会相同,模型的任何变更都会很好地映射到可视空间(well-defined)。
2 DOM到模型的映射
分为内部映射和外部映射。内部映射是DOM和模型的映射,我们希望是一对一的。外部映射是当我们拿到粘贴的内容时,需要将内容映射到我们的段落-区块模型。我们希望外部映射是有损的,首先处理纯文本,然后是粗体/任何/链接等标记,接下来是图片和其它的格式 。
将模型映射到DOM看起来类似这样:
<div> <!-- root -->
<section> <!-- section -->
<!-- section-inner -->
<div class="section-inner layout-column">
<p> <!-- paragraph -->
<strong><em>Baggins</em></strong> <!-- text -->
<div> <!-- root -->
<section> <!-- section -->
<!-- section-inner -->
<div class="section-inner layout-column">
<p> <!-- paragraph -->
<strong><em>Baggins</em></strong> <!-- text -->
【省略一段解释对应关系的,注释中都有写了。】
映射样式的时候,我们会按顺序进行:首先是A,然后STRONG,然后EM,绝不会出现A在STRONG中的情况。
3 编辑操作
定义了6种操作:添加段落、移除段落、更新段落、添加选区、移除选区、更新选区。
所有的编辑操作都可以由这6种操作进行组合而来。在这些操作下,所有的内容都是结构良好的。这些操作直接对应我们模型的变更,而不是DOM的变更 。
4 捕获编辑动作
键盘和鼠标操作被映射到这6种操作中,具体而言是从ContentEditable操作中映射而来:换行(enter/ctrl-m)、删除(delete/backspace)、输入、粘贴等。我们捕获这些事件,然后阻止它们的默认动作,然后将这些动作转换成内部的编辑操作。
对其它的键盘事件,我们不阻止默认动作,而是让ContentEditor进行操作,然后在事件结束后将段落和DOM再映射回模型。
如果每次模型变更都全量更新DOM,编辑体验会很差,因为内容在不停闪烁。我们监听了模型的变更,尽量让DOM的变化最小化。
。。。分隔线。。。
【展望未来的富文本编辑器,略,都是没影的东西。】