性能优化实用经验分享——文档编辑

373 阅读10分钟

本人曾在字节跳动文档团队担任前端开发工作,负责过文档编辑性能的优化工作,过程中遇到过不少可以优化的点,整理一下,希望能给大家带来一些收获~

背景介绍

作为一个文档类产品,编辑是用户最核心、最主要的使用场景(没有之一)。随着业务的发展,在需求的迭代过程中,经常有一些不经意的改动导致了文档使用的卡顿,导致编辑体验下降。因此,团队内在上季度开始了编辑劣化的排查及防劣化方向的推进。这里主要介绍过程中发现的一些影响编辑体验的点,和一些可行的优化方向。

个人关于性能优化的理解

在我看来,性能优化就是做小部分的加法来换得大部分的减法。加法可能有很多方面,比如说逻辑的可读性可能变差,总代码量可能变多等等,减法则是指性能优化的核心目标。

性能优化的通用核心思路是减少不必要的逻辑调用。但是,对于不同的语言特性、架构实现、框架能力、优化目标来说,性能优化的手段、侧重点会有所不同

编辑性能优化分享

在文档中,编辑指标关注的是用户按下按键到内容第一次上屏之间的耗时。在录制的 performance 中,一般是指从keydown事件开始,一直到此后的浏览器paint阶段完成结束。编辑性能,主要就是关注这个周期内的耗时情况,结合业务逻辑优化这里的耗时。

经过分析发现,编辑性能主要受两个因素影响:1. 周期内代码逻辑的耗时;2. 周期内触发浏览器重渲染的耗时。因此,编辑性能优化的主要目标之一就是降低周期内两者的一些不合理、可优化的耗时。下面本人主要从多个角度针对编辑性能优化的经验进行整理和分享。

1. 避免或降低触发浏览器重渲染的耗时

浏览器的重渲染主要是由于回流导致的。这里引用一下别人对重绘、回流概念的部分介绍

现代浏览器会对频繁的回流或重绘操作进行优化:

浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。

当你访问以下属性或方法时,浏览器会立刻清空队列:

  • clientWidthclientHeightclientTopclientLeft
  • offsetWidthoffsetHeightoffsetTopoffsetLeft
  • scrollWidthscrollHeightscrollTopscrollLeft
  • widthheight
  • getComputedStyle()
  • getBoundingClientRect()

因为队列中可能会有影响到这些属性或方法返回值的操作,即使你希望获取的信息与队列中操作引发的改变无关,浏览器也会强行清空队列,确保你拿到的值是最精确的。

另外,当某一元素产生回流时,可能导致其所有子元素以及 DOM 中紧跟其后的节点、祖先节点元素的随后回流。

上面提到的文档中也提到了一些避免回流的策略。我再补充一些优化回流的方式:

根据回流的影响范围,我们可以从下面几个手段优化降低回流的耗时:

  • 精简页面的 DOM 结构
    • 如通过按需渲染、虚拟滚动等方式,将不在可见区域的内容使用空 div 占位,这样即使触发了回流,也不会耗费过多时间
    • 降低 DOM 复杂度(当然,对于一些复杂业务来说,这只是一个美好的幻想,毕竟谁会希望把自己维护的东西做复杂呢~?)
  • 把频繁会因挂载、卸载引起回流的 DOM 放在相对靠后的位置
    • 比如经常要挂载的 Popover 组件等等

2. 尽可能减少逻辑耗时

这里的优化建议大部分具有一定普适性和实用性,大家在日常编码过程中就能注意并优化~

2.1 利用好&&||的短路效果

在 Javascript 中,&&||条件判断符具有“短路”的特征。即当同时存在多个条件时,若靠前的条件已经决定了表达式的结果时,编译器就不再关心和计算后面的条件了。

日常过程中,业务迭代时,开发同学往往会根据需求简单粗暴给业务添加逻辑,或为了可读性了做了一些提前的逻辑计算,让一些看似简单的条件判断,往往也藏着不小的性能开销。

下面做一些举例:

 // 这里举的例子默认除变量外其他条件一致!

// case 1: 把容易达成的条件放在了后面
// 条件 A 有 1/1000 频率是 false
// 条件 B 大概率是 false
// 条件 B 更容易触发短路操作,却放在了靠后的位置,导致条件 A 重复被计算
const result = conditionA() && conditionB();


// case 2: 把耗时的条件执行放在了前面
// 条件 A 平均调用耗时 1ms
// 条件 B 平均调用耗时 0.0001ms
// 条件 A 存在较大的调用损耗,却放在前面,导致逻辑一直被优先调用,存在性能损耗
const result = conditionA() || conditionB();


// case 3: 多个判断条件依赖同一变量,在条件语句前提前声明了变量
// 条件 A 不依赖任何变量
// 条件 B 依赖变量 a
// 条件 C 依赖变量 a
// 变量 a 不是任何条件都需要使用,这样子会导致变量 a 变成稳定的逻辑耗时
const a = getPeerVariable();
const result = conditionA() || conditionB(a) || conditionC(a);
// 这种一般可考虑两种方式处理:
// 1. 如果是提前返回的逻辑,可以先单独判断条件 A,不满足 A 的情况下,再判断条件 B 和 C
// 2. 如果不是提前返回的逻辑,可以考虑使用 IIFE 等方式,限定变量 a 的调用条件和范围
// 以下是建议的两种调用方式:
// 方式 1:
if (conditionA()) {
    return;
}
const a = getPeerVariable();
if (conditionB(a) || conditionC(a)) {
    return;
}
// 方式 2:
const result = conditionA() || (function() {
    const a = getPeerVariable();
    return condtionB(a) || conditionC(a);
})();

