我重写了 Vue3 的响应式,才明白 Vue2 的 defineProperty 到底差在哪

143 阅读8分钟

我重写了 Vue3 的响应式,才明白 Vue2 的 defineProperty 到底差在哪

摘要:Vue2 不是"优化不够",是 defineProperty 天生只能劫持已有属性——我用 300 行重写 Vue3 响应式,把 $set、数组重写、按需递归这三件事一次讲透。


写过 Vue2 的同学,大概率都遇到过这两个场景:

  1. 给 data 里的对象动态加了个字段,视图没更新,最后靠 this.$set 救场
  2. 直接改数组的某个索引,arr[0] = 'xxx',页面纹丝不动

网上大部分解释都是"Vue2 用的是 Object.defineProperty,它监听不到新增属性和数组索引"。

这句话没错,但它没解释"为什么监听不到"。直到我自己动手用 Proxy 重写了一遍 Vue3 的响应式,才真正想明白这件事——defineProperty 不是"性能差"或者"优化不够",它是能力边界问题。

这篇会先带你回顾 Vue2 的妥协清单,再手写一份能跑起来的 Vue3 响应式,最后回到面试里最高频的那个问题。


一、先看 Vue2 的"妥协清单"

Vue2 的响应式核心就一句话:用 Object.defineProperty 把 data 的每个属性改造成 getter/setter。

但正因为这个方案,Vue2 被逼出了下面这些东西:

Vue2 的 API / 方案它存在的理由
Vue.set / this.$set新增的属性监听不到
Vue.delete / this.$delete删除的属性监听不到
重写数组 7 个方法(push/pop/shift/unshift/splice/sort/reverse)数组索引直接赋值监听不到
初始化时递归遍历整个 data必须提前"认识"每一个属性,才能给它们加 getter/setter

注意,这四件事不是四个独立的坑,是同一个原因导致的四种现象。

Object.defineProperty 这 API 只能作用于"已经存在且被显式指定过的属性"。你告诉它 obj.key,它就只给你管 obj.key 这一个属性的读写。

新加的属性?它压根就不知道有这回事。

我最早对这件事有实感,是做后台管理系统的时候。当时列表数据是从接口拿的,我想给每行加个 checked 字段配合 Element UI 的多选表格:

// 拿到列表后,给每条数据补一个 checked
this.tableData.forEach(item => {
  item.checked = false
})

写完跑起来,勾选框点了没反应。翻文档才反应过来——checked 是接口返回之后才加上去的字段,它没被 defineProperty 处理过,压根不是响应式的。最后改成:

this.tableData.forEach((item, index) => {
  this.$set(this.tableData[index], 'checked', false)
})

那会儿只是"知道要用 $set",但没想明白为什么。后来重写 Proxy 版才反应过来——$set 干的事,其实就是"运行时再补一次 defineProperty"。


二、用 30 行写一个 Vue2 风格的响应式

先上对照物。既然要讲差距,那"旧方案"得先摆出来。

环境:Node 或浏览器均可,不依赖任何库。

// 极简依赖容器
class Dep {
  constructor() {
    this.subs = []
  }
  depend() {
    if (Dep.target) this.subs.push(Dep.target)
  }
  notify() {
    this.subs.forEach(fn => fn())
  }
}

// 把某个属性改造成响应式
function defineReactive(obj, key, val, dep) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      dep.depend()          // 访问时收集依赖
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify()          // 变化时通知更新
    }
  })
}

// 遍历 data 的所有 key
function observe(data) {
  const dep = new Dep()
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key], dep)
  })
}

跑一下:

const data = { name: '张三', age: 23 }
observe(data)

// 修改已有属性 → 触发 set
data.name = '李四'   // ✅ 会通知

// 新增属性 → 完全没有 getter/setter,天然就"看不见"
data.newKey = 'hello' // ❌ 不会通知

observe 执行完之后,data 只有 name 和 age 两个属性有 getter/setter。newKey 是运行时才被写进去的,它根本没有被 defineReactive 处理过。

那 $set 干了什么?

Vue.set = function (obj, key, val) {
  // 如果 key 还不存在,补一次 defineReactive
  defineReactive(obj, key, val, obj.__dep__)
  obj[key] = val
}

