性能优化最常见的落脚点是在网络和dom上,但是在大数据量的场景下,由于Vue本身的特性,可能会造成js运行层面的性能问题
数据篇:
<template>
<div>It's {{ firstUser }}'s show time total: {{ total }}</div>
</template>
<script>
export default {
data() {
return {
userList: {
user: [],
},
};
},
mounted() {
let i = 0;
while (i++ < 50000) {
this.userList.user.push({
id: i,
age: 18,
name: `kunkun_${i}`,
alais: "Irving",
gender: "female",
education: "senior high school",
height: "xxx",
weight: "xxx",
hobby: "xxx",
tag: "xxx",
skill: {
sing: 0,
dance: 0,
rap: 0,
basketball: 100,
},
});
}
},
computed: {
firstUser() {
return this.userList.user;
},
total() {
return this.userList.user.length;
},
},
};
</script>
如以上代码所示,模拟了5万个用户,每一个用户拥有id, name, age等等字段;
利用谷歌插件可以看到渲染时间是5.38s;

分析原因
Vue在渲染组件的时候,会对data对象进行改造,遍历data的key,调用defineProperty方法定义它的setter和getter。如果某个字段是Object,或者Array,还会递归的对这个字段进行上诉操作。
通常情况下,这个操作耗时是很短的,但是当数据量非常大的时候,对每一个数据项的每一个字段都进行defineProperty的操作就是一个昂贵的操作,所以性能优化的出发点就是减少defineProperty的次数。
方案一:减少无用字段
<template>
<div>It's {{ firstUser }}'s show time total: {{ total }}</div>
</template>
<script>
export default {
data() {
return {
userList: {
user: [],
},
};
},
mounted() {
let i = 0;
while (i++ < 50000) {
this.userList.user.push({
id: i,
age: 18,
// name: `kunkun_${i}`,
// alais: "Irving",
// gender: "female",
// education: "senior high school",
// height: "xxx",
// weight: "xxx",
// hobby: "xxx",
// tag: "xxx",
// skill: {
// sing: 0,
// dance: 0,
// rap: 0,
// basketball: 100,
// },
});
}
},
computed: {
firstUser() {
return this.userList.user;
},
total() {
return this.userList.user.length;
},
},
};
</script>
如代码所示,将push的8个string字段+一个对象字段删除,只push我想要的name+id字段,一共减少了8个String类型的字段,和一个Object类型的字段,可以减少 (8 + 4) * n次defineProperty操作和n次递归调用;

时间由5.38s变为1.26s,还是蛮明显的;
方案二:数据扁平化再组装
在当前版本(2.x)的Vue当中,对于数据变动的检测有许多限制,比如不能检测对象属性的添加和删除;不能检测到通过数据索引直接设置数据项等等
所以,当一个数组的数据项都是基本数据类型的时候,Vue不会进行任何操作。
const user = []
let i = 0
while (i++ < 50000) {
user.push(`kun_${i}`, i) // 通过index为基数还是偶数分辨是name还是id
}

从上图可以看出,结果非常的明显,从1.26s直接减少到了0.64s,几乎完全避免了性能损耗。
到此为止,性能上的问题已经解决了,但是扁平的数据会影响业务代码的开发效率和可读性,同时数据和它的index产生了深耦合,如果我们需要添加一个字段使用或者改变下顺序,很容易出问题。
不过,我们可以利用computed计算属性把已经被拍扁的数据重新组装起来。由于Vue的响应式数据改造只针对data选项和props选项,不包括computed,所以只会产生很少的函数运行耗时。
<template>
<div>It's {{ userLists }}'s show time total: {{ total }}</div>
</template>
<script>
export default {
data() {
return {
userList: {
user: [],
},
};
},
mounted() {
let i = 0;
while (i++ < 50000) {
this.userList.user.push(i, `kunkun_${i}`);
}
},
computed: {
firstUser() {
return this.userList.user.length
? { name: this.userList.user[0], id: this.userList.user[1] }
: {};
},
total() {
return this.userList.user.length / 2;
},
userLists() {
const result = [];
const user = this.userList.user;
for (let i = 0; i < user.length; i +=2) {
result.push({ 'id':user[i] ,'name':user[i+1] });
}
return result;
},
},
};
</script>

