从浏览器源码开始实现 Canvas 富文本编辑器

807 阅读13分钟

在前端领域,有许多成熟的、知名的富文本编辑器开源方案,它们大都是基于 DOM 实现的。出于练手的目的,我构建了一个运行在 Canvas 内的富文本编辑器,而且是从浏览器源码开始构造它。

项目开源地址:github.com/WaiSiuKei/n…

萌生想法

我所在的公司是一家专注于在线设计领域的 SaaS 公司。这个领域有很多知名的应用,其中最著名的是 Figma,也是我个人非常钦佩的产品之一。在我所在的公司,我负责搭建了响应式网页编辑器,用于制作落地活动页。虽然我之前没有接触过像 Figma 这样的设计工具,但是凭借过往项目的经历,以及对 Figma 技术栈的初步认识,我想要尝试模仿一下 Figma 这个行业领先产品。我们也知道,Figma 是庞大的、成熟的产品,所以我只想尝试实现 Figma 中的一些独特的、我没有接触过的东西。

在 Figma 的博客中,他们的前 CTO 提到了这个产品的技术选型动机,例如实现 Web API 还未支持的角度渐变、更好地支持 OS X 平台触控板捏合手势以及实现自定义混合模式等。虽然我个人没有遇到这些问题,但在维护响应式网页编辑器的过程中,以及在了解公司内其他团队维护的平面编辑器项目时,我能够感受到与 Figma 团队共同面临的痛点是文本布局在不同浏览器之间不一致,甚至同一款浏览器在不同平台上也存在不一致性。具体而言,一段文字渲染后的宽度可能会有小于1像素的微小差异,这导致文字容器在不同浏览器环境下可能出现不同的换行情况。对于设计工具来说,这种违背设计师意图的现象是不可接受的。解决这个问题需要通过精确感知和控制文字在不同平台的渲染情况,来控制文字的排版行为。但是目前 Web API 并未提供接口去访问文字渲染相关的字形轮廓和字距调整表,这不仅没有在 HTML 标准中定义,而且浏览器厂商也没有提供可用的私有实现。

为了解决上述问题,Figma 会自行操控 GPU 进行渲染处理。因此,它的内部代码看起来很像在浏览器中运行的浏览器,具有自己的 DOM、合成器和文本布局引擎。此外,它的开发团队也是考虑去添加一个渲染树,类似于浏览器用于呈现 HTML 的渲染树。在这之后,将使 Figma 的渲染流水线与浏览器关键渲染路径非常相似。

对浏览器源码动手

作为行业的跟随者,效仿业界的标杆公司去做同样的事情往往会让你在技术评审中胜出。因此,我也选择了走向同一条技术路线:在浏览器中运行另一个浏览器。

由于在对浏览器该怎样实现这个问题缺乏了解,所以我是从真实的、完整成熟的 Chromium 的项目它的源码开始研究。最初,我从 Chromium Blink 内核的 DOM 模块开始,将其中的 DOM 和 Layout 模块的代码一行一行地复制粘贴到一个从零开始构建的简易内核中。但我很快就放弃了,因为浏览器已经发展了这么多年,有这么多的贡献者。这样的复制粘贴工作需要花费很长时间才能让 Demo 完工。

我开始寻找更小型的、现成的 Chromium 移植版,然后找到了 @龙泉寺扫地僧 开发的 miniblink49 项目。如其名,它是基于 Blink 内核 49 版的迷你版本。据作者介绍,他去除了 Chromium Blink 内核内排版渲染以外的所有内容,只它专注于网页的排版和渲染,而且手写了一个更简单的渲染合成层。这肯定比原版简单得多。然而,我还是放弃了这个方案,因为 miniblink 至少也算是符合 HTML 标准的浏览器,其中一些元素,如 iframe 、 SVG 等以及随着 HTML 发展而积累的 table 元素和 table 布局并不是我需要的,裁剪这么多代码也不是很容易。

