从零实现 Vue 3 响应式系统:手写 reactive/ref/computed,彻底理解 Proxy + Reflect 原理

0 阅读9分钟

原文课程:Vue 3 Reactivity — Vue Mastery

如果你用过 Vue 3,一定知道 ref() 和 reactive() 很好用——改数据,视图自动更新。但你想过没有:为什么改一个变量,组件就知道该重新渲染了?  Vue 3 的响应式系统从零被重写,背后依赖的是 ES6 的 Proxy 和 Reflect,以及一套精妙的依赖追踪机制。

本文带你从零开始,一步步构建一个迷你 Vue 3 响应式系统。读完你会真正理解 refreactivecomputed 这些 API 到底是怎么工作的。

适合读者:  有 Vue 使用经验、想深入原理的前端开发者。


一、Vue 2 的痛点:为什么需要重写?

Vue 2 基于 Object.defineProperty 实现响应式,这带来了三个致命问题:

1. 无法监听对象属性的新增和删除

// Vue 2 中,动态添加的属性不是响应式的
this.obj.newProp = 'hello'; // ❌ 视图不更新
// 必须用 Vue.set(this.obj, 'newProp', 'hello')

2. 数组操作拦截不完整

// Vue 2 必须重写 7 个数组方法(push/pop/shift/unshift/splice/sort/reverse)
this.arr[0] = 'new';  // ❌ 索引赋值无法拦截,视图不更新
this.arr.length = 0;  // ❌ 修改长度无法拦截,视图不更新

3. 初始化时递归遍历,性能开销大

Object.defineProperty 在初始化时就要递归遍历对象的所有层级,每层每属性都要劫持。嵌套越深初始化越慢,对于大对象或深层数据,这就是性能灾难。


二、Proxy:ES6 的"拦截器"

Vue 3 选择 Proxy 作为响应式的基石。Proxy 可以代理一个对象,拦截对它的任意操作——不只是读写,还包括删除属性、in 操作符、for...in 遍历等,共计 13 种基础操作。

基本用法

const handler = {
  get(target, key, receiver) {
    console.log(`读取了 ${String(key)}`);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log(`设置了 ${String(key)} = ${value}`);
    return Reflect.set(target, key, value, receiver);
  },
  deleteProperty(target, key) {
    console.log(`删除了 ${String(key)}`);
    return Reflect.deleteProperty(target, key);
  }
};

const obj = new Proxy({ name: 'Vue', version: 3 }, handler);
obj.name;           // 读取了 name
obj.version = 4;    // 设置了 version = 4
delete obj.name;    // 删除了 name

Proxy 的 13 个捕获器

捕获器拦截的操作
get()属性读取
set()属性赋值
has()in 操作符
deleteProperty()delete 操作符
ownKeys()Object.keys()for...in
getOwnPropertyDescriptor()Object.getOwnPropertyDescriptor()
defineProperty()Object.defineProperty()
preventExtensions()Object.preventExtensions()
getPrototypeOf()Object.getPrototypeOf()
setPrototypeOf()Object.setPrototypeOf()
isExtensible()Object.isExtensible()
apply()函数调用
construct()new 操作符

Vue 3 响应式系统主要用到其中 get、set、deleteProperty、has、ownKeys 这 5 个。

相比 Object.defineProperty,Proxy 有三个压倒性优势:

  • 数组索引赋值和 length 修改天然可拦截
  • 属性新增和删除自动检测,告别 Vue.set / Vue.delete
  • 初始化时不需要递归遍历所有层级(懒代理策略——用到再代理)

三、Reflect:为什么不能只用 Proxy?

很多教程把 Proxy 和 Reflect 绑在一起讲,却很少解释为什么要用 Reflect。看这段反例:

// ❌ 直接在 handler 中操作 target,this 绑定出错
const obj = {
  _count: 0,
  get count() { return this._count; },
  set count(v) { this._count = v; }
};

const proxy = new Proxy(obj, {
  get(target, key) {
    console.log('get:', key);
    return target[key];  // this 指向原始对象 obj,而非代理 proxy!
  }
});

proxy.count;  // get: 'count' → 但 getter 内 this._count 绕过了 Proxy

这里有隐晦的 Bug:当对象属性是通过 getter/setter 定义的,在 handler 中直接访问 target[key],getter 内部的 this 指向的是原始对象 obj,而不是代理对象 proxy。这意味着 getter 中对 this._count 的读取会绕过代理的 get 拦截,无法被 track() 收集依赖。

Reflect 的 receiver 参数正是为此而生:

// ✅ 使用 Reflect 并传递 receiver
const proxy = new Proxy(obj, {
  get(target, key, receiver) {
    console.log('get:', key);
    return Reflect.get(target, key, receiver);  // receiver = proxy,this 正确绑定
  }
});

