读《Vue.js设计与实现》第五章·非原始值的响应式方案,Vue3的源码手写和解析

这篇文章是我在阅读《Vue.js设计与实现》这本书的过程中写的,一边写一边敲代码测试,同时去翻阅Vue3里面的源码作对照。每一小节都会解决对应的问题,并且把对应的源码也贴在一起。

推荐跟着示例整体敲一遍。看完这篇文章,你将学会Vue3中如下问题,:

  1. Proxy和Reflect是什么,有哪些API?
  2. JS的常规对象和异质对象有什么不同?JS内部方法是什么,有哪些?
  3. Proxy如何代理对象的obj.key属性访问,in操作符,以及for in遍历这三种情况?
  4. 如何拦截对象的删除操作?
  5. 当修改对象的值前后不变,或者是NaN时,Vue3是如何处理的?
  6. 当访问对象的属性时,如何避免触发原型对应的副作用函数?
  7. 对象的浅响应和深响应是什么?原理是什么?
  8. 对象的只读是什么?浅只读和深只读是什么?如何实现?
  9. 如何拦截通过索引或者length属性 修改/访问数组?
  10. 如何拦截数组的for in遍历?如何拦截数组for ...... of遍历?
  11. 如何拦截数组的查找方法,includes indexOf lastIndexOf?
  12. 如何拦截数组的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对应的副作用函数拿出来执行。

如上代码中,我们支持拦截:

  1. 访问/设置对象属性
  2. 支持拦截in操作符
  3. 支持拦截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.barparent.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表示只读。在setdeleteProperty拦截函数里面判断如果是只读就直接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函数里传newValtrigger函数,在trigger函数里面判断,如果拦截的是数组数据并且keylength,说明改的是数组的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会读取这个属性。实际上我们只需要数组的lengthkey与对应的副作用函数之间建立联系就可以了。

如何修改呢?

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
    }
  }
  ......
}
相关推荐
用户21411832636023 分钟前
dify案例分享-deepseek赋能从 Excel 表格到统计图,一键生成代码不是梦
前端
Winwin4 分钟前
老帅 Webpack 和新将 Vite 的 PK
前端·前端工程化
best6669 分钟前
预检请求是什么?
前端
前端加油站11 分钟前
单元测试入门与进阶
前端·单元测试
前端付杰16 分钟前
第八节: 全面理解vue3: 工具函数的核心作用与使用方法
前端·javascript·vue.js
Mr_sun.16 分钟前
Node.js与VUE安装
前端·vue.js·node.js
Tonychen17 分钟前
【React 源码阅读】为什么 React Hooks 不能用条件语句来执行?
前端·react.js·源码阅读
Cutey91621 分钟前
Vuex vs Pinia
前端·vue.js·面试
Sallywa27 分钟前
全局替换的思路历程
前端
Anlici32 分钟前
虚拟dom 源码分析一下
前端·javascript·前端框架