Vue.js 性能优化实战:9个核心技巧详解
本文基于 Vue.js 核心成员的分享,深入解析了9种 Vue.js 性能优化技巧。内容涵盖函数式组件、子组件拆分、局部变量缓存、v-show复用DOM、KeepAlive缓存、Deferred分批渲染、时间片切割、非响应式数据及虚拟滚动。通过源码对比与性能测试,详细阐述各技巧的原理与适用场景,帮助开发者提升 Vue.js 应用性能。
本文主要参考了 Vue.js 核心成员Guillaume Chau 在 19 年美国 Vue conf 分享的主题:9 Performance secrets revealed,其中提及了九个 Vue.js 性能优化技巧。
在阅读其分享 PPT 并深入研读相关项目源码后,我理解了这些优化技巧背后的原理,并将其中部分技巧应用到了日常开发工作中,取得了显著的效果。
尽管该分享极具实用价值,但知晓者寥寥,项目 Star 数仅数百。虽然距离分享已过去两年,但这些技巧依然不过时。为了让更多人学习这些实用技巧,我决定对原分享进行二次加工,详细阐述其优化原理,并做适当扩展。
本文主要面向 Vue.js 2.x 版本,因为在未来一段时间内,Vue.js 2.x 仍将是工作中的主流版本。
建议在学习本文时,拉取项目源码并在本地运行,通过对比优化前后的效果来加深理解。
函数式组件
第一个技巧是使用函数式组件,你可以查看这个在线示例。
优化前的组件代码如下:
<template>
<div class="cell">
<div v-if="value" class="on"></div>
<section v-else class="off"></section>
</div>
</template>
<script>
export default {
props: ['value'],
}
</script>
优化后的组件代码如下:
<template functional>
<div class="cell">
<div v-if="props.value" class="on"></div>
<section v-else class="off"></section>
</div>
</template>
随后,我们在父组件中分别渲染优化前后的组件各 800 个,并在每一帧内通过修改数据触发组件更新。开启 Chrome 的 Performance 面板记录性能,得到如下结果。
优化前:

优化后:

对比这两张图可以看到,优化前执行 script 的时间多于优化后。由于 JS 引擎是单线程运行机制,JS 线程会阻塞 UI 线程,脚本执行时间过长会导致页面卡顿。优化后 script 执行时间短,因此性能更好。
为什么使用函数式组件能缩短 JS 执行时间?这要从其实现原理说起。你可以将其理解为一个函数,它根据传递的上下文数据渲染生成一片 DOM。
函数式组件与普通对象类型组件不同,它不被视为真正的组件。在 patch 过程中,若遇到节点是组件 vnode,会递归执行子组件的初始化过程;而函数式组件的 render 生成的是普通的 vnode,没有递归子组件的过程,因此渲染开销更低。
因此,函数式组件没有状态、响应式数据或生命周期钩子。你可以将其视为将普通组件模板中的一部分 DOM 剥离出来,通过函数方式渲染,这是一种在 DOM 层面的复用。
子组件拆分
第二个技巧是子组件拆分,你可以查看这个在线示例。
优化前的组件代码如下:
<template>
<div :style="{ opacity: number / 300 }">
<div>{{ heavy() }}</div>
</div>
</template>
<script>
export default {
props: ['number'],
methods: {
heavy () {
const n = 100000
let result = 0
for (let i = 0; i < n; i++) {
result += Math.sqrt(Math.cos(Math.sin(42)))
}
return result
}
}
}
</script>
优化后的组件代码如下:
<template>
<div :style="{ opacity: number / 300 }">
<ChildComp/>
</div>
</template>
<script>
export default {
components: {
ChildComp: {
methods: {
heavy () {
const n = 100000
let result = 0
for (let i = 0; i < n; i++) {
result += Math.sqrt(Math.cos(Math.sin(42)))
}
return result
},
},
render (h) {
return h('div', this.heavy())
}
}
},
props: ['number']
}
</script>
随后,我们在父组件中分别渲染优化前后的组件各 300 个,并在每一帧内通过修改数据触发组件更新。开启 Chrome 的 Performance 面板记录性能,得到如下结果。
优化前:

