我是如何优化弹窗拖拽卡顿的?内附排查和优化过程

我心飞翔 分类:vue

最近在项目中遇到并解决了一个弹窗拖拽卡顿严重的问题,解决过程还是挺有意思挺有感触的,因此记录一下。

优化前平均执行一次 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![] &&
          !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 的修仙秘籍(点击可跳转)

我是如何优化弹窗拖拽卡顿的?内附排查和优化过程

回复

我来回复
  • 暂无回复内容