渲染开销与响应式粒度
Vue 性能优化 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
Vue 的更新是「精确通知」的,但不等于「零成本」。这一节要能回答三个问题:一次状态变化触发了哪些组件的重新渲染?为什么一个大对象放进 reactive 会让性能变差?什么时候该用 shallowRef / markRaw 主动放弃深度响应式?
问题场景
一个 5000 行的表格,每行一个组件。勾选其中一行,界面卡住半秒。用 Performance 面板录一段,发现「所有行都重渲染了」——因为父组件把整张表的数据作为一个 reactive 数组传下去,任何一行变化都被视为「数组变了」。Vue 的精确追踪在组件边界上失效了。
核心解释
先建立成本模型。一次更新的大致流程:触发 setter → 标记依赖 → 调度微任务 → 组件重新渲染(执行 render 函数)→ 打补丁(diff + DOM 操作)。优化的着力点有三处:
- 减少触发范围:让变化只影响真正依赖它的组件。
- 减少渲染工作量:跳过不必要的 diff(v-memo、key 策略)。
- 减少首屏与运行时体积:代码分割、按需加载。
响应式粒度:reactive/ref 默认深度代理——对象的每一层属性被访问时都会创建代理。对一个万级节点的树,代理创建本身就有成本。三种降级工具:
<script setup>
import { markRaw, reactive, shallowRef, triggerRef } from "vue";
import { Chart } from "chart.js";
// 1. shallowRef:只追踪 .value 的替换,不深度代理内部
const bigList = shallowRef(loadHugeList()); // 内部元素变化不会触发更新
bigList.value.push(item); // 需要手动通知
triggerRef(bigList);
// 2. markRaw:把某个对象排除在响应式之外(第三方实例)
const chart = markRaw(new Chart(canvas, config));
// 3. reactive 的浅层版本
const state = shallowReactive({ user: { name: "阿吉" } });
state.user = { name: "小北" }; // 触发
state.user.name = "小北"; // 不触发
</script>
核心判断:这份数据需要深度响应式吗。列表项整体替换(分页、重新拉取)用 shallowRef;第三方实例(图表、编辑器、地图)用 markRaw;只关心顶层字段的配置对象用 shallowReactive。
组件边界:props 传递不会让子组件「自动跳过」更新。父组件重渲染时,子组件默认也会重渲染(Vue 会做 props 浅比较,没变则跳过——但父模板里的内联函数/对象每次都是新引用,会绕过这个优化)。所以:
<!-- 每次父组件渲染都创建新对象,子组件 props 恒「变化」 -->
<Child :config="{ size: 'md' }" />
<!-- 提到 setup 顶层的常量,引用稳定 -->
<Child :config="config" />
v-once 与 v-memo:静态内容用 v-once 只渲染一次;列表用 v-memo 声明「只有这些依赖变了才重新渲染」——下一节展开。
示例拆解
一个可对比的实验:500 行列表,分别用 reactive 深度代理和 shallowRef 存放,勾选行时观察重渲染次数(在子组件 onUpdated 里计数):
<script setup>
import { onUpdated, ref } from "vue";
import RowItem from "./RowItem.vue";
const rows = ref(Array.from({ length: 500 }, (_, i) => ({ id: i, label: `行 ${i}` })));
const selected = ref([]);
function toggle(id) {
const i = selected.value.indexOf(id);
if (i >= 0) selected.value.splice(i, 1);
else selected.value.push(id);
}
</script>
<template>
<p>已选 {{ selected.length }} 行</p>
<RowItem
v-for="row in rows"
:key="row.id"
:row="row"
:is-selected="selected.includes(row.id)"
@toggle="toggle"
/>
</template>
<!-- RowItem.vue -->
<script setup>
import { onUpdated } from "vue";
const props = defineProps({ row: Object, isSelected: Boolean });
const emit = defineEmits(["toggle"]);
let count = 0;
onUpdated(() => {
count += 1;
if (count === 1) console.log(`Row ${props.row.id} 重渲染次数会持续增加`);
});
</script>
<template>
<div :class="{ selected: props.isSelected }" @click="emit('toggle', props.row.id)">
{{ props.row.label }}
</div>
</template>
改 selected 时,父组件重渲染 → 每个 RowItem 收到新的 isSelected(只有两行变化)→ Vue 的 props 浅比较会跳过大部分子组件。但父模板里 selected.includes(row.id) 是每次重算的开销 O(n²),真正的大列表瓶颈往往在这里。优化方向:用 Set 做 O(1) 查询、把选中状态下放到行组件内部、或虚拟化。
动手任务
- 完成上面的实验,用 Performance 面板对比「500 行直接渲染」与「只渲染可视区 30 行」的脚本耗时。
- 把
selected.includes改成Set,测量勾选一行的耗时变化。 - 给一个第三方对象(比如 canvas 实例)加 markRaw,验证放进 reactive 后不会报「无法代理」相关警告且内存稳定。
常见错误
- 所有数据都用 reactive 深度代理:大对象/大数组的代理开销与意外触发。按需用 shallowRef/markRaw。
- 在模板里创建对象/函数字面量:每次渲染新引用,子组件 props 恒变化,优化失效。
- 用 v-if 频繁销毁重建重组件:图表、编辑器实例反复创建。用 v-show 或 keep-alive。
- 过早优化:没有 Performance 数据就到处 v-memo。先测量再改。
检查与复盘
- 能说出一次更新的完整链路与三个优化着力点
- 能判断一份数据该用 reactive、shallowRef 还是 markRaw
- 能解释模板内联对象为什么让子组件优化失效
参考资料
- Vue 官方文档「性能优化」:官方清单与测量方法
- Vue 官方文档「响应式进阶」:shallowRef/markRaw 的机制
读完这一课了?
标记完成后,工作台会安排接下来的学习。