注意:此时页面渲染的数据同方案一相同,且时间减少了0.26s,到这里,在无需改动任何的渲染逻辑和业务逻辑的情况下,将js的运行时间从5.38s减少到了1s左右,提升了5.38倍。并且这些数据是在桌面端i5处理器下得到的;
方案三:数据静态
不是所有的数据都可以很好的进行扁平化处理,这可能涉及到方方面面的原因与权衡。那么这种情况下,如何进行优化呢?
通常在Vue组件当中,都是把数据放在data选项当中,Vue会对data选项中的数据进行响应式改造,我称之"动态数据"或者"响应式"数据,但是并不是所有的数据都是会发生变化的,很多时候,特别是大数据量场景下的数据是不会或者很少发生变化的,这种情况下,就没有必要把它放到data选项中去,而是在beforeCreated当中进行数据初始化,也不会影响数据的使用;
table列表优化
Vue列表渲染性能优化原理
大列表是容易造成性能问题的地方,一不小心就会造成大量的重绘和重排。Vue 的列表渲染实现在 v-for 指令的 update 方法, 性能优化的大部分细节在 diff 函数。
列表渲染时会为迭代列表的每一份数据并为他们生成各自对应的片段对象 frag ,frag.node 为片段对象的 DOM 元素。
frag缓存和track-by
在列表渲染过程中,当列表数据发生变化时,为了避免 frag 的重复创建和大规模的重新渲染, Vue 会尽可能复用缓存的 frag ,高效的缓存 frag 命中率也是 DOM 元素复用的关键。
new Vue({
template: `
`,
data: function(){
return {
model: [1, 2, 3]
}
}
})
当这个组件中的列表首次渲染时,Vue 会将创建的 frag 缓存到 dir.cache 。默认通过数据对象的特征来决定对已有作用域和 DOM 元素的复用程度。例如当数据对象为Array时,缓存 id 为数组的 value ,当数据对象为 Object 时,缓存 id 为对象的 $key 。对于这个例子来说三个缓存 id 为1、2、3。
这样在上面的例子中,如果 vm.model 变为 [3, 2, 1],新的列表的三个片段的缓存 id 分别为3、2、1,因此我们能做到复用全部已创建的frag。
v-for 指令中片段 frag 的缓存 id 计算规则在 getTrackByKey ,从中可以看到,当 track-by 不存在时,缓存 id 将取数组的 value 或对象的 key 。但是这里有一个问题,如果数组出现重复值,会出现缓存 id 冲突的警告。副作用就是会忽略重复的片段,这是因为相同的缓存 id 获取的 frag 将会引用同一个 DOM 元素。当该 DOM 元素在复用算法中处理过一次后会将 frag 的 reused 属性变为 false,这就导致 v-for 指令会重新尝试将该插入到DOM中,然而因为文档中已经存在该 DOM 元素,就会导致插入失败。
这时 Vue 提示我们可以使用 track-by='$index' ,它将使用数据的索引作为缓存 id ,索引作为缓存id一定是唯一的,但同时新旧数据的相同索引的缓存 id 是相同的,所使用的 frag 也是同一份。这回导致新列表的的 frags 失去和数据顺序的映射关系。而 frag.node 的数据在flushBatcherQueue更新。因为这种更新列表的方式不会移动DOM元素,而是在原位更新,简单地以对应索引的新值刷新,并不会受重复值的影响,这让数据替换非常高效,但同时也会有副作用,比如如果片段存有私有数据或状态,原位更新模式并不会同步状态。如下例
在第一个例子中,没有开启 track-by='$index' , v-for 指令会根据数组的值作为缓存 id 缓存每项 frag,当反转列表的顺序是,Vue 会根据缓存 id 为列表的每一项取出可复用的frag,但是 frags 的顺序也是反转的,Vue 会通过DOM元素移动算法将每个片段移动到正确的位置,因此当 input 有输入时,因为整个 DOM 节点发生了移动,input 的输入内容并没有错乱,这意味着我们没有丢失 frag 内 DOM 元素的私有状态。
再看第二个例子,开启了 track-by='$index' 之后,v-for 指令会根据数据的索引作为缓存 id 缓存每项片段,当反转列表的顺序时,每项的frag 会复用以索引为缓存id所缓存的 frag,所以生成的 frags 和原列表的 frags 是一样的,根据DOM元素移动算法,列表的 DOM 节点并没有移动,每个片段的数据更新会在接下来的流程中更新,所以会发现每个片段的数据更新了,但是因为 DOM 元素节点没有移动,因此每个 DOM 节点中input的输入状态并没有根据元素的变化而更新。
track-by='$index' 是一种简洁高效的优化手段,但是使用的时候你必须明白他做了什么。
在一些富交互的列表使用 track-by=‘$index’ 需要格外谨慎,但是在非 track-by=‘$index’ 模式我们仍可通过 track-by 尽量优化。有时候 v-for 指令并不能最大化优化,比如
vm.model = {
a: { id: 1, val: "model1"},
b: { id: 2, val: "model2"},
c: { id: 3, val: "model2"},
}
//列表更新
vm.model = {
d: { id: 1, val: "model1"},
e: { id: 2, val: "model2"},
f: { id: 3, val: "model2"}
}
默认情况 v-for 指令对于对象数据会将对象的键作为缓存 id,上面的例子发现列表更新后,对象的键没有重复,所以导致缓存一个都没有命中,而列表更新的结果也是重新渲染整个列表。但上面例子很明显可以看出,如果能够将对象的 a.id、b.id、c.id 作为缓存 id ,那么当列表更新时,所有缓存都能够命中,甚至连DOM元的移动都不需要, track-by='id' 就是做这样的事情.
DOM元素移动和启发算法
diff 算法中另一个重要的优化是 DOM 节点的移动。DOM 节点移动的性能开销非常大,因此减少 DOM 节点移动次数是算法的核心。当然开启 track-by='$index'不需要移动DOM元素,只需插入缺少的节点即可。
假如一个列表的 DOM 节点可以全部复用,那么列表的更新的核心就是 DOM 节点移动到合适的位置。简化之后就是下面的场景
old [1, 2, 3, 4, 5, 6, 7]
更新为
new [7, 6, 5, 4, 3, 2, 1]
//先声明
ndex= 索引
target = new[index]
targetPrv= new[index - 1]
current = map -> target (target在old里的映射)
currentPrv= current.prv
move(a, index) 在old中将a元素移动到索引为index的位置
让我们来想想怎么移动
loop new.length
move(current, index)
这个算法没有问题,可以顺利完成任务,但是这个算法会移动 new.length 次,几乎是最糟糕的情况,有时候节点在正确的地方并不需要移动。
//例如
old [1, 2, 3, 4, 5, 6, 7]
new [7, 6, 5, 4, 3 ,2 ,1]
//完全反转,需要移动7次
old [1, 2, 3, 4, 5, 6, 7]
new [1, 2, 3, 4, 5, 6, 7]
很明显移动次数应该为0,但是还是会移动7次
如果在移动之前判断一下,他是不是在正确的位置。而这里对于是否处在正确的判断,是看他们的前一个元素是否相同。
loop new.length
if targetPrv !== currentPrv
move(current, index)
//这样做可以在大部分情况下避免不必要的移动,比如对于
old [1, 2, 3, 4, 5, 6, 7]
new [7, 1, 2, 3, 4, 5, 6]
index => 0
target = 7
targetPrv = undefined
current = 7
currentPrv= 6
undefined !== 6
move(7, 0)
old [7, 1, 2, 3, 4, 5, 6]
//我们发现只需移动一次就得到想要结果。
///再看一个类似的例子
old [1, 2, 3, 4, 5, 6, 7]
new [2, 3, 4, 5, 6, 7, 1]
//这个例子和上面的例子差不多,很明显只需要移动一次就可以得到想要的结果,然而结果却出乎意料,看一看是怎样移动的
index => 0
target = 2
targetPrv = 1
current = 1
currentPrv= undefined
1 !== undefined
move(1, 0)
old [2, 1, 3, 4, 5, 6, 7]
index => 1
target = 3
targetPrv = 2
current = 3
currentPrv= 1
2 !== 1
move(3] 1)
old [2, 3, 1, 4, 5, 6, 7]
…
old [2, 3, 4, 5, 6, 1, 7]index => 6
target = 7
targetPrv = 6
current = 7
currentPrv= 1
6 !== 1
move(7, 6)
old [2, 3, 4, 5, 6, 7, 1]
直到最后我们的发现,直到移动了7次才得到想要的结果,第一个元素每一次移动都向后冒泡,成功的混淆了每一次判断结果。
如果我们能忽略第一次移动,那么之后的每一次判断都会成功
old [1, 2, 3, 4, 5, 6, 7]
new [2, 3, 4, 5, 6, 7, 1]
index = 1
target = 3
targetPrv = 2
current = 3
currentPrv= 2
2 !== 2
move(3, 1)
old [2, 3, 4, 5, 6, 7, 1]
…
old [2, 3, 4, 5, 6, 7, 1]index = 6
target = 1
targetPrv = 7
current = 1
currentPrv= undefined
7 !== undefined
move(1, 6)
old [2, 3, 4, 5, 6, 7, 1]
这样的结果才是我们想要的。
现在 Vue 判断移动的条件是
iftargetPrv !== currentPrv &&
(!currentPrv || currentPrv.prv !== targetPrv)
move(current, index)
DOM 移动的核心代码。Vue 声称实现了一些启发算法,以最大化复用 DOM 元素。这里的启发指的是执行 DOM 元素移动的条件,通过判断元素是否在正确的相对位置上,来于评估当前移动是否必要以及是否会造成愚蠢移动,没错,说的正是[1, 2, 3, 4, 5, 6,7] => [2, 3, 4, 5, 6, 7, 1]。
列表渲染优化实践
Vue 已经为我们做了大量无脑优化,主要在提高 DOM 元素的复用率和减少DOM 元素移动次数算法两方面,具体原理在上文两节中已经分析。DOM 元素的移动算法优化开发者不能做什么,但在提高DOM元素的复用率仍给开发者留有优化余地。
但是优化方式也十分简单,即给v-for列表加一个 track-by 属性,提示Vue 如何判断对象时同一份数据,提高缓存命中率。甚至可以直接加 track-by="$index" 原位复用DOM元素,这种优化效率最高,但是副作用上文中也说了,会丢失原DOM元素的临时状态组件私有状态,因此在交互复杂的列表中可能会有意想不到的问题,这时使用非$index的track-by也可以做到尽可能的性能优化。