1. 1. 单向依赖架构
  2. 2. UI逻辑分离
  3. 3. 异步依赖注入和闲时依赖注入
  4. 4. 容器化销毁&复用
  5. 5. 延迟初始化
    1. 5.1. 解决办法:
  6. 6. 只读-可编辑分离
    1. 6.1. 第一步,创建只读态sheetbar:
    2. 6.2. 第二步,将只读态sheetbar装饰成可编辑态sheetbar:
    3. 6.3. 第三步, 添加上只读态-可编辑态切换逻辑:
  7. 7. 多端分离
  8. 8. 插件化架构(洋葱架构)
  9. 9. 总结:

腾讯文档是一个对性能要求比较高,且挑战比较大的项目。除了打开性能和可编辑性能,我们还要考虑内存占用、fps(特别是滚动的时候)、卡顿等性能情况。平时我们也做了很多常规性能优化,在架构上,我们也希望能尽可能为性能的提升提供一些帮助。

腾讯文档是一个对性能要求比较高,且挑战比较大的项目。除了打开性能和可编辑性能,我们还要考虑内存占用、fps(特别是滚动的时候)、卡顿等性能情况。平时我们也做了很多常规性能优化,在架构上,我们也希望能尽可能为性能的提升提供一些帮助。

现在我们来系统化的盘点一下在架构层面可以为性能提升做的事情。

单向依赖架构

单向依赖架构除了帮我们构建一个稳定易迭代的系统之外,利用单向依赖架构,可以做许多性能有关的工作。

  • 在许多场景下,不需要加载全部层次的模块,只需要加载某些高层模块就可以,比如ssr直出就只需要加载和执行数据层的代码,加上一个轻量的dom渲染层就可以,因为canvas渲染层是单向依赖数据层的,我们可以很轻松的在ssr直出场景下,让业务不去加载复杂的canvas渲染层。

  • 我们可以利用单向依赖架构,在不同的生命周期去加载不同层次的代码,高层模块因为不依赖低层模块,我们可以让高层模块优先独立加载和执行。比如各个品类的核心编辑器都可以分为数据层、渲染层、feature层(复制粘贴等功能)、插件层(分享等组件和网络层、离线层等插件),为了保证用户第一时间看到编辑器和编辑器里的数据,我们先加载和执行最高层的数据层,接下来再去处理渲染层。

    这两层加载和执行完之后就可以让用户第一时间看到编辑器里的数据了。其中渲染层又可以按照特性的优先级别,分为了三层,我们最优先让渲染层的第一层(单元格、文字、背景颜色等)渲染出来,再去渲染第二层和第三层筛选、冻结等非主要UI部分。

最后在加载页面中的各种插件。在单向依赖架构下,我们可以按照这种方式,很方便的调节各个层级代码的加载和执行,让用户尽早看到页面,将那些不需要第一时间加载的部分很方便的进行延迟加载和执行。

UI逻辑分离

基于UI和逻辑分离架构,在某些时候,我们也可以反过来,先让UI显然出来,让用户及早看到页面相关元素,再加载逻辑代码,将逻辑代码注入给UI,完成整个功能。

这种方式适用于UI和逻辑是并列关系的情况,UI和逻辑都不依赖对方的存在,它们可以在后期通过中介者绑定起来。典型的如腾讯文档工具栏,我们可以将工具栏分为3个部分:

  • UI部分,用 workbench 配置文件的方式就可以独立的将UI绘制出来
  • 异步加载的逻辑代码,比如点击某个按钮后将要执行的 featrue
  • 中间胶水代码,作为中介者将按钮(UI代码)和点击按钮后的事件以及事件里需要执行的逻辑绑定起来(逻辑代码)

我们在第一时间先将UI部分独立渲染出来,先让用户看到完整的工具栏界面,这一步可以通过workbench+配置文件就可以完成。 接下来加载工具栏点击后将要执行的的feature,并提供一个controller中间层将对应的UI和feature一一绑定起来。用户对加载和绑定feature的动作是没有感知的,用户也可以更快的看到工具栏相关UI界面。

在一些只读权限的文档中,因为用户不会触发UI对应的真正点击事件,这种情况下,我们其实只需要完成第一步 - 将工具栏的UI部分渲染出来就可以了.

异步依赖注入和闲时依赖注入

腾讯文档业务中,除了核心的编辑区之外,其他功能基本都是可以异步加载的。一些feature组件,比如分享按钮,显然可以等到编辑区渲染完之后再去加载,用户一般也不会第一时间去使用分享按钮。

