从一次性能优化看Vue的一个“feature”

使用过 Vue 的人都知道,Vue 数据驱动视图是基于gettersetter的实现的依赖收集,实现数据变更精准更新视图,而后修改 DOM 节点,可是实际上真的那么“精准”吗?vue

背景知识

首先,咱们都知道 Vue 或者 React 得以高效更新的一个核心是使用了 virtual dom(下面称 vdom),当有数据变更的时候,经过对组件新旧 vdomdiff 操做,计算出须要实际修改的 DOM 节点而后进行增删改操做。从这能够知道,diff 的准确性和性能,是总体更新性能的一个关键环节。git

VueReact 优秀(不是指总体,单指 diff 这一块)的缘由是 Vue 使用了 gettersetter 实现的视图对数据依赖的精确收集,即:当数据更新时,能够精确触发使用了该数据的组件进行更新过程。github

可是事实真的如此吗?chrome

业务场景

当前业务要作的是一个 Web 版的 逐字歌词制做器,顾名思义,咱们平时在 QQ音乐 上看到的 逐字 歌词就是使用这个工具是作出来的,大概样子如图:api

逐字歌词(只要你喜欢这首歌,咱们就是好朋友)

以前不管是 QQ 音乐、酷狗仍是酷个人逐字歌词制做器都是内嵌在桌面版客户端中的一个工具,虽然各个工具略有差别,可是核心交互都是同样的,以下图:app

制做器界面

简单来讲就是咱们给歌词的每一个字**“打标”**,经过键盘 上下左右 等操做控制游标(蓝色框框)的移动,给一个字标记上 开始时间持续时间dom

这里面的实现细节不赘述,是一个有趣但异常复杂的过程。最后实现出来上线正常使用了一段时间,直到某个节目某些歌的出现,打破了这个功能已经完美实现了的“梦想”。工具

问题出现

那是一个月黑风高,雷电交加的夜晚,测试在群里反馈了一个问题:性能

点开发现以下面这样,第一行第二个字开始,后面所有卡住了大概两秒,直接跳到了第二行测试

很明显这是一个很是严重的性能问题,须要紧急解决。

定位问题

找到这首歌,发现是一首长达 17 分钟的说唱歌曲《现实 VS 梦想》(虽然这个 battle 梦想赢了,可是我却被现实战胜了┑( ̄Д  ̄)┍)。

普通歌曲 VS 这首说唱

咱们开启 Vue 的 performance模式,打开 chrome 的 performance 面板看看到底瓶颈在哪。

操做 20 秒,前 10 秒每秒按一次“向右”,后十秒按住“向右”不放,看看时间耗在了哪里?

不看不知道,一看吓一跳

从上面能够看出前十秒帧率波动很是大,然后面用 10 秒渲染了一帧,彻底就是卡死的节奏,对应的就是前面动图后两秒的效果。从面板下部分的火焰图能够看出来,scripting计算密密麻麻,占用了很是多的时间。

再看按一次“向右”事件的回调耗时,一次回调的耗时就达到了 ** 243毫秒**

再看 vue-dev-tool 面板

你没看错,ElButton 的更新这里显示用了 2000 多秒,一开始我觉得这是 dev-tool 的一个统计的 bug,因此直接看 lyricMaker 这个组件(就是上面那个歌词制做器)。从上面火焰图能够看出大多数的时间都花在了脚本运算,结合页面逻辑,制做器中每一行、每一个字的展现样式,都须要动态计算(每次前进后退都算一遍)的,因此引发性能问题的缘由定为:

当歌词的字很是多的时候,每次移动光标,触发了过多的 diff 计算,致使页面卡顿。

解决问题

既然知道了问题是字过多引发的 diff 计算耗时过多,那么就和解决最多见的那类型问题—— DOM节点过多怎么优化?的问题同样了:删除掉不在可视区域的节点

如上图,只须要把 >±6 当前行的行歌词都隐藏掉便可,改造后再看性能面板和 dev-tool 面板:

能够看出事件回调的耗时已经从 243ms -> 8ms,从火焰图中看出,方法的调用已经少了很是多,目前看来优化的成效是明显的。

若是咱们看问题只看表面,那到此就已经撒花结束了。不过上面截图的一个小细节,引发了个人兴趣。

Dig Deep

再看 vue-del-tool 面板,细心的话咱们能够发现 ElButton 组件的耗时从 2390310ms 减小到了 15255ms,足足减小了 99% 的耗时,其中 updateRender 占用了 99%的耗时。

lyricMaker 组件的耗时只是减小了一半,ElButton更像是引发性能大提高的关键所在,从这开始我猜想一开始的几十万毫秒,并非一个错误显示,而是真实的状况。鉴于蓝翔挖掘精神,咱们去把 dev-tool 的源码弄下来找出这个面板显示时间的统计逻辑,主要在这个方法:代码

const COMPONENT_HOOKS = [
  'beforeCreate',
  'created',
  'beforeMount',
  'mounted',
  'beforeUpdate',
  'updated',
  'beforeDestroyed',
  'destroyed'
]

