还在用 Vue2 的 $set?一文吃透 Observable 响应式原理与 Vue3 的 Proxy 重构

📌 摘要:从源码层面拆解 Vue2 Object.defineProperty 响应式三大件,讲清「新增属性不响应、数组下标不更新」的祖传缺陷根因,再对照 Vue3 Proxy 的针对性重构,附 6 个实战避坑指南。


🤔 前言

「为什么 Vue2 里给对象新增属性不生效?」 「为什么改数组下标页面不更新?」 「Vue3 又是怎么把这些坑一次性填平的?」

响应式(Reactivity)是 Vue 的灵魂。Vue2 用 Object.defineProperty 实现的 Observable 撑起了整整一个大版本,但它有几个"祖传"缺陷。Vue3 干脆换成了 Proxy,从根上重写。

这篇笔记从源码层面 讲清楚 Vue2 Observable 怎么工作,再对照 Vue3 做了哪些针对性 改动,最后落到实战------你日常写代码到底会踩哪些坑、怎么绕、Vue3 该怎么用才不翻车。


1️⃣ Vue2 Observable 是什么

Vue.observable(obj) 是 Vue 2.6+ 暴露出来的 API,作用是让一个普通对象变成"响应式对象"。它其实就是 Vue 内部 observe 用的同一套机制被单拎出来给你用。

javascript 复制代码
import Vue from 'vue'

// 一个极简的跨组件共享 store
export const store = Vue.observable({
  count: 0,
  user: { name: '张三' }
})

组件里直接用 store.count,它变了组件就重渲染------不需要 Vuex 也能做轻量状态共享。

⚠️ 版本可用性强提醒Vue.observable 只在 2.6.0(2019-02)及以后 才有!更早的 2.x 没有这个 API(可用 new Vue({ data }) 平替),而 Vue3 直接删掉了它 ,改用 reactive()


2️⃣ 源码层面:Vue2 响应式三大件

Vue2 响应式的核心是三个类,围绕 Object.defineProperty 展开:

  • Observer:把一个对象的所有属性递归转成 getter/setter
  • Dep:依赖收集器,每个响应式属性对应一个 Dep(发布者)
  • Watcher:订阅者(渲染 / computed / user Watcher)

一句话概括数据流:

🔄 读取属性 → getter 触发 → Dep 收集当前 Watcher(依赖收集);修改属性 → setter 触发 → Dep 通知所有 Watcher 更新(派发更新)。

🔧 Observer:递归劫持

javascript 复制代码
export class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep()        // Observer 自身也挂一个 dep(给 $set/数组用)
    def(value, '__ob__', this)  // 打标记,避免重复观测
    if (Array.isArray(value)) {
      protoAugment(value, arrayMethods)  // 数组:覆盖原型方法
      this.observeArray(value)
    } else {
      this.walk(value)          // 对象:遍历每个 key
    }
  }
  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
}

💡 defineReactive:核心中的核心

javascript 复制代码
export function defineReactive(obj, key, val) {
  const dep = new Dep()   // 每个 key 一个闭包 dep
  if (arguments.length === 2) val = obj[key]
  let childOb = observe(val)  // 递归观测嵌套对象

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      const value = val
      if (Dep.target) {          // Dep.target = 当前正在计算的 Watcher
        dep.depend()             // 依赖收集
        if (childOb) {
          childOb.dep.depend()   // 子对象 dep 也收集($set 靠这个)
          if (Array.isArray(value)) dependArray(value)
        }
      }
      return value
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      childOb = observe(newVal)
      dep.notify()               // 派发更新
    }
  })
}

这段代码是整个 Vue2 响应式的心脏。把它读懂,所有"坑"都能自解释。


3️⃣ Vue2 的四大"祖传"缺陷(源码根因)

理解了源码,这些坑就都有了明确的根因,而不是死记硬背。

❌ 缺陷① 新增属性不响应

javascript 复制代码
const obj = Vue.observable({ a: 1 })
obj.b = 2  // ❌ 不是响应式的,页面不更新

根因defineReactive 只在初始化那一刻 对已存在的 key 做了劫持。obj.b 是后加的,从没进过 walk,没有 getter/setter,自然没有 dep。

解法Vue.set(obj, 'b', 2) / this.$set

❌ 缺陷② 删除属性不响应

javascript 复制代码
delete obj.a  // ❌ 不触发更新

根因delete 绕过了 setter。解法Vue.delete / this.$delete

❌ 缺陷③ 数组下标 / length 不响应

javascript 复制代码
const arr = Vue.observable([1, 2, 3])
arr[0] = 100     // ❌ 不更新
arr.length = 1   // ❌ 不更新

根因 :Vue2 出于性能考量 没有对数组的每个索引做 defineProperty(数组可能很长,逐个劫持代价太大)。

🔥 缺陷④ 数组变更方法的特殊处理(源码亮点)

push/pop 为什么又能触发更新?因为 Vue2 重写了数组的 7 个变更方法

javascript 复制代码
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']