$set 的本质就是"运行时再补一次 defineProperty"。

这里就是第一个关键认知:$set 不是 API 冗余,是 defineProperty 能力边界下唯一能做的事。


三、Proxy 不是"升级版 defineProperty"

很多人把 Proxy 当成 defineProperty 的 2.0,只是"性能更好"。

不对。

它俩的作用对象根本不是一个层级:

维度Object.definePropertyProxy
作用对象属性(obj.key)整个对象(obj)
拦截范围只能拦截指定 key 的读写读写、删除、in、Object.keys 全都能拦
新增属性❌ 需要 $set✅ 自动拦截
删除属性❌ 需要 $delete✅ deleteProperty 拦截
数组索引赋值❌ 需要重写数组方法✅ 自动拦截
初始化开销必须递归遍历全部属性按需递归,访问到才处理
兼容性IE9+不支持 IE11

用一句话总结:defineProperty 是"点对点"地管属性,Proxy 是"面到面"地管对象。

再看第二层:Vue2 的"初始化慢",不完全是性能问题——是机制上必须提前把每个属性都扫一遍,因为它只能对已知属性生效。而 Proxy 可以在访问时才递归,一个在启动时付全款,一个在使用时按需付费。

我第一次翻 Vue3 的 baseHandlers.ts 看到这段时,愣了一下:

function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    // ...
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)  // ← 访问到才递归
    }
    return res
  }
}

reactive(res) 是在 get 里才被调用的。也就是说,一个嵌套很深的 data 对象,只有真正被访问到的那个分支才会变成响应式。

这不就是懒加载吗?我当时脑子里第一反应就是这个——跟 Vue2 初始化时 Observer 一路递归到底完全不是一个思路。


四、手写 Vue3 响应式(完整可跑)

代码我尽量压到 200 行以内,复制就能跑。

需要实现的 API:

  • reactive(target) —— 返回 Proxy 代理
  • effect(fn) —— 注册副作用,依赖变化时自动重跑
  • ref(value) —— 基本类型的响应式包装
  • track / trigger —— 依赖收集与派发

4.1 依赖存储结构

Vue3 用的是一个三层嵌套结构:

// WeakMap<target, Map<key, Set<effect>>>
const targetMap = new WeakMap()
let activeEffect = null
const effectStack = []

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) {
    // 复制一份再遍历,防止执行过程中 dep 被修改
    [...dep].forEach(fn => fn())
  }
}

为什么用 WeakMap?因为 target 是对象,WeakMap 的键是弱引用,当这个响应式对象不再被引用时,垃圾回收能自动回收它的依赖表,不会内存泄漏。

4.2 reactive 拦截器

const reactive = (target) => {
  if (typeof target !== 'object' || target === null) return target

  return new Proxy(target, {
    get(obj, key, receiver) {
      const result = Reflect.get(obj, key, receiver)
      track(obj, key)
      // 嵌套对象按需递归:访问到时才包一层 reactive
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      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
    },

    deleteProperty(obj, key) {
      const hadKey = Object.prototype.hasOwnProperty.call(obj, key)
      const result = Reflect.deleteProperty(obj, key)
      if (hadKey) {
        trigger(obj, key)
      }
      return result
    }
  })
}

有三个地方我踩过,先说清楚:

  1. Reflect.get(obj, key, receiver) 里的 receiver 不能省。它的作用是让对象内部的 getter/setter 里的 this 指向代理对象,而不是原始对象——否则嵌套对象的依赖收集会乱。
  2. 嵌套对象是访问时才包装的,这就是"按需递归",比 Vue2 的初始化递归省很多。
  3. deleteProperty 里为什么要判 hadKey?因为删除一个本来就不存在的 key,也不应该触发更新。

4.3 effect + ref

function effect(fn) {
  const wrapped = () => {
    effectStack.push(wrapped)
    activeEffect = wrapped
    try {
      fn()
    } finally {
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1] || null
    }
  }
  wrapped()  // 立即执行一次,收集初始依赖
  return wrapped
}

function ref(value) {
  const obj = { value }
  return reactive(obj)  // 本质就是被 reactive 包过的 { value }
}