receiver 确保在访问 getter 时,this 正确指向代理对象,这样 getter 内部对 this._count 的读取也会被代理拦截,依赖收集才完整。

一句话总结:Proxy 负责拦截,Reflect 负责执行默认行为并保持 this 指向正确。二者配合才是完整的响应式基石。

Proxy 和 Reflect 协作关系


四、核心数据结构:依赖如何存储?

Vue 3 使用三层嵌套数据结构来管理依赖关系:

WeakMap<target, Map<key, Set<effectFn>>>
   ↑            ↑          ↑
 原始对象     属性名    副作用函数集合
  • WeakMap:键是原始对象,值是依赖 Map。WeakMap 的 key 是弱引用——当对象其他地方不再使用时,可以被 GC 回收,防止内存泄漏。
  • Map:键是属性名,值是该属性的依赖集合。
  • Set:存储所有依赖该属性的副作用函数,Set 天然去重,避免同一函数被重复收集。

三层依赖存储结构

// 全局依赖存储
const targetMap = new WeakMap();

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

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

  if (activeEffect && !deps.has(activeEffect)) {
    deps.add(activeEffect);
  }
}

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

  const deps = depsMap.get(key);
  if (deps) {
    deps.forEach(effect => effect());
  }
}

五、effect:副作用注册引擎

effect 是响应式系统的心脏。它注册一个函数,并在该函数执行期间自动收集它访问了哪些响应式数据。

let activeEffect = null;

function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn;
    fn();  // fn 中访问响应式数据 → get → track,自动收集依赖
  };
  effectFn();
}

使用示例:

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

effect(() => {
  console.log(state.count);  // 执行时,state.count 的 get 将 effectFn 收集为依赖
});

state.count = 1;  // 触发 set → trigger → 自动重新执行 effectFn,输出 1

Vue 组件的 render 函数本质上就是一个 effect。组件渲染时访问的响应式数据一旦变化,组件便自动重新渲染——这就是响应式驱动视图的核心原理。


六、reactive:将一切组装起来

有了前面的铺垫,实现 reactive 就水到渠成了:

function reactive(target) {
  if (typeof target !== 'object' || target === null) return target;

  return new Proxy(target, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver);

      // 依赖收集
      track(target, key);

      // 懒代理:嵌套对象按需代理
      if (typeof value === 'object' && value !== null) {
        return reactive(value);
      }

      return value;
    },

    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver);

      // 值没变,不触发更新
      if (oldValue === value) return true;

      const result = Reflect.set(target, key, value, receiver);

      // 触发依赖更新
      trigger(target, key);

      return result;
    },

    deleteProperty(target, key) {
      const hadKey = Reflect.has(target, key);
      const result = Reflect.deleteProperty(target, key);

      // 仅当确实删除了已存在的 key 时才触发
      if (hadKey) trigger(target, key);

      return result;
    }
  });
}

几个设计细节值得品味:

  • 懒代理:不在初始化时递归遍历所有层级,而是在 get 中按需代理。绝大多数场景访问不到深层属性,这节省了大量初始化开销——这是 Vue 3 相比 Vue 2 的一大性能优势。
  • 相同值不触发oldValue === value 的比较避免了无意义的重复渲染。
  • delete 防护:只有确实删除已有 key 才派发更新,多余触发一次也不要。

七、ref:基本类型的响应式方案

reactive 只能代理对象,基本类型(string、number、boolean)怎么办?ref 用“对象包装 + getter/setter”巧妙地解决了这个问题:

class RefImpl {
  _value;
  constructor(rawValue) {
    // 如果是对象,委托给 reactive 统一处理
    this._value = isObject(rawValue) ? reactive(rawValue) : rawValue;
  }

  get value() {
    track(this, 'value');
    return this._value;
  }

  set value(newValue) {
    if (newValue === this._value) return;
    this._value = isObject(newValue) ? reactive(newValue) : newValue;
    trigger(this, 'value');
  }
}

function ref(rawValue) {
  return new RefImpl(rawValue);
}

注意 track(this, 'value') 和 trigger(this, 'value')——key 固定为 'value',因为所有 ref 都通过 .value 访问。

模板中 .value 为什么可以省略?  Vue 编译器在处理 <template> 时,会自动为顶层 ref 变量插入 .value 访问。同时,当 ref 作为 reactive 对象的属性时,reactive 的 get 拦截中也会自动解包 ref,所以你写 state.count 实际上拿到的是 ref.value


八、computed:惰性求值的派生状态

computed 的核心设计是惰性求值 + 缓存——只有依赖变化后、再次访问 .value 时才重新计算,而不是每次依赖变化都立刻计算。

