【性能优化实践】计算属性内用 cloneDeep 可能会导致性能问题

前言

性能优化的文章相信同学们都看的很多,大部分的套路基本上都了解过。比如针对资源文件优化(webpack打包优化、gzip压缩、cdn加速、浏览器缓存、懒加载、http2等),页面渲染优化(SSR、虚拟列表、减少回流重绘等)。

但我们接触的更多是业务,而大部分性能问题是开发者疏忽导致的。由于业务功能的持续迭代,最近发现项目也存在类似的性能问题,下面分享排查性能问题时遇到的一个比较隐蔽的性能问题,在过程中也得到一些思路跟同学们分享一下。

本文你将学习到:

  • 使用 Performance 工具
  • 录制并分析火焰图
  • 计算属性原理、cloneDeep 流程

性能问题

这里先大概说下数据流程,项目分为多个模块,每个模块依赖 vuex 内存储的 QuestionList,模块会基于 QuestionList 去做构造应用数据并渲染。

下面讲下主要的性能问题,在更改 QuestionList 里的 Item 的某个配置时,会卡顿 ~1.5s 才有反应。如下:

经过优化后:

这样才丝滑嘛~ 下面就来看看优化的过程思路是怎么样吧。

Performance 分析

Performance 是谷歌浏览器提供的可视化分析工具,用于记录和分析应用在运行时的所有活动,可以帮助开发者很好地定位性能问题。

准备工作

为了规避其它因素对 Performance 分析的影响,需要做一些准备工作:

  1. 浏览器开启无痕模式,避免 Chrome 插件对页面的性能影响
  2. 开发环境项目中关闭 vuex logger 插件,插件内的 deepCopy 会增加执行耗时(看项目有没有用到,主要减少项目 logger 插件的影响)

如何使用 Performance

F12打开开发者工具,选中 Performance 面板:

左上角有 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 拷贝响应式数据时,会造成依赖被过度收集,导致计算属性非预期更新的情况(如果你的业务就是要这种效果,当我没说,哈哈哈)。以上就是我在排查性能问题的全过程,希望能带给同学们一些思考和经验。

猜你喜欢

转载自juejin.im/post/7079414535894859806