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

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

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

优化前平均执行一次 mousemove 时间需要 60 ms,优化后只需要 1 ms,性能提升 60 倍

看完本篇文章,可以了解到以下内容

  • 解决问题的思考方式
  • 基本的调试技巧
  • Vue 源码的相关知识

问题描述


由于业务内容比较敏感,我这里做了一个小 Demo 来复现问题,在线体验地址

卡顿效果如下:

1686402551447.png


然后同事还告诉我,如果表格里面没有数据,就不会卡顿了

优化卡顿问题


在进行优化前,我们首先要确定卡顿的原因,根据卡顿的原因,才能找到优化的方向


确定卡顿的原因


同事 A:既然 Table 没有数据就不会卡顿,那明显就是 Table 数据量导致的,这时候我们的优化手段,应该是通过减少一次性渲染的数据量,例如分页、虚拟滚动。

当时我听了,似乎有点道理,但其实不太对。原因如下:

  • 表格数据只有 20 条,数量不多,数据量应该不是导致卡顿的核心原因

因此我用 Chrome Performance 工具尝试查找性能瓶颈,部分内容如下:

1686402468216.png


这个图怎么看呢?

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 = ( ,就能找到源码了

1686402451011.png

然后打个断点

1686402294219.png

其中 n1 和 n2,就是老的 VNode 和新的 VNode,patch 函数会比对两个 VNode 的差异,找到它们的差异,然后更新,同时也会继续对它们的 children 进行 patch

但是这样打断点,它每个元素的 patch 都会停下来,因此我们要设置条件断点,我们只关注 Table 组件,需要在 Table 组件停下来

那问题就变成了,如何设置条件断点,让在 Table 组件 patch 时停下来?

我们可以通过组件名称来判断,因此断点条件为 n1?.type?.name === 'ATable'VNode.type 属性就是我们定义Vue 组件的那个对象

1686402283243.png

这样就停在 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 的时候,就可以只对比动态部分,跳过静态部分,从而提升性能。

我们可以看这个在线例子


1686402146285.png

从上图可以看出,模板编译后的代码,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 工具截图

1686402115725.png


可以看出,每个 Task 执行时间已经降到 1 ms 左右,每帧都能绘制出一个图像

总结


当我们遇到问题时,首先要思考造成问题的原因,因为这决定了你排查和优化的方向,如果一开始就不对,可能很难达到效果。

找到排查方向后,就可以提出猜想,然后进行验证。我这里是直接通过调试源码去验证,调试过程需要一定的技巧,可以利用好全局搜索条件断点,如果对源码有一定的熟系,那就更事半功倍了。

最后,希望本文能对大家有所帮助,当遇到同类问题时,也能快速想到问题原因和解决方案。

如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)

目录
相关文章
|
Java 数据库 Android开发
性能提示-流畅运行的Android应用
性能提示-流畅运行的Android应用
63 0
|
小程序 开发者 异构计算
小程序真机调试反应很慢卡顿,界面跳转之后,页面出现空白,无法点击等问题解决方案
小程序真机调试反应很慢卡顿,界面跳转之后,页面出现空白,无法点击等问题解决方案
1165 0
小程序真机调试反应很慢卡顿,界面跳转之后,页面出现空白,无法点击等问题解决方案
|
3月前
|
安全 BI UED
分享一个在 WinForm 桌面程序中使用进度条展示报表处理进度的例子,提升用户体验
分享一个在 WinForm 桌面程序中使用进度条展示报表处理进度的例子,提升用户体验
|
6月前
|
开发工具 索引
点击一个消除游戏图标时,背后都发生了什么
点击一个消除游戏图标时,背后都发生了什么
66 1
如何实现一个丝滑的点击水波效果
本文为Varlet组件库源码主题阅读系列第九篇,读完本篇,可以了解到如何使用一个`div`创建一个点击的水波效果。
90 0
|
小程序 前端开发 iOS开发
小程序页面左右滑动如何解决
小程序页面左右滑动如何解决
|
缓存 搜索推荐 UED
你的列表很卡?这4个优化能让你的列表丝般顺滑
本篇介绍了 Flutter 列表 ListView的4个优化要点,非常实用,让你的列表不再卡顿,丝般顺滑!
551 0
你的列表很卡?这4个优化能让你的列表丝般顺滑
|
Web App开发 JavaScript 前端开发
我优化了进度条,页面性能竟提高了70%
大家好,我是零一。最近我准备在组里进行代码串讲,所以我梳理了下项目之前的业务代码。在梳理的过程中,我看到了有个进度条组件写的非常好,这又想起我刚开始学前端时写的进度条的代码,跟这个比起来真的差距太大了(大部分的初学者应该都想不到,而且我第一次实习的公司带我的mentor亦是如此)。 因此,我想给大家分享一下这个思路极好的进度条组件,同时它也存在非常严重的性能问题,本文末尾也会讲解一下问题所在以及优化方式
280 0
我优化了进度条,页面性能竟提高了70%