methodsToPatch.forEach(function (method) {
  const original = Array.prototype[method]
  def(arrayMethods, method, function mutator(...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift': inserted = args; break
      case 'splice': inserted = args.slice(2); break
    }
    if (inserted) ob.observeArray(inserted)  // 新增元素也要观测
    ob.dep.notify()   // 手动派发更新
    return result
  })
})

这就是为什么"用 push 能更新,用 arr[i]=x 不能更新"------Vue2 是打补丁式地拦截数组方法,而不是真正劫持了数组本身。


4️⃣ Vue3 的针对性重构:从 defineProperty 到 Proxy

Vue3 把上面所有缺陷从架构层面 一次性解决,核心是换成了 Proxy + Reflect

🚀 为什么 Proxy 能治本

能力 Object.defineProperty (Vue2) Proxy (Vue3)
拦截读/写
拦截新增属性 ❌ 需 $set ✅ 原生 set 拦截
拦截删除属性 ❌ 需 $delete ✅ 原生 deleteProperty 拦截
数组下标/length ❌ 需重写方法 ✅ 原生拦截
初始化开销 递归劫持全部属性 惰性,访问到才代理
监听 Map/Set

关键差异:defineProperty劫持属性 (必须提前知道有哪些 key),Proxy代理整个对象(拦截的是"操作"本身,不管 key 存不存在)。所以新增、删除、数组下标全都天然被拦截。

🔧 源码层面:reactive 的实现

typescript 复制代码
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    if (!isReadonly) track(target, TrackOpTypes.GET, key)  // 依赖收集
    // 惰性递归:只有访问到嵌套对象时才变成 reactive
    if (isObject(res)) return isReadonly ? readonly(res) : reactive(res)
    return res
  }
}

function createSetter() {
  return function set(target, key, value, receiver) {
    const oldValue = target[key]
    const hadKey = hasOwn(target, key)          // 判断是新增还是修改
    const result = Reflect.set(target, key, value, receiver)
    if (!hadKey) trigger(target, TriggerOpTypes.ADD, key)      // 新增 → 触发
    else if (hasChanged(value, oldValue)) trigger(target, TriggerOpTypes.SET, key)
    return result
  }
}

对比 Vue2 你会发现两个质变:

  1. 惰性代理 :Vue2 一次性递归全部属性;Vue3 是 get 里访问到嵌套对象才 reactive(res),大对象初始化性能大幅提升。
  2. 新增/删除天然响应set 里通过 hadKey 区分 ADD/SET,deleteProperty 陷阱处理删除------$set/$delete 彻底退休。

📊 概念对照表(帮你把两代对上号)

Vue2 Vue3 说明
Observer reactive() / Proxy handler 把对象变响应式
Dep dep(Set of effect) 依赖集合
Watcher ReactiveEffect 副作用/订阅者
Dep.target activeEffect 当前活动的副作用
dep.depend() track() 依赖收集
dep.notify() trigger() 派发更新
Object.defineProperty Proxy + Reflect 底层拦截手段

💡 ref 为什么还要存在

Proxy 只能代理对象 ,代理不了 number/string 原始值。所以 Vue3 保留了 ref:用一个带 .value 的对象包裹原始值,靠 class RefImpl 的 getter/setter 拦截 .value 的读写。对象类型的 ref 内部则委托给 reactive


5️⃣ 实战篇:日常开发到底怎么写

理论讲完,落到手上。以下是最容易踩、也最值得记住的场景。

🛠️ 实战① Vue2:新增属性必须用 $set

javascript 复制代码
this.user.age = 25          // ❌ 页面不更新
this.$set(this.user, 'age', 25)                 // ✅ 正确
this.user = { ...this.user, age: 25, city: '北京' }  // ✅ 整体替换也行

实战心法 :Vue2 里,凡是"运行时才知道 key"的对象(如后端返回的动态字段),要么预先在 data 里声明好占位,要么老实用 $set

🛠️ 实战② Vue2:数组更新的正确姿势

javascript 复制代码
this.list[0] = newItem          // ❌ 无效
this.list.length = 0             // ❌ 无效
this.list.splice(0, 1, newItem)  // ✅ 替换第 0 项
this.list.splice(0)              // ✅ 清空
this.$set(this.list, 0, newItem) // ✅ 改指定下标
this.list = []                   // ✅ 最简单粗暴的清空

🛠️ 实战③ Vue2 Observable 做轻量全局 store

不想上 Vuex 的小项目,Observable 是性价比最高的方案:

javascript 复制代码
// store.js
import Vue from 'vue'
export const store = Vue.observable({ theme: 'light', user: null })
export const actions = {
  toggleTheme() { store.theme = store.theme === 'light' ? 'dark' : 'light' },
  setUser(u) { store.user = u }
}

⚠️ 注意:同样受上面缺陷限制------给 store 新增顶层字段不响应,要么初始声明齐全,要么用 Vue.set

🛠️ 实战④ Vue3:reactive 的两个致命坑

坑 A:解构会丢失响应式

