7AA7 博客

Vue 3 响应式原理:Proxy 与依赖收集

江眠 UID:120
2026-09-27 09:52
86 2 2

Vue 3 把响应式底层从 Object.defineProperty 换成了 Proxy。这个替换不只是换个 API,它顺带解决了一批历史遗留问题,也引入了新的边界情况。

Proxy 相比 defineProperty 强在哪

defineProperty 要拦截属性读写,就必须对每个已知属性逐个定义 getter/setter。这带来三个麻烦:

  1. 新增属性拦不到,必须调 Vue.set;
  2. 删除属性拦不到,必须调 Vue.delete;
  3. 数组只能通过重写 push/pop/splice 等七个方法来间接拦截,直接 arr[0] = x 或改 arr.length 都不触发更新。

另外它必须在初始化时递归遍历整个对象,把每一层都转成 getter/setter,深层对象初始化成本高,而且这种一次性转换对”只用到一两个字段的大对象”是纯粹的浪费。

Proxy 拦截的是对象本身的操作,不是某个属性:

const proxy = new Proxy(target, {
  get(target, key, receiver) { /* 读取拦截 */ },
  set(target, key, value, receiver) { /* 写入拦截 */ },
  deleteProperty(target, key) { /* 删除拦截 */ },
  has(target, key) { /* in 操作符 */ },
  ownKeys(target) { /* Object.keys / for...in */ },
});

于是 obj.newProp = 1、delete obj.a、arr[5] = x、arr.length = 0、Object.keys() 全部可拦截。而且 Proxy 是惰性的:只有真正访问到某个嵌套对象时,才在 getter 里把它包一层代理,深层数据不访问就不转换。

Proxy 的代价也不小:

  • 无法被 polyfill,只支持 ES2015+ 的运行时环境;
  • 代理对象与原对象不相等,raw === reactive(raw) 为 false,比较身份时要用 toRaw;
  • this 指向问题。通过代理访问方法时 this 是代理对象,若方法内部持有原对象引用,会造成”同一个逻辑两套身份”。Vue 用 Reflect.get(target, key, receiver) 传 receiver 来修正。

依赖收集:track 与 trigger

核心机制只有两步:读取时把当前正在运行的副作用记为依赖(track),写入时把记录的副作用重新执行(trigger)。

存储结构是这样一层层套下去的:

WeakMap<target, Map<key, Set<effect>>>

用 WeakMap 是为了让 target 被回收时依赖关系也自动释放,不会内存泄漏。

一个最小实现:

let activeEffect = null;
const targetMap = new WeakMap();

function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let dep = depsMap.get(key);
  if (!dep) depsMap.set(key, (dep = new Set()));
  dep.add(activeEffect);
  activeEffect.deps.push(dep); // 反向记录,便于清理
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (!dep) return;
  [...dep].forEach(effect => {
    if (effect !== activeEffect) effect.run(); // 防止自身递归
  });
}

function effect(fn, options = {}) {
  const run = () => {
    cleanup(runner);       // 执行前先断开旧依赖
    activeEffect = runner;
    try { return fn(); } finally { activeEffect = null; }
  };
  const runner = Object.assign(run, { deps: [], scheduler: options.scheduler });
  runner();
  return runner;
}

const reactive = (obj) => new Proxy(obj, {
  get(t, k, r) { track(t, k); const v = Reflect.get(t, k, r);
                 return typeof v === 'object' && v !== null ? reactive(v) : v; },
  set(t, k, v, r) { const old = t[k]; const ok = Reflect.set(t, k, v, r);
                    if (!Object.is(old, v)) trigger(t, k); return ok; },
  deleteProperty(t, k) { const ok = Reflect.deleteProperty(t, k); trigger(t, k); return ok; },
});

几个容易漏掉的细节:

  • cleanup 必须有。否则条件分支里 a ? obj.x : obj.y 在切换分支后,旧依赖仍留在集合里,导致无关更新触发渲染。
  • Reflect.get 要传 receiver,否则 getter 里的 this 指向原始对象,嵌套依赖收集会失效。
  • 值没变就不触发。Object.is 判断能避免 obj.a = obj.a 引发的无意义更新;注意 NaN 与 +0/-0 的处理依赖 Object.is 的语义。

为什么需要 effect 栈

上面的实现只有单个 activeEffect,遇到嵌套 effect 就崩了:

effect(() => {
  effect(() => console.log(obj.b)); // 内层执行完 activeEffect 被置 null
  console.log(obj.a);               // 此时 track 拿不到 effect,依赖丢失
});

正确做法是用栈保存父子关系:

const effectStack = [];
const run = () => {
  cleanup(runner);
  effectStack.push(runner);
  activeEffect = runner;
  try { return fn(); } finally {
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  }
};

组件渲染函数本身就是嵌套 effect 的典型场景:父组件 render 过程中子组件渲染,形成自然的嵌套链。没有栈,整棵组件树的依赖收集都会错乱。

调度器与批量更新

如果每次 trigger 都同步执行 effect,下面这段代码会渲染三次:

state.a++; state.b++; state.c++;

调度器解决这个问题:trigger 时不直接跑 effect,而是把它推入一个微任务队列去重执行。

const queue = new Set();
let flushing = false;
function queueJob(job) {
  queue.add(job);
  if (!flushing) {
    flushing = true;
    Promise.resolve().then(() => {
      flushing = false;
      queue.forEach(j => j());
      queue.clear();
    });
  }
}

用微任务而非宏任务,是为了在当前同步代码跑完、但在浏览器渲染之前完成更新,用户不会看到中间态。nextTick 就是往这个队列后面插一个回调。

