Vue3 中的 Proxy API

在 10 月 05 日凌晨 Vue3 的源代码正式发布了

Object.defineProperty 的一些弊端

Vue2.x 中,实现数据的可响应,需要对 Object 和 Array 两种类型采用不同的处理方式。 Object 类型通过 Object.defineProperty 将属性转换成 getter/setter ,这个过程需要递归侦测所有的对象 key,来实现深度的侦测。

为了感知 Array 的变化,对 Array 原型上几个改变数组自身的内容的方法做了拦截,虽然实现了对数组的可响应,但也存在一些问题。 同时,defineProperty 通过递归实现 getter/setter 也有一定的性能问题。

更好的实现方式是通过 ES6 提供的 Proxy

Proxy 的一些坑

Proxy 具有更加强大的功能, 相比旧的 definePropertyProxy 可以代理数组,并且提供了多个 traps(主要是 getset ) ,可以实现诸多功能。但其中的一些比较容易被忽略的细节。

trap 默认行为

1
2
3
4
5
6
7
8
9
10
11
12
let data = { info: "info" };
let p = new Proxy(data, {
get(target, key, receiver) {
return target[key];
},
set(target, key, value, receiver) {
console.log("set value");
target[key] = value; // ?
}
});

p.info = 123;

通过 proxy 返回的对象 p 代理了对原始数据的操作,当对 p 设置时,便可以侦测到变化。

但是这么写实际上是有问题, 当代理的对象数据是数组时,就会报错。

1
2
3
4
5
6
7
8
9
10
11
12
let data = [1, 2];
let p = new Proxy(data, {
get(target, key, receiver) {
return target[key];
},
set(target, key, value, receiver) {
console.log("set value");
target[key] = value;
}
});

p.push(3); // 报错

将代码更改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let data = [1, 2];
let p = new Proxy(data, {
get(target, key, receiver) {
return target[key];
},
set(target, key, value, receiver) {
console.log("set value");
target[key] = value;
return true;
}
});

p.push(3);

// set value 打印 2 次

实际上,当代理对象是数组,通过 push 操作,并不只是操作当前数据,push 操作还触发数组本身其他属性更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let data = [1, 2];
let p = new Proxy(data, {
get(target, key, receiver) {
console.log("get value:", key);
return target[key];
},
set(target, key, value, receiver) {
console.log("set value:", key, value);
target[key] = value;
return true;
}
});

p.push(3);

// get value: push
// get value: length
// set value: 2 3
// set value: length 3
  1. 先看 set 操作,从打印输出可以看出,push 操作除了给数组的第 2 位下标设置值 3 ,还给数组的 length 值更改为 3。 同时这个操作还触发了 get 去获取 push 和 length 两个属性。

  2. 我们可以通过 Reflect 来返回 trap 相应的默认行为,对于 set 操作相对简单,但是一些比较复杂的默认行为处理起来相对繁琐得多,Reflect 的作用就显现出来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let data = [1, 2];
let p = new Proxy(data, {
get(target, key, receiver) {
console.log("get value:", key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log("set value:", key, value);
return Reflect.set(target, key, value, receiver);
}
});

p.push(3);

// get value: push
// get value: length
// set value: 2 3
// set value: length 3

相比自己处理 set 的默认行为,Reflect 就方便得多。

多次触发 set / get

当代理对象是数组时,push 操作会触发多次 set 执行,同时,也引发 get 操作,这点非常重要,vue3 就很好的使用了这点。 我们可以从另一个例子来看这个操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let data = [1, 2, 3];
let p = new Proxy(data, {
get(target, key, receiver) {
console.log("get value:", key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log("set value:", key, value);
return Reflect.set(target, key, value, receiver);
}
});

p.unshift("a");

// get value: unshift
// get value: length
// get value: 2
// set value: 3 3
// get value: 1
// set value: 2 2
// get value: 0
// set value: 1 1
// set value: 0 a
// set value: length 4

可以看到,在对数组做 unshift 操作时,会多次触发 get 和 set 。 仔细观察输出,不难看出,get 先拿数组最末位下标,开辟新的下标 3 存放原有的末位数值,然后再将原数值都往后挪,将 0 下标设置为了 unshift 的值 a ,由此引发了多次 set 操作。

而这对于 通知外部操作 显然是不利,我们假设 set 中的 console 是触发外界渲染的 render 函数,那么这个 unshift 操作会引发 多次 render 。

我们后面会讲述如何解决相应的这个问题,继续。

proxy 只能代理一层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let data = { foo: "foo", bar: { key: 1 }, ary: ["a", "b"] };
let p = new Proxy(data, {
get(target, key, receiver) {
console.log("get value:", key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log("set value:", key, value);
return Reflect.set(target, key, value, receiver);
}
});

p.bar.key = 2;

// get value: bar

执行代码,可以看到并没有触发 set 的输出,反而是触发了 get ,因为 set 的过程中访问了 bar 这个属性。 由此可见,proxy 代理的对象只能代理到第一层,而对象内部的深度侦测,是需要开发者自己实现的。同样的,对于对象内部的数组也是一样。

1
2
3
p.ary.push("c");

// get value: ary

同样只走了 get 操作,set 并不能感知到。

我们注意到 get/set 还有一个参数:receiver ,对于 receiver ,其实接收的是一个代理对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
let data = { a: { b: { c: 1 } } };
let p = new Proxy(data, {
get(target, key, receiver) {
console.log(receiver);
const res = Reflect.get(target, key, receiver);
return res;
},
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver);
}
});

// Proxy {a: {…}}

这里 receiver 输出的是当前代理对象,注意,这是一个已经代理后的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
let data = { a: { b: { c: 1 } } };
let p = new Proxy(data, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
console.log(res);
return res;
},
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver);
}
});

