理解数据拦截:在途中「打断」的艺术
Q: 什么是数据拦截?为什么 Vue 的响应式系统需要它?
A: 数据拦截,简单来说,就是在你对数据进行操作的中途将你打断,从而能够做一些额外的事情。想象你在路上开车,本来一路畅通,但因为你违反了交规被警察拦截下来,对你进行批评教育加罚款——这就是现实生活中的拦截。在程序世界里,数据拦截就是在代码读写对象属性的过程中插入一段自定义逻辑,Vue 的响应式系统的所有魔法,都以此为起点。
JS 中实现数据拦截的两种方式
目前 JavaScript 中实现数据拦截的方式主要有两种:Object.defineProperty 和 Proxy。它们分别对应 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:定义代理对象的行为- 返回值:返回的就是一个代理对象,外部对属性的读写都是针对代理对象来做的
同样用 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 的响应式存在两个著名的缺陷:
- 无法检测对象属性的新增和删除 —— 需要通过
Vue.set/Vue.delete来手动处理 - 无法检测数组索引赋值和 length 变化 —— 需要通过数组方法(push、splice 等)或
Vue.set来处理
Vue3 使用 Proxy 一举解决了这些问题。
性能方面: 大多数情况下 Proxy 是高效的,但不能绝对地断定 Proxy 一定比 Object.defineProperty 快。如果你只需要拦截某个特定属性的简单读写,Object.defineProperty 可能性能更好。但在实际场景中,一般都需要全面拦截一个对象的所有操作,此时 Proxy 更优。
响应式数据的本质:被拦截的对象
Q: ref 和 reactive 创建的数据为什么是响应式的?
A: 所谓响应式数据,其实就是被拦截的对象。一旦对象被拦截,针对对象的各种操作就能被 Vue 捕获,从而让框架有机会做额外的事情——比如依赖收集和派发更新。
在 Vue3 中,创建响应式数据的方式有 ref 和 reactive 两种,它们的背后都是针对对象添加拦截。但由于 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 响应式系统的核心可以用一句话概括:只有被监控的函数,在它的同步代码运行期间,读取操作被拦截的响应式数据,才会建立依赖关系。建立了依赖关系之后,响应式数据发生变化,对应的函数才会重新执行。
这个机制的三个基石分别是:
- 数据拦截(Proxy / Object.defineProperty)—— 让 Vue 能够「看见」数据的变化
- 依赖收集(track)—— 建立响应式数据和函数之间的映射关系
- 派发更新(trigger)—— 数据变化时,通知并重新执行所有关联的函数
掌握这三个概念,你就拿到了理解 Vue3 响应式系统全部魔法的大门钥匙。