理解数据拦截:在途中「打断」的艺术

Q: 什么是数据拦截?为什么 Vue 的响应式系统需要它?

A: 数据拦截,简单来说,就是在你对数据进行操作的中途将你打断,从而能够做一些额外的事情。想象你在路上开车,本来一路畅通,但因为你违反了交规被警察拦截下来,对你进行批评教育加罚款——这就是现实生活中的拦截。在程序世界里,数据拦截就是在代码读写对象属性的过程中插入一段自定义逻辑,Vue 的响应式系统的所有魔法,都以此为起点。

JS 中实现数据拦截的两种方式

目前 JavaScript 中实现数据拦截的方式主要有两种:Object.definePropertyProxy。它们分别对应 Vue1.x/2.x 和 Vue3.x 的响应式实现。

Object.defineProperty

这是 Object 上的一个静态方法,用于给一个对象添加新的属性,同时还能够对该属性进行更为详细的配置:

Object.defineProperty(obj, prop, descriptor);

关键在于属性描述符 descriptor,它可以配置如下信息:

属性 说明 默认值
value 设置属性值 undefined
writable 属性值是否可写 false
enumerable 属性是否可枚举 false
configurable 属性是否可配置 false
get 取值函数 undefined
set 存值函数 undefined

其中,value + writable 属于数据属性get + set 属于访问器属性,两者默认互斥。也就是说,一旦你定义了 getter/setter,就不能同时定义 value 和 writable。

来看一个实际例子,通过 getter/setter 实现对 name 属性的拦截:

function Student() {
  let stuName = '张三';
  Object.defineProperty(this, 'name', {
    get() {
      return stuName;
    },
    set(value) {
      if (!isNaN(value)) {
        stuName = '张三'; // 如果是数字,固定返回张三
      } else {
        stuName = value;
      }
    },
  });
}
const stu = new Student();
console.log(stu.name);  // 张三
stu.name = '李四';
console.log(stu.name);  // 李四
stu.name = 100;
console.log(stu.name);  // 张三(被拦截,数字无效)

Proxy

ES6 新提供的 Proxy 通过创建代理对象的方式来实现拦截

const p = new Proxy(target, handler);
  • target:目标对象,可以是任何类型的对象,包括数组、函数
  • handler:定义代理对象的行为
  • 返回值:返回的就是一个代理对象,外部对属性的读写都是针对代理对象来做的
Proxy 工作示意图

同样用 Student 的例子,使用 Proxy 实现:

function Student() {
  const obj = {
    name: '张三',
  };
  return new Proxy(obj, {
    get(obj, prop) {
      return obj[prop] + '是个好学生';
    },
    set(obj, prop, value) {
      if (!isNaN(value)) {
        obj[prop] = '张三';
      } else {
        obj[prop] = value;
      }
    },
  });
}
const stu = new Student(); // stu 拿到的就是代理对象
console.log(stu.name);     // 张三是个好学生
stu.name = '李四';
console.log(stu.name);     // 李四是个好学生
stu.name = 100;
console.log(stu.name);     // 张三是个好学生

两者的共同点

1. 都可以针对对象成员进行拦截。 无论是 Object.defineProperty 还是 Proxy,都能拦截属性的读取和写入操作。

2. 都可以实现深度拦截。 两者在实现深度拦截时都需要自己书写递归来实现,但都能够做到:

// Object.defineProperty 深度拦截
function deepDefineProperty(obj) {
  for (let key in obj) {
    if (obj.hasOwnProperty(key) && typeof obj[key] === 'object') {
      deepDefineProperty(obj[key]);
    }
    let _value = obj[key];
    Object.defineProperty(obj, key, {
      get() {
        console.log(`读取${key}属性`);
        return _value;
      },
      set(value) {
        console.log(`设置${key}属性`);
        _value = value;
      },
      configurable: true,
      enumerable: true,
    });
  }
}
// Proxy 深度拦截
function deepProxy(obj) {
  return new Proxy(obj, {
    get(obj, prop) {
      console.log(`读取了${prop}属性`);
      if (typeof obj[prop] === 'object') {
        return deepProxy(obj[prop]); // 递归代理
      }
      return obj[prop];
    },
    set(obj, prop, value) {
      console.log(`设置了${prop}属性`);
      if (typeof value === 'object') {
        return deepProxy(value);
      }
      obj[prop] = value;
    },
  });
}

两者的关键差异:Vue3 换用 Proxy 的根本原因

Vue3 将响应式从 Object.defineProperty 替换为 Proxy,是因为两者在拦截的广度灵活性上有本质区别:

对比维度 Object.defineProperty Proxy
拦截目标 针对对象特定属性 针对整个对象
拦截操作 仅属性的读取赋值 读取、赋值、新增、删除、原型操作、函数调用等13 种操作
新增属性 无法拦截 可以被拦截
删除属性 无法拦截 可以被拦截
数组操作 部分支持,有缺陷 完整支持

Proxy 能够拦截的额外行为示例:

