菜单

Q
发布于 2025-08-08 / 2 阅读
0
0

Object.defineProperty 到 Proxy:Vue的响应式

章节一:Vue 2 的“精准拦截” — Object.defineProperty

Vue 2 的响应式系统是基于 Object.defineProperty() 实现的。这个 API 允许我们通过定义属性的 gettersetter 来拦截对对象属性的访问和修改。

核心思想

Vue 在初始化组件时,会遍历 data 对象中的所有属性,并使用 Object.defineProperty() 将这些属性全部转为 getter/setter。这个过程是递归的,也就是说,如果属性的值也是一个对象,它会继续深度遍历。

这个过程可以概括为:

  1. 依赖收集 (Track):当代码读取一个响应式数据时(例如在模板渲染或计算属性中),getter 函数会被触发。此时,Vue 会记录下是“谁”用到了这个数据,并将这个“谁”(我们称之为“Watcher”)存起来。一个属性可能被多个 Watcher 依赖。

  2. 派发更新 (Trigger):当代码修改一个响应式数据时,setter 函数会被触发。此时,Vue 会找到所有依赖这个数据的 Watcher,并通知它们:“你们依赖的数据变了,需要重新计算或更新视图了!”

Object.defineProperty 与数据存储

很多初学者会有一个疑问:用了 getter/setter 之后,原始的属性值存到哪里去了?

答案是闭包。在 defineReactive (Vue 源码中的一个函数) 内部,会为每个属性创建一个闭包,原始值就存储在这个闭包的变量里。

JavaScript

function defineReactive(obj, key, val) {
  // 递归子属性
  observe(val);

  const dep = new Dep(); // 创建一个依赖管理器实例

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // get 拦截读取操作
    get: function reactiveGetter() {
      // 如果 Dep.target 存在,说明有 Watcher 正在读取这个值
      if (Dep.target) {
        dep.depend(); // 收集依赖,将当前 Watcher 存入 dep
      }
      return val; // 返回闭包中的 val
    },
    // set 拦截写入操作
    set: function reactiveSetter(newVal) {
      if (newVal === val) {
        return; // 如果新值和旧值一样,则不处理
      }
      val = newVal; // 更新闭包中的 val
      observe(newVal); // 如果新值是对象,也将其变为响应式
      dep.notify(); // 通知所有依赖该属性的 Watcher 进行更新
    }
  });
}

function observe(value) {
  if (typeof value !== 'object' || value === null) {
    return;
  }
  new Observer(value);
}

// ... Dep 和 Watcher 类的简化实现

在这个例子中,val 参数被保存在 defineReactive 函数作用域的闭包中。getset 函数因为定义在这个作用域内,所以可以持续访问和修改 val。这就是数据存储的秘密。

Vue 2 响应式的“痛点”

Object.defineProperty 的设计堪称精妙,它实现了对对象属性的“精准拦截”。但这种“精准”也带来了两个核心的局限性:

  1. 无法检测到对象属性的新增或删除Object.defineProperty 是在初始化时对已存在的属性进行拦截的。如果你在之后向对象添加一个新属性,或者删除一个已有属性,Vue 是无法感知的,因此不会触发视图更新。

    • 解决方案:Vue 提供了 Vue.set (或 this.$set) 和 Vue.delete (或 this.$delete) 这两个 API 来解决这个问题。

  2. 无法直接监听数组的变化Object.defineProperty 无法拦截到通过索引修改数组元素(如 arr[0] = newValue)或修改数组长度(arr.length = 0)的操作。

    • 解决方案:Vue 采用了一种“hack”的方式,它重写了数组的七个可以改变原数组的方法(push, pop, shift, unshift, splice, sort, reverse)。当调用这些方法时,除了执行原始的数组操作,还会额外触发一次更新通知。但这依然无法解决通过索引直接赋值的问题。

这些局限性促使 Vue 团队在 Vue 3 中寻找一个更完美、更现代的解决方案。


章节二:Vue 3 的“全面掌控” — Proxy 与 Reflect

为了从根本上解决 Object.defineProperty 的问题,Vue 3 引入了 ES6 的 Proxy 作为其响应式系统的核心。

Proxy 是一个非常强大的 JavaScript 特性,它可以创建一个对象的“代理”,从而实现对该对象所有基本操作的拦截和自定义。它不再是拦截对象的属性,而是直接代理了整个对象。

Proxy:一个更强大的代理

想象一下,Proxy 就像一个物业管家。业主(原始对象)把所有钥匙都交给了管家(Proxy 实例)。无论访客想做什么——读信(get)、寄信(set)、查看业主在不在家(has)、甚至扔掉家具(deleteProperty)——都必须先经过管家。管家可以在执行原始操作前后做任何他想做的事情,比如记录日志、触发警报等。

让我们看一个 Proxy 的基本用法:

JavaScript

const target = {
  name: 'Vue',
  version: 3
};

