Vue3源码,拦截对象,对比Vue2

看完这篇文章,你将学会Vue3中如下问题:

  1. Proxy和Reflect是什么,有哪些API?
  2. JS的常规对象和异质对象有什么不同?JS内部方法是什么,有哪些?
  3. Proxy如何代理对象的obj.key属性访问,in操作符,以及for in遍历这三种情况?
  4. 如何拦截对象的删除操作?
  5. 对比Vue2,Vue2如何处理对象的新增属性和删除属性的拦截操作?
  6. 当修改对象的值前后不变,或者是NaN时,Vue3是如何处理的?
  7. 当访问对象的属性时,如何避免触发原型对应的副作用函数?
  8. 对象的浅响应和深响应是什么?原理是什么?
  9. 对象的只读是什么?浅只读和深只读是什么?如何实现?

参考《Vue.js设计与实现》和Vue3 Vue2源码

5.1 理解Proxy

定义:理解Proxy和Reflect是什么?

Proxy是什么?他是一个对象,能够创建一个对象的代理。他只能代理对象,不能代理数字、字符串、布尔值。

代理是什么呢?日常生活中的代理通常是指一个组织或者个人帮忙请求方完成一个任务。在JS中,Proxy是对一个对象的基本语义的代理,他允许拦截并且重新定义对象的基本操作。

基本语义是什么?比如对象的读取和设置操作,就属于基本语义的操作。基本语义就是基本的操作

如下就是通过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()包括两个步骤:

  • 第一步:obj.fn是访问fn属性,触发get拦截函数
  • 第二步,调用,触发Proxy的apply拦截方法

小节:Proxy是什么?需要理解:

  • 代理是指中间商帮助请求方做事情。而Proxy也是创建一个代理对象
  • Proxy属于构造函数对象,能够被new关键字实例化,创建一个proxy代理对象,能够拦截对象的基本语义的操作
  • 基本语义是指基本操作,如对象的读取值和设置值。但是调用对象的方法obj.fn()属于符合操作,不属于基本语义的操作。

5.2 Reflect是什么

定义:

Reflect是一个内置对象,可以直接调用方法:Reflect.get() Reflect.set()。他不是一个构造函数对象,不可以通过new关键字来实例化

Proxy有的拦截方法,Reflect都有。比如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

但是我们此时若执行proxy1.foo++,并不会触发副作用函数重新执行。此时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++,效果如下,成功实现

小结:什么是Reflect对象?

  1. Reflect属于内置对象,内置对象可以直接调用方法;不能通过new来实例化
  2. Reflect的作用是修改函数调用过程中的this指向
  3. Reflect拥有的方法和Proxy一致

5.3 JavaScript对象及Proxy的工作原理

定义:

JavaScript中的对象分为常规对象异质对象两类,任何不属于常规对象的对象都是异质对象

什么是常规对象,什么又是异质对象?

该分类和内部方法有关系,需要先理解内部方法的概念。

JS中对象的实际语义是由对象的内部方法指定的。内部方法是对象在进行操作时在引擎内部调用的方法。对于我们开发者来说不可见。

js 复制代码
obj.foo

当调用obj.foo时,引擎内部使用的是[[GET]]这个内部方法来读取属性值。[[xxx]]是代表内部方法或者内部槽的格式。

下面记录的是JS的常规对象中的11个方法,你不需要去强行记忆他们

内部方法 签名 描述
[[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个方法。如果是函数对象则必须部署[[Call]] 和 [[Construct]]:

内部方法 签名 描述
[[Call]] (any, a list of any) -> any 将运行的代码与this关联
[[Construct]] (a list of any) -> Object 创建一个对象,通过new操作符或者super调用触发

到此可以回答,如何区分常规对象和异质对象?

  • 如上11个内部方法,必须由ECMA规范10.1.x节定义实现
  • 内部方法[[Call]],必须由ECMA规范10.2.1节定义实现
  • 内部方法[[Construct]],必须由ECMA规范10.2.2节定义实现

也可以回答:如何区分一个普通对象和一个函数对象呢?

函数对象内部必须部署了[[Call]][[Construct]]方法。但是函数对象依然是常规对象

此外,内部方法具有多态性。不同类型的对象部署了相同的内部方法,但是逻辑不同。比如普通对象和Proxy对象都有[[GET]]方法,但是普通对象的[[Get]]由ECMA规范的10.1.8节定义。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代理函数时才会部署

举例:如何拦截删除操作?

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');

小节:

  1. 区分常规对象和异质对象:部署了11种指定规范的内部方法,就是常规对象。只要对应的版本号不一致,就不是常规对象。理解例子:Proxy的[[GET]]方法和常规对象不一致。Proxy是异质对象
  2. 区分普通对象和函数对象:函数对象身上部署了call和construct的内部方法

5.4 如何代理Object

对象的读取涉及如下操作:

  • 访问属性: obj.foo
  • 判断某个对象上是否有指定的key: key in obj
  • 使用for ...... in循环遍历对象: for (key in obj) {}

拦截Proxy的读取操作,就是通过Proxy包装目标对象,通过getter拦截读取操作。示例代码如下:

js 复制代码
const obj = new Proxy(data, {
    get(target, key, receiver) {
        track(target, key)
        // return target[key]
        return Reflect.get(target, key, receiver)
    },
})

5.4.1 拦截key in obj

Vue3是通过Proxy API的has拦截函数对in操作符进行拦截,这是因为对象内部部署了内部方法[[hasProperty]]

具体来看,在ECMA-262规范的13.10.1节规范中,就有写到HasProperty

js 复制代码
......
6. Return ? HasProperty(rval, ? ToPropertyKey(lval))

代码具体拦截操作如下:

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操作符');
})