写到 ref 的时候我突然想通了:它就是一个 { value: xxx } 被 reactive 代理了一下。.value 不是"多打几个字",是因为 Proxy 代理不了基本类型,只能靠包一层对象解决。

4.4 跑起来验证

const state = reactive({ count: 0, name: 'Vue' })

effect(() => {
  console.log('count changed:', state.count)
})
// 立刻打印:count changed: 0

state.count++
// 打印:count changed: 1

state.newProp = '我是新加的'
// set 拦截器能捕获,其他 effect 如果依赖 newProp 也会被通知

原来的"新增属性不响应"没了。因为 set 拦截器不知道哪些 key 是"预定义的",它拦截的是整个对象的写操作。


五、踩坑记录(都是我自己写挂的)

写完能跑的代码不难,真正卡住我的是下面这几个地方。

坑 1:activeEffect 的嵌套覆盖

如果你在 effect 里再调 effect,activeEffect 会被覆盖。第二层 effect 执行完,外层依赖就丢失了。

解决方式就是上面代码里的 effectStack:进入时 push,退出时 pop,activeEffect 永远指向栈顶。

Vue3 源码里也是这么做的,只不过它把 activeEffect 换成了 activeSub。

坑 2:trigger 时无限递归

如果某个 effect 在运行过程中又修改了它自己的依赖,会不断触发 trigger → effect → trigger,直接爆栈。

简化版可以加个标志位,或者用 Set 去重当前正在执行的 effect。Vue3 源码用 shouldTrack + 调度器(scheduler)来管这件事。

坑 3:Reflect.get 的 receiver 忘了传

我第一次写的时候省了 receiver,结果嵌套对象里的 getter 里 this 指向了原始对象,依赖收集到原始对象上,触发时又找不到,trigger 静默失效——特别难排查,因为它不报错,只是"不生效"。

坑 4:ref 为什么不能直接用基本类型?

Proxy 的第一个参数必须是对象。所以 Vue3 里想给数字、字符串做响应式,只能把它们包进 { value } 这个壳里。

.value 不是 Vue 团队"想让你多打几个字"的设计,是这个语言层面的硬性约束。

所以 reactive 不用 .value(对象天然能被代理),ref 必须用 .value(基本类型被包了一层)。它们不是两套设计哲学,是同一个限制下的两种应对。


六、回到 Vue2 的"为什么"

现在回头看开头那两个场景,应该都能回答了。

Vue2 的坑根本原因用 Proxy 后
新增属性不响应defineProperty 只能处理已知属性set 拦截器自动处理
删除属性不响应没有"删除拦截"能力deleteProperty 拦截器处理
数组索引赋值不响应数组也是对象,索引也是"属性",同样是已知属性问题set 拦截器统一处理
this.$set 必须存在只能运行时补 defineProperty不再需要
重写 7 个数组方法索引赋值监听不到,只能拦截会改数组的方法不再需要

但别急着把 Proxy 吹上天。

Vue3 里 ref 依然存在,toRefs 依然存在,shallowRef 依然存在。这些不是 Vue3"没优化好",是 JavaScript 语言本身的边界——你能代理对象,但代理不了"一个数字"。

Vue3 的代码看起来更简洁,不是因为它更聪明,是因为 Proxy 替它兜了更多的底。


七、总结 + 面试速答

如果只记三件事:

  1. defineProperty 劫持的是属性,Proxy 代理的是整个对象——一个管点,一个管面
  2. Vue2 的 $set、$delete、数组方法重写,都是 defineProperty 能力边界下的必然产物,不是"设计冗余"
  3. 嵌套对象的"按需递归"是 Proxy 方案一个容易忽略的优势——初始化不必全量遍历

面试速答版(可以直接背):

Vue2 用 Object.defineProperty 把 data 的每个属性转成 getter/setter。局限是它只能处理初始化时已存在的属性,所以新增/删除属性、数组索引赋值都无法响应,需要 $set、$delete 和重写数组方法来补。

Vue3 用 Proxy 代理整个对象,get/set/deleteProperty 拦截器天然覆盖了这些场景;嵌套对象采用按需递归,减少了初始化开销。

但 Proxy 代理不了基本类型,所以 ref 依然需要 .value 这个壳。另外 Proxy 不支持 IE11,这也是 Vue3 不再兼容 IE 的原因之一。


参考