Object.defineProperty 的一些弊端
Vue2.x 中,实现数据的可响应,需要对 Object 和 Array 两种类型采用不同的处理方式。 Object 类型通过 Object.defineProperty 将属性转换成 getter/setter ,这个过程需要递归侦测所有的对象 key,来实现深度的侦测。
为了感知 Array 的变化,对 Array 原型上几个改变数组自身的内容的方法做了拦截,虽然实现了对数组的可响应,但也存在一些问题。 同时,defineProperty 通过递归实现 getter/setter 也有一定的性能问题。
更好的实现方式是通过 ES6 提供的 Proxy 。
Proxy 的一些坑
Proxy 具有更加强大的功能, 相比旧的 defineProperty ,Proxy 可以代理数组,并且提供了多个 traps(主要是 get 、 set ) ,可以实现诸多功能。但其中的一些比较容易被忽略的细节。
trap 默认行为
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 设置时,便可以侦测到变化。
但是这么写实际上是有问题, 当代理的对象数据是数组时,就会报错。
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); // 报错将代码更改为:
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 操作还触发数组本身其他属性更改。
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- 先看 set 操作,从打印输出可以看出,push 操作除了给数组的第 2 位下标设置值 3 ,还给数组的 length 值更改为 3。 同时这个操作还触发了 get 去获取 push 和 length 两个属性。
- 我们可以通过 Reflect 来返回 trap 相应的默认行为,对于 set 操作相对简单,但是一些比较复杂的默认行为处理起来相对繁琐得多,Reflect 的作用就显现出来了。
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 就很好的使用了这点。 我们可以从另一个例子来看这个操作:
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 只能代理一层
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 代理的对象只能代理到第一层,而对象内部的深度侦测,是需要开发者自己实现的。同样的,对于对象内部的数组也是一样。
p.ary.push("c");
// get value: ary同样只走了 get 操作,set 并不能感知到。
我们注意到 get/set 还有一个参数:receiver ,对于 receiver ,其实接收的是一个代理对象:
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 输出的是当前代理对象,注意,这是一个已经代理后的对象。
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 项目结构采用了 lerna 做 monorepo 风格的代码管理,目前比较多的开源项目切换到了 monorepo 的模式, 比较显著的特征是项目中会有个 packages/ 的文件夹。
Vue3 对功能做了很好的模块划分,同时使用 TS 。我们直接在 packages 中找到响应式数据的模块:
Vue3 中的 reactivity
其中,reactive.ts 文件提供了 reactive 函数,该函数是实现响应式的核心。 同时这个函数也挂载在了全局的 Vue 对象上。
这里对源代码做一点程度的简化:
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;
}rawToReactive 和 reactiveToRaw 是两个弱引用的 Map 结构,这两个 Map 用来保存 原始数据 和 可响应数据 ,在函数 createReactiveObject 中,toProxy和 toRaw 传入的便是这两个 Map 。
我们可以通过它们,找到任何代理过的数据是否存在,以及通过代理数据找到原始的数据。
除了保存了代理的数据和原始数据,createReactiveObject 函数仅仅是返回了 new Proxy 代理后的对象。 重点在 new Proxy中传入的 handler 参数 baseHandlers。
还记得前面提到的 Proxy 实现数据侦测的细节问题吧,我们尝试输入:
let data = { foo: "foo", ary: [1, 2] };
let r = reactive(data);
r.ary.push(3);打印结果:
可以看到打印输出了一次 trigger ...
问题一:如何做到深度的侦测数据的 ?
深度侦测数据是通过 createGetter 函数实现的,前面提到,当对多层级的对象操作时,set 并不能感知到,但是 get 会触发, 于此同时,利用 Reflect.get() 返回的“多层级对象中内层” ,再对“内层数据”做一次代理。
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 ?
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) 。
let data = ["a", "b"];
let r = reactive(data);
r.push("c");r.push('c') 会触发 set 执行两次,一次是值本身 'c' ,一次是 length 属性设置
- 设置值
'c'时,传入的新增索引key为2,target是原始的代理对象['a', 'c'],hasOwn(target, key)显然返回false,这是一个新增的操作,此时可以执行trigger ... is a add OperationType - 当传入
key为length时,hasOwn(target, key),length是自身属性,返回true,此时判断val !== oldValue,val是3, 而oldValue即为target['length']也是3,此时不执行trigger输出语句。
所以通过 判断key是否为target自身属性,以及设置 val 是否跟target[key]相等 可以确定trigger的类型,并且避免多余的trigger
总结
Vue3 并非简单的通过 Proxy 来递归侦测数据, 而是通过 get 操作来实现内部数据的代理,并且结合 WeakMap 来对数据保存,这将大大提高响应式数据的性能。
评论 (0)