function computed(getter) {
  let cachedValue;
  let dirty = true;

  // 用 effect 包装 getter,并在 scheduler 中标记 dirty
  const runner = effect(() => {
    if (dirty) {
      cachedValue = getter();
      dirty = false;
    }
  });

  return {
    get value() {
      if (dirty) {
        cachedValue = getter();
        dirty = false;
      }
      track(this, 'value');
      return cachedValue;
    }
  };
}

完整的 Vue 3 实现还需要一个 scheduler 机制:当 computed 依赖的数据变化时,scheduler 只做一件事——将 dirty 置为 true,然后通知依赖该 computed 的外部 effect(如 render effect)。等到实际读取 .value 时,才真正执行 getter 计算新值。

执行链路如下:

state.a 改变
  → trigger(state.a 的依赖)
    → computed 的 scheduler:dirty = true(只打标记!)
      → trigger(computed 的依赖)
        → 渲染 effect 重新执行
          → 访问 computed.value
            → dirty === true → 执行 getter 重算 → dirty = false

这就是为什么 computed 在依赖未变时不会重新计算,而在依赖变了之后也不会“立即”计算——它等到有人真正需要结果时才动手。


九、reactive vs ref:什么时候用哪个?

特性reactiveref
适用类型对象、数组任意类型(包括基本类型)
访问方式直接 obj.x通过 .value
深层响应自动递归代理对象值自动转 reactive
解构丢失响应性(需 toRefsref 变量本身保持响应性
整体替换需 Object.assign 合并直接 .value = newVal

实践建议:

  • 单个基本值用 ref,复杂对象/表单用 reactive
  • 需要解构或传递单个字段时,用 toRefs() 将 reactive 对象的属性转为 ref
  • 大型只读数据集用 shallowRef / shallowReactive,避免深层代理开销
  • 第三方类实例(地图、图表对象)用 shallowRef,避免 Proxy 与第三方内部逻辑冲突

十、完整流程一览

  component.render()
       │
       ▼
  effect(fn)  →  activeEffect = effectFn
       │
       ▼
  fn() 调用 render → 访问响应式数据 → get 捕获 → track(target, key)
       │
       ▼
  写入 state.count = 1
       │
       ▼
  set 捕获 → trigger(target, key) → 唤醒所有依赖的 effectFn → 组件重新渲染

环环相扣的五个步骤:

  1. Proxy — 拦截读写操作
  2. Reflect — 执行默认行为并保持 receiver 正确
  3. track — 在 get 时将 activeEffect 收集为依赖
  4. trigger — 在 set 时通知所有依赖重新执行
  5. effect — 包装渲染函数,执行前将自己设为 activeEffect

十一、常见面试追问

Q1: 为什么用 WeakMap 而不是普通 Map?

WeakMap 的 key 是弱引用。当响应式对象被销毁、没有其他引用时,WeakMap 中对应的条目可以被 GC 自动回收,不会造成内存泄漏。普通 Map 的 key 是强引用,只要 targetMap 存在,对象就永远不会被回收。

Q2: 数组方面 Vue 3 比 Vue 2 好在哪里?

Proxy 可以直接拦截 arr[0] = xarr.length = 0 等操作,所有原生数组方法都是响应式的。Vue 2 必须重写 7 个数组方法来实现近似效果,是修补方案。

Q3: 为什么 reactive 对象解构会丢失响应性?

const state = reactive({ count: 0 });
const { count } = state;  // count = 0,一个普通数字

解构等同于 const count = state.count,取出的只是当前值。此后 count 不再通过 Proxy 访问,自然无法被追踪。解决方案是用 toRefs() 将每个属性转为 ref 后再解构。

Q4: 为什么 computed 不支持异步?

三个原因:① 缓存无法兑现——第一次读到的是 Promise 而非最终值;② 渲染需要同步值,异步结果导致视图空档;③ 依赖追踪不确定——多个依赖先后变化时,无法确定哪次异步结果最新。需要异步计算时,推荐 watch + ref 的组合模式。


写在最后

Vue 3 的响应式系统是一次彻底的重新设计——用 Proxy 取代 Object.defineProperty,用 Reflect 保证语义正确,用三层 WeakMap → Map → Set 结构管理依赖,用 effect 驱动视图更新。理解这套机制,不仅能帮你更自信地使用 Vue 3,也能让你在面试中从容应对原理题。

建议直接在浏览器控制台中把文中的简化代码跑一遍。当你亲眼看到 effect 随数据变化自动执行的那一刻,响应式就不再是魔法了。


原文出处:Vue 3 Reactivity — Vue Mastery 本文基于 Vue Mastery 课程主题框架,结合 Vue 3 源码与 ES6 规范,重新组织并以中文撰写。