手写 Vue3 响应式 — 从零构建基于 Proxy 的响应式系统

在前端开发中,响应式变量只有一个简单的目的:数据变了,UI 自动重新渲染。

本文以 Vue3 的 reactive() 为例,从零开始逐步将普通对象改造为响应式对象。

一个简单的渲染设置

先来看最基础的页面渲染:

<body>
  <div id="age"></div>
  <div id="name"></div>
  <div id="console" style="color: red"></div>
  <script>
    function appendToConsole(msg) {
      document.getElementById("console").innerHTML += msg + "<br>";
    }
    function renderName() {
      appendToConsole("rendering name...");
      document.getElementById("name").innerHTML = profile.name;
    }
    function renderAge() {
      appendToConsole("rendering age...");
      document.getElementById("age").innerHTML = profile.age;
    }
    const profile = { age: 13, name: "John" };

    renderAge();
    renderName();
  </script>
</body>

如果之后修改 profile.age = 18 想让 UI 随之更新,就必须手动再调一次 renderAge()。当管理对象越来越多、依赖关系越来越复杂(比如总分依赖各科分数),手动维护会非常头疼。

我们急需一种更强的变量类型:当它被修改时,自动通知依赖它的各个函数并重新执行。

Vue3 的 reactive() 长什么样

Vue3 让它变得极其简单:

import { reactive } from "vue";

// profile 经 reactive 增强后 — 它是一个响应式对象
const profile = reactive({ age: 13, name: "John" });
renderAge();

// 这行之后,页面上的 age 自动更新为 18
profile.age = 18; 

下面尝试自己实现 reactive()。

用 Proxy 拦截对象的读写

要在变化时通知依赖方,首先得拦截对象修改。Proxy 正是合适的选择。

Proxy 基本用法:new Proxy(target, handler),其中 handler 定义 get() 和 set() — 对代理对象的所有读写都会被拦截。

const obj = { name: "John" };
const proxy = new Proxy(obj, {
  get(target, key) {
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    return true; // 表示成功
  },
});

用它对 profile 的读写进行监听:

const profile = { age: 13, name: "John" };
const reactiveProfile = new Proxy(profile, {
  get(target, key) {
    appendToConsole(`read ${String(key)}, value: ${target[key]}`);
    return target[key];
  },
  set(target, key, value) {
    appendToConsole(`set ${String(key)}: ${target[key]} → ${value}`);
    target[key] = value;
    return true;
  },
});

现在修改 reactiveProfile 就能看到变更日志了。但光知道发生了什么还不够 — 还得知道通知谁。

谁依赖了 profile?

思路很直接:每次 get 时,记录当前正在读取该属性的函数(依赖方)。为此需要:

  • 一个全局变量 currentFunc 来追踪当前正在执行的依赖函数
  • 一个包装器 wrapFunc,在执行依赖函数之前设置好 currentFunc
let currentFunc = null; 
const dependenciesMap = {}; 

function wrapFunc(func) {
  return function () {
    currentFunc = func; // 执行前标记 "我是谁"
    func();
    currentFunc = null; // 执行后清除
  };
}

有了这些基础设施,就可以在 get 陷阱中记录依赖了:

get(target, key) {
  if (currentFunc) {
    if (!dependenciesMap[key]) {
      dependenciesMap[key] = new Set();
    }
    dependenciesMap[key].add(currentFunc); // 记录依赖
  }
  return target[key];
}

set 陷阱中则遍历所有依赖方并重新执行:

set(target, key, value) {
  target[key] = value;
  const deps = dependenciesMap[key];
  if (deps) {
    deps.forEach((func) => func()); // 通知所有依赖方
  }
  return true;
}

最终 reactive() 实现

合在一起:

let currentFunc = null;
const dependenciesMap = {};

function wrapFunc(func) {
  return function () {
    currentFunc = func;
    func();
    currentFunc = null;
  };
}

