理解Proxy和Rflect
(这个书真难 代码写的像扭麻花一样😅) Vue.js的响应式数据都是基于Proxy实现的,那么我们必须的了解Proxy以及与之相关联的Reflect。
js
// 给出一个对象obj,可以对它进行一些操作,例如读取属性值、设置属性值
obj.foo // 读取属性foo的值
obj.foo++ // 读取和设置属性的值
它可以使用Proxy拦截:
js
const p = new Proxy(obj, {
// 拦截读取属性操作
get(){/*..*/}
// 拦截设置属性操作
set(){/*..*/}
})
Proxy构造函数接收两个参数。第一个参数是被代理的对象,第二个参数也是一个对象,这个对象是一组trap。其中get是用来拦截读取操作,set函数是用来拦截设置操作。
在JS的世界里,万物皆是对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:
js
const fn = (name) => {
console.log("我是:", name)
}
// 调用函数是对对象的基本操作
fn()
因此,我们可以用Proxy来拦截函数的调用操作,这里我们使用apply拦截函数的调用:
js
const p2 = new Proxy(fn, {
apply(target, thisArg, argArray){
target.call(thisArg, ...argArray)
}
})
p2('zs')
上面两个例子说明了什么是基本操作。那什么是非基本操作 呢?其实调用对象下的方法就是典型的非基本操作,我们叫它复合操作:
调用一个对象下的方法,是有两个基本语义,第一个基本语义是get,即先通过get操作得到obj.fn属性。第二个基本语义是函数调用,即通过get得到obj.fn的值后再调用它。
再来讨论Reflect。Reflect是一个全局对象。它有很多方法。方法名和Proxy相同。
js
Reflect.get()
Reflect.set()
Reflect.apply()
//...
举一个🌰,例如Refect.get方法。
js
const obj = {foo: 1}
// 直接读取
console.log(obj.foo) // 1
// 使用Reflect.get读取
console.log(Reflect.get(obj, 'foo'))
到底有啥差别呢🤔其实是Reflect.get函数还能接受第三个参数,即指定接收者,你可以理解为this。举个🌰:
js
const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', {foo: 2})) // 输出的是 2,不是 1
回顾一下上一章中实现响应式数据的基本代码。在get和set拦截函数中,我们都是直接使用原始对象target来完成对属性的读取和设置操作。
当我们修改一下obj对象。
js
const obj = {
foo: 1,
get bar(){
return this.foo
}
}
bar属性是一个访问器属性,它返回了this.foo属性的值。我们在effect副作用函数中通过代理对象p返回bar属性:
js
effect(() => {
console.log(p.bar) // 1
})
分析一下这个过程发生了什么?
js
p.foo++
执行上面代码,会发生什么?为什么呢?该怎么解决呢? 为什么和书上说的不一样???p.foo++还是会触发effect重新执行。
换一个🌰
js
let user = {
_name: "张三",
get name() {
return this._name;
},
};
let userProxy = new Proxy(user, {
get(target, key, receiver) {
return target[key];
},
});
console.log(userProxy.name); // 张三
但是一个对象admin从user继承后,我们可以观察到❌的行为:
js
let user = {
_name: "张三",
get name() {
return this._name;
},
};
let userProxy = new Proxy(user, {
get(target, key, receiver) {
return target[key];
},
});
let admin = {
__proto__: userProxy,
_name: "李四",
};
console.log(admin.name); // undefined
问题出在第10行。
- 当我们读取admin.name,由于admin对象没有对应的属性,搜索将转到其原型。
- 原型是userProxy。
- 当调用target[key]时,target是user。
为了解决这种情况,我们需要用到第三个参数receiver。用它来传递admin。Reflect.get可以做到。
js
let user = {
_name: "张三",
get name() {
return this._name;
},
};
let userProxy = new Proxy(user, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver);
},
});
let admin = {
__proto__: userProxy,
_name: "李四",
};
console.log(admin.name); // 李四
JavaScript对象及Proxy的工作原理
给出一个对象obj,如何区分它是普通对象还是函数?
js
obj.foo
引擎内部会调用[[Get]]
这个内部方法来读取属性值。 一共有13种内部方法,参考第131页。
基本对象 :这13种内部对象必须使用ECMA规范中定义实现。否则都是异质对象 。 所以Proxy是一个异质对象。
js
const p = new Proxy(obj, {/*...*/})
p.foo
如果创建代理对象时没有指定对应的拦截函数,假如没有指定get()拦截函数,当我们访问属性值时,会调用原始对象的内部方法[[Get]]
来获取属性值。
当我们要代理删除属性操作时。
js
const obj = {
foo: 1,
};
const p = new Proxy(obj, {
deleteProperty(target, key) {
return Reflect.deleteProperty(target, key);
},
});
console.log(p.foo);
delete p.foo;
console.log(p.foo);
如何代理Object
前面使用get拦截器去拦截对属性的读取操作。但是在响应系统中,"读取"是一个很宽泛 的概念,比如使用in
操作符检查对象上是否有给定的key也属于读取操作。
js
effect(() => {
'foo' in obj
})
一个普通对象的所有可能的读取操作:
- 访问属性:
obj.foo
。 - 判断对象或原型上是否存在给定的
key: key in obj
。 - 使用
for...in
循环遍历对象:for(const key in obj){}
in
操作符,应该如何拦截呢?
in
操作符通过调用一个叫作HasProperty
的抽象方法。HasProperty
的返回值是通过调用对象的内部方法[[HasProperty]]
得到的。内部方法[[HasProperty]]
对应的拦截函数是has
。(顺藤摸瓜)
当我们在副作用函数中通过in
操作符操作响应式数据时,就能够建立依赖关系。
// 代码自己写
如何拦截for...in
循环?
for...in
不会触发get方法!
有一个抽象方法EnumerateObjectProperties
,该方法返回一个迭代器对象。
我们可以使用Reflect.ownKeys拦截函数来操作。 首先我们来看一个问题:
js
const obj = { foo: 1}
const p =new Proxy(obj, {/*...*/})
effect(() => {
//for...in循环
for(const key in p){
console.log(key) // foo
}
})
添加属性
当我们尝试为对象p添加新的属性bar时,并没有触发副作用函数执行,这是为什么呢?
js
p.bar = 2
看一下现在的set拦截函数的实现:
js
const proxy = new Proxy(obj, {
set(target, get, newVal, receiver){
// 设置属性
const res = Reflect.set(target, get, newVal, receiver)
// 把副作用函数从桶里取出并执行
trigger(target, key)
return res
}
})
当为对象p添加新的属性bar
时,会触发set
拦截函数执行。set调用trigger
函数时,也只是触发了与bar
相关联的副作用函数重新执行。当我们在ownKeys将key和副作用函数相关联时,并没有bar
。
那我们怎么解决?在for...in
中我们不知道具体操作的是哪一个key,那么就需要用到Symbol
来建立一个唯一标识。
- 先用
Symbol
来建立一个唯一标识ITERATE_KEY
。 - track(target,ITERATE_KEY)
- trigger(target,ITERATE_KEY)
修改属性
和添加属性不同,修改属性对for...in循环来说只会循环一次,不需要触发副作用函数重新执行,否则会造成不必要的性能开销。无论是添加属性还是修改已有的属性,都会通过[[Set]]
拦截。
所以想在set拦截函数中区分添加属性 还是修改属性。
删除属性
delete
操作符依赖[[Delete]]
内部方法,该内部方法可以使用deleteProperty
拦截。删除属性,属性变化影响了for...in
循环,所以也需要[[Set]]
拦截。
合理地触发响应
设置了值,但值没变
首先当值没有发生变化时,应该不需要触发响应吧...
js
const obj = { foo: 1 }
const p = new Proxy(obj, {/*...*/})
effect(() => {
console.log(p.foo)
})
p.foo = 1 // 设置p.foo的值,但值没有变化
所以我们需要在set
拦截函数中,在调用trigger
函数触发响应之前,需要检查值是否真的发生了变化。
还得对NaN处理。
js
// 如果用===比较时
NaN === NaN // false
NaN !== NaN // true
如果p.foo的初始值是NaN
,后续又设置了NaN
作为新值,那么全等比较的缺陷就暴露了。
为什么NaN === NaN
会是false啊?
js
const obj = {
foo: NaN,
};
const p = new Proxy(obj, {/**.../})
effect(() => {
console.log("key--->", p.foo);
});
p.foo = NaN; // 仍然会触发副作用函数
所以我们得增加一个条件:在新值和旧值不全等的情况下,要保证他们都不是NaN
。思考🤔,对象类型呢?
接下来我们要讨论从原型链继承属性的情况。首先封装一个reactive
函数。
创建一个🌰
js
const obj = {};
const proto = { bar: 1 };
const child = reactive(obj);
const parent = reactive(proto);
// 使用parent作为child的原型
Object.setPrototypeOf(child, parent);
effect(() => {
console.log(child.bar); // 1
});
// 修改child.bar的值
child.bar = 2; // 会导致副作用函数重新执行两次
为什么副作用函数会执行两次?
分析问题:
当读取child.bar属性值时,由于child代理的对象obj本身是没有bar属性的,因此会获取对象obj的原型,也就是parent对象,所以最终得到的是parent.bar
的值。
但是parent本身也是响应式数据,因此在副作用函数中访问parent.bar的值时,会被track。所以是child.bar和parent.bar都与副作用函数建立了响应关系。
接下来分析设置操作的具体流程。因为child没有bar属性,那么会取原型的,也就是调用parent的[[set]]
方法。虽然我们操作的是child.bar,但这也会导致parent代理的set拦截函数被执行
。所以修改了child.bar的值会导致副作用函数重新执行两次。
解决方法: 我们能够在set拦截函数中区分这两次更新就可以了。无论什么情况下,receiver都是child,而target则是变化的。因此我们可以通过判断receiver是否是target的代理对象。
js
// child的set拦截函数
set(target, key, value, receiver){
// target 是child原始对象obj
// receiver是代理对象child
}
get方法中,增加代理对象能通过raw属性访问原始数据的功能。
js
child.raw === obj // true
parent.raw === proto // true
代理对象通过raw属性读取原始数据。
只有当receiver是target的代理对象时才触发更新。
浅响应与深响应
目前我们实现的reactive是浅响应的。
js
const obj = reactive({ foo: { bar: 1 } });
effect(() => {
console.log(obj.foo.bar);
});
obj.foo.bar = 2; // 并不能触发响应
js
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
// 代理对象通过raw属性访问原始数据
if (key === "raw") {
return target;
}
track(target, key);
return Reflect.get(target, key);
},
}
}
我们先来看一下现在的代码。能看出什么问题吗?
解决方法略。
并非所有情况下我们都希望深响应,这就出现了浅响应。浅响应,指的是只有对象的第一层属性是响应的,来一个🌰
js
const obj = reactive({ foo: { bar: 1 } });
effect(() => {
console.log(obj.foo.bar);
});
// obj.foo是响应的,可以触发副作用函数重新执行
obj.foo = { bar: 2 };
// obj.foo.bar不响应的,不能触发副作用函数重新执行
obj.foo.bar = 2;
重写了一遍,删掉多余的代码,可以实现了。
浅readonly和深readonly
浅readonly
我们希望一些数据是只读的,当用户尝试修改只读数据时,会收到一条警告信息。这样就实现了对数据的保护,例如组件接收到的props对象应该是一个只读数据。readonly
函数,它能将一个数据变成只读的。
js
const obj = readonly({foo: 1})
// 尝试修改数据,会得到警告
obj.foo = 2
只读本质上也是对数据对象的代理,我们可以用createReactive来实现。
- set方法中判断readonly。
- deleteProperty方法中判断readonly。
- get方法中也不用track。
深readonly
上面的代码中只做到了浅readonly。
js
const obj = readonly({ foo: { bar: 1 } });
// 尝试修改数据,依然会得到警告,和书中例子结果不同(我不太理解)???(重写了一遍确实没有警告)
obj.foo.bar = 2;
为了实现深只读,我们还应该在get拦截函数中递归地调用readonly将数据包装成只读的代理对象,并将其作为返回值返回。
代理数组
数组和普通对象有什么不同? 数组是一个异质对象,因为数组对象的[[DefineOwnProperty]]
内部方法与常规对象不同。数组对象除了[[DefineOwnProperty]]
这个内部方法之外,其他内部方法的逻辑都与常规对象相同。当实现数组的代理时,用于代理普通对象的大部分代码可以继续使用。
js
const arr = reactive(["foo"]);
effect(() => {
console.log(arr[0]);
});
arr[0] = "bar"; // 能够触发响应,打印foo
但是对数组的操作和对普通对象的操作仍然存在不同,下面来看一下数组的读取操作。
数组元素或属性的读取操作 | 代码 |
---|---|
通过索引访问数组元素值 | arr[0] |
访问数组的长度 | arr.legnth |
把数组作为对象 | 使用for...in 循环遍历 |
遍历数组 | 使用for...of 遍历数组 |
数组的原型方法 | concat/join/everty/some/find/findIndex/includes 等 |
数组元素或属性的设置操作 | |
---|---|
通过索引修改数组元素值 | arr[1] = 3 |
修改数组的长度 | arr.length = 0 |
数组的栈方法 | push/pop/shift/unshift |
修改原数组的原型方法 | splice/fill/sort 等 |
数组的索引与length
js
const arr = reactive(["foo"]);
effect(() => {
console.log(arr.length); // 只打印出1
});
arr[1] = "bar"; // 设置索引1的值,会导致数组的长度变为2,未触发副作用函数重新执行
解决方法:
- set拦截函数中加判断是否是数组的逻辑。
- trigger方法中加判断是否是数组的逻辑。
// 代码略
如果修改数组的length属性呢?
js
const arr = reactive(["foo"]);
effect(() => {
console.log(arr[0]);
});
arr.length = 0;
上面的🌰中,在副作用函数中访问了数组的第0个元素。接着把数组的length改成0,所有的元素都会被删除;当我们将length设置成100,这并不会影响到数组的第0个元素,所以也就不需要触发副作用函数重新执行。总结一下:当修改length时,只有索引值大于或等于新的length时才需要触发响应。
解决方法:
- 修改set拦截函数。在调用trigger时,应该把新的属性值传递过去。
- 在trigger方法中增加条件:当修改length时,只有索引值大于或等于新的length时才需要触发响应。
遍历数组
foo...in
既然数组也是对象,同样也可以使用for...in
循环遍历:
js
const arr = reactive(["foo"]);
effect(() => {
for (const key in arr) {
console.log(key);
}
});
使用for...in
遍历数组和普通对象没有差异,因此也可以使用之前的ownKeys
拦截函数。
回忆一下之前追踪普通对象的for...in
操作,只有添加或删除属性值时才会影响for...in
循环的结果。对于数组,哪些操作会影响for...in
循环的结果呢?
- 添加新元素:
arr[100] = 'bar'
。 - 修改数组长度:
arr.length = 0
其实无论是添加新元素,还是直接修改数组的长度,本质都是因为修改了数组的length
属性。
解决方法:
在ownKeys
拦截函数中,判断target
是否是数组,如果是,则使用length
作为key去建立响应联系。
// 代码略
for...of
与for...in
不同,for...of
是用来遍历可迭代对象(iterable object)的。
如果一个对象实现了Symbol.iterator
方法,那么这个对象就是可以迭代的。如下面的🌰
js
const obj = {
val: 0,
[Symbol.iterator]() {
return {
next() {
return {
value: obj.val++,
done: obj.val > 10 ? true : false,
};
},
};
},
};
该对象实现了Symbol.iterator
的方法,因此可以使用for...of
循环遍历它。
js
for (const value of obj) {
console.log(value); // 0,1,2,3,4,5,6,7,8,9
}
js
const arr = [1, 2, 3, 4, 5];
const itr = arr[Symbol.iterator]();
console.log(itr.next()); // {value: 1, done: false}
console.log(itr.next()); // {value: 2, done: false}
console.log(itr.next()); // {value: 3, done: false}
console.log(itr.next()); // {value: 4, done: false}
console.log(itr.next()); // {value: 5, done: false}
console.log(itr.next()); // {value: undefined, done: true}
数组迭代器的执行会读取数组的length。迭代器的模拟实验如下:
js
const arr = [1, 2, 3, 4, 5];
arr[Symbol.iterator] = function () {
const target = this;
const len = target.this;
let index = 0;
return {
next() {
return {
value: index < len ? target[index] : undefined,
done: index++ < len ? false : true,
};
},
};
};
我们不需要增加任何代码就能正常运行。
js
const arr = reactive([1, 2, 3, 4, 5]);
effect(() => {
for (const val of arr) {
console.log(val);
}
});
arr[1] = "bar"; // 能够触发响应
arr[5] = "new"; // 能够触发响应
arr.length = 0; // 能够触发响应
无论是使用for...of循环,还是调用values方法,都能正常工作。
js
const arr = reactive([1, 2, 3, 4, 5]);
effect(() => {
for (const val of arr.values()) {
console.log(val);
}
});
arr[1] = "bar"; // 能够触发响应
arr[5] = "new"; // 能够触发响应
arr.length = 0; // 能够触发响应
因为它们都会读取数组的Symbol.iterator
属性。该属性是一个Symbol
值,为了避免发生意外的错误,以及性能上的考虑,不应该在副作用函数和Symbol.iterator
这类Symbol
值之间建立响应联系,因此需要修改get拦截函数。
解决方法:
在get拦截函数中,调用track方法之前,需要增加一个判断条件。 // 如果key的类型是symbol,则不track。
数组的查找方法
数组的内部方法其实都依赖了对象的基本语义。所以大部分情况下,我们不需要做特殊处理。
js
const arr = reactive([1, 2, 3, 4, 5]);
effect(() => {
console.log(arr.includes(1)); // 初始打印true
});
arr[0] = 3; // 副作用函数重新执行,并打印false
但是includes方法并不总是按照预期工作。
js
const obj = {};
const arr = reactive([obj]);
console.log(arr.includes(arr[0])); // 应该返回true却返回false
这是为什么呢?
js
// get拦截函数
if(typeof res === 'object' && res !== null){
// 如果值可以被代理,则返回代理对象
return isReadonly ? readonly(res) : reactive(res)
}
arr.include(arr[0])
。其中,arr[0]
得到的是一个代理对象,而在includes
方法内部也通过arr访问数组元素,从而也得到一个代理对象,问题是这两个代理对象是不同的。这是因为每次调用reactive函数时都会创建一个新的代理对象:
js
function reactive(obj){
// 每次调用reactive时,都会创建新的代理对象
return createReactive(obj)
}
解决方法 :
定义一个reactiveMap
,用来存储原始对象到代理对象的映射。每次调用reactive函数创建代理对象之前,优先检查是否已经存在相应的代理对象,如果存在,则直接返回已有的代理对象。
js
const obj = {};
const arr = reactive([obj]);
console.log(arr.includes(obj)); // false
上面🌰中,为什么会打印false呢?includes内部的this指向的是代理对象arr,并且在获取数组元素时得到的也是代理对象,所以用原始对象obj去查找肯定会返回false。
我们需要重写数组的includes方法才能解决这个问题。
js
const obj = {};
const arr = reactive([obj]);
console.log(arr.includes(obj)); // false
解决方法:
- 首先我们来看如何重写includes方法:修改get拦截函数:
arr.includes
可以理解为读取代理对象arr的includes属性,这就会触发get拦截函数。在该函数内检查target是否是数组,如果是数组并且读取的键值存在于arrayInstrumentations
上,则返回定义在arrayInstrumentations
对象上响应的值。当执行arr.includes时,就是在执行arrayInstrumentations
上的includes函数,这样就实现了重写。 - 自定义includes函数。其中的this指的是代理对象,我们先在代理对象中查找,这就是
arr.includes(obj)
的默认行为。如果找不到,通过this.raw拿到原始数组,再去其中查找,最后返回结果。
除了includes方法之外,还需要类似处理的数组方法有indexOf
和lastIndexOf
。
隐式修改数组长度的原型方法
主要是指的是像push/pop/shift/unshift
数组的栈方法。
js
const arr = reactive([]);
effect(() => {
arr.push(1);
});
effect(() => {
arr.push(1);
});
如果在浏览器中运行上面这个🌰,会得到栈溢出的错误。
为什么会这样呢?
- 第一个副作用函数执行。调用arr.push方法。push会去读数组的length属性。所以会和length属性建立响应联系。
- 第二个副作用函数执行。它也会与length属性建立响应联系。arr.push方法不仅会间接读取length,还会间接设置length。
- 第二个函数的arr.push设置了length。于是响应系统把与length相关的副作用函数全部取出并执行,其中包括第一个副作用函数。
第二个副作用函数还未执行完毕,就要再次执行第一个副作用函数了。
- 第一个副作用函数再次执行。于是响应系统把与length相关的副作用函数全部取出并执行,其中包括第二个副作用函数。
如此循环往复,最终导致调用栈溢出。
这个问题的原因是push方法会间接读取length属性。
解决思路:
我们只要屏蔽对length属性的读取,从而避免在它与副作用函数之间建立响应联系。我们需要重写数组的push方法。 也就是执行push时,不track副作用函数。
ps: 除了push方法之外,pop、shift、unshift、splice等方法都需要做类似的处理。
代理Set和Map
集合类型数据与普通对象存在很大的不同。
Set的原型属性和方法 | 用法 |
---|---|
size | 返回集合中元素的数量 |
add(value) | 向集合中添加给定的值 |
clear() | 清空集合 |
delete(value) | 从集合中删除给定的值 |
has(value) | 判断集合中是否存在给定的值 |
keys() | 返回一个迭代器对象。可用于for...of循环,迭代器对象产生的值为集合中的元素 |
values() | 和keys()相同 |
entries() | 返回一个迭代器对象。迭代过程中为集合中的每一个元素产生一个数组值[value, value] |
forEach(callback[, thisArg]) | forEach函数会遍历集合中的所有元素,并对每一个元素调用callback函数,thisArg用于指定callback函数执行时的this值。 |
Map的原型属性和方法 | 用法 |
---|---|
size | 返回Map数据中的键值对数量 |
clear() | 清空Map |
delete(key) | 删除指定key的键值对 |
has(key) | 判断Map中是否存在指定key的键值对 |
get(key) | 读取指定key对应的值 |
set(key) | 为Map设置新的键值对 |
keys() | 返回一个迭代器对象。迭代过程会产生键值对的key值 |
values() | 返回一个迭代器对象。迭代过程会产生键值对的value值 |
entries() | 返回一个迭代器对象。迭代过程会产生[key, value]组成的数组值 |
forEach(callback[, thisArg]) | forEach函数会遍历Map中的所有键值对,并对每一个键值对调用callback函数,thisArg用于指定callback函数执行时的this值。 |
如何代理Set和Map
普通对象和get/set的读取和设置操作差异:
js
// 普通对象的读取和设置操作
const obj = { foo: 1 };
obj.foo; // 读取数据
obj.foo = 2; // 设置数据
// 用get/set方法操作Map数据
const map = new Map();
map.set("key", 1); // 设置数据
map.get("key"); // 读取数据
我们先来了解关于使用Proxy代理Set或Map类型数据的注意事项。
js
const s = new Set([1, 2, 3]);
const p = new Proxy(s, {});
console.log(p.size);
执行上面的代码会报错。
报错原因: 代理对象p来访问size属性,回去检查是否存在内部槽[[]SetData]]
。很显然,代理对象不存在内部槽[[SetData]]
。
为了修复这个问题,我们需要修正访问器属性的getter函数执行时的this指向。
js
const s = new Set([1, 2, 3]);
const p = new Proxy(s, {
get(target, key, receiver) {
if (key === "size") {
// this指向原始对象
return Reflect.get(target, key, target);
}
return Reflect.get(target, key, receiver);
},
});
console.log(s.size); // 3
接着我们尝试从p中删除数据。也会报错。size是一个访问器属性,而delete是一个方法。无论怎么修改receiver,delete方法执行时this都会指向代理对象p,而不是指向原始Set对象。
解决方法:
只需要把delete方法与原始数据对象绑定。
js
const p = new Proxy(s, {
get(target, key, receiver) {
if (key === "size") {
return Reflect.get(target, key, target);
}
// 将方法与原始数据对象target绑定后返回
},
}
);
建立响应联系
js
const p = reactive(new Set([1, 2, 3]));
effect(() => {
console.log(p.size);
});
// 添加值为1的元素,应该触发响应
p.add(1);
如果想实现上面的🌰。我们需要在访问size时调用track
函数。然后在add方法执行时调用trigger
函数。
解决方法: 1.get拦截函数中,key === size时去track; 2.重写add方法,在add方法中trigger;
优化代码 如果调用add时添加的元素重复了,就不需要触发响应了。
相同的思路可以实现delete
。
避免污染原始数据
借助Map类型数据的set和get来学习什么是"避免污染原始数据"及其原因。
js
const p = reactive(new Map([["key", 1]]));
effect(() => {
console.log(p.get("key"));
});
p.set("key", 2); // 触发响应
自己来重写set和get方法。
上面的🌰能正常工作,但它仍然存在问题,即set方法会污染原始对象。
js
const m = new Map();
const p1 = reactive(m);
const p2 = reactive(new Map());
p1.set("p2", p2);
effect(() => {
// 注意这里通过原始数据m访问p2
console.log(m.get("p2").size);
});
// 注意这里通过原始数据m为p2设置一个键值对foo-->1
m.get("p2").set("foo", 1);
打印出两个0。当我们通过原始数据m设置数据值时,副作用函数又重新执行了。这不是我们所期望的行为,因为原始数据不应该具有响应式数据的能力。否则就意味着用户既能操作原始数据,又能操作响应式数据,这样一来代码就会乱套了。
导致问题的原因是什么呢? 之前实现set方法时,将value原样设置到了原始数据target上。如果value是响应式数据,设置到原始对象上的也会是响应式数据。这就造成了数据污染。
解决方法: 在调用target.set函数之前,需要判断value是否是响应式数据。如果是响应式数据,那么就通过raw获取原始数据,再调用target.set函数。
处理forEach
集合类型的forEach方法类似于数组的forEach。
js
const m = new Map([[{ key: 1 }, { value: 1 }]]);
const p = reactive(m);
effect(() => {
p.forEach(function (value, key, m) {
console.log(value);
console.log(key);
});
});
任何会修改Map对象键值对数量的操作都应该触发副作用函数重新执行,例如delete和add方法等。
解决方法:
- 重写forEach方法。
- forEach方法中,让副作用函数与
ITERATE_KEY
建立响应联系。
js
const m = new Map([[{ key: 1 }, { value: 1 }]]);
const p = reactive(m);
effect(() => {
p.forEach(function (value, key, m) {
console.log(value);
console.log(key);
});
});
p.set({ key: 2 }, { value: 2 });
上面的🌰可以正常工作了。但是重写的forEach方法仍然存在缺陷。重写的forEach方法内,通过原始数据对象调用了原生的forEach方法,即:
js
target.forEach(cb)
传递给cb回调函数的参数是非响应式数据。下面这个🌰就不能正常工作了。
js
const key = { key: 1 };
const value = new Set([1, 2, 3]);
const p = reactive(new Map([[key, value]]));
effect(() => {
p.forEach(function (value, key) {
console.log(value.size);
});
});
p.get(key).delete(1);
导致问题的原因就是:当通过value.size
访问size
属性时,这里的value是原始数据对象,即new Set([1,2,3])
,因此无法建立响应联系。reactive
本身是深响应,forEach
方法的回调接受的参数应该是响应式数据才行。
解决方法:
- 重写forEach函数: 将传递给callback函数的参数包装成响应式的。
- 检查重写get函数是否正常返回。
出于严谨性,我们还需要做一些补充。因为forEach还可以接受第二个参数,该参数可以用来执行callback函数执行时的this值。
无论使用for...in
还是forEach
循环遍历一个对象,它们的响应联系都是建立在ITERATE_KEY
与副作用函数之间的。但是它们存在本质的不同 ,当使用for...in
循环时,它只关心对象的键,而不关心对象的值。
js
effect(() => {
for(const key in obj){
console.log(key)
}
})
只有当新增、删除对象的key时,才需要重新执行副作用函数。我们在trigger函数内判断type是否是ADD
或DELETE
,进而知道是否需要触发依赖。对于SET
类型来说,它不会改变对象的键的数量,所以不需要触发依赖。
但这个规则不适用于Map类型的forEach。
js
const p = reactive(new Map([["key", 1]]));
effect(() => {
p.forEach(function (value, key) {
// forEach不仅关心Map的键,还关心集合的值
console.log(value);
});
});
p.set("key", 2); // 即使type是SET,也应该触发响应
解决方法:
trigger方法中增加一个判断条件 :如果操作的目标对象是Map
类型,则type
是SET
时应该触发那些与ITERATE_KEY
相关联的副作用函数重新执行。
迭代器方法
前面讲过for...of
循环遍历数组:
- entries
- keys
- values
js
const itr = m[Symbol.iterator]()
console.log(itr.next()) // {value: ["key1", "value1"], done: false}
console.log(itr.next()) // {value: ["key2", "value2"], done: false}
for...of
循环迭代m.entries
和m
会到相同的结果。
下面这个🌰
js
const m = new Map([
["key1", "value1"],
["key2", "value2"],
]);
for (const [key, value] of m.entries()) {
// TypeError: p is not iterable
console.log(key, value);
}
p.set("key3", "value3");
当我们使用for...of
循环迭代一个代理对象时,内部会尝试从代理对象p上读取p[Symbol.iterator]
属性,这个操作会触发get拦截函数。
之前重写forEach方法时,传递给callback的参数是包装后的响应式数据。
使用for...of
也应该是包装后的响应式数据。
让副作用函数与ITERATE_KEY
建立联系的作用 : 迭代器操作与集合元素的数量有关,只要集合的size
发生变化,就应该触发依赖。
解决方法:
- 我们需要重写
Symbol.iterator
方法。 - 传递给callback的参数是包装后的响应式数据。
- 为了追踪
for...of
对数据的迭代操作,还需要调用track
函数,让副作用函数与ITERATE_KEY
建立联系。
由于p.entries
与p[Symbol.iterator]
等价,我们也可以实现对p.entries
函数的拦截(理论上)。
js
effect(() => {
for (const [key, value] of p.entries()) {
console.log(key, value);
}
});
当我们运行上面的🌰时,发现报错了:p.entries
的返回值不是一个可迭代的对象。p.entries
函数的返回值是一个对象,该对象带有next
方法,但不具有Symbol.iterator
方法,因此它确实不是一个可迭代对象。
有两个概念不要混淆了:
可迭代协议
:一个对象实现了Symbol.iterator
方法。迭代器协议
:一个对象实现了next
方法。
但一个对象可以同时实现可迭代协议
和迭代器协议
:
js
const obj = {
// 迭代器协议
next(){}
// 可迭代协议
[Symbol.iterator](){
return this
}
}
解决方案: return:迭代器协议
和可迭代协议
。
values和keys方法
values
方法的实现与entries
方法类似,不同的是:
js
effect(() => {
for (const [key, value] of m.entries()) {
console.log("entries--->", key, value);
}
for (const value of m.values()) {
console.log("values--->", value);
}
});
解决方法: valuesIterationMethod和iterationMethod有两点区别:
iterationMethod
通过target[Symbol.iterator]
获取迭代器对象,而valuesIterationMethod
通过target.values
获取迭代器对象。iterationMethod
处理的是键值对,即[wrap(value[0]), wrap(value[1])]
,而valuesIterationMethod
只处理值,即wrap(value)
。
keys方法和values方法非常相似。不同点在于,keys处理的是键而不是值。改完按理论上可行。但是下面这个🌰
js
const m = new Map([
["key1", "value1"],
["key2", "value2"],
]);
const p = reactive(m);
effect(() => {
for (const value of p.keys()) {
console.log("values--->", value); // key1 key2
}
});
p.set("key2", "value3"); // 这是一个SET类型的操作,它修改了key2的值
p.set("key2", "value3")修改了key2的值。Map类型数据的所有键都没有发生变化,所以副作用函数不应该执行。
这是因为上面我们在trigger方法中加了一个SET的判断条件。
这对于values或entries等方法来说是必需的,但对于keys方法没有必要。
解决方法:
- 我们新建一个
MAP_KEY_ITERATE_KEY
替代ITERATE_KEY
。 - values和entries等方法依然依赖
MAP_KEY_ITERATE_KEY
,keys方法则依赖ITERATE_KEY
。 - 当SET类型操作时,只会触发
ITERATE_KEY
相关联的依赖。 - 当ADD和DELETE类型操作时,需要触发
MAP_KEY_ITERATE_KEY
和ITERATE_KEY
相关联的依赖。