如上代码,在Proxy中,使用has方法并调用track方法跟踪key,返回Reflect.has(target, key)的值, 在副作用函数中,判断属性是否在proxy1对象中,最终实现拦截 in操作符

对应源码

js 复制代码
// packages/reactivity/src/baseHandlers.js - 第200行
  has(target: Record<string | symbol, unknown>, key: string | symbol): boolean {
    const result = Reflect.has(target, key)
    if (!isSymbol(key) || !builtInSymbols.has(key)) {
      track(target, TrackOpTypes.HAS, key)
    }
    return result
  }

如上拦截函数中,还对symbol的key做了判断,只有不是symbol的key才会进入到track函数的追踪

5.4.2 拦截对象的for ...... in遍历

为什么要拦截对象的for ...... in遍历?

  • 因为当新增或者删除属性时,会对for in的遍历操作产生影响,所以要通知他我这个对象的数据改了,你再重新执行一遍。比如模版中通过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的ownKeysReflect.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相关联的副作用函数产生影响。

但是注意,目前还无法实现这个功能,如下代码中,增加属性proxy.bar时,set函数里面的trigger函数接受到的key是bar属性,那么执行的是与bar属性相关联的副作用函数,不会触发与ITERATE_KEY相关联的副作用函数。

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
    }
})

如何解决?

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关联的副作用函数,并执行

如下测试,实现了我们的效果:

小节:

  1. 通过Proxy的ownKeys对for in进行拦截,并且需要设置ITERATE_KEY来建立依赖关系
  2. 问题:新增某个属性bar,无法触发ITERATE_KEY对应副作用函数;解决方式是在set拦截函数里面要把ITERATE_KEY对应副作用函数也要拿出来收集,并且要通过判断Object.prototype.hasOwnProperty判断是新增,才执行对应副作用函数

对应源码

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)
  }

上面有一个判断,如果是数组则track会追踪length属性,这个我们后续会在分析

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.4.3 拦截对象删除操作

Vue3通过Proxy的DeleteProperty方法实现拦截对象的删除操作

具体来看,根据规范:

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产生影响,所以传递一个type值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对应的副作用函数拿出来执行

在控制台调试结果如下

对应源码

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()
}

对比Vue2

Vue2的Object.defineProerty()无法直接拦截对象的for in遍历,他是通过Object.keys来对 对象的所有属性进行getter和setter的添加。而添加属性和删除属性时,必须通过Vue.set或者Vue.delete方法来触发对应的响应

如下是Vue2中针对对象的每个属性进行响应追踪的伪代码

diff 复制代码
class Observer {
    constructor (value) {
        this.value = value
        // 如果不是数组
        if (!Array.isArray(value)) {
            this.walk(value)
        }
    }

    walk (obj) {
+        const keys = Object.keys(obj) 
+        for (let i = 0; i < keys.length; i++) {
+            defineReactive(obj, keys[i], obj[keys[i]]) // 在这个里面进行响应式添加
        }
    }
}

如下是Vue2的Vue.set的实现方式,重点关注第六步,对目标对象的key再次执行defineReactive也就是再次执行了Object.defineProperty,使得这个属性也具备了响应式能力