优化后:

对比这两张图可以看到,优化后执行 script 的时间明显少于优化前,因此性能体验更好。
为什么会有差异?看优化前的组件,示例通过一个 heavy 函数模拟耗时任务,该函数在每次渲染时都会执行,导致每次组件渲染都消耗较长时间执行 JavaScript。
优化后的方式是将该耗时任务 heavy 函数的执行逻辑用子组件 ChildComp 封装。由于 Vue 的更新是组件粒度的,虽然每一帧通过数据修改导致父组件重新渲染,但 ChildComp 不会重新渲染,因为其内部没有响应式数据变化。因此,优化后的组件不会在每次渲染时执行耗时任务,JavaScript 执行时间自然减少。
不过,针对这种优化方式我提出了一些不同看法,详情可点击这个 issue。我认为在此场景下,使用计算属性比子组件拆分效果更好。得益于计算属性的缓存特性,耗时逻辑仅在第一次渲染时执行,且使用计算属性没有额外渲染子组件的开销。
在实际工作中,使用计算属性优化性能的场景很多,因为它体现了空间换时间的优化思想。
局部变量
第三个技巧是使用局部变量,你可以查看这个在线示例。
优化前的组件代码如下:
<template>
<div :style="{ opacity: start / 300 }">{{ result }}</div>
</template>
<script>
export default {
props: ['start'],
computed: {
base () {
return 42
},
result () {
let result = this.start
for (let i = 0; i < 1000; i++) {
result += Math.sqrt(Math.cos(Math.sin(this.base))) + this.base * this.base + this.base + this.base * 2 + this.base * 3
}
return result
},
},
}
</script>
优化后的组件代码如下:
<template>
<div :style="{ opacity: start / 300 }">{{ result }}</div>
</template>
<script>
export default {
props: ['start'],
computed: {
base () {
return 42
},
result ({ base, start }) {
let result = start
for (let i = 0; i < 1000; i++) {
result += Math.sqrt(Math.cos(Math.sin(base))) + base * base + base + base * 2 + base * 3
}
return result
},
},
}
</script>
随后,我们在父组件中分别渲染优化前后的组件各 300 个,并在每一帧内通过修改数据触发组件更新。开启 Chrome 的 Performance 面板记录性能,得到如下结果。
优化前:

优化后:

对比这两张图可以看到,优化后执行 script 的时间明显少于优化前,因此性能体验更好。
这里主要是优化前后组件的计算属性 result 实现差异。优化前的组件在计算过程中多次访问 this.base,而优化后的组件在计算前先用局部变量 base 缓存 this.base,后续直接访问 base 变量。
为什么这种差异会造成性能影响?因为每次访问 this.base 时,由于 this.base 是响应式对象,会触发其 getter,进而执行依赖收集相关逻辑。类似逻辑执行多次,如示例中几百次循环更新几百个组件,每个组件触发 computed 重新计算并多次执行依赖收集,性能自然下降。
从需求来看,this.base 执行一次依赖收集即可。因此,只需将其 getter 求值结果返回给局部变量 base,后续再次访问 base 时就不会触发 getter,也不会走依赖收集逻辑,性能自然提升。
这是一个非常实用的性能优化技巧。许多人在开发 Vue.js 项目时,习惯性直接写 this.xxx,因为大多数人并未注意到访问 this.xxx 背后的操作。在访问次数不多时,性能问题不明显;但一旦访问次数增多,如在大循环中多次访问,类似示例场景,就会产生性能问题。
我之前给 ZoomUI 的 Table 组件做性能优化时,在 render table body 就使用了局部变量优化技巧,并编写 benchmark 进行性能对比:渲染 1000 * 10 的表格,ZoomUI Table 更新数据重新渲染的性能比 ElementUI 的 Table 提升了近一倍。
使用 复用 DOM
第四个技巧是使用 v-show 复用 DOM,你可以查看这个在线示例。
优化前的组件代码如下:
<template functional>
<div class="cell">
<div v-if="props.value" class="on">
<Heavy :n="10000"/>
</div>
<section v-else class="off">
<Heavy :n="10000"/>
</section>
</div>
</template>
优化后的组件代码如下:
<template functional>
<div class="cell">
<div v-show="props.value" class="on">
<Heavy :n="10000"/>
</div>
<section v-show="!props.value" class="off">
<Heavy :n="10000"/>
</section>
</div>
</template>
随后,我们在父组件中分别渲染优化前后的组件各 200 个,并在每一帧内通过修改数据触发组件更新。开启 Chrome 的 Performance 面板记录性能,得到如下结果。
优化前:

