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 相关文章推荐
Jquery 获取checkbox的checked问题
Nov 16 Javascript
表格奇偶行设置不同颜色的核心JS代码
Dec 24 Javascript
使用jquery修改表单的提交地址基本思路
Jun 04 Javascript
JavaScript入门基础
Aug 12 Javascript
使用Jasmine和Karma对AngularJS页面程序进行测试
Mar 05 Javascript
jQuery简单实现iframe的高度根据页面内容自适应的方法
Aug 01 Javascript
AJAX和jQuery动态加载数据的实现方法
Dec 05 Javascript
jQuery实现一个简单的验证码功能
Jun 26 jQuery
Vue-cli3项目配置Vue.config.js实战记录
Jul 29 Javascript
小程序云函数调用API接口的方法
May 17 Javascript
SSM+layUI 根据登录信息显示不同的页面方法
Sep 20 Javascript
vue-cli3.0修改打包后的文件名和文件地址,打包后本地运行报错解决
Apr 06 Vue.js
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下过滤html代码的函数 提高程序安全性
2010/03/02 PHP
说说PHP的autoLoad自动加载机制
2012/09/27 PHP
Zend Framework实现自定义过滤器的方法
2016/12/09 PHP
PHP基于DateTime类解决Unix时间戳与日期互转问题【针对1970年前及2038年后时间戳】
2018/06/13 PHP
php快速导入大量数据的实例方法
2019/09/23 PHP
csdn 博客中实现运行代码功能实现
2009/08/29 Javascript
扩展jQuery 键盘事件的几个基本方法
2009/10/30 Javascript
js使用心得分享
2015/01/13 Javascript
javascript面向对象之访问对象属性的两种方式分析
2015/01/13 Javascript
bootstrap布局中input输入框右侧图标点击功能
2016/05/16 Javascript
JS遍历ul下的li点击弹出li的索引的实现方法
2016/09/19 Javascript
老生常谈javascript的类型转换
2016/10/12 Javascript
详解ES6中的let命令
2020/04/05 Javascript
JavaScript初学者必看“new”
2017/06/12 Javascript
浅谈vue+webpack项目调试方法步骤
2017/09/11 Javascript
vue绑定的点击事件阻止冒泡的实例
2018/02/08 Javascript
ES6 Object属性新的写法实例小结
2019/06/25 Javascript
Vue 实现拨打电话操作
2020/11/16 Javascript
一起来了解一下JavaScript的预编译(小结)
2021/03/01 Javascript
python client使用http post 到server端的代码
2013/02/10 Python
Python实现统计文本文件字数的方法
2017/05/05 Python
Python selenium抓取微博内容的示例代码
2018/05/17 Python
pycharm重置设置,恢复默认设置的方法
2018/10/22 Python
Python 的AES加密与解密实现
2019/07/09 Python
浅析HTML5中的download属性使用
2019/03/13 HTML / CSS
英国领先的奢侈品零售商之一:CRUISE
2016/12/02 全球购物
Hotels.com南非:酒店预订
2017/11/02 全球购物
JSF面试题:Jsf中导航的标签是什么
2013/04/20 面试题
《乌鸦和狐狸》教学反思
2014/02/08 职场文书
《小松树和大松树》教学反思
2014/02/20 职场文书
政府门卫岗位职责
2014/04/29 职场文书
优秀护士先进事迹
2014/05/08 职场文书
温馨提示标语
2014/06/26 职场文书
2015年团支部工作总结
2015/04/03 职场文书
2015年档案室工作总结
2015/05/23 职场文书
索尼ICF-36收音机评测
2022/04/30 无线电