最终,我发现了 YouTube 的 Cobalt 项目,它能够运行 HTML5 标准的子集。据 Cobalt 团队开发者称,他们最初也是维护一个基于 Chromium 移植版,但为了在游戏主机、机顶盒等资源受限的环境中实现基于 HTML5 的视频浏览和播放应用程序,经过不懈努力,他们开发出了 Cobalt 项目,该项目适用于内存少、CPU 慢、单核心甚至可能缺乏 GPU 的受限环境。他们从头开始构建了一个简化的 HTML 子集实现和 CSS Box 模型所需的代码,没有使用复杂的 Blink 内核,也没有使用 Chromium 复杂的合成器和渲染管线的代码。他们在 Cobalt 项目中实现了以下几个重要的技术栈:

  • DOM 层:这是实现 W3C 标准子集的地方,可以支持常用的 HTML 标签,布局方面支持流式布局和 Flex 布局。
  • 布局引擎:基于 DOM 树计算布局树,并进一步生成用于指示绘图的渲染树,以发送到渲染器。
  • 渲染器/ Skia:渲染器遍历布局引擎生成的渲染树,使用 Skia 图形库对其进行绘制、光栅化输出。

Cobalt 项目已经足够精简,可以实现一个「浏览器内的浏览器」。虽然它还不支持更先进的 Grid 布局,但使用 Flex 布局已经足以模拟 Figma 的自动布局。我先前维护的响应式网页编辑器项目是支持将导入的 Figma 设计稿转换为网页。在进行 Figma 导入时,它内部的逻辑会将 Figma 自动布局相关的属性转换为 Flex 布局相关的 CSS 属性。因此,如果我在 Cobalt 的基础上继续迭代,实现类似 Figma 自动布局的效果也是可行的。

MVVM 架构

在将 Cobalt 内核移植用于渲染后,我可以使用 HTML 来描述 DOM 结构,然后渲染内核就可以在 Canvas 元素内绘制出界面内容,但是这只是一个单纯的数据展示层,我还需要完成完整的架构设计,将各个分层模块组合起来才能形成富文本编辑器。目前,我直接沿用了响应式网页编辑器的架构,这是很容易理解的。我之前完成的响应式网页编辑器它是使用原生的 DOM 和 CSS 来声明内容并实现绘制目的。早前的架构可以很容易平移到基于移植内核的编辑器,两者的区别在于前者基于真实的 DOM,而后者则基于移植的虚拟 DOM。在具体上,在前一个编辑器项目中,我采用了 MVVM 架构,是出于这样的考虑:

为了适应未来的多人实时协同编辑场景,我在 Model 层需要考虑数据变更的适配。Figma 团队在其博客中介绍了适用于实时协同场景的数据设计的方法。Figma 文档中的内容形成了类似于 HTML DOM 树的树形结构。更进一步,每个对象都有一个 ID,因此可以将文档看作是二阶映射:Map<ObjectID, Map<Property, Value>>;或将其视为一个数据库,其中行存储是 (ObjectID, Property, Value) 元组。在 Figma 中,子对象使用小数来记录其在父对象中的顺序,这有助于减少节点移动时所需的数据点变更。此外,这个小数采用 Base95 编码来记录,是一种字节级别的数据优化。通过这些关键信息,我发现采用这种扁平的、类似于数据库的设计,在文档中进行子节点顺序改动和子节点移动到新的父节点时,可以减少数据变动,并且易于解决同步冲突。为了将来方便与基于 Yjs 社区里的实时协同服务对接,我在编码实现中引入了 Yjs 定义的数据类型。

Yjs 定义的数据类型 API 比较麻烦,不太方便直接用于前端渲染。我引入了一个 ViewModel 层去将 Yjs 定义的数据类型转换为原生类型的数据,以便于前端渲染。该层会监听 Model 层的变化,以最小的代价维护一个树状数据结构,并将其用于 View 层的渲染。Yjs 提供了一系列可以让业务方控制数据变化的 API,我可以通过监听事件回调获知变化类型(如 add/update/delete),并进行相应的数据同步操作。例如,在维护节点对象的子对象数组时,应该采用最短编辑距离的方法来更新数组。类比于 Vue 组件中计算属性的计算过程,ViewModel 层所做的工作类似于计算属性的计算过程。而且,ViewModel 树状数据的节点被定义为适合直接传递给 ProseMirror 以反序列化、从嵌套 JSON 数据构建编辑内容。

上述 ViewModel 层维护的树状结构数据,会被用于构建真正的 DOM 树。在响应式网页编辑器项目中,我使用了 Vue 来构建 DOM 树。然而,在现在这个基于移植的虚拟 DOM 的富文本编辑器中,我们可以选择一些没有虚拟 DOM 层的前端框架(例如 Svelte),以减少不必要的层级。我选择了 Pettie-vue 项目,它类似于 Vue,可以将响应式对象转换为想要的页面,但比 Vue 更简单,更容易移植到我所定义的虚拟 DOM 层上。

