看完这篇文章,你将能回答以下:
- Vue3是如何拦截通过索引或者length属性 修改/访问数组?
- Vue3如何拦截数组的for in遍历?
- Vue3如何拦截数组for ...... of遍历?
- Vue3如何拦截数组的查找方法,includes indexOf lastIndexOf?
- Vue3如何拦截数组的push/pop/shift/unshift方法?
1. 拦截数组索引
拦截数组索引
是指:当通过索引访问数组的值时,能够收集对应的依赖(副作用函数);当通过索引修改值时,能够触发对应的副作用的函数。
我的这篇文章里面的实现,已经能够满足数组的索引的访问和修改了。其实就是通过Proxy的get和set拦截函数
测试效果:
js
let arr = reactive([11])
effect(() => {
console.log(arr[0], 'arr[0]');
})
在控制台打印和修改值,成功触发对应的副作用函数
1.1 边缘情况1:Proxy无法检测length的变化
但是要处理边缘情况
,如果索引超过了数组的长度,拦截就会失效,如下某个副作用函数访问了数组的length
,我们希望当通过arr[10] = 1
这样的赋值操作后能够通过下面的副作用函数执行一次:
js
effect(() => {
console.log(arr.length, 'arr[0]');
})
没有打印长度:
不能触发的原因是,Proxy无法自动捕捉到数组的length的变化,length的变化是隐式的,是浏览器的内部行为,所以需要我们手动触发
在createReactive
的set函数
里判断如果是数组,并且修改的索引大于数组的length,则是ADD
类型:
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)
}
}
},
在trigger函数里面,判断是数组且是add
类型操作,则把length对应的副作用函数拿出来重新执行:
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)
}
})
}
......省略下面的代码
}
此时再调试,能够成功:
1.2 边缘情况2,修改数组的length属性
如果某个副作用函数访问了arr[2]
,此时把数组的length改为0,那么应该通知这个副作用函数,因为索引为2的那个数已经被删掉了。
在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函数
中,判断是数组
并且是修改length属性
,把索引大于length对应的副作用函数要拿出来执行
js
let arr = reactive([11, 12, 13])
effect(() => {
console.log(arr[2], 'arr[2]');
})
调试结果如下:
对应源码
修改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()
}
对比Vue2
Vue2中,因为Object.defineProperty
方法的限制,无法拦截到数组的length和索引
的变化,以下两个操作都无法拦截到,只能通过Vue.set
来实现。
js
this.arr[0] = 2
this.arr.length = 0
2. 拦截数组for in操作
定义:拦截数组的for in操作是指,当某个副作用函数通过for in访问数组时,其他对数组的操作能够触发这个副作用函数重新执行。
js
let arr = reactive([11, 12, 13])
effect(() => {
for (const key in arr) {
console.log(arr[key], 'arr[key]');
}
})
如下操作会影响for in遍历,只要是修改了数组的长度的操作,都会影响for ...... in的遍历
js
arr[100] = '100'
arr.length = 0
具体实现: for in访问数组,会触发Proxy的拦截函数ownKeys,判断是数组,则传length
属性给track
函数,这样length属性一旦受到影响,就会触发对应的副作用函数
js
ownKeys (target) {
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
}
对应源码
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)
}
}
3. 拦截数组的for of方法
定义:当某个副作用函数通过for of访问数组时,其他地方对数组的修改能够触发for of重新执行。
js
let arr = reactive([11, 12, 13])
effect(() => {
for (const value of arr) {
console.log(value, 'value');
}
})
如下图,我们修改数组的索引为0的值,能够触发for of重新执行,通过new Proxy已经能够实现拦截了:
这是因为数组能够被for of访问是得益于@@iterator
也就是内部方法Symbol.iterator
,其内部实现就是访问了数组的length属性
,Proxy本身能够实现对length的读取
边缘情况
要解决一个情况是,for of在遍历数组时,会把对应symbol类型的Symbol.iterator
这个key也收集到依赖中,进而会让数字和symbol进行比较,产生报错。
如下报错:
报错原因:
对应的报错代码:
实际上我们只需要数组的length
和key
与对应的副作用函数之间建立联系就可以。修改方式如下:只有在key的类型不是symbol
时才会去进行依赖追踪:
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
},
调试正常
对应源码
js
// /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
}
}
4. 拦截数组的查找方法indexOf lastIndexOf includes
实现机制:Proxy能够直接拦截侦听到indexOf、lastIndexOf、includes等方法。但是需要做一些特殊处理,比如拿原始对象去代理对象里面找,就会出现找不到的情况。
如下原始数值正常实现拦截
js
const arr = reactive([1,2,3])
effect(() => {
console.log(arr.includes(1), 'arr.includes(1)'); // true
})
修改数组的索引值时再次触发了副作用函数
边缘情况1:
但如下情况,从arr里面找arr[0],返回会是false,如果期待他返回是true还应该做额外的处理
js
const obj = {}
let arr = reactive([obj])
console.log(arr.includes(arr[0]), 'arr.includes(arr[0])'); // false
返回是false的原因是,arr[0]在访问时,产生了一个新的代理对象,includes访问数组内部时会再次针对obj产生一个代理对象,如下代码会执行两次
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
},
这两个代理对象是不同的,解决方式是用一个map数据结构记录已经转化的数据
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
}
边缘情况2:
如果直接去代理对象上找原始对象的信息,就会有找不到的情况,此时的修改方式应该是先从代理对象身上找,找不到再去原始对象身上找
找不到的情况:
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
重写数组的方法:
js
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
includes: function (...args) {
}
}
在get拦截函数里:
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
},
重写部分的内容:
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
}
}
实现效果:
进一步完善其他方法:
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
}
})
对应源码
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. 拦截splice/push/pop/unshift/shift
实现机制:这几个方法能够被Proxy直接拦截,因为他们内部会访问并修改数组的索引和length,只是额外需要处理一些边缘情况,仍然需要修改部分功能。
边缘情况
如下写法会出现栈溢出的情况:
js
effect(() => {
arr.push(1)
})
effect(() => {
arr.push(2)
})
第一个arr.push(1)
会修改 length
属性,触发依赖更新。arr.push(2)
再次调用 push
,会影响arr.push(1)
,形成递归调用链,最终导致栈溢出。
解决方式:
重写数组的这几个方法。声明了变量shouldTrack
,在方法执行前,不允许追踪length属性,改为false,方法执行完毕后,改为true。
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
}
})
track函数里面进行判断:
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
}
}
......
}