[Performance Optimization Practice] Using cloneDeep in computed properties may cause performance problems

foreword

I believe that students have read a lot of performance optimization articles, and most of the routines have basically been understood. For example, for resource file optimization (webpack packaging optimization, gzip compression, cdn acceleration, browser caching, lazy loading, http2, etc.), page rendering optimization (SSR, virtual list, reducing reflow and redrawing, etc.).

But we are more involved in business, and most performance problems are caused by the negligence of developers. Due to the continuous iteration of business functions, it has recently been found that the project also has similar performance problems. The following is a relatively hidden performance problem encountered when troubleshooting performance problems. In the process, I also got some ideas to share with my classmates.

In this article you will learn:

  • Using the Performance tool
  • Record and analyze flame graphs
  • Computed property principle, cloneDeep process

performance issues

The data flow is briefly described here. The project is divided into multiple modules. Each module depends on the QuestionList stored in vuex. The module will construct application data and render based on the QuestionList.

Let's talk about the main performance issues. When changing a certain configuration of the Item in the QuestionList, it will be stuck for ~1.5s before the response. as follows:

After optimization:

This is silky smooth~ Let's take a look at the optimization process idea.

Performance Analysis

Performance is a visual analysis tool provided by Google Chrome, which is used to record and analyze all the activities of the application at runtime, which can help developers locate performance problems well.

Ready to work

In order to avoid the influence of other factors on the Performance analysis, some preparations need to be done:

  1. Turn on the incognito mode in the browser to avoid the performance impact of the Chrome plug-in on the page
  2. Close the vuex logger plug-in in the development environment project, and the deepCopy in the plug-in will increase the execution time (depending on whether the project is used, mainly reducing the impact of the project logger plug-in)

How to use Performance

F12 opens the developer tools and selects the Performance panel:

左上角有 3 个小按钮。点击的实心圆按钮,Performance 会开始帮记录用户后续的交互操作;点击圆箭头按钮,Performance 会将页面重新加载,计算加载过程中的性能表现;点击最后一个按钮是清除记录。

因为我们是要记录用户实际操作的过程,所以选用圆按钮来记录。首先点击圆按钮开始录制,然后在页面正常进行更改配置的操作,完成后点击 stop 停止录制。

只需等待一小会,Performance 就会输出一份报告。

报告主要分为三大块,分别是:

  • 概览面板:Performance 就会将几个关键指标,诸如页面帧速 (FPS)、CPU 资源消耗、网络请求流量、V8 内存使用量 (堆内存) 等,按照时间顺序做成图表的形式展现出来。
  • 性能指标面板:它主要用来展现特定时间段内的多种性能指标数据,一般用的比较多的是 Main,它记录了渲染进程的主线程的任务执行记录。
  • 详情面板:对应于性能面板只能得到一个大致的信息,如果想要查看这些记录的详细信息,可以通过在性能面板中选中性能指标中的任何历史数据,然后选中记录的细节信息就会展现在详情面板中了。

分析火焰图

点开 Main 指标来展开主进程的任务执行记录,为了更方便分析,可以在概览面板中拖动选择一个区间具体分析任务执行记录。

一段段横条代表执行一个个任务,长度越长,花费的时间越多;竖向代表该任务的执行记录,最上面的是主任务,继续往下就是子任务,就像函数执行栈。一般我们也称它为“火焰图”。

接下来分析火焰图,引入眼帘最长的一段任务就是 get 任务,下面的子任务依次是 updateComponent、render、compomutedGetter、evaluate等,之前看过部分 Vue 源码,大概就知道是依赖属性变化让计算属性重新求值,接着是页面渲染。但这些对于我们排查意义并不大,只能有些提示的作用,因为都是 Vue 内部执行的函数。

往下就看到 svgHeight 这个业务上的函数,这个才是优先需要去排查的点。

定位瓶颈

找到相关代码:

computed: {
  questionList () {
    let list = cloneDeep(this.QuestionList).map((q, qi) => {
        // ...
        return q
    })
    return list
  },
  svgHeight () {
    let h = 0
    this.questionList.forEach((q, i) => {
      h += this._getQuestionHeight(i)
      if (i !== this.questionList.length - 1) {
        h += CONTAINER_GAP_HEIGHT
      }
    })
    return h + CREVICE_HEIGHT
  }
}
复制代码

这里可以大概定位瓶颈,在更改配置时,响应式数据变更,让计算属性(svgHeight)重新计算。

瓶颈思考

大胆假设

从上面的代码中可以看到,svgHeight 依赖另一个计算属性(questionList ),questionList 又依赖于 vuex 中的 QuestionList。

看到这里我就产生了一个疑问,计算属性只是依赖 QuestionList 这个响应式属性,并没有依赖到具体的配置字段属性,为什么在更改配置会触发重新计算?

在我百思不得其解时,突然看到 QuestionList 用了 cloneDeep 进行深拷贝。我灵光一闪,深拷贝内部不就是会去遍历属性进行拷贝的嘛。遍历属性时,触发响应式属性的 get 劫持函数,然后在内部收集了这个计算属性的依赖,这样就会过度的收集依赖,当响应式属性改变就会触发相关依赖的更新。

