最近在项目中遇到并解决了一个弹窗拖拽卡顿严重的问题,解决过程还是挺有意思挺有感触的,因此记录一下。
优化前平均执行一次 mousemove
时间需要 60 ms,优化后只需要 1 ms,性能提升 60 倍
看完本篇文章,可以了解到以下内容
- 解决问题的思考方式
- 基本的调试技巧
- Vue 源码的相关知识
问题描述
由于业务内容比较敏感,我这里做了一个小 Demo 来复现问题,在线体验地址
卡顿效果如下:
然后同事还告诉我,如果表格里面没有数据,就不会卡顿了
优化卡顿问题
在进行优化前,我们首先要确定卡顿的原因,根据卡顿的原因,才能找到优化的方向
确定卡顿的原因
同事 A:既然 Table 没有数据就不会卡顿,那明显就是 Table 数据量导致的,这时候我们的优化手段,应该是通过减少一次性渲染的数据量,例如分页、虚拟滚动。
当时我听了,似乎有点道理,但其实不太对。原因如下:
- 表格数据只有 20 条,数量不多,数据量应该不是导致卡顿的核心原因。
因此我用 Chrome Performance 工具尝试查找性能瓶颈,部分内容如下:
这个图怎么看呢?
Task:代表一个宏任务,这个 Task 是由 mousemove
事件触发的,就是我们拖拽弹窗的事件回调。
纵向虚线:两条虚线间的时间代表一帧
可以看出,在一帧内,并不能完成一个 Task,由于 JS Task 的执行,和渲染是相互阻塞的,因此会导致在几帧内,仍然无法渲染出新的图像,即引起掉帧,从用户的角度看就是卡顿。
那是什么原因导致 JS 执行时间过长呢?
从图中可以看到,执行了非常多的 patch
函数。
patch
函数,是 Vue3 的补丁函数,它的作用是:在状态改变后,比对新 VNode 和老 VNode,找出差异的部分,并进行更新。
另外,Vue 会对组件进行编译优化,大部分情况下,如果组件的 props 和 slots 没有变化,是可以跳过该组件的 patch
阶段的。
理论上,我们拖拽只改变了弹窗的 style
属性,并没有改变 Table 组件的 props 和 slots,因此 Table 组件及其子组件的 patch
理论上是会被跳过的。而 Performance 工具中搜集到的函数,不应该会有这么多 patch
函数的调用.
但事实上并不如我们想象的那样,里面有非常多的 patch
,我猜是因为某些特殊原因导致优化失效,patch
进入到 Table 组件内部
那接下来要做的,就是找到这个原因,这个我们可以直接到源码那里调试
恰好看过一点源码,我们直接去找 patch
函数的定义,我们直接在控制台 ctrl + shift + F
全局搜索关键字: const patch = (
,就能找到源码了
然后打个断点
其中 n1 和 n2,就是老的 VNode 和新的 VNode,patch
函数会比对两个 VNode 的差异,找到它们的差异,然后更新,同时也会继续对它们的 children 进行 patch
。
但是这样打断点,它每个元素的 patch
都会停下来,因此我们要设置条件断点,我们只关注 Table
组件,需要在 Table
组件停下来
那问题就变成了,如何设置条件断点,让在 Table
组件 patch
时停下来?
我们可以通过组件名称来判断,因此断点条件为 n1?.type?.name === 'ATable'
, VNode.type
属性就是我们定义Vue 组件的那个对象
这样就停在 Table
组件上了,然后我们继续执行,会进入到这几个函数
patch
> processComponent
> updateComponent
const updateComponent = (n1, n2, optimized) => { const instance = n2.component = n1.component; if (shouldUpdateComponent(n1, n2, optimized)) { if (instance.asyncDep && !instance.asyncResolved) { if (true) { pushWarningContext(n2); } updateComponentPreRender(instance, n2, optimized); if (true) { popWarningContext(); } return; } else { instance.next = n2; invalidateJob(instance.update); instance.update(); } } else { n2.el = n1.el; instance.vnode = n2; } };
我们可以看到这里有个 shouldUpdateComponent
的判断,如果组件不需要 update,就说明该元素不需要更新,就不会继续往组件里面 patch
了。
实际运行可以知道 shouldUpdateComponent
返回值为 true
,那我们看看 shouldUpdateComponent
函数源码(有节选):
export function shouldUpdateComponent( prevVNode: VNode, nextVNode: VNode, optimized?: boolean ): boolean { const { props: prevProps, children: prevChildren, component } = prevVNode const { props: nextProps, children: nextChildren, patchFlag } = nextVNode const emits = component!.emitsOptions // 判断是否可以优化 if (optimized && patchFlag >= 0) { // 如果有动态的 Slots,就需要更新组件 if (patchFlag & PatchFlags.DYNAMIC_SLOTS) { // slot content that references values that might have changed, // e.g. in a v-for return true } // 如果所有的 props 都没有改变,就不需要更新组件 // 这个是组件有动态 props(传的 props 名字也是变量)的分支,如 v-bind:[key] // 需要对比所有 props if (patchFlag & PatchFlags.FULL_PROPS) { // 对比所有 props,如果没有改变,就 return false return hasPropsChanged(prevProps, nextProps!, emits) } // 如果所有的 props 都没有改变,就不需要更新组件 // 这个是组件的所有 props 的名字固定,那就值对比部分会变化的 props 即可 else if (patchFlag & PatchFlags.PROPS) { const dynamicProps = nextVNode.dynamicProps! for (let i = 0; i < dynamicProps.length; i++) { const key = dynamicProps[i] if ( nextProps![key] !== prevProps![key] && !isEmitListener(emits, key) ) { return true } } } } else { // this path is only taken by manually written render functions // so presence of any children leads to a forced update // 翻译:这条路径是手写 render 函数,才会走的路径,所有 children 都会被强制更新 if (prevChildren || nextChildren) { // $stable 是用于跳过强制更新,但 $stable 需要手动设置,一般不会设置 if (!nextChildren || !(nextChildren as any).$stable) { return true } } if (prevProps === nextProps) { return false } if (!prevProps) { return !!nextProps } if (!nextProps) { return true } return hasPropsChanged(prevProps, nextProps, emits) } return false }
从 shouldUpdateComponent
的注释中(英文那段),可以看出,手写渲染函数时,会强制更新所有 children
由于 JSX 实际上也会编译成渲染函数,因此 JSX 也会走到该分支
而 Table 组件,由于其复杂性,大多数组件库都会选择使用 JSX 去实现,Antd vue 也不例外,因此没有走优化的分支,从而对里面的元素递归进行 patch
,由于 Table 组件内的元素非常的多,所以我们在 Performance 工具中会看到那么多的 patch
运行
为什么使用 template 的模板会有优化?
Vue 会在编译模板时,分析模板中,动态部分和静态部分,那么在比对 VNode 的时候,就可以只对比动态部分,跳过静态部分,从而提升性能。
我们可以看这个在线例子
从上图可以看出,模板编译后的代码,createElementBlock 函数(可以理解为 render 的 h 渲染函数)在渲染函数 h 的基础上,会多传一个参数 PatchFlag(3,二进制为 11) ,这就代表了,这个 VNode 对应的元素,动态部分为 Text 和 Class,其他内容都是静态的。
而我们写渲染函数的时候,是不会传 PatchFlag 的,因此 Vue 不知道哪些内容是动态的,哪些是静态的,因此没有优化。
JSX 也会经过编译,为什么它不能生成 PatchFlag?
我在《浅谈前端框架原理》中谈到过这个问题:
- JSX 一种 ECMAScript 的语法糖,基于 ECMAScript 语法
- Template 则是扩充了 HTML 语法
两者都能用于描述 UI,但 template 相对于 JSX,灵活性较低,但这也意味着其分析的难度更低,更容易找出动态部分和静态部分
而 JSX 基于 ECMAScript,ECMAScript 语法非常灵活**,难以实现静态分析**
例如:js 的对象可以复制、修改、导入导出等,用 js 变量存储的 jsx 内容,无法判断是否为静态内容,因为可能在不知道哪个地方就被修改了,无法做静态标记。
但也并不是完全没有办法,例如可以通过约束 JSX 的灵活性,使其能够被静态分析,例如 SolidJS。但目前 Vue 没有做。
实施优化
既然问题已经找到了:Table 组件是 JSX 写的,因此没有编译优化,Vue 会强制进入 Table 组件对立面的元素进行更新。
但我们只是拖拽一下弹窗,Table 组件的内容是完全没有变的,要想办法不要强制更新 Table 组件及其 children。
刚好 Vue3.2 出了一个新的命令 v-memo
,可以缓存一个模板的子树,只要 v-memo
依赖的值没变,就不会去 patch
组件
对于 v-memo
的更多内容,可以查看我写的文章《Vue v-memo 指令的使用与源码解析》
因此,我们只需要加入一行代码即可
<a-table v-memo="[columns, tableData]" :columns="columns" :data-source="tableData" bordered :pagination="false" :scroll="{ y: '650px' }" size="small" > <template #bodyCell="{ record, column, text, index }"> <template v-if="column.dataIndex === 'a'"> <div> <a-input v-model:value="tableData[index].a"></a-input> </div> </template> </template> </a-table>
优化后,就非常丝滑了,Gif 图就不放了,因为 Gif 录屏的时候掉帧了。。。可以直接到在线地址体验
优化后的 Performance 工具截图
可以看出,每个 Task 执行时间已经降到 1 ms 左右,每帧都能绘制出一个图像
总结
当我们遇到问题时,首先要思考造成问题的原因,因为这决定了你排查和优化的方向,如果一开始就不对,可能很难达到效果。
找到排查方向后,就可以提出猜想,然后进行验证。我这里是直接通过调试源码去验证,调试过程需要一定的技巧,可以利用好全局搜索和条件断点,如果对源码有一定的熟系,那就更事半功倍了。
最后,希望本文能对大家有所帮助,当遇到同类问题时,也能快速想到问题原因和解决方案。
如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)