// {b: {c: 1} }

当我们尝试输出 Reflect.get 返回的值,会发现,当代理的对象是多层结构时,Reflect.get 会返回对象的内层结构。

Vue3 如何解决 proxy 中的细节问题

Vue3 项目结构采用了 lernamonorepo 风格的代码管理,目前比较多的开源项目切换到了 monorepo 的模式, 比较显著的特征是项目中会有个 packages/ 的文件夹。

Vue3 对功能做了很好的模块划分,同时使用 TS 。我们直接在 packages 中找到响应式数据的模块:

Vue3 中的 reactivity

其中,reactive.ts 文件提供了 reactive 函数,该函数是实现响应式的核心。 同时这个函数也挂载在了全局的 Vue 对象上。

这里对源代码做一点程度的简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
const rawToReactive = new WeakMap();
const reactiveToRaw = new WeakMap();

// utils
function isObject(val) {
return typeof val === "object";
}

function hasOwn(val, key) {
const hasOwnProperty = Object.prototype.hasOwnProperty;
return hasOwnProperty.call(val, key);
}

// traps
function createGetter() {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
return isObject(res) ? reactive(res) : res;
};
}

function set(target, key, val, receiver) {
const hadKey = hasOwn(target, key);

val = reactiveToRaw.get(val) || val;
const result = Reflect.set(target, key, val, receiver);

const oldValue = target[key];

if (!hadKey) {
console.log("trigger ...");
} else if (val !== oldValue) {
console.log("trigger ...");
}

return result;
}

// handler
const mutableHandlers = {
get: createGetter(),
set: set
};

// entry
function reactive(target) {
return createReactiveObject(
target,
rawToReactive,
reactiveToRaw,
mutableHandlers
);
}

function createReactiveObject(target, toProxy, toRaw, baseHandlers) {
let observed = toProxy.get(target);
// 原数据已经有相应的可响应数据, 返回可响应数据
if (observed !== void 0) {
return observed;
}
// 原数据已经是可响应数据
if (toRaw.has(target)) {
return target;
}
observed = new Proxy(target, baseHandlers);
toProxy.set(target, observed);
toRaw.set(observed, target);
return observed;
}

rawToReactivereactiveToRaw 是两个弱引用的 Map 结构,这两个 Map 用来保存 原始数据 和 可响应数据 ,在函数 createReactiveObject 中,toProxytoRaw 传入的便是这两个 Map

我们可以通过它们,找到任何代理过的数据是否存在,以及通过代理数据找到原始的数据。

除了保存了代理的数据和原始数据,createReactiveObject 函数仅仅是返回了 new Proxy 代理后的对象。 重点在 new Proxy中传入的 handler 参数 baseHandlers

还记得前面提到的 Proxy 实现数据侦测的细节问题吧,我们尝试输入:

1
2
3
let data = { foo: "foo", ary: [1, 2] };
let r = reactive(data);
r.ary.push(3);

打印结果:
可以看到打印输出了一次 trigger …

问题一:如何做到深度的侦测数据的 ?

深度侦测数据是通过 createGetter 函数实现的,前面提到,当对多层级的对象操作时,set 并不能感知到,但是 get 会触发, 于此同时,利用 Reflect.get() 返回的“多层级对象中内层” ,再对“内层数据”做一次代理。

1
2
3
4
5
6
function createGetter() {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
return isObject(res) ? reactive(res) : res;
};
}

可以看到这里判断了 Reflect 返回的数据是否还是对象,如果是对象,则再走一次 proxy,从而获得了对对象内部的侦测。

并且,每一次的 proxy 数据,都会保存在 Map 中,访问时会直接从中查找,从而提高性能。

当我们打印代理后的对象时:

可以看到这个代理后的对象内层并没有代理的标志,这里仅仅是代理外层对象。

输出其中一个存储代理数据的 rawToReactiv

对于内层 ary: [1, 2] 的代理,已经被存储在了 rawToReactive 中。

由此实现了深度的数据侦测。

问题二:如何避免多次 trigger ?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function hasOwn(val, key) {
const hasOwnProperty = Object.prototype.hasOwnProperty;
return hasOwnProperty.call(val, key);
}
function set(target, key, val, receiver) {
console.log(target, key, val);
const hadKey = hasOwn(target, key);

val = reactiveToRaw.get(val) || val;
const result = Reflect.set(target, key, val, receiver);

const oldValue = target[key];

if (!hadKey) {
console.log("trigger ... is a add OperationType");
} else if (val !== oldValue) {
console.log("trigger ... is a set OperationType");
}

return result;
}

关于多次trigger 的问题,vue 处理得很巧妙。

set 函数中 hasOwn 前打印 console.log(target, key, val)

1
2
3
let data = ["a", "b"];
let r = reactive(data);
r.push("c");

r.push('c') 会触发 set 执行两次,一次是值本身 'c' ,一次是 length 属性设置

  • 设置值 'c' 时,传入的新增索引 key2target 是原始的代理对象 ['a', 'c']hasOwn(target, key) 显然返回 false ,这是一个新增的操作,此时可以执行 trigger ... is a add OperationType

  • 当传入 keylength 时,hasOwn(target, key)length 是自身属性,返回 true,此时判断 val !== oldValue, val3, 而 oldValue 即为 target['length'] 也是 3,此时不执行 trigger 输出语句。

所以通过 判断 key 是否为 target 自身属性,以及设置 val 是否跟 target[key]相等 可以确定 trigger 的类型,并且避免多余的 trigger

总结

Vue3 并非简单的通过 Proxy 来递归侦测数据, 而是通过 get 操作来实现内部数据的代理,并且结合 WeakMap 来对数据保存,这将大大提高响应式数据的性能。

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×