Vue 3 响应式原理:Proxy 的魔法,Vue 是怎么“自动更新”的?

你改了 data,页面就自己变了——Vue 3 到底怎么做到的?小明用最小可运行的响应式模型讲清楚 Proxy、依赖收集、派发更新(track/trigger)和 effect 的本质,让你不仅会用 reactive/ref,还能看懂源码思路。

18 分钟阅读
小明

Vue 3 响应式原理:Proxy 的魔法,Vue 是怎么“自动更新”的?

你有没有经历过这种爽感:

  • 你只写了一句 state.count++
  • 然后页面上的数字就自己变了
  • 你甚至没写任何 DOM 操作

如果你是第一次接触 Vue,你会觉得它像会读心术。

但如果你写了半年 Vue,还停留在“反正它会更新”的阶段——那你迟早会在这几个地方栽跟头:

  • 为什么我改了对象的某个属性,页面没更新?
  • reactiveref 到底有什么区别?
  • 为什么 watch 有时候会触发两次?
  • 组件为什么会“多渲染”?如何定位性能问题?

所以今天我们不背源码、不背概念,我们做一件更工程师的事:

亲手写一个迷你版 Vue 3 响应式核心。

写完你会明白:Vue 的“魔法”其实就是一套非常严谨的流程。


1. 响应式系统到底想解决什么问题?

先用一句人话定义响应式:

数据变了,依赖它的地方自动重新执行。

关键是“依赖它的地方”是谁?

  • 在 Vue 里,通常是组件渲染函数(render)或模板编译后的更新逻辑
  • 在更抽象的系统里,就是某个函数:只要它读了某个数据,那它就依赖这个数据

所以响应式系统需要做到两件事:

  1. 依赖收集(track):谁在读这个数据?把它记下来。
  2. 派发更新(trigger):数据被改了,通知所有依赖者重新跑。

Vue 3 选择用 Proxy 来做这件事,原因很朴素:

  • “读”(get)和“写”(set)都能被拦截
  • 不需要像 Vue 2 那样对每个属性 defineProperty
  • 能覆盖更多场景(新增/删除属性、数组、Map/Set 等)

2. 先造最小的舞台:effect 是谁?

在 Vue 3 里,effect 可以理解为:

一个会自动追踪依赖的函数。

你可以把它想成“自动更新的订阅者”。

我们先定义一个全局变量,用来表示“当前正在运行的 effect”。

let activeEffect = null;

function effect(fn) {
  const wrapped = () => {
    activeEffect = wrapped;
    try {
      fn();
    } finally {
      activeEffect = null;
    }
  };

  wrapped();
  return wrapped;
}

解释一下这段代码在干嘛:

  • effect(fn) 会立即执行一次 fn()
  • 执行期间,把 activeEffect 设为自己
  • 这样当 fn() 里读取响应式数据时,我们就知道“是谁在读”(也就是依赖者是谁)

这就是依赖收集的起点。


3. 用 Proxy 拦截读取:track(依赖收集)

我们要把“数据的某个属性”映射到“一堆依赖它的 effect”。

最常见的数据结构是三层 Map:

  • targetMap: WeakMap<object, Map<key, Set<effect>>>
const targetMap = new WeakMap();

function track(target, key) {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }

  dep.add(activeEffect);
}

你可以把它想成一张“通讯录”:

  • state.count 被谁读过?把那些 effect 记到 count 的 Set 里。

然后我们写一个 reactive

function reactive(target) {
  return new Proxy(target, {
    get(obj, key, receiver) {
      const result = Reflect.get(obj, key, receiver);
      track(obj, key);
      return result;
    }
  });
}

到这里,我们已经能做到“谁读了我,我就记住谁”。


4. 用 Proxy 拦截修改:trigger(派发更新)

现在做第二件事:数据变了,通知依赖重新执行。

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (!dep) return;

  for (const eff of dep) {
    eff();
  }
}

reactiveset 补上:

function reactive(target) {
  return new Proxy(target, {
    get(obj, key, receiver) {
      const result = Reflect.get(obj, key, receiver);
      track(obj, key);
      return result;
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key];
      const result = Reflect.set(obj, key, value, receiver);

      // 简化:值没变就不触发
      if (oldValue !== value) {
        trigger(obj, key);
      }

      return result;
    }
  });
}