当然,实际情况可能会复杂一些,可能会是几个 case 的组合。可以根据实际情况对条件的执行顺序进行适当优化及调整。

可能在一些情况下,调整完条件顺序的后果是代码可读性下降。个人认为可以考虑通过添加注释等方式,提升维护同学对代码的理解。

2.2 延迟不重要的逻辑调用

对于编辑性能处理来说,指标主要采集的是键盘按下后到浏览器首次 paint 之间执行的耗时。为了提升编辑的性能,将一些不重要的逻辑推迟到 paint 之后执行会是一个不错的方式。requestAnimationFrame方法是一个方便的延迟逻辑的选择。

这里需要注意的是——在逻辑中调用的第一个 RAF 还是会在第一次浏览器 paint 前执行,因此,想要保证逻辑在浏览器 paint 之后调用,实际上需要两次 RAF 的调用。

2.3 避免同一周期内相同逻辑的重复调用

随着文档架构复杂度的提升或需求的优化,很容易出现在同一周期内的不同阶段重复调用同一逻辑的情况。如果调用的数量多且逻辑比较耗时,那就会大幅影响相关的性能。这里建议有以下两种优化方式:

  1. 如果调用的逻辑在周期内结果不会产生变更,那么直接在第一次调用时进行缓存,找合适的时机清空缓存即可
  2. 如果调用的逻辑可能在周期内发生变更,但对逻辑的实时性要求不高,那么就可以通过延迟调用的方式,实现一个 RAF 版的节流逻辑,降低调用次数

例如,对于一些逻辑复杂且数量很多的 React ClassComponent 组件,有条件地调用setState会帮助提升不少性能~

2.4 提前终止不需要处理的逻辑

随着业务的发展,开发同学们经常需要监听某一变更进行有条件的逻辑处理。如果要处理的逻辑非常耗时或者复杂,那么,快速终止大部分不满足条件的情况继续处理逻辑会非常有效地提升性能。比如很多模块会对keydown事件做监听,有的只是为了针对特定的按键在特定场景下做一些处理。如果没有提前终止相关逻辑,将会产生不少的性能浪费。

以下是一些实用的场景举例:

  1. 如果你想监听网页尺寸的宽或高的变化进行一些逻辑更新,请增加相关的 diff,避免增加不必要的耗时
  2. 如果你想对指定的某个或某些快捷键做拦截或特别处理,可以用一些简单的规则过滤掉大部分不符合的按键操作

其实 2.1 优化建议也是为了提前终止不需要处理的逻辑,把这一点单拎出来主要是这个细节经常有同学没注意~

2.5 实现逻辑的动态注入,动态提升逻辑复杂度

这是一个偏架构层的编辑性能优化建议——通过对逻辑的动态注入,可以避免一些不必要的逻辑计算。怎么理解呢?我举个例子来说:如果文档内没有表格,我就不应该判断是否是在表格内进行编辑操作。

实现了逻辑的动态注入后,就不会在没有某模块时调用和该模块强相关的逻辑,对于大部分简单文档来说,能降低不少无用逻辑带来的耗时浪费。

2.6 避免在 React 的useState参数中直接调用并获取函数返回值作为初始值

对于 React 的函数式组件来说,每一次重渲染,实际就是重新调用了一遍函数。众所周知,useState这个 hook 只在组件初始化时会用到 hook 中的初始值,重渲染时,这个值会被直接忽略并丢弃。但是,如果useState的入参是一个函数实时计算的返回值,虽然它会被丢弃,但是函数计算的耗时依然存在。当然,解决办法也很简单——就是将写法改成回调函数,在回调函数里面返回函数计算结果。

React 在官网 API 介绍中也提到了这一点,也建议大家不要直接在useState中直接调用函数获取返回值作为初始值。

不建议写法:

const [value, setValue] = useState(expensiveCallFunction(args));

建议写法:

const [value, setValue] = useState(() => expensiveCallFunction(args));

当然,直接在函数式组件的 hooks 外进行耗时的计算操作也会直接拖慢 React 的更新效率。因此,尽量将耗时的计算操作放在 hooks 中吧~

2.7 一些其他的优化建议汇总

有一些不好归纳的性能优化手段,这里统一做一些列举:

  1. 如果你原本想用document.querySelection(aSelector)?.contains(bNode),可以换成bNode.closest(aSelector)
  2. 对于一些常用且不易变更的节点,建议以配置常量的方式缓存起来,不要每次都单独获取
  3. 如果可以通过新增一些标志来识别某些状态的,没必要借助 DOM 的 API(如document.querySelector())

总结

性能优化其实就在每个人身边~它也不是一件多复杂的事情。希望每位同学都能在日常编码过程中注意到这些优化点,做一名极致的 coder~