调度器还给 watch 的 flush: 'pre' | 'post' | 'sync' 提供了实现基础——组件更新前的回调与 DOM 更新后的回调,本质只是插入队列的时机不同。

ref 与 reactive 怎么选

reactive 用于对象,ref 用于任意值——包括原始类型。原始类型无法被 Proxy 代理,所以 ref 用一个带 .value 的包装对象实现:

function ref(raw) {
  const r = { get value() { track(r, 'value'); return raw; },
              set value(v) { if (!Object.is(v, raw)) { raw = v; trigger(r, 'value'); } } };
  return r;
}

实际使用中的取舍点:

  • 替换整个对象:reactive 创建的代理不能直接被替换(state = newObj 会丢掉响应性),只能逐字段赋值;ref 可以直接 state.value = newObj。所以”整体替换”场景优先 ref。
  • 解构:reactive 对象解构出来的属性丢失响应性(脱离了代理),必须配合 toRefs;ref 在模板里会自动解包,用起来更省心。
  • 函数间传递:传 ref 对象本身能保持响应性链路,传 reactive 对象的某个字段则会断开。
  • reactive 深层代理是惰性的,嵌套层级极深时仍有代理开销;shallowRef / shallowReactive 是给大对象准备的逃生口。

computed 的懒计算与缓存

计算属性本质是一个特殊的 effect:惰性求值、带脏标记,且自己也是被依赖的对象。

function computed(getter) {
  let value, dirty = true;
  const runner = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) { dirty = true; trigger(obj, 'value'); } // 通知依赖 computed 的一方
    },
  });
  const obj = {
    get value() {
      if (dirty) { value = runner(); dirty = false; }
      track(obj, 'value');   // computed 自身也要被追踪
      return value;
    },
  };
  return obj;
}

三个关键设计:一是 lazy: true,创建时不执行,第一次读 .value 才算;二是 dirty 标记,依赖没变就返回缓存值;三是读取 computed 时也要 track,这样”依赖 computed 的组件”才会更新。

少了第三步就会出现”computed 变了但用它的组件没更新”,这是手写响应式最容易漏的地方。

数组与 Map/Set 的拦截细节

数组方法调用会同时触发多次 get 和 set。比如 arr.push(x) 会读 length、写索引、再写 length。不加保护的话一次 push 会触发多轮更新。

Vue 的做法是对数组的读方法(includes、indexOf、lastIndexOf)和改变自身的方法(push、pop、shift、unshift、splice)做特殊处理:避免追踪那些不稳定的中间访问,写入时避免重复 trigger。

对 Map/Set 用了另一套代理:拦截 get、set、add、delete、has、size,并且对 forEach、keys、values 这类迭代器方法专门绑定 ITERATE_KEY,让”遍历”本身成为一个可追踪的依赖。这样新增一个键时,依赖遍历的 effect 才会重新执行——只追踪具体 key 的话,for...of 是不会更新的。

Object.keys() 与 for...in 走 ownKeys 加 ITERATE_KEY,逻辑同理。

readonly、toRaw 与 markRaw 的边界

readonly 是 reactive 的只读包装,set 时给出警告而不写入。它的价值在跨模块边界时防止误改,但要注意它是浅层的——深层嵌套对象仍可变,需要深层只读得递归包装。

toRaw 拿到代理背后的原对象,用于比较身份或传给不接受代理的第三方库。代价是对原对象的修改不触发更新。

markRaw 标记一个对象永不代理,适合放大型第三方实例(地图对象、编辑器实例、图表实例),能避免深层代理带来的性能损耗与身份错乱。

watch 与 watchEffect 的差异

watchEffect 立即执行一次,自动收集回调里访问过的所有依赖,不需要声明源:

watchEffect(() => { document.title = state.title; });

watch 需要显式指定源,惰性执行(默认不立即跑),能拿到新旧值:

watch(() => state.user.id, async (newId, oldId) => {
  cache.delete(oldId);
  profile.value = await fetchProfile(newId);
}, { immediate: true, deep: false });

选择依据:

  • 需要新旧值对比(如清理旧缓存、撤销操作)→ watch;
  • 只是”某个数据变了就做点副作用” → watchEffect 更简洁;
  • 依赖动态分支的场景 → watchEffect 天然支持,因为每次执行重新收集;
  • 监听多个源 → watch([a, b], ([na, nb], [oa, ob]) => ...)。

一个真实的坑:watch 一个 reactive 对象默认是深层监听(隐式 deep),而 watch 一个返回对象的 getter 则不是——除非显式写 deep: true。这个不一致经常导致”为什么没触发”的困惑。深层监听要遍历整棵对象树收集依赖,对大对象是实打实的开销,能指定具体字段就别开 deep。

另一个坑是异步 watch 的竞态:连续两次请求,先发的后返回会覆盖新值,需要在回调里做过期判断:

watch(id, async (newId, oldId, onCleanup) => {
  let cancelled = false;
  onCleanup(() => { cancelled = true; });   // 下一次触发前调用
  const data = await fetchProfile(newId);
  if (!cancelled) profile.value = data;
});

理解这套机制之后,很多”为什么没更新”的问题会直接变成”哪里断了链路”:是解构丢了代理、是替换了 reactive 引用、还是用了非响应式 API 写入。

2.5k浏览量
86赞
2评论
2参与用户
18 分钟阅读时间

评论(2)

  • 祝青禾 · 2026-09-27 12:23

    有个地方想确认:这个是不是只对大流量有效?

    回复
  • 码同客 · 2026-09-27 21:31

    有个错别字,「部署」写成了「布署」,不影响阅读。

    回复