function deepProxy(obj) {
  return new Proxy(obj, {
    get(obj, prop) { /* ... */ },
    set(obj, prop, value) { /* ... */ },
    deleteProperty(obj, prop) {
      console.log(`删除了${prop}属性`);
      delete obj[prop];
    },
    getPrototypeOf(obj) {
      console.log('拦截获取原型');
      return Object.getPrototypeOf(obj);
    },
    setPrototypeOf(obj, proto) {
      console.log('拦截设置原型');
      return Object.setPrototypeOf(obj, proto);
    },
  });
}

正因为这个差异,Vue2 的响应式存在两个著名的缺陷:

Vue2 响应式的缺陷
  1. 无法检测对象属性的新增和删除 —— 需要通过 Vue.set / Vue.delete 来手动处理
  2. 无法检测数组索引赋值和 length 变化 —— 需要通过数组方法(push、splice 等)或 Vue.set 来处理

Vue3 使用 Proxy 一举解决了这些问题。

性能方面: 大多数情况下 Proxy 是高效的,但不能绝对地断定 Proxy 一定比 Object.defineProperty 快。如果你只需要拦截某个特定属性的简单读写,Object.defineProperty 可能性能更好。但在实际场景中,一般都需要全面拦截一个对象的所有操作,此时 Proxy 更优。

响应式数据的本质:被拦截的对象

Q: ref 和 reactive 创建的数据为什么是响应式的?

A: 所谓响应式数据,其实就是被拦截的对象。一旦对象被拦截,针对对象的各种操作就能被 Vue 捕获,从而让框架有机会做额外的事情——比如依赖收集和派发更新。

在 Vue3 中,创建响应式数据的方式有 refreactive 两种,它们的背后都是针对对象添加拦截。但由于 JS 中数据拦截(无论是 Object.defineProperty 还是 Proxy)都是针对对象的,ref 在处理原始值时是如何做到的呢?

答案在于 ref 的源码设计:

class RefImpl<T> {
  private _value: T
  private _rawValue: T

  public dep?: Dep = undefined
  public readonly __v_isRef = true

  constructor(
    value: T,
    public readonly __v_isShallow: boolean,
  ) {
    this._rawValue = __v_isShallow ? value : toRaw(value)
    // 原始值直接存,对象则调用 reactive 处理
    this._value = __v_isShallow ? value : toReactive(value)
  }

  get value() {
    // 收集依赖
    return this._value
  }

  set value(newVal) {
    // 派发更新
  }
}

// 判断是否是对象,是对象就用 reactive 来处理
export const toReactive = <T extends unknown>(value: T): T =>
  isObject(value) ? reactive(value) : value

而 reactive 的源码则直接使用 Proxy:

function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers, proxyMap) {
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
  );
  proxyMap.set(target, proxy);
  return proxy;
}

从源码可以看出:

  • ref:使用 Object.defineProperty(拦截 .value 的 get/set)+ Proxy(当值为对象时交给 reactive)
  • reactive:使用 Proxy

判断操作是否产生拦截:一个必须掌握的技能

理解一个操作是否会产生拦截,是深入掌握 Vue3 响应式系统的关键。只有产生拦截,才会有后续的依赖收集和派发更新。

拦截规则非常简单:

  • Object.defineProperty:拦截特定属性的读取和赋值
  • Proxy:拦截代理对象的成员读取、赋值、新增、删除

来看以下测试题目,判断每个操作是否会触发拦截:

// demo1 - ref 原始值
let state = ref(1);
state;                    // 不会拦截
console.log(state);       // 不会拦截
console.log(state.value); // ✅ 会拦截,访问了 value 属性(get)
console.log(state.a);     // 不会拦截
state.a = 3;              // 不会拦截
state.value = 3;          // ✅ 会拦截,value 的 set 操作
delete state.value;       // 不会拦截
state = 3;                // 不会拦截
// demo2 - ref 对象
let state = ref({ a: 1 });
console.log(state.value);   // ✅ 会拦截,value 的 get
console.log(state.value.a); // ✅ 会拦截,value 的 get + a 的 get
state.value.a = 3;          // ✅ 会拦截,value 的 get + a 的 set
delete state.value.a;       // ✅ 会拦截,value 的 get + a 的 delete
state.value = 3;            // ✅ 会拦截,value 的 set
delete state.value;         // 不会拦截
// demo3 - reactive 对象
let state = reactive({});
console.log(state.a);       // ✅ 会拦截
state.a = 3;                // ✅ 会拦截
state.a = { b: { c: 3 } }; // ✅ 会拦截,a 的 set 操作
console.log(state.a.b.c);   // ✅ 会拦截
delete state.a.b;           // ✅ 会拦截,a 是 get、b 是 delete
// demo4 - 关键:k 是 Proxy 对象,操作 k 的成员仍会触发拦截
const state = ref({ a: 1 });
const k = state.value;
console.log(k);  // 不会拦截,没有针对成员操作
k.a = 3;         // ✅ 会拦截,k 是 Proxy 对象
const n = k.a;   // ✅ 会拦截

