0. 前言

vue2 即将结束 LTS,一个时代结束。

vue2 到 vue3 的转变有很多,组合式 api,新的响应式,vite,pinia 等。今天简单学习了一下 vue3 的响应式,记录一点简单理解。

1. Object.defineProperty 和 Proxy

vue2 使用的 Object.defineProperty 去劫持对象的 getter 和 setter,通过订阅发布模式以实现前端的响应式更新。
vue3 使用最新的 API Proxy,劫持对象的 getter 和 setter,通过订阅发布实现前端的响应式。

为什么要做了这个替换呢,要知道做了这个替换可是下了很大决心的,Proxy 这个新的 API 是没有 Polyfill 的,也就是说 Vue3 将完全无法支持 IE 等不支持 Proxy 的浏览器。

原因主要有:

  1. Object.defineProperty 对于深层对象只能循环递归,性能上比 Proxy 低。
  2. Object.defineProperty 无法监听数组的增删,vue2 之所以能够对一些常用操作做监听例如 push,pop,是因为 vue2 重写了数组方法以触发响应式,对于无法触发响应式的操作,文档也提及了 Vue.$set 方法
  3. Object.defineProperty 无法监听对象的增删。vue2 中也是只能使用 Vue.$set 方法。

以上,vue3 决心放弃 Object.defineProperty 转为使用 Proxy

2.Proxy 如何实现劫持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
作者:artist
链接:https://juejin.cn/post/6995731724365725710
来源:稀土掘金
*/
var obj = new Proxy(
{},
{
get: function (target, propKey, receiver) {
console.log(`getting ${propKey}!`);
return Reflect.get(target, propKey, receiver);
},
set: function (target, propKey, value, receiver) {
console.log(`setting ${propKey}!`);
return Reflect.set(target, propKey, value, receiver);
},
}
);

引用一段抄来的代码,Proxy 接受一个对象,通过劫持对象的 get 操作和 set 操作,通过反射 Reflect 作用到劫持的对象上。在 get 和 set 中可以实现订阅发布。

3. Reactive VS Ref

刚学 vue3 的时候,会有很多疑惑,在 vue2 里面 this.data.a 直接赋值就行了,为什么 vue3 里面有些需要带.value,有些不需要带.value,有些对象可以直接赋值,有些对象要带.value 赋值等。

我的理解是:

牢记:Proxy 代理的永远是【对象】

3.1 Reactive

首先讲一下 reactive,reactive 的功能其实就是上述伪代码的功能,将一个对象转化为响应式对象,类似于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 伪代码,非具体实现
// reactive将传入的对象使用Proxy劫持,并处理订阅发布
const reactive = (obj) =>
new Proxy(obj, {
get: function (target, propKey, receiver) {
console.log(`getting ${propKey}!`);
return Reflect.get(target, propKey, receiver);
},
set: function (target, propKey, value, receiver) {
console.log(`setting ${propKey}!`);
return Reflect.set(target, propKey, value, receiver);
},
});

const obj = {
a: 1,
b: 2,
};

const myReactiveObj = reactive(obj);

这样子,我们在使用 myReactiveObj.a 的时候,就进入到 Proxy 的劫持了,Proxy 将从劫持的对象中取出数据 1。

3.2 Ref

那为什么又存在 ref 呢?许多教程提到过,ref 是用来将原始值(String / Number / BigInt / Boolean / Symbol / Null / Undefined)转为响应式对象的。为什么是原始值呢?

上文说到过 Proxy 代理的永远是对象,那么原始值我们怎么监听呢?

ref 的做法是,把他放到一个对象里面去就好了。

类似于把传入 ref 的变量变为

1
2
3
4
5
6
7
8
9
// 伪代码,非具体实现
// ref的劫持好像不是用Proxy的
const ref = (value) =>
reactive({
value,
});

const a = 1;
const myRef = ref(a);

这样子,其实我们劫持的是对象{ value: 1 },所以我们在使用的时候,并不是使用 myRef,myRef 是一个劫持对象。所以需要使用 myRef.value 才能取到我们想要的数据 1。

转为对象的时候还有一个优势,就是将 ref 作为函数参数传参的时候,使用的是引用传值,可以将响应式传递下去

4. 响应式丢失

响应式丢失是什么问题?

1
2
3
4
5
6
7
8
9
10
11
const obj = {
a: 1,
b: 2,
};

const myReactiveObj = reactive(obj);

let { a, b } = myReactiveObj;

// 不会触发视图更新,即丢失了响应式
a += 1;

为什么会出现这种情况呢?关键还是上面说的

牢记:Proxy 代理的永远是【对象】

const { a, b } = myReactiveObj 这一步中,我们对 myReactiveObj 对象中的 a 属性进行了 get 调用,然后把调用的返回值赋予给了新的变量 a。这个返回值,已经是原始值 1(Number)了,对一个原始值再怎么赋值,也跟响应式无关了,因为 Proxy 劫持的是对象,劫持的是obj.a的操作和obj.a += 1的操作。一切都要在劫持对象 obj 的框架下进行。解构之后就与 obj 无关了。

那么要如何保持响应式的同时使用解构呢?答案是使用 toRefs。

1
2
3
4
5
6
7
8
9
10
11
const obj = {
a: 1,
b: 2,
};

const myReactiveObj = reactive(obj);

let { a, b } = toRefs(myReactiveObj);

// 会触发视图更新,具有响应式链接
a.value += 1;

toRefs 使用 ref 对传入的对象的每个 key 进行包裹,建立一个ref(myReactiveObj.a).valuemyReactiveObj.a的响应式传递,即

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
a: 1,
b: 2,
};

const myReactiveObj = reactive(obj);

let { a, b } = toRefs(myReactiveObj);

// 这段等同于 myReactiveObj.a = myReactiveObj.a + 1;
// 视图中使用a.value的地方也会被myReactiveObj.a的赋值触发更新
a.value = a.value + 1;

5. 小测

综上,我们可以轻易得出一下问题的结论

1
2
3
// 无效
let x = reactive({ name: "John" });
x = reactive({ todo: true });

这段代码响应式无效是因为,x 原本为 Proxy 对象,直接替换 Proxy 对象,无法触发对象属性的劫持,不会触发响应式。

1
2
3
// 有效
const x = ref({ name: "John" });
x.value = { todo: true };

这段代码有效是因为,x 是 Proxy 对象,对.value 的赋值操作会触发响应式更新。