我是如何优化弹窗拖拽卡顿的?内附排查和优化过程
最近在项目中遇到并解决了一个弹窗拖拽卡顿严重的问题,解决过程还是挺有意思挺有感触的,因此记录一下。
优化前平均执行一次 mousemove
时间需要 60 ms,优化后只需要 1 ms,性能提升 60 倍
看完本篇文章,可以了解到以下内容
- • 解决问题的思考方式
- • 基本的调试技巧
- • Vue 源码的相关知识
问题描述
由于业务内容比较敏感,我这里做了一个小 Demo 来复现问题,在线体验地址[1]
卡顿效果如下:

然后同事还告诉我,如果表格里面没有数据,就不会卡顿了
优化卡顿问题
在进行优化前,我们首先要确定卡顿的原因,根据卡顿的原因,才能找到优化的方向
确定卡顿的原因
同事 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![] &&
!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 的时候,就可以只对比动态部分,跳过静态部分,从而提升性能。
我们可以看这个在线例子[2]

从上图可以看出,模板编译后的代码,createElementBlock 函数(可以理解为 render 的 h 渲染函数)在渲染函数 h 的基础上,会多传一个参数 PatchFlag(3,二进制为 11) ,这就代表了,这个 VNode 对应的元素,动态部分为 Text 和 Class,其他内容都是静态的。
而我们写渲染函数的时候,是不会传 PatchFlag 的,因此 Vue 不知道哪些内容是动态的,哪些是静态的,因此没有优化。
JSX 也会经过编译,为什么它不能生成 PatchFlag?
我在《浅谈前端框架原理》[3]中谈到过这个问题:
- • 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 录屏的时候掉帧了。。。可以直接到在线地址[4]体验
优化后的 Performance 工具截图

可以看出,每个 Task 执行时间已经降到 1 ms 左右,每帧都能绘制出一个图像
总结
当我们遇到问题时,首先要思考造成问题的原因,因为这决定了你排查和优化的方向,如果一开始就不对,可能很难达到效果。
找到排查方向后,就可以提出猜想,然后进行验证。我这里是直接通过调试源码去验证,调试过程需要一定的技巧,可以利用好全局搜索和条件断点,如果对源码有一定的熟系,那就更事半功倍了。
最后,希望本文能对大家有所帮助,当遇到同类问题时,也能快速想到问题原因和解决方案。
引用链接
[1]
在线体验地址: https://stackblitz.com/edit/vitejs-vite-xoopyd?file=src%2FApp.vue,src%2Fcomponents%2FHelloWorld.vue,src%2Fmain.ts,package.json,src%2Fcomponents%2Fuse-drag-modal.ts&terminal=dev[2]
在线例子: https://sfc.vuejs.org/#eNp9j81qw0AMhF9F3YtbiL3kapxAb32DXnRxHTl28P4grdOD2XevNimltJCbZjT6GG3mNcbmupJpTScDzzGBUFrjEf3sYuAEGzCNkGHk4KDSaIUe/RC8JHByhkPZP1dvtCwB3gMvp6fqBX1n7zgFqUjk4tInUgXQTXtoh6UXOaBRBJrjtt1YOXd22muosz8XZmfuTWrXx+YiwWvXrXDweyFoWrg5xdOGRaOZUorSWivjUD68SBP4bHVqePVpdtSQuPqDw6cQKxjN7hfDqnklrpn8iZj4EfNP9B+3YDP6bPIXOFx/ag==[3]
《浅谈前端框架原理》: https://juejin.cn/post/7194473892268736549[4]
在线地址: https://stackblitz.com/edit/vitejs-vite-xoopyd?file=src%2FApp.vue,src%2Fcomponents%2FHelloWorld.vue,src%2Fmain.ts,package.json,src%2Fcomponents%2Fuse-drag-modal.ts&terminal=dev