const handler = {
  get(target, property, receiver) {
    console.log(`正在读取属性: ${property}`);
    return target[property];
  },
  set(target, property, value, receiver) {
    console.log(`正在设置属性: ${property} = ${value}`);
    target[property] = value;
    return true; // set 操作必须返回一个布尔值
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.name); // 输出: 正在读取属性: name, 然后是 Vue
proxy.version = 3.2;    // 输出: 正在设置属性: version = 3.2

相比 Object.defineProperty 只能拦截 getsetProxyhandler 对象可以配置多达 13 种拦截操作(如 get, set, has, deleteProperty, ownKeys 等),覆盖了对象操作的方方面面。这直接解决了 Vue 2 的两大痛点:

  • 属性增删:当你给 proxy 赋一个新属性时(proxy.newProp = 'hello'),set 陷阱会被触发。当你删除属性时(delete proxy.name),deleteProperty 陷阱会被触发。Vue 3 可以完美感知。

  • 数组操作Proxy 是对整个对象的代理,数组也是对象。所以当你执行 proxyArr[0] = 1 时,set 陷阱同样会被触发,property 会是 '0'。数组的 .length 修改也会被拦截。Vue 2 的数组问题迎刃而解。

Reflect:Proxy 的最佳拍档

在上面的 handler 示例中,我们直接使用了 target[property] 来获取和设置值。但在复杂的场景下,这可能会引发问题,尤其是当 target 对象中有 getter 并且涉及到 this 指向时。

这时,Reflect 就登场了。Reflect 是一个内置的对象,它提供了一组与 Proxyhandler 方法同名且参数相同的静态方法。

Reflect 存在的意义:

  1. 函数式与默认行为Reflect 的方法提供了对象操作的默认行为。例如,Reflect.get() 就是 get 操作的默认行为。这使得在 Proxy 内部调用原始操作变得非常简单和标准。

  2. 确保 this 指向正确:这是最关键的一点。Reflect 方法在调用时可以传递一个 receiver 参数,确保原始操作中的 this 指向代理对象,而不是原始对象。

  3. 更可靠的返回值:像 Reflect.set() 会返回一个布尔值表示操作是否成功,这比直接赋值(可能会在严格模式下抛错)更健壮。

让我们用 Reflect 来重构上面的 handler

JavaScript

const handler = {
  get(target, property, receiver) {
    console.log(`正在读取属性: ${property}`);
    // 使用 Reflect.get,并将 receiver 传进去
    return Reflect.get(target, property, receiver);
  },
  set(target, property, value, receiver) {
    console.log(`正在设置属性: ${property} = ${value}`);
    // 使用 Reflect.set,并将 receiver 传进去
    const success = Reflect.set(target, property, value, receiver);
    if (success) {
      // 在这里触发更新 (trigger)
    }
    return success;
  }
};

深入理解 receiver

receiver 参数是理解 ProxyReflect 联动机制的关键,它解决了 this 的指向问题。

想象一个场景:

JavaScript

const target = {
  _name: 'Vue',
  get name() {
    // 这里的 this 期望指向代理对象,以便对 _name 的访问也能被拦截
    return this._name;
  }
};

const handler = {
  get(target, property, receiver) {
    console.log(`拦截 get: ${property}`);
    // 如果不用 Reflect,直接返回 target[property]
    // 当访问 proxy.name 时,会触发 target.name 的 getter
    // 此时 getter 内部的 this 指向的是 target,而不是 proxy
    // 导致对 this._name 的访问逃离了 Proxy 的拦截!
    // return target[property];

    // 使用 Reflect,receiver 会将 this 绑定到代理对象 proxy 上
    return Reflect.get(target, property, receiver);
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.name);

运行结果分析

  • 当我们访问 proxy.name 时,handlerget 被触发,property'name'receiverproxy 本身。

  • Reflect.get(target, 'name', proxy) 被调用。它会执行 target 上的 name getter。

  • 关键来了:Reflect.get 会将 name getter 内部的 this 绑定为 receiver,也就是 proxy

  • 因此,getter 内部执行 this._name 时,实际上是在执行 proxy._name

  • 这次访问又会被 Proxy 拦截,再次触发 handlerget,此时 property'_name'

  • 这样,整个访问链条都在 Proxy 的掌控之下,响应式得以维持。

Vue 3 响应式的优势

  1. 性能更优:Vue 2 需要在初始化时递归遍历所有属性,如果数据量大,启动会很慢。Vue 3 的 Proxy 是懒代理,只在访问属性时才会进行处理,初始化的开销非常小。

  2. 功能更强:原生支持对象属性的增删和数组所有操作的监听,不再需要 Vue.set 等 API。

  3. 代码更简洁:响应式部分的代码逻辑更加清晰,不再需要为数组和对象写两套不同的逻辑。

当然,Proxy 也有其缺点,那就是无法兼容 IE 浏览器,因为 ProxyReflect 都是 ES6 的特性,没有 Polyfill。这也是 Vue 3 无法支持 IE 的根本原因。


章节三:终极对决:Vue 2 vs. Vue 3

特性

Vue 2 (Object.defineProperty)

Vue 3 (Proxy)

核心原理

拦截对象属性的 gettersetter

代理整个对象,拦截所有操作

监听范围

需在初始化时递归遍历所有已知属性

代理整个对象,无需预先遍历

新增属性

无法监听,需使用 Vue.set

原生支持

删除属性

无法监听,需使用 Vue.delete

原生支持

数组监听

仅支持被重写的数组方法,不支持索引赋值

原生支持所有操作(包括索引和.length

this 指向

无此问题,因为是直接改写属性

需要 Reflectreceiver 配合解决 this 指向问题

性能

初始化时开销大,运行时开销小

初始化开销小,运行时因为是代理层,有一定开销但综合更优

浏览器兼容

兼容到 IE9

不支持 IE (无法 Polyfill)


评论