likes
comments
collection
share

vuejs设计与实现-非原始值的响应式方案

作者站长头像
站长
· 阅读数 35

理解 Proxy 和 Relfect

Proxy: 对一个对象基本语义的代理, 允许拦截重新定义一个对象的基本操作.

obj.foo++ // 对象的读取设置操作

const fn = (a) => console.log(a) 
// 函数的调用操作
const fnP = new Proxy(fn, {
    apply(target, thisArg, argArray) {
        console.log(target, thisArg, argArray)
        target.call(thisArg, 'hhhhh')
    }
})

// 非基本操作(复合操作)  1. get操作得到 obj.fn  2. (obj.fn)()函数调用
obj.fn()

Relfect: 全局对象, 提供了访问一个对象属性的默认行为. 第三个参数可以指定接收者receiver, 可以理解为this.

// bar 是一个访问器属性,返回了 this.foo 属性的值 
const obj = {
    foo: 1,
    get bar(){
        return this.foo
    }
}

Relfect.get(obj, 'foo', { foo: 2 })

const p = new Proxy(obj, {
    get(target, key, receiver){
        track(target, key)
        // return target[key]
        return Relfect.get(target, key, receiver)
    }
})

effect(() => {
    // 此时访问器属性bar中的this指向代理对象p
    // target[key] 此种情况执向原对象 obj.foo 无法建立联系
    console.log(p.bar)
})

javascript对象及Proxy的工作原理

js中对象分为常规对象异质对象. 对象的实际语义是由对象的内部方法(对一个对象操作时在引擎内部调用的方法)指定的. 函数对象会部署内部方法[[call]]或者[[construct]](构造函数通过new关键字调用).

proxy是一个异质对象, 其内部的[[get]]方法不同于普通方法的实现. 如果创建代理对象时没有指定对应的拦截函数, 代理对象内部的[[get]]会调用原始对象的内部方法[[get]]获取属性值. 拦截函数用于自定义代理对象的内部方法和行为, 而不是被代理对象.

如何代理Object

响应系统需要拦截所有读取操作, 普通对象所有可能的读取操作:

const obj = { foo: 1 }
// 访问属性: obj.foo
const p = new Proxy(obj, {
    get(target, key, receiver){
        track(target, key)
        return Reflect.get(target, key, receiver)
    }
})
effect(() => {
    p.foo
})


// in操作符 key in obj
const p = new Proxy(obj, {
    has(target, key){
        track(target, key)
        return Reflect.has(target, key)
    }
})
effect(() => {
    'foo' in p
})


// for...in循环
// 不与具体的key绑定, 所以构造一个唯一的key
const ITERATE_KEY = Symbol()
const p = new Proxy(obj, {
    ownKeys(target){
        track(target, ITERATE_KEY)
        // 返回keys数组
        return Reflect.ownKeys(target)
    }
})
effect(() => {
    for(const key in p){ ... }
})


// 修改trigger方法, 增加对 for...in 的处理
fcuntion 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)
        }
    })
    
    // 只有新增或删除操作, 才触发与ITERATE_KEY相关联的副作用函数
    // 其中proxy对象set操作的拦截方法中需要对操作类型作判断 add|edit
    if(type === 'add' || type === 'delete'){
        const iterateEffects = depsMap.get(ITERATE_KEY)
        iterateEffects && iterateEffects.forEach(effectFn => {
        if(effectFn !== activeEffect){
            effectsToRun.add(effectFn)
        }
    })
    }
    
    
    effectsToRun.forEach(fn => {
        // 如果 scheduler 存在就调用, 并将副作用函数作为参数传递
        if(fn.options.scheduler){
            fn.options.scheduler(fn)
        }else {
            fn()
        }
    })
}

合理地触发响应

我们只需要在值发生变化时触发响应, 且NaN === NaNfalse同样不需要触发响应.

function reactive(obj){
    return new Proxy(obj, {
        get(target, key, receiver){
            // 代理对象可以通过raw属性访问原始数据
            if(key === 'raw') return target
            
            track(target, key)
            return Relfect.get(target, key, receiver)
        },
        set(target, key, newVal, receiver){
            const oldVal = target[key]
            const type = Object.prototype.hasOwnProperty.call(target, key) ? 'set' : 'add';
            const res = Reflect.set(target, key, newVal, receiver)
            // 只有当receiver是target的代理对象时才触发更新, 屏蔽由原型引起的更新
            if(target === receiver.raw){
                if(oldVal !== newVal && (oldVal === oldVal || newVal === newVal)){
                    trigger(target, key, type)
                }
            }
        }
        // ...其他拦截方法
    })
}

