Vue 3 里 1 万个输入框的更新耗时:拆分组件前后的对照实测
前言
Vue 的组件优化平时关注得不多。可当一个组件的节点过多时,改动一个数据就可能导致整个组件重新 render,这部分开销是不必要的。下面用一个 1 万字段的表单做对照:先在单组件里渲染全部输入框,再把每个字段拆成子组件,看 patch 的耗时差别。
下面的耗时都是本机单次测量,只能看量级,换机器、换浏览器、换 Vue 版本结果都会变。
一、组件性能损耗?
全量 patch
先写一个单组件案例,直观感受 render 的消耗时长:
10,000 字段全量更新测试
请输入内容并观察控制台:
检测到数据变更,正在触发全量 Diff...
{{ key }}:
onFieldInput(key, e.target.value)"
/>
import { reactive, onUpdated } from "vue";
// 1. 生成 10,000 个字段
const keys = Array.from({ length: 10000 }, (_, i) => `field_${i}`);
const formData = reactive(Object.fromEntries(keys.map((k) => [k, ""])));
// 使用普通变量,不要用 ref,避免触发额外的响应式追踪
let renderStartTime = 0;
let isBenchmarking = false;
const onFieldInput = (key, value) => {
if (!isBenchmarking) {
// 记录开始时间:数据变化前的一瞬间
renderStartTime = performance.now();
isBenchmarking = true;
console.log("--- 渲染开始 ---");
}
// 改变数据,触发父组件重新渲染
formData[key] = value;
};
onUpdated(() => {
if (isBenchmarking) {
const renderEndTime = performance.now();
const duration = (renderEndTime - renderStartTime).toFixed(2);
console.log(
`%c[Result] 10,000个节点全量 Patch 耗时: ${duration}ms`,
"color: white; background: red; padding: 4px 8px; font-weight: bold;",
);
// 渲染完成后重置锁
isBenchmarking = false;
renderStartTime = 0;
}
});
在同一个组件内渲染 1 万个 input,每个 input 绑定一个数据。修改任意一个 input,可以看到父组件触发全量 patch,耗时在十几毫秒这个量级。
组件拆分
接下来把 input 拆成子组件:
10,000 字段【原子化优化】实验
请输入内容,你会发现父组件**完全不触发**更新:
父组件状态:静默(不参与渲染比对)
import { reactive, provide, onUpdated, ref } from "vue";
import AtomicInput from "./AtomicInput.vue"; // 引入下面的子组件
const a = ref(0);
const keys = Array.from({ length: 10000 }, (_, i) => `field_${i}`);
const formData = reactive(Object.fromEntries(keys.map((k) => [k, ""])));
// 1. 提供数据源和更新方法
provide("formContext", {
state: formData,
update: (key, val) => {
formData[key] = val;
handleChange();
},
});
const handleChange = () => {
console.log(formData);
};
// 2. 这里的 onUpdated 在输入时绝对不会触发
onUpdated(() => {
console.log("%c[Warning] 父组件触发了重绘!", "color: orange");
});
AtomicInput.vue:
{{ name }}:
0" style="color: blue; margin-left: 10px">
耗时: {{ renderTime }}ms
import { inject, computed, ref, onUpdated } from "vue";
const props = defineProps(["name"]);
const { state, update } = inject("formContext");
const localValue = computed(() => state[props.name]);
const renderTime = ref(0);
let startTime = 0;
const onInput = (e) => {
startTime = performance.now();
update(props.name, e.target.value);
};
onUpdated(() => {
if (startTime > 0) {
renderTime.value = (performance.now() - startTime).toFixed(2);
console.log(
`%c[Atomic Result] 字段 ${props.name} 更新耗时: ${renderTime.value}ms`,
"color: green",
);
startTime = 0;
}
});
这时 patch 耗时只有几毫秒,而且父组件的 onUpdated 一次都没有触发。
二、原因
差距来自 Vue 更新时的粒度:响应式数据被哪个组件的 render 读过,就只让哪个组件重新渲染。模板执行时会读取响应式属性,Vue 据此把该组件的渲染副作用登记为这些属性的依赖;属性变化时只重新执行依赖了它的组件的 render,再对新旧 vnode 做 diff,其他组件整棵跳过。
上面的例子里,父组件模板中没有出现 formData,那么 formData 的单个 key 变化不会让父组件重新渲染;依赖落在真正读取它的 AtomicInput 里——computed(() => state[props.name]) 读的是 state 上那一个 key,所以只有被编辑字段对应的那个子组件会重渲染。用 provide 传下去,就是为了让父组件的 template 完全不碰这个响应式对象。
只要父组件用到了 formData,情况立刻反过来:
...
{{ formData }}
与上面的唯一区别是父组件渲染了 formData。此时输入内容,父组件就会 render,耗时也变长。
把 formData 的某个值传给子组件也一样:
...
formData[key] 在父模板里被读取,依赖就记在父组件上,照样触发父组件的 render。这就是上面改用 provide 的原因。
总结
数据量大时,渲染时长本身未必特别大;但如果单个组件过于复杂,叠加起来就可能出现卡顿。
拆的时候也要看到代价:
provide/inject是隐式依赖。子组件凭空拿到formContext,只看子组件代码看不出数据从哪来,跨层重构时不好追。- 组件实例变多。1 万个
AtomicInput就是 1 万个组件实例,各自有渲染副作用和生命周期,首次挂载和内存占用都比单组件高。 - 类型要自己约束。
inject("formContext")默认拿不到类型,需要用InjectionKey或手写接口把state、update的形状声明出来,否则一路 any。
不该拆的情况也很明确:节点量本来就不大、单次渲染开销可以忽略时,拆了只是增加文件和复杂度;父组件必须展示聚合结果(总字数、整体校验状态)时,父级仍然要读这些数据,依赖照样落在父组件上,拆分并不能省掉这次 render。
tips:以上代码由 AI 生成。