周末研究了一下 Google 文档的页面架构设计,不得不感叹在如何写好文档这件事情上 Google 下了大量的功夫。
image
架构设计主要分为两部分,第一部分是分页设计和排版引擎。为了更流畅的性能和多端更好的兼容性,谷歌选择使用 Canvas 来绘制整个页面,每一次输入都会发生一次 OP(Operational Transformation)传输和画布重绘,从接口的交互状态来看,前后端的排版计算引擎应该是同构的。另外一部分是插件的实现,考虑到性能和安全问题,插件的运行跟主进程之间是完全隔离的,渲染引擎会给插件提供两块能力,一块是取数据,另外一块是插入数据,注意,修改文档的能力,比如删除和替换,是没有提供的,这样的设计也保障了整个文档的编辑历史一定是由人的操作来生成的,而不是程序或者插件,在做编辑历史记录和多人协同交互的时候,能避免很多不必要的麻烦。

插件获取数据的方式就是通过读取页面暴露的 getSelection API,不过它只能探测到用户当前选中的文本位置,比如 [2038, 2044],表示从第 2038 个字符开始选中了 7 个字符,再附加一些辅助信息,也就是说插件是无法直接获取到用户文档内容的。获取不到数据,那如何对选中的内容做处理?从交互的流程上看,插件服务跑在 Google 的后端平台上,当前台插件发生交互,如将选中内容转换成 Markdown 格式的时候,会发起一个网络请求将位置信息和 token 授权信息传递给后端平台,由后端执行插件的逻辑,处理完成后再返回给前台,最后还需要在前台插件上执行插入操作,将内容插入到当前光标位置,或者执行复制操作,将内容放到剪贴板。

从插件的类型上看,大致可以分为三类,需要与文档深度数据耦合,比如划词评论、提出建议等,属于第一类,由编辑器自己实现,“划词评论”有非常多的富交互,不适合频繁与后端服务发生网络交互,而“提出建议”这个能力就做的更强大了,有点像 git 的 pull request 功能,允许评审人直接修改,作者来决定合并修改或是拒绝修改,这个交互过程中是有很多冲突需要处理的,考虑到性能和实现成本,在内核外延部分去实现这块逻辑会更方便;第二类是 Google 关联的业务,比如 Calendar、Note、Keep 等,它们也是独立运行在页面上的,不与文档发生直接关联,主要是共享数据以建立关系,比如发起一个会议、插入一条笔记等,数据可被文档消费;第三类是丰富多样的三方插件,比如生成二维码、格式转换、代码插入、绘图等,考虑到安全问题,基本上只能通过 getSelection 和 insertToDoc 这两个能力与页面发生交互,这类插件逻辑跟交互是分离的,数据的处理都在后端进行,处理完了之后插入到文档中。
image
除了排版和插件两块核心能力,另外一块比较重要的是协同部分,这就依赖协同服务对 Operational Transformation 的高效处理了,由于传输的数据格式过于复杂,就没有仔细研究了。

体验了几个小时下来,深刻感受到 Google Docs 在考虑帮助用户写好文档上下的功夫,这里的写好文档有两层含义,一是能够流畅无阻地进行编辑,即便文档再大、内容再多也可以保证交互过程不丢帧;另外一层是指能够帮助用户写出好的文档,利用一方、二方、三方插件,几乎可以做到不离开页面完成文档的撰写,比如翻译、文字纠错、文献索引、书记内容查询、绘图能力等等,如果还不够用,它甚至都提供了一个叫做「脚本编辑器」的能力,允许用户自定义脚本处理文本内容,不随意切换用户的工作上下文,是高效写作的充分条件。
image
Google 将一篇文档视作一个容器,在这个容器编辑的过程中可以配置各种各种的插件、脚本、服务,配置了的资源都可以在页面的工具栏和插件栏中找到入口,不得不说这是非常极客的做法。但是,这样的做法也并非没有缺点,主运行环境和插件运行环境的隔离设计,在让内核保持清洁高效的同时,也让用户失去了很多所见即所得的能力,比如想翻译一下,首先需要安装插件,安装好了之后等待启动、初始化和装载数据,期间要等待至少一次网络 IO,如果在墙内操作,使用体验还是太糟糕的,非常慢。