Learn
Vue/13-performance

性能优化

Vue 的细粒度响应式已经很快,但仍有几处「人为拖慢」的高频坑。本章按收益排序讲清楚。

1. computed 缓存 vs 方法:最大的误区

模板里常有人写「方法」代替 computed:

<template>
  <!-- ❌ 每次渲染都重新拼接、重新过滤 -->
  <p>{{ fullName() }}</p>
  <li v-for="u in filterUsers()">{{ u.name }}</li>
 
  <!-- ✅ 依赖没变就直接用缓存,不重算 -->
  <p>{{ fullName }}</p>
  <li v-for="u in filterUsers">{{ u.name }}</li>
</template>
 
<script setup lang="ts">
const first = ref('Ada');
const last = ref('Lovelace');
// 方法:每次渲染调用一次,无缓存
function fullName() { return first.value + ' ' + last.value; }
// computed:依赖(first/last)不变就返回上次结果
const fullNameComputed = computed(() => first.value + ' ' + last.value);
</script>

关键区别:computed 的依赖(这里 first、last)没变时,多次读取返回缓存、不重跑 getter;方法每次调用都重跑。列表过滤、复杂派生一定要用 computed。

computed 缓存:依赖没变就不重算
// ---- 迷你响应核心(computed 用 scheduler 设计,见第 12 章) ----
type Dep = Set<Effect>;
const targetMap = new WeakMap<object, Map<PropertyKey, Dep>>();
let activeEffect: Effect | null = null;
interface Effect { deps: Dep[]; scheduler?: () => void; run: () => void; }
function cleanup(e: Effect): void { for (const d of e.deps) d.delete(e); e.deps.length = 0; }
function effect(fn: () => void, options?: { scheduler?: () => void }): Effect {
  const e: Effect = { deps: [], scheduler: options?.scheduler, run: () => { cleanup(e); activeEffect = e; try { fn(); } finally { activeEffect = null; } } };
  e.run(); return e;
}
function track(t: object, k: PropertyKey): void {
  if (!activeEffect) return;
  let m = targetMap.get(t); if (!m) { m = new Map(); targetMap.set(t, m); }
  let dep = m.get(k); if (!dep) { dep = new Set(); m.set(k, dep); }
  dep.add(activeEffect); activeEffect.deps.push(dep);
}
function trigger(t: object, k: PropertyKey): void {
  const m = targetMap.get(t); if (!m) return;
  const dep = m.get(k); if (!dep) return;
  for (const e of [...dep]) { if (e === activeEffect) continue; if (e.scheduler) e.scheduler(); else e.run(); }
}
function isObject(v: unknown): v is object { return typeof v === 'object' && v !== null; }
const REF = Symbol('ref');
function isRef(v: any): boolean { return v != null && typeof v === 'object' && v[REF] === true; }
function reactive<T extends object>(target: T): T {
  if (!isObject(target)) return target;
  return new Proxy(target, {
    get(obj: any, key: PropertyKey, r: any) {
      const res = Reflect.get(obj, key, r);
      if (isRef(res)) return res.value;
      track(obj, key);
      return isObject(res) ? reactive(res) : res;
    },
    set(obj: any, key: PropertyKey, value: any, r: any) {
      const old = obj[key];
      if (isRef(old) && !isRef(value)) { old.value = value; return true; }
      const had = Object.prototype.hasOwnProperty.call(obj, key);
      const res = Reflect.set(obj, key, value, r);
      if (!had || !Object.is(old, value)) trigger(obj, key);
      return res;
    },
  });
}
interface Ref<T> { get value(): T; set value(v: T); }
function ref<T>(initial: T): Ref<T> { const r = reactive({ value: initial }); (r as any)[REF] = true; return r as unknown as Ref<T>; }
function computed<T>(getter: () => T): Ref<T> {
  let value: T; let dirty = true;
  const subs = new Set<Effect>();
  const runner = effect(() => { value = getter(); dirty = false; }, {
    scheduler: () => { if (!dirty) { dirty = true; for (const e of [...subs]) e.run(); } },
  });
  const c: any = { get value() { if (activeEffect) { subs.add(activeEffect); activeEffect.deps.push(subs); } if (dirty) runner.run(); return value; }, set value(_v: T) { throw new Error('ro'); } };
  c[REF] = true; return c as Ref<T>;
}
 
