做者: 前端小透明 from 迅雷前端javascript
原文地址:Cookbook:优化 Vue 组件的运行时性能html
Vue 2.0 在发布之初,就以其优秀的运行时性能著称,你能够经过这个第三方 benchmark 来对比其余框架的性能。Vue 使用了 Virtual DOM 来进行视图渲染,当数据变化时,Vue 会对比先后两棵组件树,只将必要的更新同步到视图上。前端
Vue 帮咱们作了不少,但对于一些复杂场景,特别是大量的数据渲染,咱们应当时刻关注应用的运行时性能。vue
本文仿照 Vue Cookbook 组织形式,对优化 Vue 组件的运行时性能进行阐述。java
在下面的示例中,咱们开发了一个树形控件,支持基本的树形结构展现以及节点的展开与折叠。node
咱们定义 Tree 组件的接口以下。data
绑定了树形控件的数据,是若干颗树组成的数组,children
表示子节点。expanded-keys
绑定了展开的节点的 key
属性,使用 sync
修饰符来同步组件内部触发的节点展开状态的更新。git
<template>
<tree :data="data" expanded-keys.sync="expandedKeys"></tree>
</template>
<script> export default { data() { return { data: [{ key: '1', label: '节点 1', children: [{ key: '1-1', label: '节点 1-1' }] }, { key: '2', label: '节点 2' }] } } }; </script>
复制代码
Tree 组件的实现以下,这是个稍微复杂的例子,须要花几分钟时间阅读一下。github
<template>
<ul class="tree">
<li v-for="node in nodes" v-show="status[node.key].visible" :key="node.key" class="tree-node" :style="{ 'padding-left': `${node.level * 16}px` }" >
<i v-if="node.children" class="tree-node-arrow" :class="{ expanded: status[node.key].expanded }" @click="changeExpanded(node.key)" >
</i>
{{ node.label }}
</li>
</ul>
</template>
<script> export default { props: { data: Array, expandedKeys: { type: Array, default: () => [], }, }, computed: { // 将 data 转为一维数组,方便 v-for 进行遍历 // 同时添加 level 和 parent 属性 nodes() { return this.getNodes(this.data); }, // status 是一个 key 和节点状态的一个 Map 数据结构 status() { return this.getStatus(this.nodes); }, }, methods: { // 对 data 进行递归,返回一个全部节点的一维数组 getNodes(data, level = 0, parent = null) { let nodes = []; data.forEach((item) => { const node = { level, parent, ...item, }; nodes.push(node); if (item.children) { const children = this.getNodes(item.children, level + 1, node); nodes = [...nodes, ...children]; node.children = children.filter(child => child.level === level + 1); } }); return nodes; }, // 遍历 nodes,计算每一个节点的状态 getStatus(nodes) { const status = {}; nodes.forEach((node) => { const parentStatus = status[node.parent && node.parent.key] || {}; status[node.key] = { expanded: this.expandedKeys.includes(node.key), visible: node.level === 0 || (parentStatus.expanded && parentStatus.visible), }; }); return status; }, // 切换节点的展开状态 changeExpanded(key) { const index = this.expandedKeys.indexOf(key); const expandedKeys = [...this.expandedKeys]; if (index >= 0) { expandedKeys.splice(index, 1); } else { expandedKeys.push(key); } this.$emit('update:expandedKeys', expandedKeys); }, }, }; </script>
复制代码
展开或折叠节点时,咱们只需更新 expanded-keys
,status
计算属性便会自动更新,保证关联子节点可见状态的正确。web
一切准备就绪,为了度量 Tree 组件的运行性能,咱们设定了两个指标。chrome
在 Tree 组件中添加代码以下,使用 console.time
和 console.timeEnd
能够输出某个操做的具体耗时。
export default {
// ...
methods: {
// ...
changeExpanded(key) {
// ...
this.$emit('update:expandedKeys', expandedKeys);
console.time('expanded change');
this.$nextTick(() => {
console.timeEnd('expanded change');
});
},
},
beforeCreate() {
console.time('first rendering');
},
mounted() {
console.timeEnd('first rendering');
},
};
复制代码
同时,为了放大可能存在的性能问题,咱们编写了一个方法来生成可控数量的节点数据。
<template>
<tree :data="data" :expanded-keys.sync="expandedKeys"></tree>
</template>
<script> export default { data() { return { // 生成一个有 3 层,每层 10 个共 1000 个节点的节点树 data: this.getRandomData(3, 10), expandedKeys: [], }; }, methods: { getRandomData(layers, count, parent) { return Array.from({ length: count }, (v, i) => { const key = (parent ? `${parent.key}-` : '') + (i + 1); const node = { key, label: `节点 ${key}`, }; if (layers > 1) { node.children = this.getRandomData(layers - 1, count, node); } return node; }); }, }, }; <script> 复制代码
你能够经过这个 CodeSandbox 完整示例来实际观察下性能损耗。点击箭头展开或折叠某个节点,在 Chrome DevTools 的控制台(不要使用 CodeSandbox 的控制台,不许确)中输出以下。
first rendering: 406.068115234375ms
expanded change: 231.623779296875ms
复制代码
在笔者的低功耗笔记本下,初次渲染耗时 400+ms,展开或折叠节点 200+ms。下面咱们来优化 Tree 组件的运行性能。
若你的设备性能强劲,可修改生成的节点数量,如
this.getRandomData(4, 10)
生成 10000 个节点。
Chrome 的 Performance 面板能够录制一段时间内的 js 执行细节及时间。使用 Chrome 开发者工具分析页面性能的步骤以下。
console.time
输出的值也会显示在 Performance 中,帮助咱们调试。更多关于 Performance 的内容能够点击这里查看。
咱们往下翻阅 Performance 分析结果,发现大部分耗时都在 render 函数上,而且下面还有不少其余函数的调用。
在遍历节点时,对于节点的可见性咱们使用的是 v-show
指令,不可见的节点也会渲染出来,而后经过样式使其不可见。所以尝试使用 v-if
指令来进行条件渲染。
<li v-for="node in nodes" v-if="status[node.key].visible" :key="node.key" class="tree-node" :style="{ 'padding-left': `${node.level * 16}px` }" >
...
</li>
复制代码
v-if
在 render 函数中表现为一个三目表达式:
visible ? h('li') : this._e() // this._e() 生成一个注释节点
复制代码
即 v-if
只是减小每次遍历的时间,并不能减小遍历的次数。且 Vue.js 风格指南中明确指出不要把 v-if
和 v-for
同时用在同一个元素上,由于这可能会致使没必要要的渲染。
咱们能够更换为在一个可见节点的计算属性上进行遍历:
<li v-for="node in visibleNodes" :key="node.key" class="tree-node" :style="{ 'padding-left': `${node.level * 16}px` }" >
...
</li>
<script> export { // ... computed: { visibleNodes() { return this.nodes.filter(node => this.status[node.key].visible); }, }, // ... } </script>
复制代码
优化后的性能耗时以下。
first rendering: 194.7890625ms
expanded change: 204.01904296875ms
复制代码
你能够经过改进后的示例 (Demo2) 来观察组件的性能损耗,相比优化前有很大的提高。
在前面的示例中,咱们使用 .sync
对 expanded-keys
进行了“双向绑定”,其其实是 prop 和自定义事件的语法糖。这种方式能很方便地让 Tree 的父组件同步展开状态的更新。
可是,使用 Tree 组件时,不传 expanded-keys
,会致使节点没法展开或折叠,即便你不关心展开或折叠的操做。这里把 expanded-keys
做为外界的反作用了。
<!-- 没法展开 / 折叠节点 -->
<tree :data="data"></tree>
复制代码
这里还存在一些性能问题,展开或折叠某一节点时,触发父组件的反作用更新 expanded-keys
。Tree 组件的 status
依赖了 expanded-keys
,会调用 this.getStatus
方法获取新的 status
。即便只是单个节点的状态改变,也会致使从新计算全部节点的状态。
咱们考虑将 status
做为一个 Tree 组件的内部状态,展开或折叠某个节点时,直接对 status
进行修改。同时定义默认的展开节点 default-expanded-keys
。status
只在初始化时依赖 default-expanded-keys
。
export default {
props: {
data: Array,
// 默认展开节点
defaultExpandedKeys: {
type: Array,
default: () => [],
},
},
data() {
return {
status: null, // status 为局部状态
};
},
computed: {
nodes() {
return this.getNodes(this.data);
},
},
watch: {
nodes: {
// nodes 改变时从新计算 status
handler() {
this.status = this.getStatus(this.nodes);
},
// 初始化 status
immediate: true,
},
// defaultExpandedKeys 改变时从新计算 status
defaultExpandedKeys() {
this.status = this.getStatus(this.nodes);
},
},
methods: {
getNodes(data, level = 0, parent = null) {
// ...
},
getStatus(nodes) {
// ...
},
// 展开或折叠节点时直接修改 status,并通知父组件
changeExpanded(key) {
console.time('expanded change');
const node = this.nodes.find(n => n.key === key); // 找到该节点
const newExpanded = !this.status[key].expanded; // 新的展开状态
// 递归该节点的后代节点,更新 status
const updateVisible = (n, visible) => {
n.children.forEach((child) => {
this.status[child.key].visible = visible && this.status[n.key].expanded;
if (child.children) updateVisible(child, visible);
});
};
this.status[key].expanded = newExpanded;
updateVisible(node, newExpanded);
// 触发节点展开状态改变事件
this.$emit('expanded-change', node, newExpanded, this.nodes.filter(n => this.status[n.key].expanded));
this.$nextTick(() => {
console.timeEnd('expanded change');
});
},
},
beforeCreate() {
console.time('first rendering');
},
mounted() {
console.timeEnd('first rendering');
},
};
复制代码
使用 Tree 组件时,即便不传 default-expanded-keys
,节点也能正常地展开或收起。
<!-- 节点能够展开或收起 -->
<tree :data="data"></tree>
<!-- 配置默认展开的节点 -->
<tree :data="data" :default-expanded-keys="['1', '1-1']" @expanded-change="handleExpandedChange" >
</tree>
复制代码
优化后的性能耗时以下。
first rendering: 91.48193359375ms
expanded change: 20.4287109375ms
复制代码
你能够经过改进后的示例 (Demo3) 来观察组件的性能损耗。
到此为止,Tree 组件的性能问题已经不是很明显了。为了进一步扩大性能问题,查找优化空间。咱们把节点数量增长到 10000 个。
// 生成 10000 个节点
this.getRandomData(4, 1000)
复制代码
这里,咱们故意制造一个可能存在性能问题的改动。虽然这不是必须的,当它能帮助咱们了解接下来所要介绍的问题。
将计算属性 nodes
修改成在 data
的 watcher
中去获取 nodes
的值。
export default {
// ...
watch: {
data: {
handler() {
this.nodes = this.getNodes(this.data);
this.status = this.getStatus(this.nodes);
},
immediate: true,
},
// ...
},
// ...
};
复制代码
这种修改对于实现的功能是没有影响的,那么性能状况如何呢。
first rendering: 490.119140625ms
expanded change: 183.94189453125ms
复制代码
使用 Performance 工具尝试查找性能瓶颈。
咱们发现,在 getNodes
方法调用以后,有一段耗时很长的 proxySetter
。这是 Vue 在为 nodes
属性添加响应式,让 Vue 可以追踪依赖的变化。getStatus
同理。
当你把一个普通的 JavaScript 对象传给 Vue 实例的
data
选项,Vue 将遍历此对象全部的属性,并使用 Object.defineProperty 把这些属性所有转为 getter/setter。
对象越复杂,层级越深,这个过程消耗的时间越长。当咱们存在 1w 个节点时,proxySetter
的时间就会很是长了。
这里存在一个问题,咱们不会对 nodes
某个具体的属性作修改,而是每当 data
变化时从新去计算一次。所以,这里为 nodes
添加的响应式是无用的。那么怎么把不须要的 proxySetter
去掉呢?一种方法是将 nodes
改回计算属性,通常状况下计算属性没有赋值行为。另外一种方法就是冻结数据。
使用 Object.freeze()
来冻结数据,这会阻止修改现有的属性,也意味着响应系统没法再追踪变化。
this.nodes = Object.freeze(this.getNodes(this.data));
复制代码
查看 Performance 工具,getNodes
方法后已经没有 proxySetter
了。
性能指标以下,对于初次渲染的提高仍是很可观的。
first rendering: 312.22998046875ms
expanded change: 179.59326171875ms
复制代码
你能够经过改进后的示例 (Demo4) 来观察组件的性能损耗。
那咱们可否用一样的办法优化 status
的跟踪呢?答案是否认的,由于咱们须要去更新 status
中的属性值 (changeExpanded
)。所以,这种优化只适用于其属性不会被更新,只会更新整个对象的数据。且对于结构越复杂、层级越深的数据,优化效果越明显。
咱们看到,示例中不论是节点的渲染仍是数据的计算,都存在大量的循环或递归。对于这种大量数据的问题,除了上述提到的针对 Vue 的优化外,咱们还能够从减小每次循环的耗时和减小循环次数两个方面进行优化。
例如,可使用字典来优化数据查找。
// 生成 defaultExpandedKeys 的 Map 对象
const expandedKeysMap = this.defaultExpandedKeys.reduce((map, key) => {
map[key] = true;
return map;
}, {});
// 查找时
if (expandedKeysMap[key]) {
// do something
}
复制代码
defaultExpandedKeys.includes
的事件复杂度是 O(n),expandedKeysMap[key]
的时间复杂度是 O(1)。
更多关于优化 Vue 应用性能能够查看 Vue 应用性能优化指南。
应用性能对于用户体验的提高是很是重要的,也每每是容易被忽视的。试想一下,一个在某台设备运行良好的应用,到了另外一台配置较差的设备上致使用户浏览器崩溃了,这必定是一个很差的体验。又或者你的应用在常规数据下正常运行,却在大数据量下须要至关长的等待时间,也许你就所以错失了一部分用户。
性能优化是一个长久不衰的话题,没有一种通用的办法可以解决全部的性能问题。性能优化是能够持续不端地进行下去的,但随着问题的深刻,性能瓶颈会愈来愈不明显,优化也越困难。
本文的示例具备必定的特殊性,但它为咱们指引了性能优化的方法论。