大部分通用服务,其实也是可以异步加载的,比如看起来优先级很高的协同层和离线层。因为用户一般也不会第一时间就产生操作数据。即使用户在协同层还没加载好时就需要提交操作数据,我们也可以用一些简单的办法,比如先在本地保存好用户操作编辑数据,延迟到协同层加载好之后,再通过协同层来提交这些数据。

可以说,除了最主体的编辑器部分(我们希望让用户第一时间看到数据,和可以编辑数据)。其他模块在理想情况下最好都通过异步加载或者闲时加载的方式去处理。

但这么多的异步加载或者闲时加载模块,会对代码的可读性和可维护性产生不少冲击,前端代码中对异步逻辑和同步逻辑的处理还是有蛮大的区别,特别是一些嵌套的异步处理场景会更加麻烦,开发者要将90%的异步模块和10%的同步模块很好的融合在一起是不太可能的,对异步模块的加载和处理一般都散落在业务逻辑中,在很多异步模块和很多同步模块混杂在一起的时候,业务开发者很难看清它们的依赖关系。

我们利用依赖注入容器,扩展了异步依赖注入、闲时依赖注入、worker依赖注入,让开发者可以用同样的视角去对待异步模块和同步模块,可以很大程度上减轻业务开发者在处理异步模块和同步模块分歧上的痛苦。同时,异步模块也可以像同步模块一样,被容器化销毁和复用机制管理起来。

虽然异步依赖注入、闲时依赖注入、worker依赖注入不会直接提高业务的性能,但可以帮助业务开发者更有动机和信心,也能更方便和快捷的将一个同步模块转换为异步模块。

容器化销毁&复用

利用依赖注入容器的销毁&复用策略,我们可以将每个对象的状态管理起来,在“切换页面”的时候,我们不用再销毁整个页面,重新加载执行代码,也不用从头开始构建系统中的整个对象树。

而是可以按照需求,在同一个页面内销毁应该销毁的对象(有状态并且产生了新状态的对象),复用可以复用的对象(无状态或者没有产生新状态的对象),达到快速跳转页面和节省内存的效果,像vscode一样,如果能利用好容器化销毁&复用策略,对提高页面跳转速度和降低内存占用都能非常明显的做硬。

延迟初始化

利用依赖注入容器的能力,我们可以将容器里的对象全部设置为延迟初始化,当依赖注入容器分析完class与class之间的依赖关系之后,并不会真正去创建一个个真实对象实例,而是先创建一些没有任何属性的空对象作为proxy代理,这个空对象占用的资源是很少的,它占用掉的内存和创建它所花掉的时间都可以忽略不计。

image-20220504141359860

当消费者真正开始调用它想要调用的对象的时候,这时候才会在proxy的get方法里去new出对应的真实对象,消费者的每一次调用实际上都是传递给proxy,proxy再委托给真实对象。

除了容器里对象粒度的延迟初始化,同样的思路可以被我们用在更多需要延迟加载或者延迟执行的模块里。

比如上报模块,我们也利用了类似的思路来降低上报代码的加载和执行对于首屏打开速速的影响。

上报是一种基础功能,站在上报功能开发者的角度,不知道我们的消费者-业务开发者会在什么时候调用上报,所以通常来说,我们会将上报代码在最前面就加载进来。

上报操作也是很耗资源的,一方面公司的上报系统很多,产品和开发需要的观测的数据也很繁杂,导致整个上报基础库非常庞大,占用的下载资源会影响其他重要模块,比如可见和可编辑模块的加载。另一方面,这些上报动作也会抢占http上行带宽。其实我们对具体上报的时机没有太高要求,服务端早几秒和晚几秒拿到上报数据没有区别。

我们最好可以让上报代码加载和上报动作的执行都在浏览器空闲的时候去执行,将最宝贵的时间留给首屏元素渲染。

解决办法:

创建一个跟report仓库具有相同的接口的proxy类,强制业务代码统一去调用proxy类,proxy类并不包含真正的上报代码,当Client开始上报动作时: Proxy只需要做这两件事情:

  • 如果Report本体已经加载,直接将请求传递给Report本体
  • 如果Report本体还没有加载,则将请求保存起来,等Report本地加载完之后再将请求传递给Report本体

image-20220504141528653

Proxy类的代码只有几十行,通过这种方式,我们可以解决加载Report库和具体Report代码带来的性能问题。

