前言
性能优化的文章相信同学们都看的很多,大部分的套路基本上都了解过。比如针对资源文件优化(webpack打包优化、gzip压缩、cdn加速、浏览器缓存、懒加载、http2等),页面渲染优化(SSR、虚拟列表、减少回流重绘等)。
但我们接触的更多是业务,而大部分性能问题是开发者疏忽导致的。由于业务功能的持续迭代,最近发现项目也存在类似的性能问题,下面分享排查性能问题时遇到的一个比较隐蔽的性能问题,在过程中也得到一些思路跟同学们分享一下。
本文你将学习到:
- 使用 Performance 工具
- 录制并分析火焰图
- 计算属性原理、cloneDeep 流程
性能问题
这里先大概说下数据流程,项目分为多个模块,每个模块依赖 vuex 内存储的 QuestionList,模块会基于 QuestionList 去做构造应用数据并渲染。
下面讲下主要的性能问题,在更改 QuestionList 里的 Item 的某个配置时,会卡顿 ~1.5s 才有反应。如下:
经过优化后:
这样才丝滑嘛~ 下面就来看看优化的过程思路是怎么样吧。
Performance 分析
Performance 是谷歌浏览器提供的可视化分析工具,用于记录和分析应用在运行时的所有活动,可以帮助开发者很好地定位性能问题。
准备工作
为了规避其它因素对 Performance 分析的影响,需要做一些准备工作:
- 浏览器开启无痕模式,避免 Chrome 插件对页面的性能影响
- 开发环境项目中关闭 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”,即使配置发生更改,这里也不会再重新触发计算属性求值。那就验证了上面的猜想。
解决方案
知道瓶颈后,解决方案就简单了。其实有几种思路:
- 就像上面的做法,不在计算属性内调用 cloneDeep,而是提前拷贝好一份,以此作为计算属性的依赖。
- 但第一种的做法会有个弊端,如果我们刚好就是要依赖 QuestionList 中的某几个属性,那么可以通过 this.QuestionList[index].xxx 的方法单独访问,让需要的响应式属性去收集依赖。
但是这部分对于本篇文章来说,并不是重点,因为都是基于业务场景去解决,最重要的还是要懂得分析和定位问题。
总结
本文主要分享使用 Performance 录制火焰图并定位问题的过程,和思考问题的思路。还分析了一波 cloneDeep 流程和计算属性原理。
在此也能得出一个结论,在计算属性中使用 cloneDeep 拷贝响应式数据时,会造成依赖被过度收集,导致计算属性非预期更新的情况(如果你的业务就是要这种效果,当我没说,哈哈哈)。以上就是我在排查性能问题的全过程,希望能带给同学们一些思考和经验。