Vue响应式原理(9)-对象代理机制完善

在前面的文章中,我们重点对副作用注册函数 effect、依赖收集函数 track 和依赖触发函数 trigger 做了原理解释和代码编写。而对于响应式对象的代理,我们还未做深入的探讨和了解。截至目前,我们仅对原始对象的 getset 方法进行了代理,因此只能满足对响应式对象读取操作的依赖收集,并在设置对象属性时触发相应依赖。然而实际上,对于对象的操作不仅只有读取,还包括对对象属性是否存在的判断,对象属性的遍历等。如以下代码:

effect(() => {
    'foo' in obj
})

代码中我们使用 in 操作符检查对象上是否具有名为 fookey,这从某种意义上来说也属于读取操作,假设我们删除了对象的 foo 属性时,应该触发依赖重新执行副作用函数。但是目前我们的代理对象并为对 in 操作进行依赖收集,依赖触发也就无从谈起。因此,我们需要对对象的代理进行完善:

1. proxy 工作原理

JavaScript 中,对象的实际语义是由对象的内部方法(internal method)指定的。所谓内部方法,指的是当我们对一个对象进行操作时在引擎内部调用的方法,这些方法对于 JavaScript 使用者来说是不可见的。举个例子,当我们访问对象属性时:

obj.foo

引擎内部会调用 [[Get]] 这个内部方法来读取属性值。这里补充说明一下,在 ECMAScript 规范中使用 [[xxx]] 来代表内部方法或内部槽。

内部方法具有多态性,就是说不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑。例如,普通对象和 Proxy 对象都部署了[[Get]] 这个内部方法,但它们的逻辑是不同的,普通对象部署的[[Get]] 内部方法的逻辑是由 ECMA 规范的 10.1.8 节定义的,而Proxy 对象部署的 [[Get]] 内部方法的逻辑是由 ECMA 规范的10.5.8 节来定义的。

当我们通过代理对象访问属性值时:

const p = new Proxy(obj, {/* ... */})
p.foo

实际上,引擎会调用部署在对象 p 上的内部方法 [[Get]]。到这一步,其实代理对象和普通对象没有太大区别。它们的区别在于对于内部方法 [[Get]] 的实现,这里就体现了内部方法的多态性,即不同的对象部署相同的内部方法,但它们的行为可能不同。具体的不同体现在,如果在创建代理对象时没有指定对应的拦截函数,例如没有指定 get() 拦截函数,那么当我们通过代理对象访问属性值时,代理对象的内部方法 [[Get]] 会调用原始对象的内部方法 [[Get]] 来获取属性值,这其实就是代理透明性质。而如果设置了相应的拦截函数,则会调用指定的拦截函数。因此,创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。

下表列出了 Proxy 对象部署的所有内部方法以及用来自定义内部方法和行为的拦截函数名字:

内部方法 处理器函数
[[GetPrototypeOf]] getPrototypeOf
[[SetPrototypeOf]] setPrototypeOf
[[IsExtensible]] IsExtensible
[[PreventExtensions]] preventExtensions
[[GetOwnProperty]] getOwnPropertyDescriptor
[[DefineOwnProperty]] defineProperty
[[HasProperty]] has
[[Get]] get
[[Set]] set
[[Delete]] deleteProperty
[[OwnPropertyKeys]] ownKeys
[[Call]] apply
[[Constructor]] constructor

举个例子,当我们要删除代理对象上的某个属性时,所需要调用的内部方法是[[Delete]],那么对应的拦截函数名就是 deleteProperty,代码如下:

const obj = { foo: 1 }
const p = new Proxy(obj, {
    deleteProperty(target, key) {
        return Reflect.deleteProperty(target, key)
    }
})

console.log(p.foo) // 1
delete p.foo
console.log(p.foo) // 未定义

这里需要强调的是,为了删除被代理对象上的属性值,在拦截函数 deleteProperty 中我们需要使用
Reflect.deleteProperty(target, key) 来完成原始对象的属性删除操作。

2. 对象 in 操作的代理