// ===== 演示:computed 缓存 vs 方法 =====
const first = ref('Ada');
const last = ref('Lovelace');
let computedCalls = 0;
const fullName = computed(() => { computedCalls++; return first.value + ' ' + last.value; });
let methodCalls = 0;
function fullNameMethod() { methodCalls++; return first.value + ' ' + last.value; }
 
// 模拟「渲染」:每次渲染都读一次 computed 与方法
function render(tag: string): void {
  const c = fullName.value;
  const m = fullNameMethod();
  console.log("[" + tag + "] computed=" + c + " method=" + m);
}
 
render('render1');          // 首次渲染
render('render2');          // 依赖没变再渲染一次:computed 命中缓存,方法仍重算
last.value = 'Byron';       // 改依赖 -> computed 必须重算
render('render3');
console.log("computedCalls=" + computedCalls + " methodCalls=" + methodCalls);

渲染 3 次:第 2 次改了 last(computed 依赖之一),computed 重算(calls=2);第 3 次只改 theme,名字没变——computed 命中缓存(calls 停在 2),而方法照算(calls=3)。在一个长列表里,这个差异就是「卡」与「流畅」的分界。

2. v-once 与 v-memo:跳过静态/可预测子树

<!-- 一次性渲染,之后永不更新 -->
<h1 v-once>{{ expensiveStaticTitle }}</h1>
 
<!-- 只有依赖变化才更新;签名数组里的项变了才重渲染该 li -->
<li v-for="item in list" v-memo="[item.id]">{{ item.text }}</li>
 
<!-- 整个大列表只有 selectedId 变才重算 -->
<div v-memo="[selectedId]">
  <HeavyChart :data="data" />
</div>

v-once 适合纯静态内容;v-memo 适合「依赖明确、其余不变」的子树,能跳过整片 diff。

3. v-for 的 key 要稳定

<!-- ❌ 用 index 当 key:列表增删/排序时会错配节点、丢失状态 -->
<li v-for="(u, i) in users" :key="i">
 
<!-- ✅ 用稳定唯一 id -->
<li v-for="u in users" :key="u.id">

用 index 当 key,在「中间插入/删除/重新排序」时会复用错误的 DOM 节点,导致输入框内容错位、动画错乱。id 才是正确身份标识。

4. 大对象 / 外部库对象:shallowRef 与 markRaw

第 9 章讲过:reactive 会递归代理每一层。一棵 10 万节点的树、一个地图/图表实例,用 shallowRef 包起来,只在「整体替换」时更新,省掉海量无意义代理。

import { shallowRef, markRaw } from 'vue';
const bigTree = shallowRef(loadHugeTree());   // 不递归代理内部
const chart = markRaw(new ThirdPartyChart());  // 第三方实例永不代理

5. 组件级:defineAsyncComponent 与懒加载

import { defineAsyncComponent } from 'vue';
const HeavyModal = defineAsyncComponent(() => import('./HeavyModal.vue'));

把不常出现的重组件拆成异步块,首屏只加载必要代码(配合路由懒加载,见第 14 章)。

小结

手段解决什么
computed 代替方法避免派生值每次渲染重算
v-once / v-memo跳过静态/可预测子树的 diff
稳定 :key正确的节点复用与状态保持
shallowRef / markRaw大对象/外部库避免深层代理开销
defineAsyncComponent重组件拆块,减小首屏体积
🎯练习 1:方法改成 computed

下面模板每次渲染都会重新过滤 1 万条数据。改成 computed,并说明为什么这样更快。

<script setup lang="ts">
import { ref } from 'vue';
const users = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i, active: i % 2 === 0 })));
function activeUsers() { return users.value.filter((u) => u.active); }
</script>
<template>
  <li v-for="u in activeUsers()" :key="u.id">{{ u.id }}</li>
</template>
🎯练习 2:v-memo 优化大列表

有一个大列表 items,父组件有个 selectedId 频繁变化,但列表数据本身很少变。用 v-memo 让列表只在数据变化时重渲染,而不是每次 selectedId 变都重渲染整个列表。

🎯练习 3:为什么 key 不能用 index?

解释「用 v-for 的 index 当 key」在「列表头部插入一项」时会发生什么错乱,并给出正确写法。