function track(target, key) {
  if (currentFunc) {
    if (!dependenciesMap[key]) {
      dependenciesMap[key] = new Set();
    }
    dependenciesMap[key].add(currentFunc);
  }
}

function trigger(target, key) {
  const deps = dependenciesMap[key];
  if (deps) {
    deps.forEach((func) => func());
  }
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key);
      return target[key];
    },
    set(target, key, value) {
      target[key] = value;
      trigger(target, key);
      return true;
    },
  });
}

用我们自己的 reactive() 改写最初的例子:

const profile = reactive({ age: 13, name: "John" });

renderAge = wrapFunc(renderAge);
renderName = wrapFunc(renderName);

renderAge(); // 首次渲染 — age get 触发,renderAge 被记录为 age 的依赖方
renderName(); // name 同理

profile.age = 18; // age set 触发,renderAge 自动重新执行

无论何时修改 profile.age,renderAge 都会自动重新执行、UI 随之更新 — 这便是 Vue3 reactive() 的核心原理。

补充一:用 Reflect 处理 this

直接使用 target[key] 大多数情况下没问题。但如果对象有 getter/setter 或原型链属性,this 可能指向原始对象而非代理对象,导致依赖追踪丢失。

举个例子:

const obj = {
  _name: "John",
  get name() {
    return this._name; // this 指向谁?
  },
};

const proxy = new Proxy(obj, {
  get(target, key) {
    console.log("get:", key);
    return target[key]; // 直接读原始对象
  },
});

console.log(proxy.name);

输出:

get: name

注意:读取 _name 时并没有触发 get 陷阱!因为 getter 内部的 this 指向的是 obj(原始对象),而不是 proxy。换句话说,如果 _name 变了,依赖 name 的函数不会重新渲染。

将 target[key] 替换为 Reflect.get(target, key, receiver)。receiver 就是代理对象自身,这样 getter 内部的 this 就能正确指向代理对象,_name 的读取也会被拦截。

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

receiver 参数就是代理对象自身,配合 Reflect 确保 getter/setter 内部的 this 始终指向代理 — 不会遗漏依赖追踪。

补充二:wrapFunc → effect,自动执行

之前的 wrapFunc 需要手动首次调用来注册依赖 — 略显啰嗦。Vue3 里对应的概念叫 effect,包装后立即自动执行一次,省去手动首次调用:

function effect(fn) {
  const wrapped = () => {
    currentFunc = wrapped;
    fn();
    currentFunc = null;
  };
  wrapped(); // 立即执行,自动收集依赖
}

有了 effect,示例更加干净:

const profile = reactive({ age: 13, name: "John" });

effect(renderAge); // 自动执行一次,顺手完成依赖收集
effect(renderName); // 同理

profile.age = 18; // 自动触发 renderAge 重新执行

不再需要手动 renderAge() 或 renderName()。干净利落。

补充三:用 WeakMap 扩展到多个对象

我们当前 dependenciesMap = {} 只能管一个响应式对象。试试建两个:

const profile = reactive({ age: 13 });
const settings = reactive({ age: 99 }); // 两个对象共享同一个 key "age"!

两个对象在 dependenciesMap 中共用同一个扁平 key "age",导致依赖方乱套 — 修改 profile.age 会错误触发 settings 的依赖方。

Vue3 用 WeakMap 来解决 — 以原始目标对象为 key 建立依赖图:

const targetMap = new WeakMap(); 

function track(target, key) {
  if (!currentFunc) return;
  let depsMap = targetMap.get(target); 
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map())); 
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(currentFunc);
}

function trigger(target, key) {
  const depsMap = targetMap.get(target); 
  if (!depsMap) return;
  const deps = depsMap.get(key);
  if (deps) {
    deps.forEach((func) => func());
  }
}

现在每个响应式对象都有自己隔离的 depsMap。为什么是 WeakMap 而不是 Map?当响应式对象在任何地方都不再被引用时,它在 WeakMap 中的条目会被自动垃圾回收 — 无需手动清理,没有内存泄漏。这就是 Vue3 内部响应式系统底层所做的。