优化后:

对比这两张图可以看到,优化后执行 script 的时间明显少于优化前,因此性能体验更好。
优化前后的主要区别是用 v-show 指令替代了 v-if 指令来控制组件显隐。虽然表现上,v-show 和 v-if 类似,都是控制组件显隐,但内部实现差距很大。
v-if 指令在编译阶段会编译成三元运算符进行条件渲染。例如,优化前的组件模板经编译后生成如下渲染函数:
function render() {
with(this) {
return _c('div', {
staticClass: "cell"
}, [(props.value) ? _c('div', {
staticClass: "on"
}, [_c('Heavy', {
attrs: {
"n": 10000
}
})], 1) : _c('section', {
staticClass: "off"
}, [_c('Heavy', {
attrs: {
"n": 10000
}
})], 1)])
}
}
当条件 props.value 值变化时,会触发对应组件更新。对于 v-if 渲染的节点,由于新旧节点 vnode 不一致,在核心 diff 算法比对过程中,会移除旧的 vnode 节点,创建新的 vnode 节点,从而创建新的 Heavy 组件,并经历 Heavy 组件自身初始化、渲染 vnode、patch 等过程。
因此,使用 v-if 每次更新组件都会创建新的 Heavy 子组件,当更新组件增多时,自然会造成性能压力。
而当我们使用 v-show 指令时,优化后的组件模板经编译后生成如下渲染函数:
function render() {
with(this) {
return _c('div', {
staticClass: "cell"
}, [_c('div', {
directives: [{
name: "show",
rawName: "v-show",
value: (props.value),
expression: "props.value"
}],
staticClass: "on"
}, [_c('Heavy', {
attrs: {
"n": 10000
}
})], 1), _c('section', {
directives: [{
name: "show",
rawName: "v-show",
value: (!props.value),
expression: "!props.value"
}],
staticClass: "off"
}, [_c('Heavy', {
attrs: {
"n": 10000
}
})], 1)])
}
}
当条件 props.value 值变化时,会触发对应组件更新。对于 v-show 渲染的节点,由于新旧 vnode 一致,它们只需一直 patchVnode 即可。那么它是如何控制 DOM 节点显示和隐藏的呢?
原来在 patchVnode 过程中,内部会对执行 v-show 指令对应的钩子函数 update,然后根据 v-show 指令绑定的值来设置其作用 DOM 元素的 style.display 值以控制显隐。
因此,相比于 v-if 不断删除和创建新 DOM,v-show 仅更新现有 DOM 的显隐值,所以 v-show 的开销比 v-if 小得多,当其内部 DOM 结构越复杂,性能差异越大。
但是,v-show 相比于 v-if 的性能优势是在组件更新阶段。如果仅在初始化阶段,v-if 性能还要高于 v-show,原因在于它仅渲染一个分支,而 v-show 将两个分支都渲染了,通过 style.display 控制对应 DOM 的显隐。
在使用 v-show 时,所有分支内部的组件都会渲染,对应的生命周期钩子函数都会执行;而使用 v-if 时,未命中的分支内部组件不会渲染,对应的生命周期钩子函数也不会执行。
因此,你需要清楚它们的原理及差异,才能在不同场景使用适合的指令。
KeepAlive
第五个技巧是使用 KeepAlive 组件缓存 DOM,你可以查看这个在线示例。
优化前的组件代码如下:
<template>
<div id="app">
<router-view/>
</div>
</template>
优化后的组件代码如下:
<template>
<div id="app">
<keep-alive>
<router-view/>
</keep-alive>
</div>
</template>
我们点击按钮在 Simple page 和 Heavy Page 之间切换,会渲染不同视图,其中 Heavy Page 的渲染非常耗时。我们开启 Chrome 的 Performance 面板记录性能,然后分别在优化前后执行如上操作,会得到如下结果。
优化前:


优化后:


对比这两张图可以看到,优化后执行 script 的时间明显少于优化前,因此性能体验更好。
在非优化场景下,每次点击按钮切换路由视图,都会重新渲染组件。渲染组件会经过组件初始化、render、patch 等过程。如果组件复杂或嵌套较深,整个渲染耗时就会很长。
而在使用 KeepAlive 后,被 KeepAlive 包裹的组件在经过第一次渲染后,其 vnode 以及 DOM 都会被缓存起来。再次渲染该组件时,直接从缓存中拿到对应的 vnode 和 DOM 进行渲染,无需再走组件初始化、render 和 patch 等一系列流程,减少了 script 的执行时间,性能更好。
但是,使用 KeepAlive 组件并非没有成本,因为它会占用更多内存进行缓存,这是典型的空间换时间优化思想的应用。
延时分批渲染
第六个技巧是使用 Deferred 组件延时分批渲染组件,你可以查看这个在线示例。
优化前的组件代码如下:
<template>
<div class="deferred-off">
<VueIcon icon="fitness_center" class="gigantic"/>
<h2>I'm an heavy page</h2>
<Heavy v-for="n in 8" :key="n"/>
<Heavy class="super-heavy" :n="9999999"/>
</div>
</template>
优化后的组件代码如下:
<template>
<div class="deferred-on">
<VueIcon icon="fitness_center" class="gigantic"/>
<h2>I'm an heavy page</h2>
<template v-if="defer(2)">
<Heavy v-for="n in 8" :key="n"/>
</template>
<Heavy v-if="defer(3)" class="super-heavy" :n="9999999"/>
</div>
</template>
<script>
import Defer from '@/mixins/Defer'
export default {
mixins: [
Defer(),
],
}
</script>
我们点击按钮在 Simple page 和 Heavy Page 之间切换,会渲染不同视图,其中 Heavy Page 的渲染非常耗时。我们开启 Chrome 的 Performance 面板记录性能,然后分别在优化前后执行如上操作,会得到如下结果。
优化前:

优化后:

对比这两张图可以发现,优化前当我们从 Simple Page 切到 Heavy Page 时,在一次 Render 接近结尾时,页面渲染的仍然是 Simple Page,会给人页面卡顿的感觉。而优化后当我们从 Simple Page 切到 Heavy Page 时,在一次 Render 靠前位置页面就已经渲染了 Heavy Page,并且 Heavy Page 是渐进式渲染出来的。
优化前后的差距主要是后者使用了 Defer 这个 mixin,那么它具体是如何工作的,我们来一探究竟:
export default function (count = 10) {
return {
data () {
return {
displayPriority: 0
}
},
mounted () {
this.runDisplayPriority()
},
methods: {
runDisplayPriority () {
const step = () => {
requestAnimationFrame(() => {
this.displayPriority++
if (this.displayPriority < count) {
step()
}
})
}
step()
},
defer (priority) {
return this.displayPriority >= priority
}
}
}
}
Defer 的主要思想是把一个组件的一次渲染拆成多次。它内部维护了 displayPriority 变量,然后通过 requestAnimationFrame 在每一帧渲染时自增,最多加到 count。然后使用 Defer mixin 的组件内部就可以通过 v-if="defer(xxx)" 的方式控制在 displayPriority 增加到 xxx 时渲染某些区块了。
当你有渲染耗时的组件,使用 Deferred 做渐进式渲染是不错的注意,它能避免一次 render 由于 JS 执行时间过长导致渲染卡住的现象。
时间片切割
第七个技巧是使用 Time slicing 时间片切割技术,你可以查看这个在线示例。
优化前的代码如下:
fetchItems ({ commit }, { items }) {
commit('clearItems')
commit('addItems', items)
}
优化后的代码如下:
fetchItems ({ commit }, { items, splitCount }) {
commit('clearItems')
const queue = new JobQueue()
splitArray(items, splitCount).forEach(
chunk => queue.addJob(done => {
// 分时间片提交数据
requestAnimationFrame(() => {
commit('addItems', chunk)
done()
})
})
)
await queue.start()
}
我们先通过点击 Genterate items 按钮创建 10000 条假数据,然后分别在开启和关闭 Time-slicing 的情况下点击 Commit items 按钮提交数据,开启 Chrome 的 Performance 面板记录性能,会得到如下结果。
优化前:

优化后:

对比这两张图可以发现,优化前总的 script 执行时间比优化后还要少一些,但从实际观感上看,优化前点击提交按钮,页面会卡死约 1.2 秒。在优化后,页面不会完全卡死,但仍会有渲染卡顿的感觉。
为什么在优化前页面会卡死?因为一次性提交的数据过多,内部 JS 执行时间过长,阻塞了 UI 线程,导致页面卡死。
优化后页面仍有卡顿,是因为我们拆分数据的粒度是 1000 条,这种情况下,重新渲染组件仍有压力。我们观察 fps 只有十几,会有卡顿感。通常只要让页面 fps 达到 60,页面就会非常流畅。如果我们把数据拆分粒度变成 100 条,基本上 fps 能达到 50 以上,虽然页面渲染变流畅了,但完成 10000 条数据总的提交时间还是变长了。
使用 Time slicing 技术可以避免页面卡死。通常在这种耗时任务处理时会加一个 loading 效果。在这个示例中,我们可以开启 loading animation,然后提交数据。对比发现,优化前由于一次性提交数据过多,JS 一直长时间运行,阻塞 UI 线程,这个 loading 动画是不会展示的;而优化后,由于我们拆成多个时间片去提交数据,单次 JS 运行时间变短了,这样 loading 动画就有机会展示了。
这里要注意的一点是,虽然我们拆时间片使用了
requestAnimationFrameAPI,但使用requestAnimationFrame本身不能保证满帧运行。requestAnimationFrame保证的是在浏览器每一次重绘后会执行对应传入的回调函数。想要保证满帧,只能让 JS 在一个 Tick 内的运行时间不超过 17ms。
非响应式数据
第八个技巧是使用 Non-reactive data,你可以查看这个在线示例。
优化前代码如下:
const data = items.map(
item => ({
id: uid++,
data: item,
vote: 0
})
)
优化后代码如下:
const data = items.map(
item => optimizeItem(item)
)
function optimizeItem (item) {
const itemData = {
id: uid++,
vote: 0
}
Object.defineProperty(itemData, 'data', {
// Mark as non-reactive
configurable: false,
value: item
})
return itemData
}
还是前面的示例,我们先通过点击 Genterate items 按钮创建 10000 条假数据,然后分别在开启和关闭 Partial reactivity 的情况下点击 Commit items 按钮提交数据,开启 Chrome 的 Performance 面板记录性能,会得到如下结果。
优化前:

优化后:

对比这两张图可以看到,优化后执行 script 的时间明显少于优化前,因此性能体验更好。
之所以有这种差异,是因为内部提交数据时,会默认把新提交的数据也定义成响应式。如果数据的子属性是对象形式,还会递归让子属性也变成响应式。因此,当提交数据很多时,这个过程就变成了一个耗时过程。
而优化后,我们把新提交数据中的对象属性 data 手动变成了 configurable 为 false。这样内部在 walk 时通过 Object.keys(obj) 获取对象属性数组会忽略 data,也就不会为 data 这个属性 defineReactive。由于 data 指向的是一个对象,这样也就减少了递归响应式的逻辑,相当于减少了这部分的性能损耗。数据量越大,这种优化的效果就会更明显。
其实类似这种优化的方式还有很多,比如我们在组件中定义的一些数据,也不一定都要在 data 中定义。有些数据我们并不是用在模板中,也不需要监听它的变化,只是想在组件的上下文中共享这个数据,这个时候我们可以仅仅把这个数据挂载到组件实例 this 上,例如:
export default {
created() {
this.scroll = null
},
mounted() {
this.scroll = new BScroll(this.$el)
}
}
这样我们就可以在组件上下文中共享 scroll 对象了,即使它不是一个响应式对象。
虚拟滚动
第九个技巧是使用 Virtual scrolling,你可以查看这个在线示例。
优化前组件的代码如下:
<div class="items no-v">
<FetchItemViewFunctional
v-for="item of items"
:key="item.id"
:item="item"
@vote="voteItem(item)"
/>
</div>
优化后代码如下:
<recycle-scroller
class="items"
:items="items"
:item-size="24"
>
<template v-slot="{ item }">
<FetchItemView
:item="item"
@vote="voteItem(item)"
/>
</template>
</recycle-scroller>
还是前面的示例,我们需要开启 View list,然后点击 Genterate items 按钮创建 10000 条假数据(注意,线上示例最多只能创建 1000 条数据,实际上 1000 条数据并不能很好地体现优化的效果,所以我修改了源码的限制,本地运行,创建了 10000 条数据),然后分别在 Unoptimized 和 RecycleScroller 的情况下点击 Commit items 按钮提交数据,滚动页面,开启 Chrome 的 Performance 面板记录性能,会得到如下结果。
优化前:

优化后:

对比这两张图我们发现,在非优化的情况下,10000 条数据在滚动情况下 fps 只有个位数,在非滚动情况下也就十几,原因是非优化场景下渲染的 DOM 太多,渲染本身的压力很大。优化后,即使 10000 条数据,在滚动情况下的 fps 也能有 30 多,在非滚动情况下可以达到 60 满帧。
之所以有这个差异,是因为虚拟滚动的实现方式:只渲染视口内的 DOM。这样总共渲染的 DOM 数量就很少了,自然性能就会好很多。
虚拟滚动组件也是 Guillaume Chau 写的,感兴趣的同学可以去研究它的源码实现。它的基本原理就是监听滚动事件,动态更新需要显示的 DOM 元素,计算出它们在视图中的位移。
虚拟滚动组件也并非没有成本,因为它需要在滚动的过程中实时去计算,所以会有一定的 script 执行成本。因此,如果列表的数据量不是很大的情况,我们使用普通的滚动就足够了。
总结
通过这篇文章,我希望你能了解到 Vue.js 的九种性能优化技巧,并能运用到实际的开发项目中。除了上述技巧之外,还有懒加载图片、懒加载组件、异步组件等等常用的性能优化手段。
在做性能优化前,我们需要分析性能的瓶颈在哪,才能因地制宜。另外,性能优化都需要数据支撑的,你在做任何性能优化前,需要先采集优化前的数据,这样优化后才能够通过数据对比看到优化的效果。
希望你在日后的开发过程中,不再只满足于实现需求,写每一行代码的时候,都能思考它可能产生的性能方面的影响。
参考资料
[1] vue-9-perf-secrets slidse:https://slides.com/akryum/vueconfus-2019
[2] vue-9-perf-secrets 分享演讲视频:https://www.vuemastery.com/conferences/vueconf-us-2019/9-performance-secrets-revealed/
[3] vue-9-perf-secrets 项目源码:https://github.com/Akryum/vue-9-perf-secrets
[4] vue-9-perf-secrets 在线演示地址:https://vue-9-perf-secrets.netlify.app/
[5] vue-9-perf-secrets 讨论 issue:https://github.com/Akryum/vue-9-perf-secrets/issues/1
[6] vue-virtual-scroller 项目源码:https://github.com/Akryum/vue-virtual-scroller
到此这篇关于Vue.js九个性能优化技巧(值得收藏)的文章就介绍到这了,更多相关Vue.js性能优化技巧内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!
UmiJS 项目构建速度优化与打包体积精简技巧
2026-11-04 22:37:09
原生JS操作DOM与Vue/React虚拟DOM的本质区别
2026-11-04 17:52:27
怎么使用console.group和table优化JS日志可读性
2026-11-03 23:14:34
基于AST抽象语法树的JS代码反混淆实战教程
2026-11-03 21:22:24
JS手机号输入校验的性能优化与用户体验提升
2026-11-03 19:19:19