js 复制代码
export function set(target: Array<any> | Object, key: any, val: any): any {
  // 1. 处理数组
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  
  // 2. 对象已经有这个属性,直接赋值
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  
  // 3. 获取对象的 __ob__ 属性(Observer 实例)
  const ob = target.__ob__
  
  // 4. 不能向 Vue 实例或 $data 添加根级响应式属性,_isVue用于判断是不是Vue实例;ob.bmCount判断是否是根对象
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  
  // 5. 如果对象不是响应式对象,直接赋值
  if (!ob) {
    target[key] = val
    return val
  }
  
  // 6. 将新属性转换为响应式
  defineReactive(ob.value, key, val)
  
  // 7. 通知依赖更新
  ob.dep.notify()
  
  return val
}

Vue2的Vue.delete实现原理,重点关注第七点ob.dep.notify(),在删除完毕后会去通知所有的Watcher这个数据更新了。

js 复制代码
export function del(target: Array<any> | Object, key: any) {
  // 1. 处理数组,直接删掉对应索引值
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  
  // 2. 获取对象的 __ob__ 属性,有__obj__属性表示是响应式数据
  const ob = target.__ob__
  
  // 3. 不能删除 Vue 实例或 $data 的根级属性
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  
  // 4. 如果key不是target的自有属性,直接返回
  if (!hasOwn(target, key)) {
    return
  }
  
  // 5. 删除属性
  delete target[key]
  
  // 6. 如果对象不是响应式的,直接返回
  if (!ob) {
    return
  }
  
  // 7. 通知依赖更新
  ob.dep.notify()
}

反观Vue3的Proxy能够直接检测到新增属性和删除属性的操作,然后执行对应的依赖

5.5 解决响应拦截的一些问题

旧值不更新以及处理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

原型更新两次问题

当访问响应式数据的某个属性时,如果这个属性存在原型链上,可能会导致副作用函数被多次触发执行的问题。

如下代码:

js 复制代码
const reactive = function (obj) {
    return new Proxy(obj, {
        ......
    })
}
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对象身上找,触发child的bar属性对应的依赖收集,找不到又去parent上找,触发了parent对应bar属性的依赖收集。

在执行设置时,他们内部的set也会执行两次。但是有一点注意,他们执行两次set时,receiver始终是child代理对象

child.bar执行时

diff 复制代码
set (target, key, receiver) {
   // target是原始对象obj
+   // receiver是child代理对象
}

parent.bar执行时

diff 复制代码
set (target, key, receiver) {
   // target是原始对象proto
+   // 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'
            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其实就是代理对象child,receiver.raw拿到的就是child代理对象。访问代理对象的某个属性就会触发get。

对应源码

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 浅响应和深响应

定义:

  • 深响应会递归的将对象的所有属性转化为响应式
  • 浅响应只转换对象的第一层级为响应式

示例分析:

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: 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
  }
}

对比Vue2的实现方式:

diff 复制代码
// 改造该函数
function defineReactive (data, key, val) {
    if (typeof val === 'object') {
+        new Observer(val)
    }
    ......
}

在Observer类里面

diff 复制代码
class Observer {
    constructor (value) {
        this.value = value
        // 如果不是数组
        if (!Array.isArray(value)) {
            this.walk(value)
        }
    }

    walk (obj) {
        const keys = Object.keys(obj) 
        for (let i = 0; i < keys.length; i++) {
+            defineReactive(obj, keys[i], obj[keys[i]])
        }
    }
}

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函数进行追踪:

diff 复制代码
get(target, key, receiver) {
    if (key === 'raw') {
        return target
    }
+    if (!isReadonly) {
+        track(target, key)
+    }
    ......
}

但是目前只是浅只读,如果对象是嵌套数据,深层的属性依然还是可以修改,如下修改数据结构后,再去控制台调试:

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,
  )
}

对比Vue2

若Vue2想实现某个数据是只读的,可以设置Object.freeze()把对象进行冻结,这样就无法修改了

相关推荐
小吕学编程5 分钟前
ES练习册
java·前端·elasticsearch
Asthenia041213 分钟前
Netty编解码器详解与实战
前端
袁煦丞17 分钟前
每天省2小时!这个网盘神器让我告别云存储混乱(附内网穿透神操作)
前端·程序员·远程工作
一个专注写代码的程序媛1 小时前
vue组件间通信
前端·javascript·vue.js
一笑code1 小时前
美团社招一面
前端·javascript·vue.js
懒懒是个程序员2 小时前
layui时间范围
前端·javascript·layui
NoneCoder2 小时前
HTML响应式网页设计与跨平台适配
前端·html
凯哥19702 小时前
在 Uni-app 做的后台中使用 Howler.js 实现强大的音频播放功能
前端
烛阴2 小时前
面试必考!一招教你区分JavaScript静态函数和普通函数,快收藏!
前端·javascript
GetcharZp2 小时前
xterm.js 终端神器到底有多强?用了才知道!
前端·后端·go