对于 in 操作符,应该如何拦截呢?我们可以先查看上表,尝试寻找与 in 操作符对应的拦截函数,但表中没有与 in 操作符相关的内容。这时我们就需要查看关于 in 操作符的相关规范。在 ECMA-262 规范的 13.10.1 节中,明确定义了 in 操作符的运行时逻辑,如图所示:

Vue响应式原理(9)-对象代理机制完善

图中可以观察到 in 操作符整个内部执行过程,在第六步中调用了叫做 HasProperty 的抽象方法,关于 HasProperty 抽象方法,可以在 ECMA-262 规范的 7.3.11 节中找到,它的操作如图所示:

Vue响应式原理(9)-对象代理机制完善

其执行过程如下:

01. 断言:Type(O) 是 Object。
02. 断言:IsPropertyKey(P) 是 true03. 返回 ? O.[[HasProperty]](P)。

在第 3 步中,可以看到 HasProperty 抽象方法的返回值是通过调用对象的内部方法 [[HasProperty]] 得到的。而[[HasProperty]] 内部方法可以在表中找到,它对应的拦截函数名叫 has,因此我们可以通过 has 拦截函数实现对 in 操作符的代理。

const obj = { foo: 1 }
const p = new Proxy(obj, {
    has(target, key) {
        track(target, key)
        return Reflect.has(target, key)
    }
})

这样,当我们在副作用函数中通过 in 操作符操作响应式数据时,就能够建立依赖关系:

effect(() => {
    'foo' in p // 将会建立依赖关系
})

3. for…in循环代理

当我们在副作用函数中循环遍历一个对象的属性时,我们希望当对象属性发生新增或删除时,副作用函数会重新得到执行,因此我们首先需要完成对 for...in 循环的依赖收集过程。对象的任何操作其实都是由基本语义方法及其组合实现的,for...in 循环也不例外。要搞清楚 for...in 内部实现中用到了基本语义方法,也要通过查阅规范。这个过程相对较为复杂,此处不展开叙述。我们最终可以确定,for...in 操作可以通过 ownKeys 方法来进行拦截。

但是对 for...in 的依赖收集存在一个问题,在之前的依赖收集过程中,往往都涉及到对象的某一个具体属性值,因此我们可以在拦截函数中获取到参数 key,但是在 for...in 循环操作中,并不涉及某一个具体的 key 值,因此我们在依赖收集中就无法将副作用函数收集到对象某个属性值对应的依赖集合 deps 中。

要解决这个问题并不困难。既然没有 key,那我们可以自己设置一个 key,同时我们要保证这个 key 不会和对象的属性同名导致冲突。但是对象会有怎样的属性是我们无法预知的,我们必须保证我们所设置的这个 key 和所有属性都不会产生冲突。Symbol 类型的数据正好能满足我们的要求,我们可以设置一个 Symbol 类型的 key 值专用于收集 for...in 循环对应的副作用函数,代码如下:

const obj = { foo: 1 }
const ITERATE_KEY = Symbol()

const p = new Proxy(obj, {
    ownKeys(target) {
        // 将副作用函数与 ITERATE_KEY 关联
        track(target, ITERATE_KEY)
        return Reflect.ownKeys(target)
    }
})

通过 ownKeys 拦截函数,我们能够对 for...in 循环进行代理,在 ownKeys 函数中调用 track 函数完成依赖收集,传递的参数除对象本身外,还要传递我们设置的变量 ITERATE_KEY,此时所建立的依赖关系为:

target
    └── ITERATE_KEY
        └── fn

当完成依赖收集后,我们需要考虑在何时触发依赖。很显然,for...in 循环完成的是对对象属性的遍历,那么我们希望在对象属性发生变化的时候将副作用函数重新执行。这里的属性发生变化是指对象的属性发生了新增而修改,而对对象某一个属性的值进行修改是不应该触发副作用函数的,因为无论怎么修改一个属性的值,对于 for...in 循环来说都是循环相同的次数,读取到相同的 key 值。所以在这种情况下,我们不需要触发副作用函数重新执行,否则会造成不必要的性能开销。