只读和浅只读

代理数组

数组是一个特殊的对象(也是异质对象), 除了内部方法[[defieOwnProperty]]外, 其他内部方法的逻辑与常规对象相同, 但数组的操作与普通对象有些不同. 以下是所有对数组元素或属性的读取操作:

  • 通过索引访问元素: arr[0]
  • 访问数组长度: arr.length
  • 把数组作为对象, 使用for...in遍历
  • 使用for...of遍历数组
  • 数组的原型方法: concat/join/some/every/find(Index)/includes 以及其他不改变原数组的方法.

及设置操作:

  • 通过索引修改元素: arr[0] = 1
  • 修改数组长度: arr.length = 0
  • 数组的栈方法: push/pop/shift/unshift
  • 修改原数组的方法: splice/fill/sort 等

当这些操作发生时, 应该正确地建立响应联系或触发响应.

数组的索引与length

一般来说, 通过索引访问元素与对象是类似的. 但是如果设置的索引值大于当前长度, 此次操作也会更新length属性; 如果设置length属性的新值小于原来的值, 则会删除多余的元素. 此类操作都应该触发响应.

// 1. 判断当前的操作类型, 当前索引是否大于数组长度
// 2. lenght属性赋值, newV 是新的长度
function createReactive(obj, isShallow = false, isReadonly = false){
    return new Proxy(obj, {
        set(target, key, newVal, receiver) {
            if(isReadonly) {
                console.warn(`属性${key}是只读的`)
                return true
            }
            const oldVal = target[key]
            // 判断代理对象是否为数组
            // 数组: 判断设置的索引值是否小于数组长度
            // 对象: 判断key是否存在
            const 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)
            if(target === receiver.raw){
                if(oldVal !== newVal && (oldVal === oldVal || newVal === newVal)){
                    // 修改length值时, newVal就是新的长度
                    trigger(target, key, type, newVal)
                }
            }
            return res
        }
    })
}


fcuntion trigger(target, key, type, newVal){     
    const depsMap = bucket.get(target)
    if(!depsMap) return 
    // ...省略其他代码
    
    // 如果是数组且为add操作, 数组的length属性也应变化触发响应
    if(Array.isArray(target) && type === 'add') {
        const lengthEffects = depsMap.get('length')
        lengthEffects && lengthEffects.forEach(effectFn => {
            if(effectFn !== activeEffect){
                effectsToRun.add(effectFn)
            }
        })
    }
    
    // 如果是数组, 且修改了length属性, newVal便是新的length
    if(Array.isArray(target) && key === 'length') {
        // 索引大于或等于新的length值的元素, 将其所有相关联的副作用函数取出执行
        depsMap.forEach((effects, key) => {
            if(key >= newVal){
                effects.forEach(effectFn => {
                    if(effectFn !== activeEffect){
                        effectsToRun.add(effectFn)
                    }
            }
        }) 
    }
    
    
    effectsToRun.forEach(fn => {
        // 如果 scheduler 存在就调用, 并将副作用函数作为参数传递
        if(fn.options.scheduler){
            fn.options.scheduler(fn)
        }else {
            fn()
        }
    })
}

遍历数组

对于普通对象, 添加或删除属性时才会影响for...in循环的结果. 对于数组, 只要是length发生变化, 都应该触发响应.

const p = new Proxy(obj, {
    // ...,
    ownKeys(target){
        // 如果是数组, 则使用length属性代替ITERATE_KEY
        track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
        return Reflect.ownKeys(target)
    }
})

for...in遍历不同, for...of是用来遍历可迭代对象的.

数组的查找方法

数组的方法内部其实都依赖了对象的基本语义. 大多数情况下不需要特殊处理即可让这些方法按预期工作.

const arr = reactive([1, 2])

effect(() => {
    console.log(arr.includes(1)) // 初始打印true
})
arr[0] = 3 // 重新执行打印false
  • 通过代理对象访问元素值, 如果值仍然是可以被代理的, 那么得到的值就是新的代理对象而非原始对象. 通过arr[0]includes都是得到代理对象, 因为每次调用reactive函数都会创建新的代理对象. 因此需要一个存储原始对象到代理对象的映射, 避免多次创建代理对象.
  • 通过includes判断原始对象是否存在时, 直觉上应该为真. 但实际上由于访问的是代理对象, 会返回false. 因此需要重写数组方法...
const obj = {}
const arr = reactive([obj])
console.log(arr.includes(arr[0])) // false
console.log(arr.includes(obj)) // false

// 1: 使用map实例存储映射关系 解决第一种情况
const reactiveMap = new Map()
function reactive(obj){
    let proxy = reactiveMap.get(obj)
    if(proxy) return proxy
    
    proxy = createReactive(obj)
    reactiveMap.set(obj, proxy)
    return proxy
}

// 2.重写includes等方法, 分别对原始对象和代理对象尝试执行此方法
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
    }
})
function createReactive(obj){
    return new Proxy(obj, {
        get(target, key, receiver){
            // ...
            // 如果是数组, 且是对应的操作则返回重写后的值
            if(Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
                return Reflect.get(arrayInstrumentations, key, receiver)
            }
            // ...
        }
    })
}

