Vue.js 运行时性能优化笔记
核心理念:具体情况,具体分析
优化是一个庞大的话题,没有一成不变的公式。本笔记中提到的方法都是常见的优化手段,但不应盲目应用到所有项目中。
- 核心目标:软件是为商业服务的工具,追求的是利润(收益 - 成本)。
- 优化原则:在投入的时间成本和带来的性能收益之间找到平衡。避免为了微不足道的性能提升(如减少 0.01ms 渲染时间)而花费大量开发时间。
- 开发者能力:作为开发者,核心能力是掌握这些优化工具,并能在具体项目中分析性能瓶颈,针对性地解决问题。
优化技巧详解
1. v-for 循环中的 key
这是最基础也是最重要的优化点之一。Vue 的 diff 算法依赖 key 来高效地更新 DOM。
目的:在列表项发生变动时,最大程度地复用已有的 DOM 元素,减少不必要的删除、新增和修改操作。
无
key的问题:当列表顺序改变(如在列表头部插入新元素)时,Vue 会采用“就地复用”策略。它会逐个对比新旧列表的节点,如果节点类型相同,则尝试修改内容。这可能导致大量不必要的 DOM 更新。示例:
- 旧列表:
[A, B, C, D] - 新列表:
[E, A, B, C, D](在头部插入 E) - 无
key的diff过程:- 对比旧
A和新E,发现内容不同 -> 修改A为E - 对比旧
B和新A,发现内容不同 -> 修改B为A - 对比旧
C和新B,发现内容不同 -> 修改C为B - 对比旧
D和新C,发现内容不同 -> 修改D为C - 新增一个元素
D
- 对比旧
- 结果: 4 次修改,1 次新增。
- 旧列表:
有
key的优势:Vue 通过key识别出每个节点的唯一性,从而知道哪些元素只是移动了位置,而不是内容变了。- 有
key的diff过程:- 发现
A,B,C,D的key都在,只是位置移动了。 - 发现
E是一个全新的key。 - 直接在
A的前面插入一个新元素E。
- 发现
- 结果: 1 次新增,0 次修改。效率远高于前者。
- 有
2. 使用冻结对象 (Object.freeze)
对于纯展示、不需要响应式更新的大型数据,冻结对象可以显著提升性能。
- 冻结对象特性:被
Object.freeze()处理过的对象,其属性无法被修改、添加或删除。
const user = { name: 'Alice', id: 1 };
Object.freeze(user);
user.name = 'Bob'; // 静默失败或在严格模式下报错
console.log(user.name); // 输出 'Alice'
console.log(Object.isFrozen(user)); // 输出 true- Vue 中的优化:当 Vue 在初始化数据时,它会深度遍历
data对象,将每个属性转换为响应式的(通过getter/setter)。但如果它检测到一个对象是冻结的 (Object.isFrozen()),它会跳过对该对象及其所有子属性的响应式处理。 - 优势:免去了将大型、深度嵌套的数据转换为响应式数据所需的时间和内存开销。
- 适用场景:
- 只需要在界面上展示,后续不会改变的数据。
- 例如:文章列表、评论列表、历史记录等。
- 注意:一旦冻结,该数据就不再是响应式的。任何对它的修改都不会触发视图更新。
3. 使用函数式组件 (functional)
对于无状态、无实例的纯展示组件,可以声明为函数式组件以节约性能。
- 特点:
- 没有
this上下文。 - 没有
data、计算属性等状态。 - 没有生命周期钩子。
- 本质上只是一个接收
props并返回渲染结果的函数。
- 没有
- 优势:
- 创建开销小:Vue 不需要为它创建完整的组件实例,减少了初始化时间和内存占用。
- 渲染速度快。
- 适用场景:只依赖外部
props来展示内容的组件,如自定义按钮、图标、列表项等。 - 语法:
<template functional>
<div>{{ props.count }}</div>
</template>
<script>
export default {
// 关键配置
functional: true,
props: {
count: Number
}
}
</script>- 性能对比:在渲染大量(如上万个)组件的场景下,函数式组件相比普通组件能显著减少脚本执行时间和内存消耗。
4. 使用计算属性 (computed)
当模板中多次使用到基于某个数据源的复杂运算时,应优先使用计算属性。
- 问题:如果在模板中直接写方法调用或复杂表达式,每次组件重新渲染时,这些方法和表达式都会被重新执行。html
<div>{{ money.toFixed(2) }}</div> <p>总计:{{ money.toFixed(2) }}</p> - 优势:计算属性是基于它的响应式依赖进行缓存的。只有在相关依赖发生改变时,它才会重新求值。如果依赖不变,多次访问计算属性会立即返回之前的计算结果,而不会再次执行函数。
- 适用场景:任何在模板中需要进行数据转换、格式化或复杂计算的场景。
- 权衡:计算属性不接受参数。如果需要根据不同参数动态计算,还是需要使用方法。
5. 非实时绑定的表单项
v-model 会在每次 input 事件触发时同步数据,这可能导致性能问题,尤其是在有动画或其他高频任务同时进行的页面。
- 问题:高频的
input事件 -> 高频的数据更新 -> 高频的re-render-> JS 线程长时间占用 -> 阻塞浏览器渲染线程 -> 动画卡顿。 - 解决方案:
v-model.lazy:将数据同步的时机从input事件改为change事件(即输入框失去焦点时)。html<input v-model.lazy="message">- 手动绑定:不使用
v-model,而是监听特定事件(如keydown.enter或按钮click),手动从 DOM 元素获取值并更新数据。html<input :value="message" @keydown.enter="updateMessage">javascriptmethods: { updateMessage(event) { // 直接从事件目标获取值,而不是通过 data const newValue = event.target.value; // 在需要的时候才更新数据 this.addToList(newValue); event.target.value = ''; // 手动清空输入框 } }
- 权衡:手动绑定虽然性能更好,但背离了 Vue 数据驱动的核心思想,引入了直接的 DOM 操作,可能会降低代码的可读性和可维护性。
v-model.lazy是一个很好的折中方案。
6. 保持对象引用稳定
Vue 通过 === 来判断数据是否变化。如果一个对象的引用地址没有变,Vue 会认为它没有变化,从而跳过不必要的更新。
- 反面模式:每次更新列表(如添加一项)后,都从服务器重新请求整个列表数据,然后用新数据完全替换掉
data中的旧数组。javascript// 错误做法:每次都获取全量数据并替换 async addComment() { await api.add(); // 请求服务器添加 // 重新获取所有评论,这会生成一个全新的数组和全新的对象 this.comments = await api.getComments(); } - 问题:即使列表中的旧数据内容完全没变,但由于
JSON.parse会为每个对象创建新的内存地址,导致新数组中所有对象的引用都与之前不同。当这些新对象作为props传递给子组件时,所有子组件都会因为prop引用变化而强制重新渲染。 - 正确做法:只对数据进行增量更新。让后端在添加成功后返回新创建的那一项数据,然后手动将其
push到现有数组中。javascript// 正确做法:增量更新 async addComment() { // API 返回新增的那条评论 const newComment = await api.add(); // 只在原数组上追加,不改变原有对象的引用 this.comments.push(newComment); } - 好处:保证了已有对象的引用稳定,只有新增的组件会被创建和渲染,其他组件不会进行不必要的
update。
7. 合理使用 v-if 和 v-show
v-if:是“真正”的条件渲染。如果条件为假,组件及其子组件会被完全销毁,DOM 中不存在对应节点。切换时有较高的创建和销毁开销。v-show:无论条件真假,元素总会被渲染,只是通过 CSS 的display: none;来控制显隐。初始渲染开销较高,但后续切换开销很小。- 选择原则:
- 频繁切换:使用
v-show。 - 运行时条件很少改变:使用
v-if。 - 优化首屏加载:如果一个模块初始不需要显示,用
v-if可以避免其初始渲染开销。 - 元素内容复杂/渲染成本高:并且需要频繁切换时,
v-show是更好的选择。
- 频繁切换:使用
8. 使用延迟装载(分帧渲染)
对于初始加载时需要渲染大量组件导致的“白屏”问题,可以通过分批、分帧渲染来优化用户体验。
- 问题:页面初始加载时,JS 需要执行大量计算来创建所有组件的虚拟 DOM,同时浏览器需要渲染大量真实 DOM 节点,这两个过程都会长时间阻塞主线程,导致页面长时间白屏。
- 核心思路:不一次性渲染所有内容,而是将渲染任务拆分成小块,利用
requestAnimationFrame将每个小任务分配到浏览器的不同渲染帧中去执行。 - 实现原理:
- 利用
requestAnimationFrame可以在浏览器每次重绘之前执行一个回调函数的特性。 - 在组件
mounted后,启动一个requestAnimationFrame循环,每一帧都增加一个内部的“帧计数器”。 - 提供一个方法(如
differ(frame)),它会判断当前的“帧计数器”是否达到了指定的frame数。 - 在模板中,用
v-if="differ(index)"来包裹需要渲染的组件。
- 利用
- 效果:
- 第 0 个组件在第 0 帧渲染。
- 第 1 个组件在第 1 帧渲染。
- ...
- 将一个巨大的、阻塞性的渲染任务,分解成了多个小的、非阻塞的任务。
- 用户体验:虽然总加载时间可能略有增加,但用户会先看到一部分优先加载的内容,然后看到其他内容逐步出现,而不是长时间面对白屏,体验大大提升。
- 本质:这是一种时间分片(Time Slicing)思想的应用,用总时长换取响应速度,避免页面假死。
实现示例
下面是一个具体的代码实现,用于将 20 个“重组件”的渲染任务分散到 20 个渲染帧中。
1. defer.js (延迟加载混入) 创建一个混入(Mixin),它负责追踪渲染帧并提供一个判断方法。
// defer.js
export default function (maxFrameCount) {
return {
data() {
return {
// 当前渲染的帧数
frameCount: 0,
};
},
mounted() {
this.updateFrameCount();
},
methods: {
updateFrameCount() {
// 使用 requestAnimationFrame 在下一帧渲染前更新帧数
requestAnimationFrame(() => {
this.frameCount++;
// 如果当前帧数还未达到最大目标帧数,则继续循环
if (this.frameCount < maxFrameCount) {
this.updateFrameCount();
}
});
},
// 判断当前组件是否达到了渲染时机
// n 是组件的目标渲染帧
differ(n) {
// 只有当前帧数达到或超过了目标帧数,才渲染组件
return this.frameCount >= n;
},
},
};
}2. HeavyComponent.vue (模拟的重组件) 这个组件模拟一个内部结构复杂、渲染耗时的组件。
<template>
<div class="heavy-component">
<div v-for="i in 5000" :key="i" class="cell"></div>
</div>
</template>
<style scoped>
.heavy-component {
border: 1px solid #ccc;
margin: 2px;
height: 50px;
background: #eee;
display: flex;
flex-wrap: wrap;
}
.cell {
width: 1px;
height: 1px;
background: #aaa;
}
</style>3. App.vue (主组件) 在主组件中使用混入和 v-if 来实现延迟加载。
<template>
<div id="app">
<HeavyComponent
v-for="n in 20"
:key="n"
v-if="differ(n)"
/>
</div>
</template>
<script>
import HeavyComponent from "./components/HeavyComponent.vue";
import defer from "./mixins/defer";
export default {
name: "App",
components: {
HeavyComponent,
},
// 使用混入,并传入最大帧数。
// 这意味着我们计划在 20 帧内完成所有组件的渲染。
mixins: [defer(20)],
};
</script>