A

Vue 响应式

2025-12-19 17:22

把“视图是状态的函数”落到 JavaScript 语义上。响应式并不是“魔法的数据绑定”,而是一套对读写、枚举、增删等基础语义的拦截与调度:读时收集依赖,写时触发副作用;语义保持正确,性能不会崩。

为什么需要“响应式”

在现代前端框架中,一个核心假设始终成立:

视图是状态的函数。

当状态发生变化时,视图应当以确定、可预测的方式重新计算并更新。 问题在于:JavaScript 本身并不知道“哪些状态被谁用过”,也不会在状态变化时主动通知视图。

因此,所谓“响应式”,并不是一种魔法的数据绑定机制, 而是一套建立在 JavaScript 语义之上的约定与拦截系统

  • 读时:记录“谁依赖了这个状态”
  • 写时:通知“哪些副作用需要重新执行”

要实现这一点,框架必须回答一个根本问题:

如何在不改变 JavaScript 语言语义的前提下,感知状态的读取与修改?

Vue 的响应式系统,正是围绕这个问题展开的工程解法。

响应式到底“是什么”

一个最小定义是:当某个对象的某个键被读取时,记录是谁在用它;当这个键被写入时,通知这些使用者重新执行 用工程语句说:构建 目标对象 → 键 → 副作用集合 的依赖图;提供执行队列与调度策略,让副作用有序、去重、可批处理

依赖图:可运行的最小骨架

JS
// ===================================== // 全局依赖存储结构 // targetMap: WeakMap<target, Map<key, Set<effect>>> // ===================================== // 使用 WeakMap 的原因: // 1. target 一般是响应式对象 // 2. 当 target 被 GC 回收时,其依赖应自动释放,避免内存泄漏 const targetMap = new WeakMap(); // 当前正在执行的副作用函数(依赖收集的“指针”) let activeEffect = null; // 副作用函数栈,用于支持 effect 嵌套 // effect 内部再调用 effect 时,必须恢复上一个 activeEffect const effectStack = []; // ===================================== // 调度相关:任务队列 + 微任务刷新 // ===================================== // 用 Set 去重,保证同一个副作用在同一轮只执行一次 const queue = new Set(); // 是否正在刷新队列,防止重复调度 let flushing = false; // 将副作用函数统一放到微任务中执行 // 达到: // - 去重 // - 批量执行 // - 避免同步递归触发 function flush() { if (flushing) return; flushing = true; Promise.resolve().then(() => { queue.forEach((job) => job()); queue.clear(); flushing = false; }); } // ===================================== // effect:副作用注册与执行 // ===================================== function effect(fn, options = {}) { // 包装后的副作用函数 const eff = () => { try { // 入栈:当前副作用成为活跃副作用 effectStack.push(eff); activeEffect = eff; // 执行用户函数 // 在执行过程中,所有被读取的响应式属性 // 都会通过 track() 记录到当前 eff return fn(); } finally { // 出栈:恢复上一个副作用 effectStack.pop(); activeEffect = effectStack[effectStack.length - 1] || null; } }; // 调度器(computed / watch 会用) eff.scheduler = options.scheduler; // 是否惰性执行(如 computed) eff.lazy = options.lazy; // 非 lazy 的 effect 会立即执行一次,用于完成首次依赖收集 if (!eff.lazy) eff(); return eff; } // ===================================== // track:依赖收集 // ===================================== function track(target, key) { // 只有在 effect 执行期间,才需要收集依赖 if (!activeEffect) return; // 取得当前 target 的依赖表 let depsMap = targetMap.get(target); if (!depsMap) { // 每个 target 对应一个 Map<key, Set<effect>> depsMap = new Map(); targetMap.set(target, depsMap); } // 取得当前 key 对应的副作用集合 let dep = depsMap.get(key); if (!dep) { dep = new Set(); depsMap.set(key, dep); } // 将当前活跃副作用加入集合 dep.add(activeEffect); } // ===================================== // trigger:依赖触发 // ===================================== function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const dep = depsMap.get(key); if (!dep) return; dep.forEach((eff) => { if (eff.scheduler) { // 有调度器:交给调度器处理(如 computed / watch) queue.add(eff.scheduler); flush(); } else { // 无调度器:立即重新执行副作用 eff(); } }); }

Vue2:Object.defineProperty 的语义与边界

Object.defineProperty 是 Vue2 响应式系统的基础,它的语义与边界是:

  • 只能拦截已存在的属性,无法动态新增/删除属性
  • 只能拦截属性的读取与写入,无法拦截属性的枚举、in 等操作
  • 访问器属性(get/set)的 this 绑定容易出错,需要手动处理
  • 无法拦截数组索引与 length 的语义变化
  • 因为 Object.defineProperty 只能作用在“已知的对象属性”上,而 Vue 2 又无法在“访问未知层级时”再介入,所以只能在初始化阶段一次性递归劫持
JS
function defineReactive(obj) { Object.keys(obj).forEach((key) => { let val = obj[key]; if (typeof val === "object" && val !== null) defineReactive(val); Object.defineProperty(obj, key, { get() { track(obj, key); return val; }, set(newVal) { val = newVal; if (typeof newVal === "object" && newVal !== null) defineReactive(newVal); trigger(obj, key); }, }); }); return obj; }

Object.defineProperty 是“属性级劫持”, Proxy是“对象语义代理”。所以 Vue3不是简单的实现升级而是语义层级的跃迁。

