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

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 停止录制

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-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 工具尝试查找性能瓶颈。

我们发现,在 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 应用性能优化指南。

这样做的价值

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

总结

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

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

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

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

(0)

相关推荐

  • mpvue性能优化实战技巧(小结)

    最近一直在折腾mpvue写的微信小程序的性能优化,分享下实战的过程. 先上个优化前后的图: 可以看到打包后的代码量从813KB减少到387KB,Audits体验评分从B到A,效果还是比较明显的.其实这个指标说明不了什么,而且轻易就可以做到,更重要的是优化小程序运行过程中的卡顿感,请耐心往下看. 常规优化 常规的Web端优化方法在小程序中也是适用的,而且不可忽视. 一.压缩图片 这一步最简单,但是容易被忽视.在tiny上在线压缩,然后下载替换即可. 我这项目的压缩率高达72%,可以说打包后的代码从

  • 如何提升vue.js中大型数据的性能

    你好!欢迎大家访问VueDose的第一篇文章!我们在VueDose中开始冒险吧,你会喜欢这些对你有帮助的小技巧. VueDose的所有的文章都非常的简洁,我相信人们在这种格式下更容易找到有用的东西.所以,让我们直奔主题. 通常我们需要获取对象数据,比如用户,项目,文章,等等等等····· 有时,我们甚至不需要修改它们,只是为了展示它们或在(a.k.a. Vuex)中存贮它们的全局状态.那么获取这个数据的简单代码如下: export default { data: () => ({ users:

  • 浅谈Vue 初始化性能优化

    前言 一般来说,你不需要太关心vue的运行时性能,它在运行时非常快,但付出的代价是初始化时相对较慢.在最近开发的一个Hybrid APP里,Android Webview初始化一个较重的vue页面竟然用了1200ms ~ 1400ms,这让我开始重视vue的初始化性能,并最终优化到200 ~ 300ms,这篇文章分享我的优化思路. 性能瓶颈在哪里? 先看一下常见的vue写法:在html里放一个app组件,app组件里又引用了其他的子组件,形成一棵以app为根节点的组件树. <body> <

  • 使用异步组件优化Vue应用程序的性能

    单页应用其一个问题是首屏屏渲染速度较慢.这是因为页面首次加载时服务器将向客户端发送大量JavaScript,在屏幕上显示任何内容之前必须下载并解析.可以想象,随着应用程序规模的扩大,这个问题对用户体验的影响也会越来越突出. 现在幸运的是,当使用Vue CLI构建Vue应用程序时(使用webpack),可以采取一些措施来抵消这种情况.在本文中,我将演示如何在应用程序的初始渲染之后使用 异步组件 和webpack的代码分割功能加载到页面的某些部分.这将使初始加载时间降至最低,并为您的应用程序提供更好

  • 浅谈Vue 性能优化之深挖数组

    背景 最近在用 Vue 重构一个历史项目,一个考试系统,题目量很大,所以核心组件的性能成为了关注点.先来两张图看下最核心的组件 Paper 的样式. 从图中来看,分为答题区与选择面板区. 稍微对交互逻辑进行下拆解: 答题模式与学习模式可以相互切换,控制正确答案显隐. 单选与判断题直接点击就记录答案正确性,多选是选择答案之后点击确定才能记录正确性. 选择面板则是记录做过的题目的情况,分为六种状态(未做过的,未做过且当前选择的,做错的,做错的且当前选择的,做对的,做对的且当前选择的),用不同的样式去

  • vue短信验证性能优化如何写入localstorage中

    平时我们在项目中进行注册等的时候,会经常用到短信验证的功能,但是现在现在很多短信验证都是存在下面几个问题,例如短信验证时间为60s的时候, 1. 当点击完按钮时,倒计时还没到60s过完时,刷新浏览器,验证码按钮又可以重新点击 2.当点击按钮倒计时开始,例如在50s的时候我关闭了浏览器,过了5s后,我在打开,此时时间倒计时的时间应该是45s左右,但是当重新打开浏览器的时候,按钮又可以重新点击了 为了解决上面的两个问题,就需要把时间都写到localstorage里面去,当打开页面的时候,就去loca

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

    Vue 2.0 在发布之初,就以其优秀的运行时性能著称,你可以通过这个第三方 benchmark来对比其他框架的性能.Vue 使用了 Virtual DOM 来进行视图渲染,当数据变化时,Vue 会对比前后两棵组件树,只将必要的更新同步到视图上. Vue 帮我们做了很多,但对于一些复杂场景,特别是大量的数据渲染,我们应当时刻关注应用的运行时性能. 本文仿照Vue Cookbook组织形式,对优化 Vue 组件的运行时性能进行阐述. 基本的示例 在下面的示例中,我们开发了一个树形控件,支持基本的树

  • 优化Vue中date format的性能详解

    在很多项目中,都需要将长整型的日期格式转换成其他的日期格式,而在Vue中,我们可以利用自定义相应的 filter 过滤器来进行 date format 的功能.如果直接引入 moment 的方法,在利用 npm run build --report 时,会发现 moment.js 的文件相当大.显而易见,这么小的功能点却大大影响了项目的性能. 本文主要是利用 npm run build --report 来分析打包文件的大小,进而对 date format 的功能点进行优化. 自定义 'date

  • Vue组件间的通信方式详析

    目录 前言 组件之间通信的场景 父子组件之间的通信 父组件通过 prop 向子组件传递数据 子组件通过自定义事件向父组件传递数据 兄弟组件之间的通信 状态提升 隔代组件之间的通信 attrs/attrs/listeners provide/inject 基于 $parent/$children 实现的 dispatch 和 broadcast 前言 在Vue组件库开发过程中,Vue组件之间的通信一直是一个重要的话题,虽然官方推出的 Vuex 状态管理方案可以很好的解决组件之间的通信问题,但是在组

  • 学习Vue组件实例

    Vue实例 项目启动过程 看一下现在我们的项目,想想整个项目的启动过程是什么(以直接打开index.html的方法访问为例来说明)? 你首先打开了index.html,里面只有一个写了一个id='root'的div,还有你引入了打包之后的代码,然后Vue自己肯定运行了一下(可以认为是Vue初始化). 接着,应该是执行了entry.js(因为打包是webpack打包的,你配置的入口文件就这一个). entry.js干了什么,是的,创建了一个Vue实例对象,然后这个对象管理的区域根据el属性知道,应

  • Vue中父子组件通讯之todolist组件功能开发

    一.todolist功能开发 <div id="root"> <div> <input type="text" v-model="inputValue"> <button @click="handleSubmit">提交</button> </div> <ul> <li v-for="(item, index ) of list

  • 详解Vue组件复用和扩展之道

    概述 软件编程有一个重要的原则是 D.R.Y(Don't Repeat Yourself),讲的是尽量复用代码和逻辑,减少重复.组件扩展可以避免重复代码,更易于快速开发和维护.那么,扩展 Vue 组件的最佳方法是什么? Vue 提供了不少 API 和模式来支持组件复用和扩展,你可以根据自己的目的和偏好来选择. 本文介绍几种比较常见的方法和模式,希望对你有所帮助. 扩展组件是否必要 要知道,所有的组件扩展方法都会增加复杂性和额外代码,有时候还会增加性能消耗. 因此,在决定扩展组件之前,最好先看看有

  • Vue组件之间的通信你知道多少

    目录 Vue组件间通信 1.父向子传递数据 1.props的大小写 2.props的两种写法 3.传递动态props 2.子向父传递数据 3.兄弟(任意)组件间的传值 3.1全局事件总线 3.2消息订阅与发布 总结 Vue组件间通信 vue组件间通信分为以下几种: 父向子传递数据,用自定义属性 子向父传递数据,用自定义事件 兄弟(任意)组件间传递数据,用全局事件总线或者消息订阅与发布 背吧,面试题要是说让你简述一下,就上面这段话拿过来回答呗.下面就介绍一下这几种通信方式的简单用法 1.父向子传递

  • Vue组件之间的通信方式详细讲解

    目录 前言 一.父级传数据给子级 1.传输固定的具体数据 2.动态语法 3.子组件调用父组件 二.子级传数据给父级 1.使用自定义事件 2.$refs的使用 3.同级别组价以及任意组件之间的数据传递 前言 在前面,我们已经了解了vue的组件以及vue组件之间的层级关系,这个在博主的往期博客,感兴趣的可以往前挪,地址是: 1.vue组件 2.vue组件的层级关系 本文主要编写记录的是,组件之间的通信的模式以及通信的方式,我们的组件之间只能调用自己的属性和自己的方法,不能调用其他组件的属性以及方法,

  • Vue组件开发技巧总结

    前言 临近毕业,写了个简单个人博客,项目地址是点我访问项目地址(顺便求star),本篇是系列总结第一篇.接下来会一步一步模仿一个低配版的Element 的对话框和弹框组件. 正文 Vue 单文件组件开发 当使用vue-cli初始化一个项目的时候,会发现src/components文件夹下有一个HelloWorld.vue文件,这便是单文件组件的基本开发模式. // 注册 Vue.component('my-component', { template: '<div>A custom compo

随机推荐