这篇文章是我在阅读《Vue.js设计与实现》这本书的过程中写的,一边写一边敲代码测试,同时去翻阅Vue3里面的源码作对照。每一小节都会解决对应的问题,并且把对应的源码也贴在一起。
推荐跟着示例整体敲一遍。看完这篇文章,你将学会Vue3中如下问题,:
- Proxy和Reflect是什么,有哪些API?
- JS的常规对象和异质对象有什么不同?JS内部方法是什么,有哪些?
- Proxy如何代理对象的obj.key属性访问,in操作符,以及for in遍历这三种情况?
- 如何拦截对象的删除操作?
- 当修改对象的值前后不变,或者是NaN时,Vue3是如何处理的?
- 当访问对象的属性时,如何避免触发原型对应的副作用函数?
- 对象的浅响应和深响应是什么?原理是什么?
- 对象的只读是什么?浅只读和深只读是什么?如何实现?
- 如何拦截通过索引或者length属性 修改/访问数组?
- 如何拦截数组的for in遍历?如何拦截数组for ...... of遍历?
- 如何拦截数组的查找方法,includes indexOf lastIndexOf?
- 如何拦截数组的push/pop/shift/unshift方法?
5.1 理解Proxy和Reflect
本节目标:理解Proxy和Reflect
Proxy
是什么?Proxy能够创建一个代理对象
,实现对其他对象的代理。他只能代理对象
,不能代理数字、字符串、布尔值。
代理
是什么呢?指的是对一个对象的基本语义
的代理。他允许我们拦截
并且重新定义
对象的基本操作。
基本语义是什么?如下针对对象的读取和设置操作,就属于基本语义的操作,Proxy可以拦截
js
console.log(obj.foo) // 读取
console.log(obj.foo++) // 读取并设置值
如下就是通过Proxy,对一个对象的读取和设置操作
js
const p = new Proxy(obj, {
// 拦截读取操作
get () {},
// 拦截设置操作
set () {}
})
我们还可以对一个函数的调用进行拦截的操作,如下代码:
js
const fn = (name) => {
console.log('我是一个函数', name);
}
// fn()
const p2 = new Proxy(fn, {
apply (target, thisArg, argArray) {
console.log(thisArg, 'thisArg');
console.log(argArray, 'argArray');
target.call(thisArg, ...argArray)
}
})
p2('xhg') // 打印:我是一个函数 xhg
函数也是对象,函数的调用属于对象的基本操作。
Proxy能够拦截对象的基本操作,但是不能拦截他的复合操作。复合操作是什么?
js
obj.fn()
调用对象里面的方法,就是复合操作。包括两步:第一步:通过obj.fn(),要通过get拿到obj.fn;第二步,调用他,触发apply
那么Reflect又是什么?
- 他是一个全局对象,有许多方法,Reflect.get() Reflect.set(),并且他下面的方法和Proxy拦截器的方法名字相同。
Reflect.get()的功能,就是访问一个对象属性的属性,如下:
js
const obj2 = {
foo: 1
}
console.log(obj2.foo, 'obj2.foo'); //
console.log(Reflect.get(obj2, 'foo')); // 1
区别在于Reflect.get()接受第三个参数,就是this指向,如下代码中,Reflect.get()传入了第三个参数,就是receiver,可以理解为就是函数调用过程中的this指向,bar访问器函数执行时,打印的this就是我们传入的{foo: 2},打印最终是2
js
const obj3 = {
foo: 1,
get bar () {
return this.foo
}
}
console.log(Reflect.get(obj3, 'bar', { foo: 2 })); // 打印的是2
Reflect.get()在什么场景下有用?如下:
js
let obj = {
foo: 1,
get bar () {
return this.foo
}
}
const proxy1 = new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return target[key]
// return Reflect.get(target, key, receiver)
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
effect(() => {
console.log(proxy1.bar, 'proxy1.bar');
})
如上代码中,obj对象里面有一个foo属性和一个bar访问器属性,接着我们使用Proxy对obj对象进行代理。我们在副作用函数中访问proxy1.bar属性,能够打印到this.foo的值是1
但是我们还希望此时foo属性能够和副作用函数建立联系,因为借助this关键字,副作用函数间接访问到foo属性。如下图,打印proxy1.foo++,并不会触发副作用函数重新执行
为什么呢?打印this是谁,发现this不是Proxy对象,而是obj对象,
diff
let obj = {
foo: 1,
get bar () {
+ console.log(this, 'this');
return this.foo
}
}
如果是proxy对象应该是这样的
为什么this指向的是obj而不是Proxy对象呢?因为在track函数中返回的是target[key]。这个target就是obj对象
diff
const proxy1 = new Proxy(obj, {
get(target, key) {
track(target, key)
+ return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
该如何解决这个问题?就要用到我们的Reflect.get(),如下,get访问器还接受第三个参数receiver就是当前的proxy对象,然后把他传递给Reflect.get()的第三个参数,这样在访问属性时,this就指向当前的Proxy对象了
diff
const proxy1 = new Proxy(obj, {
+ get(target, key, receiver) {
track(target, key)
// return target[key]
+ console.log(receiver, 'receiver');
+ return Reflect.get(target, key, receiver)
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
通过打印receiver,可以看到就是当前的proxy1对象
在控制台调试proxy1.foo++,效果如下,成功实现
5.2 JavaScript对象及Proxy的工作原理
在JavaScript中,存在两种对象。一种是常规对象
,还有一种是异质对象
。这两种对象包含了JS中所有的对象,任何不属于常规对象的对象都是异质对象。
在JS中,对象的实际语义是由对象的内部方法指定的。内部方法是对象在进行操作时在引擎内部调用的方法。这对于我们开发者来说不可见。
js
obj.foo
如上,引擎内部使用的是[[GET]]这个内部方法来读取属性值。[[xxx]]是代表内部方法或者内部槽的格式
内部方法 | 签名 | 描述 |
---|---|---|
[[GetPrototypeOf]] | () -> object 或者 Null | 查明为该对象提供继承属性的对象 |
[[SetPrototypeOf]] | (Object 或者 Null) -> any | 将该对象与提供继承属性的另一个对象相关联 |
[[IsExtensible]] | () -> Boolean | 查明是否允许向该对象添加其他属性 |
[[PreventExtensions]] | () -> Boolean | 控制能否向该对象添加其他属性 |
[[GetOwnProperty]] | (PropertyKey) -> undefined 或者PropertyDescriptor | 返回该对象自身属性的描述符 |
[[DefineOwnProperty]] | (PropertyKey, PropertyDescriptor) -> Boolean 或者 | 创建或者更改自己的属性 |
[[HasProperty]] | (PropertyKey) -> Boolean | 该对象是否已经拥有某个属性 |
[[GET]] | (Property, Reveiver) -> any | 从该对象返回键为PropertyKey的属性的值 |
[[SET]] | (propertyKey, value, receiver) -> Boolean | 其键值为PropertyKey的属性值设置为value |
[[Delete]] | (PropertyKey) -> Boolean | 从对象中删除某个键 |
[[OwnPropertyKeys]] | () -> List of PropertyKey | 返回一个都是对象自身属性的list |
如上有11个方法,对象必须部署这11个方法。此外还有两个额外的必要内部方法[[Call]] 和 [[Construct]]:
内部方法 | 签名 | 描述 |
---|---|---|
[[Call]] | (any, a list of any) -> any | 将运行的代码与this关联 |
[[Construct]] | (a list of any) -> Object | 创建一个对象,通过new操作符或者super调用触发 |
如何区分一个普通对象和一个函数对象呢?函数对象内部必须部署了[[Call]]方法
此外,内部方法具有多态性。不同类型的对象部署了相同的内部方法,但是逻辑不同。比如普通对象和Proxy对象都有[[GET]]方法,但是普通对象的[[Get]]由ECMA规范的10.1.8节定义。Proxy对象的[[Get]]方法由[[10.5.8]]节定义。
如何区分常规对象和异质对象?
- 如上11个内部方法,必须由ECMA规范10.1.x节定义实现
- 内部方法[[Call]],必须由ECMA规范10.2.1节定义实现
- 内部方法[[Construct]],必须由ECMA规范10.2.2节定义实现
所有不符合上面三点,都是异质对象。Proxy的[[Get]]方法是由10.5.8节定义,所以Proxy对象是异质对象。
注意,如果
js
let obj = {
foo: 1,
get bar () {
return this.foo
}
}
const proxy1 = new Proxy(obj, {
})
console.log(proxy1.foo, 'proxy1.foo')
访问proxy1.foo和访问obj.foo,区别在于内部[[GET]]的实现不同,如果proxy对象内部没有使用get拦截器,那么代理对象内部的[[GET]]会转而去调用普通对象的[[GET]]来获取属性值。这是代理透明性质
Proxy内部的方法如下:
内部方法 | 处理器函数 |
---|---|
[[GetPrototypeOf]] | getPrototypeOf |
[[SetPrototypeOf]] | setPrototypeOf |
[[IsExtensible]] | IsExtensible |
[[PreventExtensions]] | PreventExtensions |
[[GetOwnProperty]] | GetOwnProperty |
[[DefineOwnProperty]] | DefineOwnProperty |
[[HasProperty]] | has |
[[GET]] | get |
[[SET]] | set |
[[Delete]] | deleteProperty |
[[OwnPropertyKeys]] | ownKeys |
[[Call]] | apply |
[[Construct]] | construct |
以上方法,[[Call]]和[[Construct]]只有在Proxy代理函数时才会部署
删除Proxy对象的属性写法:
js
let obj = {
foo: 1,
get bar () {
return this.foo
}
}
const proxy1 = new Proxy(obj, {
// 部署删除方法
deleteProperty(target, key) {
// 调用Reflect方法来进行删除
return Reflect.deleteProperty(target, key)
}
})
delete proxy1.foo
console.log(proxy1.foo, 'delete');
5.3 如何代理Object
对象的读取涉及如下操作:
- 访问属性: obj.foo
- 判断某个对象上是否有指定的key: key in obj
- 使用for ...... in循环遍历对象: for (key in obj) {}
读取属性的代理我们之前已经实现过了:
js
const obj = new Proxy(data, {
get(target, key, receiver) {
track(target, key)
// return target[key]
return Reflect.get(target, key, receiver)
},
})
5.3.1 拦截key in obj
对于in操作符的拦截,在ECMA-262规范的13.10.1节中,有这样一段描述:
js
......
6. Return ? HasProperty(rval, ? ToPropertyKey(lval))
里面用到HasProperty方法,这个方法能在ECMA-262规范的7.3.11节中找到,有这样一段描述:
js
3. 返回? O.[[HasProperty]](P)
里面使用了[[HasProperty]]内部方法,其实就对应了has方法。代码具体拦截操作如下:
js
let obj = {
foo: 1,
get bar () {
return this.foo
}
}
const proxy1 = new Proxy(obj, {
has (target, key) {
track(target, key)
return Reflect.has(target, key)
}
})
effect(() => {
console.log('foo' in proxy1, 'effect函数执行 in操作符');
console.log('bar' in proxy1, 'effect函数执行 in操作符');
})
如上代码中,我们在new Proxy中,对has方法进行了拦截,同时调用了track方法跟踪了这个属性,同时返回了Reflect.has(target, key)的值, 紧接着在副作用函数中,判断属性是否在proxy1对象中,最终实现拦截 in操作符
5.3.2 拦截 for (key in obj) {}
这里涉及三个问题:
- 如何拦截for in的遍历操作?
- 什么操作后要触发for in 对应的副作用函数?
- 如何区分新增属性和设置属性?
如何去拦截for (key in obj) {}操作呢?在ECMA-262规范的14.7.5.6中有一段描述
js
让iterator 的值为 EnumerateObjectProperties(obj)
其中的EnumerateObjectProperties(obj)是一个抽象方法,规范中这样实现它:
js
function * EnumerateObjectProperties (obj) {
const visited = new Set()
for (const key of Reflect.ownKeys()) {
......
}
......
}
其中Reflect.ownKeys是关键,用来获取只属于对象自身拥有的键,具体代码实现如下:
js
let ITERATE_KEY = Symbol()
const proxy1 = new Proxy(obj, {
ownKeys (target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target, ITERATE_KEY)
}
})
effect(() => {
for (const key in proxy1) {
console.log(key, 'key');
}
})
如上代码,使用借助Proxy的ownKeys和Reflect.ownKeys实现针对"对象"的for in遍历进行拦截,注意ownKeys只能接受一个target对象作为参数,因为for in遍历是对象身上所有可枚举属性,不像set/get函数那样传某个具体的key作为第二个参数。所以这里我们主动构造一个空的Symbol值ITERATE_KEY
作为Key。
问题:什么时候需要触发与ITERATE_KEY
相关联的副作用函数重新执行呢?看如下代码:
js
let obj = {
foo: 1,
}
const proxy = new Proxy(obj, {
......
})
effect(() => {
for (const key in proxy) {
console.log(key, 'key');
}
})
上面代码中,obj只有foo一个属性,effect函数执行里面的for in只会执行一次。
js
proxy.bar = 2
上方代码,增加一个属性,会对for in遍历产生影响。所以,增加新属性 会对 ITERATE_KEY
相关联的副作用函数 产生影响。但是此时并不会触发副作用函数重新执行,也就是for in遍历的那个函数没有重新执行,为什么?
diff
const proxy = new Proxy(obj, {
ownKeys (target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target, ITERATE_KEY)
},
get(target, key, receiver) {
track(target, key)
// return target[key]
return Reflect.get(target, key, receiver)
},
+ set(target, key, newVal, receiver) {
+ const res = Reflect.set(target, key, newVal, receiver)
+ trigger(target, key)
+ return res
}
})
如上,增加属性proxy.bar时,set函数里面的trigger函数接受到的key是bar属性,那么执行的是与bar
属性相关联的副作用函数,不会触发与ITERATE_KEY
相关联的副作用函数。
如何解决?
diff
function trigger (target, key) {
let depsMap = bucket.get(target)
if (!depsMap) return
// 取得与key相关联的副作用函数
const effects = depsMap.get(key)
// 取得与ItERATE_KEY关联的副作用函数
+ const iterateEffects = depsMap.get(ITERATE_KEY)
console.log(iterateEffects, 'iterateEffects');
const effectsToRun = new Set()
// 将与key相关联的副作用函数添加到effectsToRun
+ effects && effects.forEach(effectFn => {
+ if (effectFn !== activeEffect) {
++ effectsToRun.add(effectFn)
+ }
+ })
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
+ iterateEffects && iterateEffects.forEach(effectFn => {
+ if (effectFn !== activeEffect) {
+ effectsToRun.add(effectFn)
+ }
+ })
effectsToRun && effectsToRun.forEach(effectFn => {
if (effectFn.options && effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
如上代码中,当触发proxy.bar = 2,新增属性时触发trigger,此时不仅会把bar对应的副作用函数拿出来,还会把ITERATE_KEY
相关联的副作用函数也拿出来,最终一起执行。
这样就能实现效果,如下在控制台修改proxy.bar = 2,成功执行了副作用函数里面的for in遍历
但是此时还是存在问题,如果是单纯的修改某个属性值,不是新增,那么不应该触发for in对应的副作用函数重新触发,因为属性值没有增加。但是我们当前封装的会再次触发
如何修改呢?
diff
const proxy = new Proxy(obj, {
ownKeys (target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target, ITERATE_KEY)
},
get(target, key, receiver) {
track(target, key)
// return target[key]
return Reflect.get(target, key, receiver)
},
set(target, key, newVal, receiver) {
+ let type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
target[key] = newVal
const res = Reflect.set(target, key, newVal, receiver)
+ trigger(target, key, type)
return res
}
})
diff
+function trigger (target, key, type) {
let depsMap = bucket.get(target)
if (!depsMap) return
// 取得与key相关联的副作用函数
const effects = depsMap.get(key)
const effectsToRun = new Set()
// 将与key相关联的副作用函数添加到effectsToRun
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
+ if (type === 'ADD') {
// 取得与ItERATE_KEY关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun && effectsToRun.forEach(effectFn => {
if (effectFn.options && effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
如上代码,通过Object.prototype.hasOwnProperty.call(target, key)
判断对象是否是自己有这个属性,有的话就是SET
,否则是ADD
,只有是ADD的时候才需要去取ItERATE_KEY关联的副作用函数,并执行
如下测试,实现了我们的效果:
对应源码
ownKeys的源码如下:
js
// /packages/reactivity/src/baseHandlers.ts
ownKeys(target: Record<string | symbol, unknown>): (string | symbol)[] {
track(
target,
TrackOpTypes.ITERATE,
isArray(target) ? 'length' : ITERATE_KEY,
)
return Reflect.ownKeys(target)
}
set函数中
diff
// /packages/reactivity/src/baseHandlers.ts
const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
+ : hasOwn(target, key) // 通过hasOwn判断是否新增key还是设置key
const result = Reflect.set(
target,
key,
value,
isRef(target) ? target : receiver,
)
// don't trigger if target is something up in the prototype chain of original
if (target === toRaw(receiver)) {
if (!hadKey) {
+ trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
+ trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
hasOwn的实现方法
js
// /packages/shared/src/general.ts
const hasOwnProperty = Object.prototype.hasOwnProperty
export const hasOwn = (
val: object,
key: string | symbol,
): key is keyof typeof val => hasOwnProperty.call(val, key)
trigger函数:
diff
// /packages/reactivity/src/deps.ts
switch (type) {
// 如果是新增,
+ case TriggerOpTypes.ADD:
+ if (!targetIsArray) {
// 把ITERATE_KEY对应的副作用函数拿出来,执行run,就是执行副作用函数
+ run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isArrayIndex) {
// new index added to array -> length changes
run(depsMap.get('length'))
}
break
case TriggerOpTypes.DELETE:
if (!targetIsArray) {
run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
if (isMap(target)) {
run(depsMap.get(ITERATE_KEY))
}
break
}
}
5.3.3 如何拦截删除操作
删除操作是这样的
js
delete proxy.foo
根据规范:
js
Let deleteStatus be ? baseObj.[[Delete]](ref.[[ReferencedName]]).
从上面的规范中可以看到[[Delete]]内部方法是删除的关键,根据之前的表格可知道,该内部方法依赖deleteProperty方法去实现
实现如下:
diff
const proxy = new Proxy(obj, {
......省略其他拦截函数
// 删除
+ deleteProperty (target, key) {
+ // 检查被操作的属性是否是对象自己的属性
+ let isOwnKey = Object.prototype.hasOwnProperty.call(target, key)
+ // 删除操作
+ let deleteRes = Reflect.deleteProperty(target, key)
+ // 是自己的key,并且删除成功
+ if (isOwnKey && deleteRes) {
+ trigger(target, key, 'DELETE')
+ }
+ }
})
如上实现中,拦截proxy的deleteProxy方法,要判断删除的属性是否是自身的,接着调用Reflect.deleteProxy方法,如果是自身的属性并且删除成功,则执行trigger。为什么要执行trigger呢?因为删除属性后,对象的属性少了,会对for in产生影响,所以传递一个DELETE给到trigger函数。
diff
function trigger (target, key, type) {
let depsMap = bucket.get(target)
if (!depsMap) return
// 取得与key相关联的副作用函数
const effects = depsMap.get(key)
const effectsToRun = new Set()
// 将与key相关联的副作用函数添加到effectsToRun
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
+ if (type === 'ADD' || type === 'DELETE') {
// 取得与ItERATE_KEY关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun && effectsToRun.forEach(effectFn => {
if (effectFn.options && effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
如上方法只需要增加一个type === DELETE的判断,新增和删除属性都需要把ITERATE_KEY
对应的副作用函数拿出来执行。
如上代码中,我们支持拦截:
- 访问/设置对象属性
- 支持拦截in操作符
- 支持拦截for in 操作符,支持新增和删除属性的时候重新触发for in遍历,并和修改属性区分开来
对应源码
deleteProperty拦截函数:
diff
// /packages/reactivity/src/baseHandlers.ts
class MutableReactiveHandler extends BaseReactiveHandler {
constructor(isShallow = false) {
super(false, isShallow)
}
deleteProperty(
target: Record<string | symbol, unknown>,
key: string | symbol,
): boolean {
+ const hadKey = hasOwn(target, key)
const oldValue = target[key]
const result = Reflect.deleteProperty(target, key)
+ if (result && hadKey) {
+ // 如果是自己的key,并且删除成功,则触发trigger
+ trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
}
return result
}
}
trigger函数里面判断是否是删除,下面代码有点长,观察高亮的部分即可:
diff
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>,
): void {
if (type === TriggerOpTypes.CLEAR) {
// collection being cleared
// trigger all effects for target
depsMap.forEach(run)
} else {
const targetIsArray = isArray(target)
const isArrayIndex = targetIsArray && isIntegerKey(key)
if (targetIsArray && key === 'length') {
const newLength = Number(newValue)
depsMap.forEach((dep, key) => {
if (
key === 'length' ||
key === ARRAY_ITERATE_KEY ||
(!isSymbol(key) && key >= newLength)
) {
run(dep)
}
})
} else {
// schedule runs for SET | ADD | DELETE
if (key !== void 0 || depsMap.has(void 0)) {
run(depsMap.get(key))
}
// schedule ARRAY_ITERATE for any numeric key change (length is handled above)
if (isArrayIndex) {
run(depsMap.get(ARRAY_ITERATE_KEY))
}
// also run for iteration key on ADD | DELETE | Map.SET
switch (type) {
case TriggerOpTypes.ADD:
if (!targetIsArray) {
run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isArrayIndex) {
// new index added to array -> length changes
run(depsMap.get('length'))
}
break
+ case TriggerOpTypes.DELETE:
+ if (!targetIsArray) {
+ run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
if (isMap(target)) {
run(depsMap.get(ITERATE_KEY))
}
break
}
}
}
endBatch()
}
5.4 合理地触发响应
旧值不更新以及处理NaN
接下来要处理两个问题:
- 如果修改响应式值,但是和旧值是一样的没有变化,则不需要触发副作用函数
- 处理修改值为NaN的情况,因为NaN === NaN总是false
下图,当前我们修改值不变,还是会触发副作用函数:
js
effect(() => {
console.log(proxy.foo, 'proxy.foo');
})
修改如下:
diff
set(target, key, newVal, receiver) {
// 旧值
+ let oldVal = target[key]
let type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
// 新旧值不相等才执行trigger
+ if (oldVal !== newVal) {
trigger(target, key, type)
}
return res
},
上方增加了新旧值的判断,不相等才执行trigger
但是如果是NaN的话,每次修改值为NaN还是会触发,因为NaN总是会不等于NaN,也符合条件,如下测试
可以这样修改Set函数
diff
set(target, key, newVal, receiver) {
// 旧值
let oldVal = target[key]
let type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newVal, receiver)
// 新旧值不相等才执行trigger
+ if (oldVal !== newVal && (!Number.isNaN(oldVal) || !Number.isNaN(newVal))) {
trigger(target, key, type)
}
return res
},
如上,借助Number.isNaN(),判断新值或者旧值不能都是NaN
原型更新两次问题
我们先封装一个reactive函数:其内部拦截方法和之前的一致
js
const reactive = function (obj) {
return new Proxy(obj, {
......
})
}
如下代码:
js
const obj = {}
const proto = {
bar: 1
}
const child = reactive(obj)
const parent = reactive(proto)
// 把parent设置为child的原型对象
Object.setPrototypeOf(child, parent)
console.log(child.__proto__ === parent, 'child.__proto__ === parent'); // true
effect(() => {
console.log(child.bar, 'child.bar');
})
我们创建一个obj
对象和一个proto
对象,针对obj
创建child
代理对象,针对proto
创建parent
代理对象。并且利用Object.setPrototype
方法把parent设置为child的原型对象
,通过最后一行的打印能够看出效果。最后我们执行副作用函数并在里面访问child.bar属性
接着我们修改child.bar的值,发现副作用函数调用了两次:
这是为什么呢?
按理来说,之前我们在副作用函数里面只访问了child.bar,那么只应该把这个属性对应的副作用函数存到桶内,修改child.bar的时候仅仅把他拿出来,执行一次。
访问属性是会触发track函数,里面利用了Reflect.get(obj, 'bar', receiver)方法进行返回值。这实际上就是调用对象的内部方法[[GET]]。这个方法的执行流程如下:
js
If desc is undefined, then:
a. Let parent be ? O.[[GetPrototypeOf]]().
b. If parent is null, return undefined.
c. Return ? parent.[[GET]](P, Receiver).
如果对象自身没有这个属性,会去parent身上找,[[GetPrototypeOf]]就是访问原型
,调用原型
的[[Get]]方法。没有parent则返回undefined
访问child.bar
但是child上没有bar属性,就会去parent
身上去找。因此,不仅访问了child.bar,也访问了parent.bar,这两个属性都和副作用函数建立了联系。
上面能够解释两个属性都和副作用函数建立联系。但是为什么设置child.bar时,会触发两次副作用函数。
在设置的时候,我们使用了Reflect.set(target, key, newVal, receiver)
方法给对象的属性赋值。会调用obj对象部署的[[Set]]内部方法
js
If ownDesc is undefined, then:
a. Let parent be ? O.[[GetPrototypeOf]]().
b. If parent is not null, then
i. Return ? parent.[[Set]](P, V, Receiver).
如果child对象身上没有bar属性,就会调用原型对象parent的内部[[Set]]方法。再加上读取child.bar
和parent.bar
时,副作用函数都已被收集,那么他们都会在child.bar++
时被执行。
如何解决访问child.bar执行两次副作用函数的问题?也即只希望执行一次
执行两次,需要屏蔽其中一次。把parent.bar触发的那次副作用函数执行给屏蔽。两次执行都是在set函数触发时执行的。
child.bar执行时
diff
set (target, key, receiver) {
// target是原始对象obj
+ // receiver是child代理对象
}
parent.bar执行时
diff
set (target, key, receiver) {
// target是原始对象proto
+ // receiver是代理对象child
}
可以发现,receiver是不会变化的,修改child.bar++, receiver始终是child。这里是书本上的分析,但是在实际敲代码时我出现了一些问题
diff
function reactive (obj) {
return new Proxy(obj, {
get(target, key, receiver) {
+ if (key === 'raw') {
+ return target
+ }
track(target, key)
// return target[key]
return Reflect.get(target, key, receiver)
},
set(target, key, newVal, receiver) {
// 旧值
let oldVal = target[key]
let type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
target[key] = newVal
const res = Reflect.set(target, key, newVal, receiver)
+ console.log(receiver === child, 'receiver === child');
// 普通对象和receiver指向的raw相等
+ if (target === receiver.raw) {
if (oldVal !== newVal && (!Number.isNaN(oldVal) || !Number.isNaN(newVal))) {
trigger(target, key, type)
}
}
},
})
}
上面代码中,我们在get拦截函数里面,判断key值如果是raw,则直接返回target
,target是原始对象。这个在什么时候执行呢?目标移动到下面set拦截函数
里面我们增加了一个if判断,如果target === receiver.raw
,则执行下面的trigger函数,这里receiver.raw
会触发get函数,并且此时key就是'raw', receiver其实就是代理对象,访问代理对象的某个属性就会触发get。
到此为止,其实不能实现我们的功能,在控制台打印child.bar++,副作用函数还是会触发两次,如下图:
为什么呢,原因就是我这里多了一行代码忘记删掉了:
diff
set(target, key, newVal, receiver) {
// 旧值
let oldVal = target[key]
let type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
+ target[key] = newVal
const res = Reflect.set(target, key, newVal, receiver)
console.log(receiver === child, 'receiver === child');
// 普通对象和receiver指向的raw相等
if (target === receiver.raw) {
if (oldVal !== newVal && (!Number.isNaN(oldVal) || !Number.isNaN(newVal))) {
trigger(target, key, type)
}
}
},
为什么这个会有影响呢?我试着解释清楚我的理解,首先这里的执行顺序是
js
1. 修改child.bar++
2. 触发child代理对象的set函数
3. 执行到target[key] = newVal
4. 由于这个target身上没有bar属性,此时会触发原型对象parent的set函数
5. 原型对象的set函数会先全部执行一整套,注意他执行时receiver并不是child,你看我们下面的打印,这时receiver是parent,为什么呢?就是target[key] = newVal影响的,删掉这行代码就好了,他不能保证原型对象身上的receiver还是child。只有用Reflect.set(target, key, newVal, receiver)去修改值,receiver才是对的,第四个参数receiver就是为了保证上下文的代理对象指向一致
6. 接着才会去执行child的set函数剩余的部分,判断target === receiver.raw
删掉这行代码后,副作用函数就执行了一次。
对应源码
get函数如下:
diff
// /packages/reactivity/src/baseHandlers.ts
get(target: Target, key: string | symbol, receiver: object): any {
if (key === ReactiveFlags.SKIP) return target[ReactiveFlags.SKIP]
const isReadonly = this._isReadonly,
isShallow = this._isShallow
if (key === ReactiveFlags.IS_REACTIVE) {
return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {
return isReadonly
} else if (key === ReactiveFlags.IS_SHALLOW) {
return isShallow
+ } else if (key === ReactiveFlags.RAW) {
if (
receiver ===
(isReadonly
? isShallow
? shallowReadonlyMap
: readonlyMap
: isShallow
? shallowReactiveMap
: reactiveMap
).get(target) ||
// receiver is not the reactive proxy, but has the same prototype
// this means the receiver is a user proxy of the reactive proxy
Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)
) {
// get函数里面判断如果访问的raw属性,直接返回target原始对象本身
+ return target
}
// early return undefined
return
}
}
set函数拦截:
diff
// /packages/reactivity/src/baseHandlers.ts
class MutableReactiveHandler extends BaseReactiveHandler {
constructor(isShallow = false) {
super(false, isShallow)
}
......
set(
target: Record<string | symbol, unknown>,
key: string | symbol,
value: unknown,
receiver: object,
): boolean {
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
+ trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
}
......
}
toRaw方法:
js
// /packages/reactivity/src/reactive.ts
export function toRaw<T>(observed: T): T {
// 访问receiver.__v_raw属性,拿到原始对象值
const raw = observed && (observed as Target)[ReactiveFlags.RAW]
return raw ? toRaw(raw) : observed
}
hasChanged方法:
js
// /packages/shared/src/general.ts
// compare whether a value has changed, accounting for NaN.
export const hasChanged = (value: any, oldValue: any): boolean =>
// 通过Object.is排除NaN和值不变的情况
!Object.is(value, oldValue)
5.5 浅响应和深响应
本节将介绍:reactive(深响应)和shallowReactive(浅响应)的区别。理解并实现响应式对象如何递归处理深层次属性
js
// obj是一个嵌套对象,里面的foo还是对象
let obj = {
foo: {
bar: 1
}
}
let proxy1 = reactive(obj)
effect(() => {
console.log(proxy1.foo.bar, 'obj.foo.bar');
})
此时若我们在控制台修改proxy1.foo.bar
值,不会触发副作用函数的更新,控制台打印如下:
为什么proxy1.foo.bar属性没有和副作用函数建立联系呢?因为我们通过proxy1.foo拿到的是对象{bar: 1},这只是一个普通对象,此时再去访问proxy1.foo.bar,并不是从响应式数据中读取过来的。
要在get拦截函数中进行判断:
diff
get(target, key, receiver) {
if (key === 'raw') {
return target
}
track(target, key)
const res = Reflect.get(target, key, receiver)
+ if (typeof res === 'object' && res !== null) {
+ return reactive(res)
+ }
return res
},
如上代码,判断res是对象,则执行reactive函数再进行一层封装
如何实现浅响应呢?我们修改一下之前封装的函数:
diff
function reactive (obj) {
+ return createReactive(obj)
}
function shallowReactive (obj) {
+ return createReactive(obj, true)
}
function createReactive (obj, isShallow = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') {
return target
}
track(target, key)
const res = Reflect.get(target, key, receiver)
+ if (isShallow) {
+ return res
+ }
+ if (typeof res === 'object' && res !== null) {
+ return reactive(res)
+ }
+ return res
+ }
})
}
如上,之前的reactive函数改名为createReactive
函数,这个函数里面返回Proxy
对象。然后在reactive
函数和shallowReactive
函数里面去调用createReactive函数。该函数新增参数isShallow,如果是true表示对象是浅响应,只有第一层属性是响应式,后续都是普通对象,shallowReactive函数要传true
,表示浅响应
。默认是深响应,也即整个对象都是响应式数据。
对应源码
如下在get函数中进行的拦截,只需关注高亮的代码
diff
// /packages/reactivity/src/baseHandlers.ts
class BaseReactiveHandler implements ProxyHandler<Target> {
constructor(
protected readonly _isReadonly = false,
protected readonly _isShallow = false,
) {}
get(target: Target, key: string | symbol, receiver: object): any {
const res = Reflect.get(
target,
key,
// if this is a proxy wrapping a ref, return methods using the raw ref
// as receiver so that we don't have to call `toRaw` on the ref in all
// its class methods
isRef(target) ? target : receiver,
)
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res
}
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
+ if (isShallow) {
+ return res
+ }
if (isRef(res)) {
// ref unwrapping - skip unwrap for Array + integer key.
return targetIsArray && isIntegerKey(key) ? res : res.value
}
+ if (isObject(res)) {
// Convert returned value into a proxy as well. we do the isObject check
// here to avoid invalid value warning. Also need to lazy access readonly
// and reactive here to avoid circular dependency.
// readonly是判断是否只是只读,在下一节会分析
+ return isReadonly ? readonly(res) : reactive(res)
+ }
+ return res
}
}
5.6 只读和浅只读
本节目标:将实现响应式数据只读,不能修改也不能删除,比如props传递的数据。并且实现嵌套处理深层次的值
实现如下:
diff
function readonly(obj) {
return createReactive(obj, false, true)
}
function createReactive (obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
......
set(target, key, newVal, receiver) {
// 只读
+ if (isReadonly) {
+ console.warn(`属性 ${key} 是只读的`);
+ return true
+ }
// 旧值
let oldVal = target[key]
let type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
// target[key] = newVal
const res = Reflect.set(target, key, newVal, receiver)
// 普通对象和receiver指向的raw相等
if (target === receiver.raw) {
if (oldVal !== newVal && (!Number.isNaN(oldVal) || !Number.isNaN(newVal))) {
trigger(target, key, type)
}
}
},
deleteProperty (target, key) {
// 只读
+ if (isReadonly) {
+ console.warn(`属性 ${key} 是只读的`);
+ return true
+ }
// 检查被操作的属性是否是对象自己的属性
let isOwnKey = Object.prototype.hasOwnProperty.call(target, key)
// 删除操作
let deleteRes = Reflect.deleteProperty(target, key)
// 是自己的key,并且删除成功
if (isOwnKey && deleteRes) {
trigger(target, key, 'DELETE')
}
}
})
}
创建一个只读函数,在里面调用createReactive
函数,传递第三个参数isReadonly
,为true表示只读。在set
和deleteProperty
拦截函数里面判断如果是只读就直接return。
控制台调试如下:
当一个数据是只读的时候,任何地方都无法修改他,所以访问这个值后不需要通知其他任何函数,所以只读的数据不需要通过track函数进行追踪:
js
但是目前只是浅只读,如果对象是嵌套数据,深层的属性依然还是可以修改,如下修改数据结构后,再去控制台调试:
diff
+let obj = {
+ foo: {
+ bar: 1
+ }
+}
let proxy1 = readonly(obj)
effect(() => {
console.log(proxy1.foo.bar, 'obj.foo.bar');
})
修改proxy1.foo.bar属性后发现修改成功了:
如何能够实现深只读呢?
diff
get(target, key, receiver) {
if (key === 'raw') {
return target
}
if (!isReadonly) {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
+ return isReadonly ? readonly(res) : reactive(res)
}
return res
},
如上代码中,在get拦截函数里面增加判断,如果是isReadonly,并且属性值还是对象,则递归调用readonly(res)函数,这样里面那层{bar: 1}也被拦截了。
此时再去修改最内层的属性bar的值,就能够触发拦截器:
这里还想多一嘴。当执行proxy.foo.bar = 2
这样的语句时,会先触发 proxy.foo的get拦截,然后触发proxy.foo.bar的get拦截,最终再去执行他的set拦截函数。
对应源码
只读的类
js
// /packages/reactivity/src/baseHandlers.ts
class ReadonlyReactiveHandler extends BaseReactiveHandler {
constructor(isShallow = false) {
super(true, isShallow)
}
set(target: object, key: string | symbol) {
if (__DEV__) {
warn(
`Set operation on key "${String(key)}" failed: target is readonly.`,
target,
)
}
return true
}
deleteProperty(target: object, key: string | symbol) {
if (__DEV__) {
warn(
`Delete operation on key "${String(key)}" failed: target is readonly.`,
target,
)
}
return true
}
}
get拦截函数
里面递归处理
diff
// /packages/reactivity/src/baseHandlers.ts
class BaseReactiveHandler implements ProxyHandler<Target> {
constructor(
protected readonly _isReadonly = false,
protected readonly _isShallow = false,
) {}
get(target: Target, key: string | symbol, receiver: object): any {
// ......省略其他代码
+ if (isObject(res)) {
+ // Convert returned value into a proxy as well. we do the isObject check
+ // here to avoid invalid value warning. Also need to lazy access readonly
+ // and reactive here to avoid circular dependency.
+ // 如果是对象,则递归处理数据位只读
+ return isReadonly ? readonly(res) : reactive(res)
+ }
return res
}
}
readonly函数
:
diff
// /packages/reactivity/src/baseHandlers.ts
export function readonly<T extends object>(
target: T,
): DeepReadonly<UnwrapNestedRefs<T>> {
+ return createReactiveObject(
target,
true,
readonlyHandlers,
readonlyCollectionHandlers,
readonlyMap,
)
}
5.7 代理数组
JS的对象分为常规对象和异质对象,而其数组属于异质对象。原因是数组内部的方法[[DefineOwnProperty]]和常规方法不同,数组其他的内部方法都和常规方法相同。
通过数组的索引访问值和修改值,set和get拦截函数能够正常运行, 修改值时副作用函数也会触发响应
js
let arr = reactive([11])
effect(() => {
console.log(arr[0], 'arr[0]');
})
数组的访问和设置还有自己独特的操作:
js
访问:
1. 通过索引访问:arr[0]
2. 访问数组长度: arr.length
3. 把数组作为对象使用 for ...... in进行遍历
4. 使用for ...... of进行遍历
5. 数组原型方法: concat/join/every/some/find/findIndex/includes,以及其他不改变数组原型方法
修改:
1. 通过索引修改:arr[0] = 1
2. 修改数组长度:arr.length = 0
3. 数组的栈方法:push/pop/shift/unshift
4. 修改原数组的原型方法:splice/fill/sort/reverse
5.7.1 数组索引和length
通过索引修改数组元素值,之前我们的代码可以实现拦截。但是如果索引超过了数组的长度,拦截就会失效,如下图
当通过索引设置数组的值,会执行数组对象部署的[[Set]]方法,[[Set]]方法依赖[[DefineOwnProperty]],而数组的[[DefineOwnProperty]]内部方法和别的对象不一样:
js
if index >= oldLen, then
i. Set oldLenDesc.[[Value]] to index + 1
ii. Let succeeded be OrdinaryDefineProperty(A, 'length', oldLenDesc)
iii. Assert: succeeded is true
如上面规范所说,如果通过索引改数组值,会修改数组的length值。所以触发响应后,应该要触发与length属性相关联的副作用函数重新执行。
在createReactive
的set函数里增加
diff
set(target, key, newVal, receiver) {
// 只读
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`);
return true
}
// 旧值
let oldVal = target[key]
+ let type = Array.isArray(target) ?
+ (Number(key) < target.length ? 'SET' : 'ADD')
+ :
+ (Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD')
const res = Reflect.set(target, key, newVal, receiver)
// 普通对象和receiver指向的raw相等
if (target === receiver.raw) {
if (oldVal !== newVal && (!Number.isNaN(oldVal) || !Number.isNaN(newVal))) {
trigger(target, key, type)
}
}
},
如上高亮代码,修改数组值时,在set函数里面判断target是数组,紧接着要看修改数组的索引是否比数组的长度要小,如果小就是修改,否则就是新增
在如下trigger函数中
diff
function trigger (target, key, type) {
let depsMap = bucket.get(target)
if (!depsMap) return
// 取得与key相关联的副作用函数
const effects = depsMap.get(key)
const effectsToRun = new Set()
// 将与key相关联的副作用函数添加到effectsToRun
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
// 操作类型是ADD的时候,把length对应的副作用函数取出来,加入到effectsToRun中拿出来执行
+ if (Array.isArray(target) && type === 'ADD') {
+ const lengthOfEffects = depsMap.get('length')
+ lengthOfEffects && lengthOfEffects.forEach(effectFn => {
+ if (effectFn !== activeEffect) {
+ effectsToRun.add(effectFn)
+ }
+ })
+ }
if (type === 'ADD' || type === 'DELETE') {
// 取得与ItERATE_KEY关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
......省略下面的代码
}
如上高亮,如果type是ADD新增值,并且是数组的数据结构,则把length对应的副作用函数都拿出来添加到effectsToRun集合中,后续执行。如下在副作用函数中访问arr的length属性
js
effect(() => {
console.log(arr.length, 'arr[0]');
})
如下调试,索引超出数组的长度,副作用函数重新执行了:
把副作用函数改为访问数组第0个元素:
js
let arr = reactive([11, 12, 13])
effect(() => {
console.log(arr[2], 'arr[2]');
})
反过来思考,若数组长度是1,直接修改数组的length为2,这时不应该触发副作用,因为不会对第0个元素产生影响,但是若把数组的length改为0,这个时候就应该触发副作用函数,因为第0个元素已经没了,具体应该修改set拦截函数,实现如下:
diff
set(target, key, newVal, receiver) {
// 只读
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`);
return true
}
// 旧值
let oldVal = target[key]
let type = Array.isArray(target) ?
(Number(key) < target.length ? 'SET' : 'ADD')
:
(Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD')
// target[key] = newVal
const res = Reflect.set(target, key, newVal, receiver)
// 普通对象和receiver指向的raw相等
if (target === receiver.raw) {
if (oldVal !== newVal && (!Number.isNaN(oldVal) || !Number.isNaN(newVal))) {
// 传递新的newVal参数过去
+ trigger(target, key, type, newVal)
}
}
},
在trigger函数:
diff
function trigger (target, key, type, newVal) {
let depsMap = bucket.get(target)
if (!depsMap) return
// 取得与key相关联的副作用函数
const effects = depsMap.get(key)
const effectsToRun = new Set()
......省略其他副作用函数
// 如果操作目标是数组,并且修改了数组的key属性
+ if (Array.isArray(target) && key === 'length') {
+ depsMap.forEach((effects, effectKey) => {
+ if (effectKey >= newVal) {
+ effects.forEach(effectFn => {
+ if (effectFn !== activeEffect) {
+ effectsToRun.add(effectFn)
+ }
+ })
+ }
+ })
+ }
effectsToRun && effectsToRun.forEach(effectFn => {
if (effectFn.options && effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
如上代码中,在set
函数里传newVal
给trigger
函数,在trigger函数里面判断,如果拦截的是数组数据并且key
是length
,说明改的是数组的length属性,就要遍历所有的副作用函数键值对depsMap
,而effectKey
就是索引,如果索引
大于新的length,说明这部分的值被删掉了,就要更新对应的副作用函数。
比如数组长度原本是3,更新数组length为2,那么索引2的数据就被删掉了,更新索引2对应的副作用函数。控制台调试如下,打印的是undefined
对应源码
修改arr[3]时,索引3超过数组长度:
diff
// /packages/reactivity/src/baseHandlers.ts
class MutableReactiveHandler extends BaseReactiveHandler {
constructor(isShallow = false) {
super(false, isShallow)
}
set(
target: Record<string | symbol, unknown>,
key: string | symbol,
value: unknown,
receiver: object,
): boolean {
let oldValue = target[key]
......省略一部分代码
// 如果是数组,并且key是整数,则判断key是否小于数组的长度
+ const hadKey =
+ isArray(target) && isIntegerKey(key)
+ ? Number(key) < target.length
+ : hasOwn(target, key)
const result = Reflect.set(
target,
key,
value,
isRef(target) ? target : receiver,
)
// don't trigger if target is something up in the prototype chain of original
if (target === toRaw(receiver)) {
// hadKey是false,则执行add操作,是新增的属性/值
+ if (!hadKey) {
+ trigger(target, TriggerOpTypes.ADD, key, value)
+ } else if (hasChanged(value, oldValue)) {
// 有这个key,则执行set操作
+ trigger(target, TriggerOpTypes.SET, key, value, oldValue)
+ }
}
return result
}
......省略其他拦截函数
}
修改数组索引,索引超过原本长度 以及 修改length触发副作用函数都在下面
diff
// /packages/reactivity/src/dep.ts
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>,
): void {
if (type === TriggerOpTypes.CLEAR) {
// collection being cleared
// trigger all effects for target
depsMap.forEach(run)
} else {
const targetIsArray = isArray(target)
const isArrayIndex = targetIsArray && isIntegerKey(key)
if (targetIsArray && key === 'length') {
const newLength = Number(newValue)
depsMap.forEach((dep, key) => {
if (
key === 'length' ||
key === ARRAY_ITERATE_KEY ||
+ // 直接修改arr.length = 0,length属性修改触发副作用函数
+ (!isSymbol(key) && key >= newLength)
) {
+ run(dep)
}
})
} else {
// schedule runs for SET | ADD | DELETE
if (key !== void 0 || depsMap.has(void 0)) {
run(depsMap.get(key))
}
// schedule ARRAY_ITERATE for any numeric key change (length is handled above)
if (isArrayIndex) {
run(depsMap.get(ARRAY_ITERATE_KEY))
}
// also run for iteration key on ADD | DELETE | Map.SET
+ switch (type) {
case TriggerOpTypes.ADD:
if (!targetIsArray) {
run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
+ } else if (isArrayIndex) {
+ // 修改arr[3] = 1,索引3超过数组长度
+ // new index added to array -> length changes
+ run(depsMap.get('length'))
}
break
case TriggerOpTypes.DELETE:
if (!targetIsArray) {
run(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
run(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
if (isMap(target)) {
run(depsMap.get(ITERATE_KEY))
}
break
}
}
}
endBatch()
}
5.7.2 遍历数组
如何拦截拦截for ...... in
这节的目标是掌握对for ...... in拦截数组的实现。
数组和对象一样都可以使用for ...... in来进行遍历。但是不推荐使用for ...... in来遍历数组。为什么不推荐呢?
- for ...... in适合对象遍历,for ...... in遍历是没有顺序的,但是数组的顺序很重要
- for ...... in可能会遍历到原型链上面的属性和方法,所以性能也稍微会差一点
之前我们使用Proxy的ownKeys
和Reflect的ownKeys
方法来拦截for ...... in的遍历,这里同样也用它来拦截数组。
哪些操作会影响数组的for ...... in遍历呢?
- arr[100] = '100'
- arr.length = 0
只要是修改了数组的长度的操作,都会影响for ...... in的遍历,具体实现如下:
首先修改副作用函数的访问:
js
let arr = reactive([11, 12, 13])
effect(() => {
for (const key in arr) {
console.log(arr[key], 'arr[key]');
}
})
修改Proxy的拦截函数ownKeys()里面的实现:
js
ownKeys (target) {
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
}
如上代码,要判断是否是数组,如果是数组,则传length
属性给track
函数。
当for ...... in访问了数组,会触发Proxy的ownKeys的拦截,调用track函数,让length属性和对应的副作用函数建立了联系。后续数组的length属性一旦修改,就会触发对应副作用函数。测试如下:
对应源码
diff
// /packages/reactivity/src/baseHandlers.ts
class MutableReactiveHandler extends BaseReactiveHandler {
constructor(isShallow = false) {
super(false, isShallow)
}
+省略其他拦截函数
+ ownKeys(target: Record<string | symbol, unknown>): (string | symbol)[] {
track(
target,
TrackOpTypes.ITERATE,
+ isArray(target) ? 'length' : ITERATE_KEY,
)
return Reflect.ownKeys(target)
}
}
如何拦截for ...... of
for......of 用来遍历可迭代对象。一个数据能否被for ...... of遍历,取决于其内部是否实现了@@iterator方法。注意@@[name]指代JS中的内建Symbol值,@@iterator就是Symbol.iterator方法。
字符串中的Symbol.iterator
值
js
let str = '123'
const strFn = str[Symbol.iterator]()
console.log(strFn.next(), 'strFn.next()');
console.log(strFn.next(), 'strFn.next()');
console.log(strFn.next(), 'strFn.next()');
console.log(strFn.next(), 'strFn.next()');
数组里面的Symbol.iterator
值
js
let arr1 = [1, 2, 3]
const arrFn = arr1[Symbol.iterator]()
console.log(arrFn.next(), 'arrFn.next()');
console.log(arrFn.next(), 'arrFn.next()');
console.log(arrFn.next(), 'arrFn.next()');
console.log(arrFn.next(), 'arrFn.next()');
咱也可以让obj对象能被for ...... of遍历
js
const obj = {
val: 0,
// 在这里我们自己添加这个内建方法
[Symbol.iterator]() {
return {
next () {
return {
value: obj.val++,
done: obj.val > 3 ? true : false
}
}
}
}
}
for (const value of obj) {
console.log(value, 'value');
}
在ES的23.1.5.1节中定义了数组迭代器的执行流程,其中有两句关键
js
Let len be ? LengthOfArray(array)
......
Let elementValue be ? Get(array, elementKey)
迭代器会读取数组的length属性,还会读取数组的索引。所以我们之前的实现已经对数组的索引和length属性实现拦截了。
因此,我们不需要添加任何代码就可以实现对for of的拦截,如下代码所示:
js
let arr = reactive([11, 12, 13])
effect(() => {
for (const value of arr) {
console.log(value, 'value');
}
})
如下我们修改数组的索引为0的值,能够触发for of重新执行。
但是我们如果修改数组的length,会有如下的报错:
为什么会有这个报错呢?我们查看一下bucket里面的数据情况:
报错的代码:
如上发现,数组的Symbol.iterator
属性也被加入到桶里面去了,因为for ...... of会读取这个属性。实际上我们只需要数组的length
和key
与对应的副作用函数之间建立联系就可以了。
如何修改呢?
diff
get(target, key, receiver) {
if (key === 'raw') {
return target
}
+ if (!isReadonly && typeof key !== 'symbol') {
+ track(target, key)
+ }
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
},
如上代码中,在get拦截函数里面,如果key是Symbol值,则不能触发track函数。如下调试就正常了
对应源码
diff
// /packages/reactivity/src/baseHandlers.ts
get(target: Target, key: string | symbol, receiver: object): any {
......省略一部分代码
const res = Reflect.get(
target,
key,
// if this is a proxy wrapping a ref, return methods using the raw ref
// as receiver so that we don't have to call `toRaw` on the ref in all
// its class methods
isRef(target) ? target : receiver,
)
// 如果是symbol的key,则执行这里
+ if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res
}
// 非只读才会执行这里
+ if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
if (isShallow) {
return res
}
if (isRef(res)) {
// ref unwrapping - skip unwrap for Array + integer key.
return targetIsArray && isIntegerKey(key) ? res : res.value
}
if (isObject(res)) {
// Convert returned value into a proxy as well. we do the isObject check
// here to avoid invalid value warning. Also need to lazy access readonly
// and reactive here to avoid circular dependency.
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
}
5.7.3 数组的查找方法
数组的内部方法都依赖对象的基本语义,所以基本上不用做特殊处理就可以正常工作。如下调用includes方法:
js
const arr = reactive([1,2,3])
effect(() => {
console.log(arr.includes(1), 'arr.includes(1)'); // true
})
再次修改数组的索引值,副作用函数触发,如下图。这是因为includes方法会访问数组的length属性和索引
但是如下场景不适合,打印的是false:
js
const obj = {}
let arr1 = reactive([obj])
console.log(arr1.includes(arr[0]), 'arr.includes(arr[0])'); // false
为什么?看如下的includes的规范,ECMA的23.1.3.13节给出了解释
js
1. Let O be ? ToObject(this value)
......
10. Repeat, while k < len,
a. Let elementK be the result of ? Get(O, !ToString(F(k)))
上面有一个toObject(this value),this指代的就是arr代理对象。而第10.a里面的Get(O, !ToString(F(k)))就是通过key去读取arr代理对象的值。
在get拦截函数里面有这样一段代码:
diff
get(target, key, receiver) {
if (key === 'raw') {
return target
}
if (!isReadonly && typeof key !== 'symbol') {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
+ if (typeof res === 'object' && res !== null) {
+ return isReadonly ? readonly(res) : reactive(res)
+ }
return res
},
如果get拦截的值还是一个对象,那么会继续递归处理,reactive(res)会返回一个新对象,如下所示,
js
function reactive (obj) {
return createReactive(obj)
}
所以,arr.includes(arr[0]),includes会在arr里面访问元素产生一个代理对象,而arr[0]又是一个新的代理对象,这两个对象是不同的代理对象,所以返回值是false。可以通过如下方式测试出我们这个结论:
在get函数的这个位置进行debugger,发现在执行console.log(arr.includes(arr[0]), 'arr.includes(arr[0])');
这个打印语句时,下面的代码执行了两次,产生了两个不同的proxy对象
diff
get(target, key, receiver) {
if (key === 'raw') {
return target
}
if (!isReadonly && typeof key !== 'symbol') {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
+ debugger
return isReadonly ? readonly(res) : reactive(res)
}
return res
},
如何解决呢?
js
const reactiveMap = new Map()
function reactive (obj) {
// 通过obj去查找是否之前根据他创建过一个proxy对象,如果创建了直接返回
const existProxy = reactiveMap.get(obj)
if (existProxy) {
return existProxy
}
// 如果没有创建过则创建一次,存储到map中
const proxy = createReactive(obj)
reactiveMap.set(obj, proxy)
return proxy
}
此时打印正常了
但是如果直接这样打印,还是会返回false
diff
const obj = {}
let arr = reactive([obj])
console.log(arr.includes(arr[0]), 'arr.includes(arr[0])'); // false
+console.log(arr.includes(obj), 'arr.includes(obj)'); // false
直接拿obj这个原始对象去找肯定访问不到,因为这样就是拿原始对象和proxy代理对象去进行比较。
重写includes方法:
首先获取数组原型上的includes方法,存到originMethod
上,并定义对象arrayInstrumentations
其属性是includes方法
js
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
includes: function (...args) {
}
}
在get拦截函数里面,判断如果拦截的是数组,并且访问了数组的includes属性,该属性我们存在了arrayInstrumentations
上,而arr.includes()其实就是访问属性,则返回对应的值。
diff
get(target, key, receiver) {
if (key === 'raw') {
return target
}
// arr.includes拦截数组的includes属性
if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
// 返回定义在arrayInstrumentations上面的key值
return Reflect.get(arrayInstrumentations, key, receiver)
}
if (!isReadonly && typeof key !== 'symbol') {
track(target, key)
}
const res = Reflect.get(target, key, receiver)
if (isShallow) {
return res
}
if (typeof res === 'object' && res !== null) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
},
如下,执行原型链上的includes方法,this先指向proxy对象,先去代理对象里面找;若找不到,this改为指向this.raw,也即数组的原生对象,去原生对象上面找。
diff
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
+ includes: function (...args) {
+ // this是代理对象,先在代理对象中查找,将结果存到res中
+ let res = originMethod.apply(this, args)
+ if (res === false) {
+ // 如果没找到,通过this.raw拿到原始数组,去原始对象上查找
+ res = originMethod.apply(this.raw, args)
+ }
+ return res
}
}
调试如下,能够返回true了。
includes和indexOf和lastIndexOf这几个都需要进行拦截,都是查找方法:
js
const arrayInstrumentations = {
};
['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function (...args) {
let res = originMethod.apply(this, args)
if (res === false || res === -1) {
res = originMethod.apply(this.raw, args)
}
return res
}
})
如下执行都是true
js
const obj = {}
let arr = reactive([obj])
console.log(arr.includes(arr[0]), 'arr.includes(arr[0])'); // true
console.log(arr.includes(obj), 'arr.includes(obj)'); // true
console.log(arr.indexOf(obj), 'arr.includes(obj)'); // true
对应源码
diff
// /packages/reactivity/src/arrayInstrumentations.ts
export const arrayInstrumentations: Record<string | symbol, Function> = <any>{
......省略其他方法
includes(...args: unknown[]) {
+ return searchProxy(this, 'includes', args)
},
indexOf(...args: unknown[]) {
+ return searchProxy(this, 'indexOf', args)
},
lastIndexOf(...args: unknown[]) {
+ return searchProxy(this, 'lastIndexOf', args)
},
}
// instrument identity-sensitive methods to account for reactive proxies
function searchProxy(
self: unknown[],
method: keyof Array<any>,
args: unknown[],
) {
const arr = toRaw(self) as any
track(arr, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
// we run the method using the original args first (which may be reactive)
+ const res = arr[method](...args)
+ // if that didn't work, run it again using raw values.
if ((res === -1 || res === false) && isProxy(args[0])) {
+ args[0] = toRaw(args[0])
return arr[method](...args)
}
return res
}
5.7.4 隐式修改数组长度的原型方法
手写
数组的push/pop/unshift/shift等方法会隐式
修改数组的长度,还有splice。如下展示push的内部方法是如何生效的
js
2. Let len be ? LengthOfArray(O)
......
6.Perform ? Set(O, "length", true)
如上展示的两点,不仅会访问数组的length,还会修改数组的length。push方法会导致两个独立的副作用函数互相影响,如下:
首先,讲一个题外话,set函数内部最好都返回true,否则会出现这个提示:
如下,在两个副作用函数中往数组push值
js
effect(() => {
arr.push(1)
})
effect(() => {
arr.push(2)
})
此时会出现栈溢出
为什么会出现栈溢出?是因为当第一次执行arr.push(1)的时候,会访问arr代理对象的length属性,将length属性与这个副作用函数1建立联系;等到第二次执行arr.push(2)的时候,因为也会修改length属性,当前副作用函数2也会和length建立联系,同时会把所有与length相关的副作用函数拿出来执行,那么之前的副作用函数1又被拿出来执行了,这样循环往复。
解决方案就是,在get拦截函数里面,当执行push操作的时候,不执行track步骤下面的拦截操作
js
let shouldTrack = true
;['push', 'pop', 'unshift', 'shift'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function (...args) {
shouldTrack = false
let res = originMethod.apply(this, args)
shouldTrack = true
return res
}
})
如上代码中,我们重写了数组的这几个方法。声明了变量shouldTrack
,然后在方法执行前,不允许追踪length属性,改为false,方法执行完毕后,改为true。
在如下的track函数中,判断shouldTrack
如果是false,则不允许执行。
diff
function track (target, key) {
+ if (!activeEffect || !shouldTrack) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
如上代码执行后,就解决了这个栈溢出的问题。
对应源码
diff
// /packages/reactivity/src/arrayInstrumentations.ts
// instrument length-altering mutation methods to avoid length being tracked
// which leads to infinite loops in some cases (#2137)
function noTracking(
self: unknown[],
method: keyof Array<any>,
args: unknown[] = [],
) {
// 这个就是我们上面写的暂停追踪
+ pauseTracking()
startBatch()
// 执行函数
const res = (toRaw(self) as any)[method].apply(self, args)
endBatch()
// 恢复追踪
+ resetTracking()
return res
}
pauseTracking函数
diff
// /packages/reactivity/src/effect.ts
export function pauseTracking(): void {
trackStack.push(shouldTrack)
+ shouldTrack = false
}
如下是track函数追踪
diff
// /packages/reactivity/src/dep.ts
export class Dep {
......
constructor(public computed?: ComputedRefImpl | undefined) {
if (__DEV__) {
this.subsHead = undefined
}
}
+ track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
if (!activeSub || !shouldTrack || activeSub === this.computed) {
return
}
}
......
}