恭喜你:一个能工作的“响应式系统最小闭环”已经完成了。


5. 跑起来:让代码自己“更新”

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

function effect(fn) {
  const wrapped = () => {
    activeEffect = wrapped;
    try {
      fn();
    } finally {
      activeEffect = null;
    }
  };

  wrapped();
  return wrapped;
}

function track(target, key) {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }

  dep.add(activeEffect);
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (!dep) return;

  for (const eff of dep) eff();
}

function reactive(target) {
  return new Proxy(target, {
    get(obj, key, receiver) {
      const result = Reflect.get(obj, key, receiver);
      track(obj, key);
      return result;
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key];
      const result = Reflect.set(obj, key, value, receiver);
      if (oldValue !== value) trigger(obj, key);
      return result;
    }
  });
}

const state = reactive({ count: 0 });

effect(() => {
  console.log('render:', state.count);
});

state.count += 1; // render: 1
state.count += 1; // render: 2

你会看到 effect 自动重新运行。

把这段“render”想象成 Vue 的组件渲染:数据一变,它就重新跑,从而生成新的 UI。


6. 真实的 Vue 3 不止 track/trigger:它还解决了 3 个“工程级难题”

我们写的迷你版本能跑,但离 Vue 3 还有一段距离。Vue 3 还必须解决这三个工程级问题:

  1. 依赖清理(cleanup):条件分支变化时,旧依赖要移除,否则会“多余更新”。
  2. 调度与批处理(scheduler + batching):同一轮同步代码里多次 set,应该合并成一次渲染。
  3. 可停止与嵌套 effect(stop + effect stack):组件卸载要停掉 effect;effect 里再触发 effect 也要正确。

6.1 依赖清理:为什么 Vue 不会越用越“多订阅”?

看一个典型场景:

const state = reactive({ ok: true, text: 'hello', count: 0 });

effect(() => {
  // ok 为 true 时依赖 text;为 false 时依赖 count
  console.log(state.ok ? state.text : state.count);
});

state.ok = false;
state.text = 'world'; // 这次其实不应该触发 effect(因为分支变了)

如果没有 cleanup,你第一次运行时订阅了 text,后来分支切换订阅了 count,但 text 的订阅没删掉,于是你会得到“我明明不读 text 了,它还让我更新”。

Vue 3 的做法(概念级描述):

  • 每次运行 effect 前,先把它从旧的依赖集合里移除
  • 运行过程中重新 track,得到新的依赖集合

这就是为什么 Vue 的响应式在复杂条件渲染下仍能保持“依赖正确”。

6.2 调度与批处理:为什么连续 count++ 不会渲染 100 次?

如果 trigger 里直接执行 effect,下面代码会很惨:

for (let i = 0; i < 100; i++) {
  state.count += 1;
}

真实的 Vue 3 会把更新放进队列,同一轮同步任务只渲染一次

一个很像 Vue 思路的极简调度器(可运行心智模型):

const jobQueue = new Set();
let isFlushing = false;

function queueJob(job) {
  jobQueue.add(job);
  if (isFlushing) return;
  isFlushing = true;
  Promise.resolve().then(() => {
    try {
      for (const j of jobQueue) j();
    } finally {
      jobQueue.clear();
      isFlushing = false;
    }
  });
}

在 Vue 里,trigger 不是“立刻跑 effect”,而是“把 effect 的 runner 扔给 scheduler”。

6.3 stop:组件卸载后,effect 必须能停

组件卸载后如果 effect 还在跑,轻则多余渲染,重则内存泄漏。

Vue 的 effect runner 会有一个“激活/停止”的状态,停止时会把自己从所有依赖集合里移除。


7. computed 为什么快?核心是 lazy + dirty(不是“缓存”两个字那么简单)

很多人把 computed 记成“带缓存的 computed”,但真正的关键是两个机制:

  • lazy(惰性):没人读它,它就不算。
  • dirty(脏标记):依赖变了先把自己标脏,不立刻重算;等下次有人读时再算。

极简心智模型:

依赖变了 → computed 不马上算 → 只把 dirty=true
有人读取 computed.value → 如果 dirty=true 才重新计算