而这些问题 Vue2 是通过一些“补丁式方案”来解决的,如:

  • $set/$delete 方法:动态新增/删除属性时,手动触发依赖收集与触发
  • $watch 方法:监听未知属性时,手动触发依赖收集与触发
  • 数组的 splice 方法:重写变更方法,手动触发依赖收集与触发

Vue3:Proxy + Reflect 的语言级拦截

为了解决 Vue2 中“属性级劫持”的一些问题,Vue3 引入了“对象语义代理”的 Proxy 机制。Proxy 拦截的是操作语义,而不是数据结构类型。

JS
function reactiveShallow(target) { if (typeof target !== "object" || target === null) return target; return new Proxy(target, { get(t, key, receiver) { const res = Reflect.get(t, key, receiver); track(t, key); return res; }, set(t, key, value, receiver) { const old = Reflect.get(t, key, receiver); const ok = Reflect.set(t, key, value, receiver); if (ok && old !== value) trigger(t, key); return ok; }, }); }

要点

  • Proxy 拦截的是 obj[key]、key in obj、delete obj[key] 等语言操作本身
  • 配合 Reflect.* 可保持 receiver / this、原型链与对象不变式的一致性
  • 新增、删除、枚举等行为在语义层面天然可被拦截,不再依赖额外 API
  • Vue 3 在此基础上引入懒代理、调度器与集合类型支持,构成完整响应式系统

懒代理与缓存:性能与语义的统一解

JS
const proxyCache = new WeakMap(); const ITERATE_KEY = Symbol("iterate"); function reactive(target) { if (typeof target !== "object" || target === null) return target; const cached = proxyCache.get(target); if (cached) return cached; const p = new Proxy(target, { get(t, key, receiver) { const res = Reflect.get(t, key, receiver); track(t, key); if (typeof res === "object" && res !== null) return reactive(res); return res; }, set(t, key, value, receiver) { const old = Reflect.get(t, key, receiver); const hadKey = Object.prototype.hasOwnProperty.call(t, key); const ok = Reflect.set(t, key, value, receiver); if (ok && old !== value) { trigger(t, key); if (!hadKey) trigger(t, ITERATE_KEY); if (Array.isArray(t) && key === "length") trigger(t, "length"); } return ok; }, deleteProperty(t, key) { const had = Object.prototype.hasOwnProperty.call(t, key); const ok = Reflect.deleteProperty(t, key); if (ok && had) { trigger(t, key); trigger(t, ITERATE_KEY); } return ok; }, ownKeys(t) { track(t, ITERATE_KEY); return Reflect.ownKeys(t); }, }); proxyCache.set(target, p); return p; }

要点

  • 懒代理:访问到嵌套对象时再创建子代理,避免全量深度初始化
  • 缓存:同一原对象只对应一个代理,引用稳定且性能更好
  • 迭代依赖:对枚举键集合的读取建立专用依赖,新增/删除属性应同时触发迭代依赖
  • 数组长度:对 length 的变更单独追踪与触发

调度器:副作用的节流与批处理

JS
function schedulerJob(job) { queue.add(job); flush(); }

在复杂场景下,副作用不应“立即直跑”,而是进入队列去重并批处理。调度器提供了这一层把控。

computedwatch:语义版实现

JS
function computed(getter) { let cached; let dirty = true; const runner = effect(getter, { lazy: true, scheduler: () => { dirty = true; }, }); return { get value() { if (dirty) { cached = runner(); dirty = false; } return cached; }, }; } function traverse(val, seen = new Set()) { if (typeof val !== "object" || val === null || seen.has(val)) return val; seen.add(val); for (const k in val) traverse(val[k], seen); return val; } function watch(source, cb, options = {}) { let getter = typeof source === "function" ? source : () => source; if (options.deep) { const baseGetter = getter; getter = () => traverse(baseGetter()); } let oldValue, cleanup; function onCleanup(fn) { cleanup = fn; } const job = () => { const newValue = runner(); if (cleanup) cleanup(); cb(newValue, oldValue, onCleanup); oldValue = newValue; }; const runner = effect(getter, { lazy: true, scheduler: () => schedulerJob(job) }); if (options.immediate) { job(); } else { oldValue = runner(); } }

示例:对象、数组、原型链与迭代场景

JS
const state = reactive({ a: 1, nested: { b: 2 }, list: [1, 2] }); effect(() => { state.a; }); effect(() => { state.nested.b; }); effect(() => { state.list.length; }); effect(() => { Object.keys(state); }); state.a = 2; state.nested.b = 3; state.list.push(3); const base = reactive({ x: 1 }); const child = Object.create(base); const proxyChild = reactive(child); proxyChild.x = 2; delete state.a;

小结与实践建议

  • Vue2 的 Object.defineProperty 是历史上的合理权衡,但在新增属性、数组与初始化成本上有硬缺陷
  • Vue3 的 Proxy + Reflect 是语言级拦截的正解,必须搭配 receiver 与不变式意识
  • 深度代理不是默认选项:采用“懒代理 + WeakMap 缓存”实现性能与语义的平衡
  • 响应式的本质是“依赖图 + 调度”:track/trigger 是地基,迭代依赖与批处理是工程关键

如果你读到这里,感谢你的耐心。希望这套语义化的路径能帮你在工程实践里更稳地把握响应式的边界与取舍。