下面来了解下 cloneDeep 流程和计算属性是怎么收集依赖的。

cloneDeep 流程

cloneDeep.js:

function cloneDeep(value) {
  return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG);
}
复制代码

cloneDeep 方法主要调用了 baseClone。

_baseClone.js:

function baseClone(value, bitmask, customizer, key, object, stack) {
  // 1. 参数处理(赋值、判断 return)
  // 2. 初始化参数
  // 3. 检查循环引用返回
  // 4. 针对 Set 和 Map 的拷贝

  var keysFunc = isFull
    ? (isFlat ? getAllKeysIn : getAllKeys)
    : (isFlat ? keysIn : keys);

  var props = isArr ? undefined : keysFunc(value);
  // 5. 一般会走这里的遍历
  arrayEach(props || value, function(subValue, key) {
    if (props) {
      key = subValue;
      // value[key] 访问属性
      subValue = value[key];
    }
    // Recursively populate clone (susceptible to call stack limits).
    assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack));
  });
  return result;
}
复制代码

baseClone 内部会遍历访问属性,递归调用 baseClone,如果这里是一个响应式属性,就会触发它的 get 劫持函数去收集依赖了。

计算属性收集依赖

// 源码位置:/src/core/instance/state.js 
function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  // computed properties are just getters during SSR
  const isSSR = isServerRendering()
    
  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get

    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        { lazy: true }
      )
    }

    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    }
  }
}
复制代码

初始化计算属性配置时,会循环为每个计算属性 Watcher 创建一个“计算属性Watcher”。

// 源码位置:/src/core/observer/watcher.js 
evaluate() {
  this.value = this.get()
  this.dirty = false
}
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm) // 计算属性求值
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    popTarget()
    this.cleanupDeps()
  }
  return value
}
复制代码

在模板渲染时会触发计算属性的 get 劫持函数,然后调用 evaluate,接着调用 get 函数。

pushTarget 将当前的“计算属性Watcher”挂到全局的 Dep.target 上。

执行 this.getter (定义计算属性时对应的回调函数),内部如果访问了响应式属性时,会触发响应式属性的 get 劫持函数,进行依赖收集。在收集依赖依赖时,就是从 Dep.target 上取的,所以我们常说的依赖也就是 Watcher。

// 源码位置:/src/core/observer/dep.js 
notify () {
  // stabilize the subscriber list first
  const subs = this.subs.slice()
  if (process.env.NODE_ENV !== 'production' && !config.async) {
    // subs aren't sorted in scheduler if not running async
    // we need to sort them now to make sure they fire in correct
    // order
    subs.sort((a, b) => a.id - b.id)
  }
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}
复制代码

每个响应式属性会有个 subs 的数组存放 Watcher,当触发 set 劫持函数时就会执行 notify,然后循环执行 Watcher 的 update 方法。

结合回实际场景,在初始化模板渲染时 svgHeight 会执行对应的计算属性回调,然后 cloneDeep 遍历了每一个响应式属性,响应式属性收集对应的“计算属性Watcher”。后续当响应式属性更改时,就会触发计算属性更新了。

想了解更多Vue原理,可以看我之前的文章:

小心求证

当然以上只是我的猜想(我觉得八九不离十),但本着严谨的态度,还是需要做些实验来验证猜想。

如何验证呢?既然计算属性内遍历响应式属性会收集依赖,那将这步操作放到 created 中,提前拷贝好,然后计算属性再依赖这个拷贝好的数据。

data () {
  return {
    tempList: []
  }  
},
computed: {
  questionList () {
    let list = cloneDeep(this.tempList).map((q, qi) => {
        // ...
        return q
    })
    return list
  }
},
created () {
  this.tempList = cloneDeep(this.QuestionList)
}
复制代码

重新录制火焰图:

果然,没有看到 svgHeight 函数的执行,耗时也大大减少。因为对应配置字段没有收集到“计算属性Watcher”,即使配置发生更改,这里也不会再重新触发计算属性求值。那就验证了上面的猜想。

解决方案

知道瓶颈后,解决方案就简单了。其实有几种思路:

  1. 就像上面的做法,不在计算属性内调用 cloneDeep,而是提前拷贝好一份,以此作为计算属性的依赖。
  2. 但第一种的做法会有个弊端,如果我们刚好就是要依赖 QuestionList 中的某几个属性,那么可以通过 this.QuestionList[index].xxx 的方法单独访问,让需要的响应式属性去收集依赖。

但是这部分对于本篇文章来说,并不是重点,因为都是基于业务场景去解决,最重要的还是要懂得分析和定位问题。

总结

本文主要分享使用 Performance 录制火焰图并定位问题的过程,和思考问题的思路。还分析了一波 cloneDeep 流程和计算属性原理。

在此也能得出一个结论,在计算属性中使用 cloneDeep 拷贝响应式数据时,会造成依赖被过度收集,导致计算属性非预期更新的情况(如果你的业务就是要这种效果,当我没说,哈哈哈)。以上就是我在排查性能问题的全过程,希望能带给同学们一些思考和经验。

Guess you like

Origin juejin.im/post/7079414535894859806