无论是添加新属性还是修改已有的属性值,其基本语义都是 [[Set]],我们都是通过 set 拦截函数来实现拦截的。但是我们需要的是在新增属性的时候触发依赖,而在设置对象属性值时不要触发依赖。要解决上述问题,就需要当设置属性操作发生时我们在 set 拦截函数进行判断,区分操作的类型是添加新属性还是设置已有属性。而判断是新增还是设置属性的依据就是当前处理的属性是否已经存在于对象上。对 set 拦截函数进行改进:

const p = new Proxy(obj, {
    // 拦截设置操作
    set(target, key, newVal, receiver) {
        // 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
        const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
        // 设置属性值
        const res = Reflect.set(target, key, newVal, receiver)
        // 将 type 作为第三个参数传递给 trigger 函数
        trigger(target, key, type)
        return res
    }
})

如以上代码所示,我们优先使用 Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上,如果存在,则说明当前操作类型为 'SET',即修改属性值;否则认为当前操作类型为 'ADD',即添加新属性。最后,我们把类型结果 type 作为第三个参数传递给 trigger 函数。在 trigger 函数内就可以通过类型 type 来区分当前的操作类型,并且只有当操作类型 type'ADD' 时,才会触发与 ITERATE_KEY 相关联的副作用函数重新执行,这样就避免了不必要的性能损耗。

相应的,我们需要对 trigger 函数进行改进:

function trigger(target, key, type) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    const effectsToRun = new Set()
    effects && effects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    })

    // 只有当操作类型为 'ADD' 时,才触发与 ITERATE_KEY 相关联的副作用函数重新执行
    if (type === 'ADD') {
        const iterateEffects = depsMap.get(ITERATE_KEY)
        iterateEffects && iterateEffects.forEach(effectFn => {
            if (effectFn !== activeEffect) {
                effectsToRun.add(effectFn)
            }
        })
    }
    
    effectsToRun.forEach(effectFn => {
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

至此,我们完成了对象新增属性的依赖收集和依赖触发流程,而对于对象删除属性的操作则相对简单一些,查阅规范可以得知,delete 操作符的行为依赖 [[Delete]] 内部方法,因此可以通过 deleteProperty 拦截。

const p = new Proxy(obj, {
    deleteProperty(target, key) {
        // 检查被操作的属性是否是对象自己的属性
        const hadKey = Object.prototype.hasOwnProperty.call(target, key)
        // 使用 Reflect.deleteProperty 完成属性的删除
        const res = Reflect.deleteProperty(target, key)        
        if (res && hadKey) {
            // 只有当被删除的属性是对象自己的属性并且成功删除时,才触发更新
            trigger(target, key, 'DELETE')
        }
        return res
    }
})

如以上代码所示,首先检查被删除的属性是否属于对象自身,然后调用 Reflect.deleteProperty 函数完成属性的删除工作,只有当这两步的结果都满足条件时,才调用 trigger 函数触发副作用函数重新执行。需要注意的是,在调用 trigger 函数时,我们传递了新的操作类型 'DELETE'。由于删除操作会使得对象的键变少,它会影响 for...in 循环的次数,因此当操作类型为 'DELETE' 时,我们也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行:

function trigger(target, key, type) {
    const depsMap = bucket.get(target)

    if (!depsMap) return
    const effects = depsMap.get(key)
    const effectsToRun = new Set()

    effects && effects.forEach(effectFn => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    })

    // 当操作类型为 ADD 或 DELETE 时,需要触发与 ITERATE_KEY 相关联的副作用函数重新执行
    if (type === 'ADD' || type === 'DELETE') {
        const iterateEffects = depsMap.get(ITERATE_KEY)
        iterateEffects && iterateEffects.forEach(effectFn => {
            if (effectFn !== activeEffect) {
                effectsToRun.add(effectFn)
            }
        })
    }

    effectsToRun.forEach(effectFn => {
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

到这一步,我们对于响应式对象的代理、依赖收集、依赖触发机制有了相对完善的代码实现,在后续文章中,我们会针对数组类型对象进行进一步的完善。

原文链接:https://juejin.cn/post/7326923332196302887 作者:明教教主张5G

(0)
上一篇 2024年1月24日 上午10:11
下一篇 2024年1月24日 上午10:21

相关推荐

发表回复

登录后才能评论