在完成了这个 MVVM 架构的搭建之后,当编辑器的 Model 层数据发生变化时,它就会按照以下路径指示 Skia 绘图,从而更新界面内容:

Model -> ViewModel -> View(DOM 树)-> Layout 树数据 -> Render 树数据

移植 ProseMirror

上述搭建的 MVVM 架构只完成了编辑器使用链路中的半条链路——将数据更新到视图,而另一半链路是如何通过用户在视图上的鼠标键盘交互来更新数据。

在解决后半条链路的问题时,首先需要解决的是如何将 Canvas 内的鼠标键盘事件发送到外部这个问题。我在前公司的 Canvas 图表项目开发过程中是顺带写了一个面向对象风格的 Canvas 库,它提供了类似于 DOM 事件模型的事件机制,让业务代码可以通过类似于 node.addEventListener 的 API 来监听事件。这种方式模拟了 DOM API,实现方案和目标效果非常清晰易懂。然而,在编辑器中,经常会出现对象的增加和删除,如果直接在对象上添加事件监听器,会让编辑器忙于增加和删除事件监听器,这样的设计会非常麻烦。在前端开发实践中,一种解决这种问题的方法是采用事件委托。例如,VSCode 的 Monaco 编辑器内部的事件模型,也使用事件委托的方式在 View 层集中处理事件。从社区信息中可以得知,Monaco 是脱离浏览器自带编辑能力而独立实现光标和排版引擎的。在当前编辑器开发中,我也需要自己解决光标和键盘事件问题。因此,我参考了 VSCode 和 Monaco 的代码,直接得到了各种鼠标移动、拖拽和键盘输入、特别是 IME 输入相关的处理方案,加速了 Demo 的开发进程。

在完成了上述事件机制之后,我回头结合之前研究的浏览器源码来对 ProseMirror 进行移植。Cobalt 源码中没有 Chromium 中所有与文本编辑相关的部分,但是两者都是基于遵守 HTML 标准去实现 Node、Text、Element 和 HTMLElement 这些类。这使得我可以直接从 Chromium 的 Editing 模块中复制与 Range 和 Selection 相关的代码来使用。在事件模型和 DOM 模型都准备好之后,移植 ProseMirror 就非常容易了。这个过程中,我也会遇到一些业务代码上的问题,例如 ProseMirror 和 Pettie-Vue 都会构造出新的 DOM Node 实例来组成 DOM 树,然后形成页面内容。我需要改造它们,使它们可以在一个已有的 DOM 结构中建立实例并正常工作。

总结

现在这个新的编辑器已经实现了编辑和渲染反馈的完整链路,感兴趣的朋友可以在 Demo 页面中体验横排文字的编辑,包括中英文等。我也将整个项目中可以公开的源代码更新到了 GitHub,地址是 github.com/WaiSiuKei/n… 。大家可以看到很多 VSCode、Cobalt、Chromium 项目的代码,以及我在许多周末和下班时间写的代码。

作为一个练手项目,这个编辑器已经验证了将浏览器应用移植到 Canvas 中的可行性。在移植了浏览器源码之后,如果我们继续移植现有成熟的基于 DOM 的项目,就可以让这些项目在 Canvas 中运行。基于 DOM 的生态有多大,基于「浏览器内的浏览器」的生态就有多大。

这个「浏览器内的浏览器」不仅遵循 HTML 标准以提供标准的环境,还可以实现尚未定案或在不同浏览器之间兼容性不足的标准。例如,我们知道 W3C 和 WHATWG 多年来一直在推进 CSS Houdini,但从目前(2023年)的情况来看,标准定案、浏览器厂商实现并推广到用户替代旧版本,最后主流版本都支持该特性,这个过程需要持续很多年。但是,如果我们基于「浏览器内的浏览器」这种技术路线,就可以在当前就使用尚未定案或兼容性不足的特性。换句话说,这将成为 HTML 标准的 Polyfill。尽管这样的 Polyfill 方案仍然会受到浏览器软硬件特性(特别是色彩空间和 GPU 方面)的限制,但在排版及其应用方面,我们还有很多有趣的应用等待实现。