隐式修改数组长度的原型方法

push/pop/shift/unshift等方法会修改原数组, 改变数组长度. 比如push操作, 需要在末位插入元素, 既会读取又会设置lenght.

// 是否允许追踪
let shouldTrack = true;
function track(){
    // ...
    if(!shouldTrack || !activeEffect) return
    // ...
}

['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
    const originMethod = Array.prototype[method]
    arrayInstrumentations[method] = function(...args){
        shouldTrack = false
        let res = originMethod.apply(this, args)
        // 调用方法之后才允许追踪, push读取length但此时阻止了追踪
        shouldTrack = true
        return res
    }
})

代理Set和Map

集合类型对象与普通对象有很大不同, 有自己独特的属性及方法.

如何代理Set和Map

所以进行代理时需要做特殊的处理.

const reactiveMap = new Map()

fucntion reactive(obj){
    const existionProxy = reactiveMap.get(obj)
    if(existionProxy) return existionProxy
    const proxy = createReactive(obj)
    
    reactiveMap.set(obj, proxy)
    return proxy
}

fucntion createReactive(obj, isShallow, isReadonly = false){
    return new Proxy(obj, {
        get(target, key, receiver) {
            // map.size 集合类对象特殊处理, 访问size属性时this指向原始对象
            if(key === 'size') {
                return Reflect.get(target, key, target)
            }
            // map.delete(...) 方法特殊处理, 将方法与原始对象绑定后返回
            return target[key].bind(target)
        }
    })
}

建立响应联系

了解代理时的关键, 就可以实现响应式方案了.

  • size属性: 读取size属性时, 调用track函数建立响应联系. 由于新增和删除操作都会改变size属性, 所以需要在ITERATE_KEY与副作用之间建立联系.
  • 同时需要实现自定义新增删除方法, 将返回值target[key].bind(target)改为mutableInstrumentations[key], 在自定义方法中执行trigger.
  • 另, 集合类对象在新增(如果已存在)或删除(如果不存在)时, 不需要触发响应!

避免污染原始数据

响应式数据设置到原始数据的行为就是数据污染, 比如Map类型的set、Set类型的add、普通对象的写值操作、数组添加元素等, 都需要避免污染.

避免污染的方法, 在进行上述操作时, 如果要写入的值为响应式数据, 则通过raw(源码中用Symbol类型)属性获取原始数据, 再把原始数据写入target.

处理forEach

与数组方法的forEach方法不同, 其回调函数有三个参数(value, key, map), Map对象既关心key也关心value. 除了与ITERATE_KEY建立联系, 也要对set操作进行处理. 总之, 不同对象的forEach方法有其各自的特点, 均需要根据实际语义对其做处理.

迭代器方法

总结

  • vue3响应式数据基于Proxy代理实现, 其中Reflect.*解决了this指向的问题.
  • Obejct对象的代理
  • 深响应与浅响应以及深只读与浅只读, 深响应(只读)返回值需要做一层包装.

...未完成

转载自:https://juejin.cn/post/7208540587077763109
评论
请登录