Vue2 的 defineProperty 差在哪四层追问筛掉九成候选人

能背出「Vue3 用 Proxy 代替 defineProperty」只值 0 分。讲清 Vue2 哪些场景天生处理不了、Proxy 又漏了哪些坑、Vue3 为什么留着 markRaw,才算及格。

一句正确的废话,值 0 分

「Vue2 和 Vue3 的响应式有什么区别?」

候选人张口就来:Vue2 用 Object.defineProperty,Vue3 用 Proxy。

没错,但这是句废话。好比问 MySQL 和 PostgreSQL 的区别,你答「一个用 B+ 树一个也用 B+ 树」------你报的是 API 名字,不是工程判断。

真正该往下追问三层:defineProperty 的硬伤是什么?Proxy 补了哪些线上坑?换完之后,还有哪些场景 Proxy 也绕不开?

defineProperty 的签名就写死了它的上限

Vue2 通过 Object.defineProperty 把 data 上的每个属性转成 getter/setter,读取时收集依赖,赋值时触发更新。

js 复制代码
function defineReactive(obj, key, val) {
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      dep.depend()        // 收集当前 Watcher
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify()        // 通知更新
    }
  })
}

关键限制藏在 defineReactive 的签名里:它一次只能处理一个 key,而且是调用那一刻已经存在的 key。

所以 obj.newField = 1 和 delete obj.field 这两个操作,defineProperty 根本没机会介入。

四个硬伤,两个是数组,两个是对象

三年经验的人在这一层开始模糊。具体是四条:

  1. 数组下标赋值监听不到 :arr[0] = 'x' 不会触发 setter,因为 Vue2 压根没给数组下标做 defineProperty
  2. 数组长度修改监听不到 :arr.length = 0 同理
  3. 对象新增、删除属性监听不到 :所以才有 Vue.set / this.$$set 和 Vue.delete
  4. 只支持普通对象和数组:Map、Set、WeakMap 这类原生集合类型完全无能为力

前两条的补丁是重写七个数组原型方法:

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

methodsToPatch.forEach(method => {
  const original = Array.prototype[method]
  def(arrayMethods, method, function mutator(...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    if (method === 'push' || method === 'unshift') inserted = args
    else if (method === 'splice') inserted = args.slice(2)
    if (inserted) ob.observeArray(inserted)  // 新插入的元素也要变成响应式
    ob.dep.notify()
    return result
  })
})

很多人知道要写 this.$$set,但说不出为什么非它不可------因为 defineProperty 的拦截能力在定义那一刻就封死了。

来看 Vue2 的完整数据流,注意那条断掉的支路:

flowchart TD A[render 函数执行] -->|读取 key| B[defineProperty getter] B --> C[Dep.depend 收集 Watcher] D[修改已有 key] -->|赋值| E[setter] E --> F[Dep.notify] F --> G[scheduler 异步更新队列] G --> A H[新增 key] -->|无拦截点| I[响应式丢失 视图不更新] J[删除 key] -->|无拦截点| I K[arr 下标赋值] -->|无拦截点| I

Proxy 填了旧坑,自己又挖了新坑

Proxy 代理的是整个对象,不是某个 key,所以天生能拦截 13 种操作。

js 复制代码
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      track(target, key)
      const res = Reflect.get(target, key, receiver)
      return isObject(res) ? reactive(res) : res  // 懒代理,访问到才递归
    },
    set(target, key, value, receiver) {
      const oldValue = target[key]
      const result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) trigger(target, key)  // 新增 key 也走这里
      return result
    },
    deleteProperty(target, key) {
      const hadKey = Object.prototype.hasOwnProperty.call(target, key)
      const result = Reflect.deleteProperty(target, key)
      if (hadKey) trigger(target, key)
      return result
    }
  })
}

对比一下两边到底谁修好了谁:

场景 Vue2 defineProperty Vue3 Proxy
新增属性 不触发,要 $$set 原生触发
删除属性 不触发,要 $$delete 原生触发
数组下标赋值 不触发 原生触发
数组长度修改 不触发 原生触发
Map / Set 不支持 支持
初始化开销 递归遍历所有 key 懒代理,访问才递归
IE11 兼容 支持 不支持

Reflect 存在的意义不是仪式感,是保证 receiver 正确传递 ,让 this 在继承和代理链里指向代理对象而不是原始对象。少了它,super 访问和 getter 里的 this 都会指错。

那 Proxy 就没坑了?想得美,至少三类:

js 复制代码
const state = reactive({ count: 0, nested: { a: 1 } })

// 1. 解构:拿到的是普通 number,跟响应式断联
let { count } = state
count++  // 视图不动

// 2. 展开:copy 是全新的普通对象
const copy = { ...state }

// 3. 直接改原始对象:Proxy 是包装,不是克隆
const raw = { a: 1 }
const p = reactive(raw)
raw.a = 2   // 绕过代理,不触发更新

还有 ref 传原始值、reactive 不接受基础类型------因为 Proxy 的 target 必须是对象,Proxy(1, {}) 直接抛 TypeError 。这就是 Ref 存在的全部理由:给原始值套一层 { value } 的壳,再对这个壳做代理。

flowchart TD A[响应式入口] --> B[数据类型判断] B -->|对象 数组 Map Set| C[reactive 走 Proxy] B -->|string number boolean| D[Ref 包一层 value 对象] D --> E[get value 时 track 收集] D --> F[set value 时 trigger 派发] C --> G[嵌套对象访问时才递归代理] D --> H[value 是对象则内部再 reactive]

markRaw 不是摆设,是性能账

到这一层才叫懂框架。

为什么 Vue3 还留着 markRaw? 因为不是所有数据都值得被代理。一个 5000 行的 ECharts options、一个 Frozen 的配置常量、一个第三方库的实例对象------把它们塞进 reactive,Proxy 的 get/set 拦截会在每次访问时叠加开销。

js 复制代码
import { markRaw } from 'vue'

const state = reactive({
  chart: markRaw(echarts.init(dom))  // 打标记,跳过代理
})

markRaw 的实现就是加个标志位:

js 复制代码
export function markRaw(value) {
  def(value, ReactiveFlags.SKIP, true)
  return value
}

reactive 内部一看到 SKIP 就直接返回原对象。这也是为什么 shallowRef / shallowReactive 在大数据列表场景是刚需------几万条数据的列表,全量递归代理的内存开销和 GC 压力都不是小数目。

性能账也要算清楚:

  • 初始化:Vue2 必须递归遍历 data 的所有 key 打 defineProperty,对象越大启动越慢;Vue3 是懒代理,访问到哪层代理哪层
  • 更新:Vue2 每次 set 直接打 dep;Vue3 要多走一层 Proxy 的 trap + Reflect,单次访问略慢,但省掉了递归初始化的巨坑
  • 兼容性 :Proxy 是浏览器原生 API,IE 完全不支持,且无法 polyfill------这是 Vue3 彻底放弃 IE11 的核心原因,不是团队懒,是能力边界

下次这么答

再被问到这道题,别急着报 API 名字。按这个顺序说:defineProperty 只能拦截已有 key,所以数组下标、对象增删、Map/Set 全都漏;Proxy 代理整个对象把这些补上,代价是原始值的 Ref 包装、解构和展开的断联、以及 IE 的彻底出局。

面试官想听的不是「Proxy 比 defineProperty 好」,而是「Vue3 在能力、性能、兼容性之间怎么做的取舍」。

写在最后

你们线上被响应式坑过吗?我见过最典型的是 reactive 里放了一个第三方实例,结果每次改它的内部属性都触发视图更新,白烧性能。评论区聊聊你们踩过最刁钻的 Vue 响应式坑。

有用的话点个赞。

相关推荐
三天不学习28 分钟前
Egg.js 4 突然爆火,原因是否归结于AI 原生落地需求爆发?
前端·javascript·全栈·egg.js
xcs194051 小时前
前端 vue 的前端页面debugger 进不去
前端·javascript·vue.js
明月_清风2 小时前
Deno 终局来了:从挑战 Node 到被 Cloudflare 收编
前端·后端·node.js
Csvn3 小时前
框架性能优化
前端
雪芽蓝域zzs5 小时前
第 5 节:Marker 点击弹出 InfoWindow 信息窗口
vue.js
回眸&啤酒鸭5 小时前
【回眸】OpenSwarm 多智能体协作系统实战指南
大数据·前端·人工智能
用户69371750013845 小时前
2026,程序员的时代拐点到了
android·前端·后端
大龄秃头程序员5 小时前
一次 iBeacon + BLE 无感解锁方案的实现记录
前端
小兔子5 小时前
Python 的 GIL 与 free-threading:3.13 之后「去 GIL」走到哪一步了
前端
IT_陈寒5 小时前
SpringBoot自动配置差点让我加班到凌晨
前端·人工智能·后端