Cookbook组件形式:优化 Vue 组件的运行时性能


Posted in Javascript onNovember 25, 2018

Vue 2.0 在发布之初,就以其优秀的运行时性能著称,你可以通过这个第三方 benchmark 来对比其他框架的性能。Vue 使用了 Virtual DOM 来进行视图渲染,当数据变化时,Vue 会对比前后两棵组件树,只将必要的更新同步到视图上。

Vue 帮我们做了很多,但对于一些复杂场景,特别是大量的数据渲染,我们应当时刻关注应用的运行时性能。

本文仿照Vue Cookbook 组织形式,对优化 Vue 组件的运行时性能进行阐述。

基本的示例

在下面的示例中,我们开发了一个树形控件,支持基本的树形结构展示以及节点的展开与折叠。

我们定义 Tree 组件的接口如下。 data 绑定了树形控件的数据,是若干颗树组成的数组, children 表示子节点。 expanded-keys 绑定了展开的节点的 key 属性,使用 sync 修饰符来同步组件内部触发的节点展开状态的更新。

<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 组件的实现如下,这是个稍微复杂的例子,需要花几分钟时间阅读一下。

<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-keysstatus 计算属性便会自动更新,保证关联子节点可见状态的正确。

一切准备就绪,为了度量 Tree 组件的运行性能,我们设定了两个指标。

初次渲染时间 节点展开 / 折叠时间

在 Tree 组件中添加代码如下,使用 console.timeconsole.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 查找性能瓶颈

Chrome 的 Performance 面板可以录制一段时间内的 js 执行细节及时间。使用 Chrome 开发者工具分析页面性能的步骤如下。

打开 Chrome 开发者工具,切换到 Performance 面板 点击 Record 开始录制 刷新页面或展开某个节点 点击 Stop 停止录制

Cookbook组件形式:优化 Vue 组件的运行时性能

console.time 输出的值也会显示在 Performance 中,帮助我们调试。更多关于 Performance 的内容可以点击这里查看。

优化运行时性能

条件渲染

我们往下翻阅 Performance 分析结果,发现大部分耗时都在 render 函数上,并且下面还有很多其他函数的调用。

Cookbook组件形式:优化 Vue 组件的运行时性能

在遍历节点时,对于节点的可见性我们使用的是 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-ifv-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) 来观察组件的性能损耗,相比优化前有很大的提升。

双向绑定

在前面的示例中,我们使用 .syncexpanded-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-keysstatus 只在初始化时依赖 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 修改为在 datawatcher 中去获取 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 工具尝试查找性能瓶颈。

Cookbook组件形式:优化 Vue 组件的运行时性能

我们发现,在 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 了。

Cookbook组件形式:优化 Vue 组件的运行时性能

性能指标如下,对于初次渲染的提升还是很可观的。

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 应用性能优化指南。

这样做的价值

应用性能对于用户体验的提升是非常重要的,也往往是容易被忽视的。试想一下,一个在某台设备运行良好的应用,到了另一台配置较差的设备上导致用户浏览器崩溃了,这一定是一个不好的体验。又或者你的应用在常规数据下正常运行,却在大数据量下需要相当长的等待时间,也许你就因此错失了一部分用户。

总结

性能优化是一个长久不衰的话题,没有一种通用的办法能够解决所有的性能问题。性能优化是可以持续不端地进行下去的,但随着问题的深入,性能瓶颈会越来越不明显,优化也越困难。

本文的示例具有一定的特殊性,但它为我们指引了性能优化的方法论。

  • 确定衡量运行时性能的指标
  • 确定优化目标,例如实现 1W+ 数据的秒出
  • 使用工具(Chrome Performance)分析性能问题
  • 优先解决问题的大头(瓶颈)
  • 重复 3 4 步直到实现目标

以上所述是小编给大家介绍的Cookbook组件形式:优化 Vue 组件的运行时性能,希望对大家有所帮助,如果大家有任何疑问欢迎给我留言,小编会及时回复大家的!