使用proxy的方式,除了上报模块之外,还有很多其他同步模块可以用类似的方式,转化为异步去加载执行。但有2个重点的地方需要注意:

  • 可以将同步模块转化为异步去加载执行的前提是,要么其他模块不关心这个模块的处理结果,要么就需要处理好同步模块转化为异步模块之后,这些模块和其他模块的依赖关系
  • proxy要和本体拥有一样的接口,消费者不用去他们在接口背后的真正差异。作为消费者,不愿意去关注它调用的到底是不是proxy,不能因为使用了proxy而给消费者增加额外的负担,否则可能得不偿失。在消费者看来,它调用的就是原来的模块。

只读-可编辑分离

腾讯文档在满足业务复杂性同时,保持高性能是不太容易的。页面中存在几百个大的组件,这些组件的全部代码下载下来就占了几十万行.

腾讯文档分为只读和可编辑两种状态,这两种状态下需要执行的逻辑是非常不一样的。只读态下需要之行的逻辑和需要下载的代码都要比可编辑态下少的多。

拿sheetbar举例,只读状态下只需要渲染好相关UI,并且支持切换sheet就可以。移动、新增、删除、重命名、创建副本、设置sheet权限等功能都只有在可编辑态下才起作用。

同时腾讯文档只读和可编辑的比例在各个品类中大致都占比60%以上,在某些场景下甚至达到了80%。也就是说,如果我们可以把组件的只读和可编辑状态分开,那么某些组件可能在80%的场景只需要加载和执行原本20%的代码.

我们用状态模式 + 装饰者模式来解决只读-可编辑分离的问题。大致思路如下:

  1. 只读组件优先单独开发,只考虑只读的场景,可编辑的代码、事件等都不需要被包含在只读组件中
  2. 可编辑代码通过高阶组件给只读组件动态装饰上可编辑功能
  3. 系统初始化时优先加载只读组件代码,当系统中触发只读-可编辑切换时,给对象组件加载并装饰可编辑代码,并切换到可编辑组件

入下图:

image-20220504141739481

第一步,创建只读态sheetbar:

  1. 将sheetbar组件的逻辑和UI完全分开
  2. 将sheetbar的UI部分默认写成只读模式,只渲染只读相关的UI
  3. 将只读状态相关的逻辑组合到一起,并注入sheetbar的只读UI

到这里就完成了只读态下的sheetbar的渲染

第二步,将只读态sheetbar装饰成可编辑态sheetbar:

  1. 通过高阶组件将只读态sheetbar的UI装饰成可编辑态sheetbar的UI
  2. 将可编辑相关的逻辑组合到一起,并通过装饰者模式,装饰在只读状态相关逻辑上面,一起注入可编辑态UI

第三步, 添加上只读态-可编辑态切换逻辑:

  1. 新建一个负责readonly和editabel这两种状态切换的manager,
  2. 将根据当前权限创建出来的只读态组件或者可编辑态组件分别放入manager
  3. manager监听文档状态的变化,并调用当前组件(可能是只读态,也可能是可编辑态)的destory方法进行销毁,同时调用另外一个组件的init方法进行初始化.

用这种方式,我们可以在大部分只读态场景下,只加载和执行只读态相关的少量代码,在权限进行动态切换的时候,再切换成可编辑态组件或者只读态组件。在一些对性能要求比较高的组件,且可编辑逻辑占大多数的时候(比如sheetbar组件,工具栏组件,查找替换组件等),这种只读-可编辑分离的方式对提高性能是比较有效的。

总体而言,通过只读-可编辑分离的思想和实践,我们可以得到这些好处:

  • 在只读权限下只加载和执行只读逻辑相关的代码,节省资源下载事件、代码解析时间和内存占用
  • 在可编辑权限下,先加载只读权限的相关代码,让组件先可见,再动态给组件装饰上可编辑功能,不影响用户最终使用的前提下,可以尽快的让用户先看到组件。

多端分离

当各个终端的代码混在一起的时候,除了业务逻辑的耦合交叉降低代码可维护性之外,对性能也有非常大的损伤。

1
2
3
4
5
6
7
if (pc){
// showPcTips();
}else if (mobile){
// showMobileTips();
}else if (native){
// showNativeTips();
}

像这些代码里,在pc端要加载mobile端的代码,在mobile端要加载pc端的代码,都会严重增大代码包的体积,我们用依赖注入+多态service的方式来分离不同端的代码,让各个端的代码尽量不要交叉被包含在同一个文件中,对有效提升业务性能也有不错的帮助。

插件化架构(洋葱架构)

前面我们介绍过,整个系统都希望基于单向依赖原则去构建。一旦各个模块之间的联系都遵了单向依赖架构,那整个系统也很容易被改造成插件化架构(洋葱架构)