javascript 复制代码
const state = reactive({ count: 0 })
let { count } = state    // ❌ count 变成普通 number,断了连接
const { count } = toRefs(state)
count.value++            // ✅ 用 toRefs 保留响应式

坑 B:整体替换会丢失响应式

javascript 复制代码
let state = reactive({ list: [] })
state = reactive({ list: newData })   // ❌ 模板还引用着旧的 Proxy
state.list = newData                  // ✅ 改属性而非改引用
const stateRef = ref({ list: [] })
stateRef.value = { list: newData }    // ✅ 或用 ref

🛠️ 实战⑤ Vue3:ref vs reactive 选型建议

javascript 复制代码
const count = ref(0)                       // 基本值 → 必须 ref
const form = reactive({ username: '', password: '' })  // 一组状态 → reactive
const bigTable = shallowRef([])            // 大数据/第三方实例 → shallowRef

团队实践共识 :默认用 ref(心智负担小、不怕解构),只有一组明显内聚的状态才用 reactive。性能敏感的大数据用 shallowRef + 整体替换。

🛠️ 实战⑥ Vue3 的"隐性利好"

得益于 Proxy,下面这些在 Vue2 里要小心翼翼的操作,Vue3 里直接写就行

javascript 复制代码
const state = reactive({ obj: {}, arr: [] })
state.obj.newKey = 1       // ✅ 新增属性直接响应
delete state.obj.newKey    // ✅ 删除直接响应
state.arr[0] = 100         // ✅ 数组下标直接响应
state.arr.length = 0       // ✅ 改 length 直接响应
const m = reactive(new Map())
m.set('k', 'v')            // ✅ Map/Set 也能响应式(Vue2 完全做不到)

🧭 一图总结

javascript 复制代码
Vue2 Observable                  Vue3 Reactivity
─────────────                    ───────────────
Object.defineProperty  ─重构─▶   Proxy + Reflect
  ├─ 递归全量劫持(慢)             ├─ 惰性按需代理(快)
  ├─ 新增属性 ✗ → $set            ├─ 新增属性 ✓
  ├─ 删除属性 ✗ → $delete         ├─ 删除属性 ✓
  ├─ 数组下标 ✗ → splice          ├─ 数组下标 ✓
  ├─ 重写 7 个数组方法(补丁)      ├─ 原生拦截,无需补丁
  └─ 不支持 Map/Set               └─ 支持 Map/Set/WeakMap/WeakSet

✅ 高频面试 & 自测

  1. Vue2 为什么给对象新增属性不响应?defineReactive 只在初始化劫持已有 key,新 key 没 getter/setter。
  2. Vue2 数组为什么下标改不生效但 push 生效? → 没对索引做 defineProperty(性能),但重写了 7 个变更方法。
  3. Vue3 为什么用 Proxy? → 拦截"操作"而非"属性",天然支持新增/删除/数组下标/Map/Set,且可惰性代理提升性能。
  4. Vue3 reactive 解构为什么失效?怎么救? → 解构出的原始值断开代理链;用 toRefs
  5. ref 和 reactive 底层关系? → ref 包原始值走 getter/setter,包对象则内部委托 reactive;Proxy 代理不了原始值,所以 ref 不可替代。

📝 写在最后

响应式是 Vue 面试和实战的高频考点,但真正读懂源码,比背八股有用得多 。Vue2 的 Object.defineProperty 虽然坑多,但每一处限制都有清晰的工程权衡;Vue3 的 Proxy 则是从架构层面一次性填平,顺便把性能也优化了。

💬 如果你觉得有用,欢迎点赞 + 收藏 + 关注,评论区聊聊你被 Vue2 响应式坑得最惨的一次~

源码版本参考:Vue 2.6.x(src/core/observer/)、Vue 3.x(packages/reactivity/src/

相关推荐
英勇无比的消炎药1 小时前
层级选择不再手写联动:TinyVue Cascader 级联选择器上手
vue.js
摇滚侠2 小时前
SpringBoot3+Vue3 全套视频教程 61
vue.js
breeze jiang2 小时前
告别等待焦虑:Vue 3 + DeepSeek 实现 AI 打字机流式对话,用户体验提升立竿见影
vue.js·人工智能·ux
Listen·Rain2 小时前
Vue概述
前端·javascript·vue.js
尽兴-3 小时前
传统 Java/Vue 系统如何一步步接入 AI 维护
java·vue.js·人工智能·ai·大模型
张人玉3 小时前
基于Vue 3 + ECharts / ECharts-GL / echarts-wordcloud 的数据可视化大屏——地形与产业数据可视化大屏
vue.js·信息可视化·echarts
十铭忘3 小时前
Vue3实现Pixso中的钢笔工具3——梳理函数调用
前端·vue.js
碎碎念_49217 小时前
SpringBoot + Vue 前后端分离从 0 到 1 完整环境配置流程
vue.js·spring boot·后端
shuaijie051818 小时前
强制修改调用接口的api地址。
javascript·vue.js·ecmascript