const RENDER_HOOKS = {
  beforeMount: { after: 'mountRender' },
  mounted: { before: 'mountRender' },
  beforeUpdate: { after: 'updateRender' },
  updated: { before: 'updateRender' }
}
function applyHooks (vm) {
  if (vm.$options.$_devtoolsPerfHooks) return
  vm.$options.$_devtoolsPerfHooks = true

  const renderMetrics = {}

  COMPONENT_HOOKS.forEach(hook => {
    const renderHook = RENDER_HOOKS[hook]

    const handler = function () {
      if (SharedData.recordPerf) {
        // Before
        const time = performance.now()
        if (renderHook && renderHook.before) {
          // Render hook ends before one hook
          const metric = renderMetrics[renderHook.before]
          if (metric) {
            metric.end = time
            addComponentMetric(vm.$options, renderHook.before, metric.start, metric.end)
          }
        }

        // After
        this.$once(`hook:${hook}`, () => {
          const newTime = performance.now()
          addComponentMetric(vm.$options, hook, time, newTime)
          if (renderHook && renderHook.after) {
            // Render hook starts after one hook
            renderMetrics[renderHook.after] = {
              start: newTime,
              end: 0
            }
          }
        })
      }
    }
    const currentValue = vm.$options[hook]
    if (Array.isArray(currentValue)) {
      vm.$options[hook] = [handler, ...currentValue]
    } else if (typeof currentValue === 'function') {
      vm.$options[hook] = [handler, currentValue]
    } else {
      vm.$options[hook] = [handler]
    }
  })
}
复制代码

从上面代码看出(COMPONENT_HOOKS.forEach开始),updateRender 这个耗时是从 beforeUpdate 开始到 updated 结束这段时间,是每一个组件都会算到统计中,好比当次数据变化有 1 千个 button 更新,共花了 1 秒,那么这个面板统计 ElButton 的 updateRender 时间是 1秒 * 1000,也就是 10000毫秒。

因此说上面咱们的 ElButton updateRender 用了 2390 秒是真实存在的,只不过还须要除以一个组件个数,并非显示错误!

咱们先来看一个 Demo,点击“点我”按钮,更新 i 的值,能够看到控制台输出 3 btn updated

Wait~ 思考 3 秒,是否是有哪里怪怪的?

1 秒。

2 秒。。

3 秒。。。

button 组件为何会更新?!和文章一开始说的高效更新机制是否是有点冲突了?咱们从代码能够看出 button 并无使用到 i 变量,那么在 i 变化先后应该是不会触发更新的。而逐字制做器中的 button 写法也是如此:

<div class="tool">
    <el-button type="info" @click="handleLineModify(lineIndex)">修改</el-button> <el-button type="danger" @click="handleLineDelete(lineIndex)">删除</el-button> </div> 复制代码

一样由于不相关的状态数据更新,引发了这两个按钮的更新。这一切的一切究竟是人性的扭曲仍是道德的沦丧,敬请关注今晚23点 59分。。。咳咳,串场了抱歉...

When you have eliminated the impossible, whatever remains,however improbable,must be the truth.
----Sherlock·Holmes

既然事已至此,咱们去深刻研究下 Vue 的更新流程是怎样的。(这里省略一万字漫长的研究 Vue 源码的过程)

最终能够定位到,在 ElButton 被触发更新前,是由于 lyricMaker 组件在 diff 过程当中当遍历到 ElButton 这个元素的时候,强制执行了 ElButton$forceUpadte 方法,从而引发的性能雪崩。

完整源码在这:代码

精简版代码

从源码能够看出,有两种状况致使触发强制更新:

  • 第一个是:hasDynamicScopedSlot 为 true,至于这个值什么时候为真,又是一个能够写一篇文章的故事,这里暂且不表,如今主要看第二个。

  • 第二个是 当组件拥有子元素(静态的、动态的)的时候,每次 diff 都会强制更新,这是 Vue 的一个 Feature!。咱们看看 Vue 的做者尤大是怎么说的:github.com/vuejs/vue/p…

野生翻译菌:包含静态 slot 的组件,由于有可能在父组件状态更新以后,slot 已经发生了变化,因此须要强制更新一次此子组件。

可是如 PR 所说,这个问题在 Vue3.0中就不会存在了,全部 slot 都会统一为 scope slot 处理。当前咱们也有一个不太优雅的解决方案是手动把全部静态内容都设置为 scope slot。能够看这个 Demo

思考题

  • 上面的第一个 Demo,若是把 <!-- updateCount: {{updateCount}} --> 这个注释放开,会发生什么事?

总结

  1. 有时候咱们解决问题了,也可能只是歪打正着而已。

  2. 有时候咱们代码出问题了,并非 bug,是 Feature!


版权声明:原创文章,如需转载,请注明出处“本文首发于xlaoyu.info

相关文章
相关标签/搜索