再次强调:一定要学会判断针对一个对象进行操作时是否会发生拦截,这是后续理解依赖收集和派发更新的基础。 关键判断标准是——你是否在通过代理对象访问或修改成员属性。

响应式的本质:函数与数据的映射关系

Q: 响应式的本质是什么?依赖收集和派发更新又是怎么回事?

A: 响应式的本质,就是函数和数据的一组映射。当数据发生变化时,将该数据对应的所有函数全部执行一遍。这里的数据必须是响应式数据函数必须是被监控的函数

  • 依赖收集:收集函数和数据之间的映射关系
  • 派发更新:当数据变化时,通知被关联的函数重新执行

Vue 中的响应式数据包括:

  • ref
  • reactive
  • props
  • computed

依赖关系的定义: 函数在运行期间,出现了读取响应式数据被拦截的情况,我们就称之为两者之间产生了依赖。这个依赖会被收集起来,方便响应式数据发生变化时重新执行对应的函数。

关键判断标准: 只需要判断在函数的运行期间,是否存在读取操作行为的拦截,只要存在这种类型的拦截,那么该函数就和该响应式数据存在依赖关系。

来看以下练习:

// demo1 - 没有依赖,a 不是响应式数据
var a;
function foo() { console.log(a); }
// demo2 - 没有依赖,虽然用了 ref,但没有触发读取拦截
var a = ref(1);
function foo() { console.log(a); } // 访问的是 ref 对象本身,不是 .value
// demo3 - 有依赖,foo 依赖 value 属性
var a = ref(1);
function foo() { console.log(a.value); }
// demo4 - 有依赖
var a = ref({ b: 1 });
const k = a.value;
const n = k.b;
function foo() {
  a;           // 没有
  a.value;     // ✅ 依赖 value
  k.b;         // ✅ 依赖 b(k 是 Proxy)
  n;           // 没有,n 是普通值
}
// demo5 - 内部函数也会被收集
var a = ref({ b: 1 });
const k = a.value;
function foo() {
  function fn2() {
    a.value.b;  // ✅ 依赖 value 和 b
  }
  fn2();
}
// foo 同样依赖 value 和 b 属性

异步处理的特殊情况: 如果在函数的运行期间存在异步代码(如 await),那么之后的代码统统不纳入依赖收集的范畴。

// demo6 - 异步后的代码不建立依赖
var a = ref({ b: 1 });
const k = a.value;
async function foo() {
  a;
  a.value;   // ✅ 建立依赖
  await 1;
  k.b;       // ❌ 异步后的代码,不建立依赖
}

被监控的函数:谁能建立依赖关系?

并不是任意一个函数都能和响应式数据建立依赖。函数必须是被监控的函数,主要包括:

  • effect:Vue3 源码内部的底层实现
  • watchEffect:自动追踪依赖的侦听器
  • watch:手动指定源的侦听器
  • 组件渲染函数:每个组件对应的 render 函数

结合之前的依赖判断规则和实际代码,来做一些综合练习:

// 练习1:能重新运行
import { ref, watchEffect } from 'vue';
const state = ref({ a: 1 });
const k = state.value;
const n = k.a;
watchEffect(() => {
  console.log('运行');
  state;             // 无
  state.value;       // ✅ value
  state.value.a;     // ✅ value、a
  n;                 // 无
});
setTimeout(() => {
  state.value = { a: 3 }; // ✅ 重新运行,修改了 value
}, 500);
// 练习2:不会重新运行(值相同)
import { ref, watchEffect } from 'vue';
const state = ref({ a: 1 });
// ...
watchEffect(() => {
  state.value.a;     // value、a
});
setTimeout(() => {
  state.value.a = 1; // ❌ 不重新运行,值没有变化
}, 500);
// 练习3:通过 proxy 对象修改也会触发
const state = ref({ a: 1 });
const k = state.value;
watchEffect(() => {
  state.value.a;     // value、a
});
setTimeout(() => {
  k.a = 2;           // ✅ 重新运行,k 是 proxy 对象
}, 500);
// 练习4:修改 state.value 改变了代理对象身份
let state = ref({ a: 1 });
const k = state.value;
watchEffect(() => {
  state.value.a;     // value、a
});
setTimeout(() => {
  state.value = { a: 1 }; // ✅ 重新运行
}, 500);
setTimeout(() => {
  k.a = 3;                // ❌ 不重新运行,k 还是指向旧的 proxy 对象
}, 1000);

总结

Vue3 响应式系统的核心可以用一句话概括:只有被监控的函数,在它的同步代码运行期间,读取操作被拦截的响应式数据,才会建立依赖关系。建立了依赖关系之后,响应式数据发生变化,对应的函数才会重新执行。

这个机制的三个基石分别是:

  1. 数据拦截(Proxy / Object.defineProperty)—— 让 Vue 能够「看见」数据的变化
  2. 依赖收集(track)—— 建立响应式数据和函数之间的映射关系
  3. 派发更新(trigger)—— 数据变化时,通知并重新执行所有关联的函数

掌握这三个概念,你就拿到了理解 Vue3 响应式系统全部魔法的大门钥匙。