从低层往高层看,任何低层模块对于它的高层模块而言,都可以看成高层模块的插件,它们是可选可组装的,低层模块可以像洋葱一样,一层一层被剥离掉,而不影响高层模块的功能,高层模块不需要知道低层模块的存在。

image-20220504142131242

比如当我们剥离掉模块D,这时候A,B,C模块可以脱离D模块在系统中独立运行。同理在一些场景下,我们也可以剥离掉模块C和模块D,让A,B这两个模块独立运行。

利用这个机制,我们可以对不同层级的模块是否需要加载,需要在什么时机加载非常方便的进行控制。

比如在编辑器内部,先加载最高层的数据层,接下来是渲染层,最后是feature层,即使feature层还没有加载好,数据层和渲染层也足够为用户提供编辑操作能力。

对整个sheet或者doc,我们可以先加载编辑器,再加载低层的工具栏插件、分享插件、网络层插件、离线层插件、和其他点击按需加载的插件,即使这些插件还没有加载好,也不影响腾讯文档的主体部分开始工作,而这些插件的加载和执行,我们可以很方便的用配置文件或者task的方式来控制。

总结:

性能的问题是多维度的,架构不能帮助业务解决所有性能问题,但希望在一些地方可以提供帮助,开发人员可以不那么艰难的去独自面对性能问题。

略带一些遗憾的是,因为人力等一些原因,只读-可编辑,容器化销毁&复用等策略还只应用在了极少部分模块里,预计21年会在更多模块补好,争取给用户提供一个性能更优的腾讯文档。

在架构优化和性能优化的过程中,开发者还要多去考虑架构与性能的平衡,性能和架构有时候是矛盾的,在这时候我们需要做一些取舍。

用两个例子说明下:

  • 在19年下半年,我和yussicahe同学曾经对sheet曾经做过一次性能优化,当时我出了个主意,为了减少首屏代码的下载和执行,我们将一些代码量非常大的核心模块(mutation、request)中的每个类都拆成了两部分,一部分是首屏渲染所需的代码,一部分是完整可编辑需要的代码。

    一个mutation里可能有30个方法,只要执行完其中10个方法就足够完成对首屏渲染的支持,于是我们将这10个方法放到一个类里,在首屏渲染之间就加载它们,另外20个方法放在另外一个类里,等到首屏渲染完成之后再加载它们,并跟前面一个类动态合成最终的类。

    上线后性能优化的效果非常不错,首屏加载时间降低了大概800ms,lighthouse的评分也秒杀了谷歌文档。但在后续的需求迭代中,问题就暴露出来了,开发人员写一个需求的时候,都不知道他的代码到底应该放在哪个类中,维护起来也很麻烦,随着业务需求的变化我们可能要不停的去调整这些类之间的关系。

    为了性能优化,我们破坏了类的封装性与内聚性,导致了后面开发者的维护困难。虽然提高了性能,但破坏了架构的合理性,这是不划算的,虽然性能数据好看,但它如同肌肉版的特兰克斯,无法真正在竞争中带来优势。很快我们又将代码改了回去。

  • 曾经我们做过一些CSR优化,为了让用户第一时间看到页面,sheet、doc等品类都会在前端用canvas每隔一段时间在本地截取一张图片,下次用户打开页面时优先使用这张图片,就能更快的看到页面。在客户端app环境下,我们将这张图片保存在了客户端,客户端在webview启动之前就可以将图片展示出来,看起来具有惊人的打开速度。

    但我们也付出了另外一些代价:

    • 用户机器资源占用比较大,如果用户的表格复杂,一张图片可能有10m以上,而且要为用户保存多张图片(用户会打开很多表格的各个子表).
    • 截图的过程中可能造成用户操作卡顿
    • canvas画出来的图渲染出来,和真实在浏览器用canvas渲染出来,经常会出现一些细微的差别,导致用户的页面有跳动感。而且canvas缓存图片是有时间间隔的,大概10秒缓存一次,如果这10秒间用户有编辑动作,就会导致缓存图片和真实的见面有差别,这时候跳动就更大了。
    • 我们将图片保存到了客户端,并且依赖了一些客户端接口。这样的方案不具有一致性,如果又增加了别的客户端宿主,又得重新实现一遍这些客户端接口。而且跟在web上的实现还不一致,比如图片是存在indexdb里面,又要考虑indexdb的兼容性和一些容错机制。 根据以上原因,虽然CSR秒看能大幅提高页面打开速度,但它带来的副作用也促使我们决定去寻找更加适合的方案。