这也是为什么 computed 适合“参与渲染的派生状态”:它把计算成本推迟到真正需要的时候。


8. watch 的本质:你以为它在“监听”,其实它在“重新跑 getter”

watch(source, cb) 的 source 通常是:

  • 一个 getter:() => state.count
  • 或一个 ref/reactive

watch 的核心思路是:

  1. 用 effect 跑一遍 getter,完成依赖收集
  2. 依赖变化时触发 scheduler
  3. scheduler 里重新跑 getter,拿到新旧值,再调用回调

为什么 watch 有 flush: 'pre' | 'post' | 'sync'

  • 因为它要控制回调发生在渲染的哪个阶段(这直接影响你能不能拿到最新 DOM)

这也是为什么很多“watch 触发两次/时机不对”的问题,根源在于:

  • 你监听的 source 是不是稳定?
  • 你是不是在 effect/渲染期间又改了依赖?
  • 你选择的 flush 阶段是否符合预期?

9. Vue 3 为什么还需要 ref?reactive 不够吗?

你可能会问:既然 reactive 这么好用,为啥还要 ref

原因一句话:

reactive 适合对象;ref 适合原始值(number/string/boolean)和“需要稳定引用”的场景。

在 JavaScript 里,Proxy 只能代理对象。

reactive(1) // ❌ 代理不了原始值

所以 Vue 3 用 ref 包了一层:

const count = ref(0)
count.value++

.value 这层看起来麻烦,但它解决了“原始值无法被 Proxy 代理”的问题,也让引用在解构时更可控。


10. 真实世界的坑:为什么你的页面没更新?

7.1 解构丢失响应式(最常见)

const state = reactive({ count: 0 });
const { count } = state;

effect(() => {
  console.log(count); // ❌ 这里不会被 track
});

state.count++;

原因:

  • count 在解构时已经变成普通值
  • 后续 effect 里并没有触发 Proxy.get

在 Vue 组件里,这类问题通常通过 toRefs/toRef 解决。

你可以把 toRefs 理解成:

reactive 对象的每个属性都变成一个“带 .value 的引用”,让解构后仍能保持响应式。

10.2 “更新了但没渲染”:你可能被批处理骗了

Vue 3 的更新通常是异步批处理的(同一轮任务合并)。所以你经常会遇到:

  • 我明明 state.count++ 了,怎么 console.log 里 DOM 还没变?

这不是没更新,而是更新被安排到微任务队列里了。

工程里常见的正确姿势是:

  • 需要等 DOM 更新再读:用 nextTick

10.3 递归更新与“无限触发”

如果你在 effect/watch 回调里又去写自己依赖的状态,很容易变成:

触发更新 → 回调执行 → 再次写入 → 再次触发更新 → …

Vue 内部会做一些保护与调度,但你仍需要在业务逻辑上避免“自触发回路”。

10.4 依赖没被“读到”,就不会被收集

响应式系统不是“你改了就更新所有东西”,而是“你改了就更新依赖它的东西”。

如果某个分支下的属性在首次渲染时没被读取,它就不会被追踪。


11. 面试怎么讲 Vue 3 响应式?(可直接背,但你得先理解)

你可以按这个顺序回答:

  1. Vue 3 用 Proxy 拦截对象的 get/set
  2. 读取时执行 track:把当前运行的 effect 收集到 target -> key 的依赖集合里。
  3. 修改时执行 trigger:从依赖集合里取出所有 effect 重新执行。
  4. reactive 负责对象响应式,ref 解决原始值响应式与稳定引用。

面试官追问你“为什么 Vue 2 不行?”你可以补一句:

  • Vue 2 主要基于 Object.defineProperty,无法天然覆盖新增/删除属性、数组索引等,需要各种补丁;Vue 3 的 Proxy 更完整。

总结

  • Vue 3 响应式核心就两件事:track 收集依赖trigger 派发更新
  • Proxy 是关键:它让“读/写”都可拦截,依赖收集更自然。
  • reactive 适合对象;ref 适合原始值与稳定引用。
  • 常见踩坑:解构丢响应式没读就没依赖

小明金句收尾:

Vue 的响应式不是魔法,是一套“你读我记,你改我追”的规矩。