Javascript 相关文章推荐
js中的值类型和引用类型小结 文字说明与实例
Dec 12 Javascript
jquery仿百度经验滑动切换浏览效果
Apr 14 Javascript
js简单实现网页换肤功能
Apr 07 Javascript
Vue开发中整合axios的文件整理
Apr 29 Javascript
Angular 2 利用Router事件和Title实现动态页面标题的方法
Aug 23 Javascript
原生JS实现图片无缝滚动方法(附带封装的运动框架)
Oct 01 Javascript
vue 实现 ios 原生picker 效果及实现思路解析
Dec 06 Javascript
详解js正则表达式验证时间格式xxxx-xx-xx形式
Feb 09 Javascript
详解创建自定义的Angular Schematics
Jun 06 Javascript
vue中的router-view组件的使用教程
Oct 23 Javascript
layer.msg()去掉默认时间,实现手动关闭的方法
Sep 12 Javascript
关于对TypeScript泛型参数的默认值理解
Jul 15 Javascript
vscode 开发Vue项目的方法步骤
Nov 25 #Javascript
浅谈Vue.js 中的 v-on 事件指令的使用
Nov 25 #Javascript
electron + vue项目实现打印小票功能及实现代码
Nov 25 #Javascript
vue组件实践之可搜索下拉框功能
Nov 25 #Javascript
详解离线安装npm包的几种方法
Nov 25 #Javascript
vue将单页面改造成多页面应用的方法
Nov 25 #Javascript
vue下拉菜单组件(含搜索)的实现代码
Nov 25 #Javascript
You might like
PHP从FLV文件获取视频预览图的方法
2015/03/12 PHP
php判断表是否存在的方法
2015/06/18 PHP
php使用file函数、fseek函数读取大文件效率对比分析
2016/11/04 PHP
php删除一个路径下的所有文件夹和文件的方法
2018/02/07 PHP
laravel实现简单用户权限的示例代码
2019/05/28 PHP
js实现动态添加、删除行、onkeyup表格求和示例
2013/08/18 Javascript
仿淘宝TAB切换搜索框搜索切换的相关内容
2014/09/21 Javascript
Bootstrap模态框禁用空白处点击关闭
2016/10/20 Javascript
Angular2实现自定义双向绑定属性
2017/03/22 Javascript
js仿微信公众平台打标签功能
2017/04/08 Javascript
解决vue中虚拟dom,无法实时更新的问题
2018/09/15 Javascript
4个顶级JavaScript高级文本编辑器
2018/10/10 Javascript
微信jssdk逻辑在vue中的运用详解
2018/11/14 Javascript
JS实现倒序输出的几种常用方法示例
2019/04/13 Javascript
vue2 中二级路由高亮问题及配置方法
2019/06/10 Javascript
[00:31]DOTA2荣耀之路7:Miracle-空血无敌斩
2018/05/31 DOTA
Python虚拟环境项目实例
2017/11/20 Python
更改Python的pip install 默认安装依赖路径方法详解
2018/10/27 Python
详解python Todo清单实战
2018/11/01 Python
解决pyttsx3无法封装的问题
2018/12/24 Python
关于Pycharm无法debug问题的总结
2019/01/19 Python
Python一行代码解决矩阵旋转的问题
2019/11/30 Python
Python networkx包的实现
2020/02/14 Python
最新2019Pycharm安装教程 亲测
2020/02/28 Python
如何用Anaconda搭建虚拟环境并创建Django项目
2020/08/02 Python
Python安装并操作redis实现流程详解
2020/10/13 Python
HomeAway的巴西品牌:Alugue Temporada
2018/04/10 全球购物
自我介绍演讲稿
2014/01/15 职场文书
汽车销售经理岗位职责
2014/06/09 职场文书
元旦趣味活动方案
2014/08/22 职场文书
在职员工证明书
2014/09/19 职场文书
村党支部书记个人对照材料汇报
2014/10/26 职场文书
2014年基层党建工作总结
2014/11/11 职场文书
公安忠诚教育心得体会
2016/01/23 职场文书
导游词之阆中古城
2019/12/23 职场文书
PyTorch 如何自动计算梯度
2021/05/23 Python