Vuejs design and implementation – a responsive solution for non-primitive values

Understanding Proxy and Relfect

Proxy: A proxy for an object’s basic semantics, allowing intercept and redefinition of an object’s basic operations.

obj.foo + + // object read set operation

const fn = (a) => console. log(a)
// Function call operation
const fnP = new Proxy(fn, {<!-- -->
    apply(target, thisArg, argArray) {<!-- -->
        console. log(target, thisArg, argArray)
        target. call(thisArg, 'hhhhh')
    }
})

// Non-basic operation (composite operation) 1. Get operation to get obj.fn 2. (obj.fn)() function call
obj. fn()

Relfect: Global object, which provides the default behavior of accessing an object property. The third parameter can specify the receiver receiver, which can be understood as this .

// bar is an accessor property that returns the value of the this.foo property
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(() => {<!-- -->
    // At this time, this in the accessor attribute bar points to the proxy object p
    // target[key] In this case, the connection to the original object obj.foo cannot be established
    console.log(p.bar)
})

The working principle of javascript objects and Proxy

Objects in js are divided into regular objects and heterogeneous objects. The actual semantics of an object is determined by the internal method of the object (when operating on an object The method called inside the engine). The function object will deploy the internal method [[call]] or [[construct]] (the constructor is called by the new keyword).

Proxy is a heterogeneous object, and its internal [[get]] method is different from the implementation of ordinary methods. If the corresponding interception function is not specified when creating the proxy object, the [ [get]] will call the internal method [[get]] of the original object to get the attribute value. The interception function is used to customize the internal method and behavior of the proxy object, not the proxy object .

How to proxy Object

The response system needs to intercept all read operations, all possible read operations for common objects:

  • Access properties: obj.foo
  • Determine whether an object or prototype exists with a given key: key in obj
  • Use for…in to loop through objects
const obj = {<!-- --> foo: 1 }
// access property: obj.foo
const p = new Proxy(obj, {<!-- -->
    get(target, key, receiver){<!-- -->
        track(target, key)
        return Reflect. get(target, key, receiver)
    }
})
effect(() => {<!-- -->
    p.foo
})


// in operator 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 loop
// Not bound to a specific key, so construct a unique key
const ITERATE_KEY = Symbol()
const p = new Proxy(obj, {<!-- -->
    ownKeys(target){<!-- -->
        track(target, ITERATE_KEY)
        // return the keys array
        return Reflect.ownKeys(target)
    }
})
effect(() => {<!-- -->
    for(const key in p){<!-- --> ... }
})


// modify the trigger method, add the processing of 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 & amp; & amp; effects.forEach(effectFn => {<!-- -->
        // If the current side effect function is being executed, it will not be executed anymore
        if(effectFn !== activeEffect){<!-- -->
            effectsToRun.add(effectFn)
        }
    })
    
    // Only add or delete operations will trigger the side effect function associated with ITERATE_KEY
    // Among them, the interception method of the proxy object set operation needs to judge the operation type add|edit
    if(type === 'add' || type === 'delete'){<!-- -->
        const iterateEffects = depsMap. get(ITERATE_KEY)
        iterateEffects & amp; & amp; iterateEffects.forEach(effectFn => {<!-- -->
        if(effectFn !== activeEffect){<!-- -->
            effectsToRun.add(effectFn)
        }
    })
    }
    
    
    effectsToRun.forEach(fn => {<!-- -->
        // Called if the scheduler exists, passing the side effect function as an argument
        if(fn.options.scheduler){<!-- -->
            fn.options.scheduler(fn)
        } else {<!-- -->
            fn()
        }
    })
}

Trigger responses reasonably

We only need to trigger a response when the value changes, and NaN === NaN is false also does not need to trigger a response.

function reactive(obj){<!-- -->
    return new Proxy(obj, {<!-- -->
        get(target, key, receiver){<!-- -->
            // The proxy object can access the raw data through the raw property
            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)
            // The update is triggered only when the receiver is the proxy object of the target, shielding the update caused by the prototype
            if(target === receiver.raw){<!-- -->
                if(oldVal !== newVal & amp; & amp; (oldVal === oldVal || newVal === newVal)){<!-- -->
                    trigger(target, key, type)
                }
            }
        }
        // ...other interception methods
    })
}

Read-only and shallow-read

Array of Proxies

Array is a special object (also a heterogeneous object), except for the internal method [[defieOwnProperty]], the logic of other internal methods is the same as that of regular objects, but the operation of arrays is somewhat different from that of ordinary objects. The following are all read operations on array elements or properties:

  • Access elements by index: arr[0]
  • Access array length: arr.length
  • Treat the array as an object and use for…in to traverse
  • Use for…of to iterate over an array
  • Array prototype methods: concat/join/some/every/find(Index)/includes and other methods that do not change the original array.

And setting operation:

  • Modify element by index: arr[0] = 1
  • Modify the array length: arr.length = 0
  • Array stack methods: push/pop/shift/unshift
  • Methods to modify the original array: splice/fill/sort etc.

When these operations occur, the response association should be properly established or the response triggered.

The index and length of the array

Generally speaking, accessing elements and objects through indexes is similar. But if the set index value is greater than the current length, this operation will also update the length attribute; if set length If the new value of the attribute is less than the original value, the redundant elements will be removed. Such operations should trigger a response.

// 1. Determine the current operation type, whether the current index is greater than the length of the array
// 2. lenght attribute assignment, newV is the new length
function createReactive(obj, isShallow = false, isReadonly = false){<!-- -->
    return new Proxy(obj, {<!-- -->
        set(target, key, newVal, receiver) {<!-- -->
            if(isReadonly) {<!-- -->
                console.warn(`Property ${<!-- -->key} is read-only`)
                return true
            }
            const oldVal = target[key]
            // Determine whether the proxy object is an array
            // Array: Determine whether the set index value is less than the length of the array
            // object: determine whether the key exists
            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 & amp; & amp; (oldVal === oldVal || newVal === newVal)){<!-- -->
                    // When modifying the length value, newVal is the new length
                    trigger(target, key, type, newVal)
                }
            }
            return res
        }
    })
}


fcuntion trigger(target, key, type, newVal){<!-- -->
    const depsMap = bucket. get(target)
    if(!depsMap) return
    // ...omit other code
    
    // If it is an array and it is an add operation, the length property of the array should also change to trigger a response
    if(Array.isArray(target) & amp; & amp; type === 'add') {<!-- -->
        const lengthEffects = depsMap. get('length')
        lengthEffects & amp; & amp; lengthEffects.forEach(effectFn => {<!-- -->
            if(effectFn !== activeEffect){<!-- -->
                effectsToRun.add(effectFn)
            }
        })
    }
    
    // If it is an array, and the length property is modified, newVal is the new length
    if(Array.isArray(target) & amp; & amp; key === 'length') {<!-- -->
        // For elements whose index is greater than or equal to the new length value, remove and execute all associated side-effect functions
        depsMap.forEach((effects, key) => {<!-- -->
            if(key >= newVal){<!-- -->
                effects.forEach(effectFn => {<!-- -->
                    if(effectFn !== activeEffect){<!-- -->
                        effectsToRun.add(effectFn)
                    }
            }
        })
    }
    
    
    effectsToRun.forEach(fn => {<!-- -->
        // Called if the scheduler exists, passing the side effect function as an argument
        if(fn.options.scheduler){<!-- -->
            fn.options.scheduler(fn)
        } else {<!-- -->
            fn()
        }
    })
}

Loop through the array

For ordinary objects, adding or deleting attributes will affect the result of the for...in loop. For arrays, as long as the length changes, a response should be triggered.

const p = new Proxy(obj, {<!-- -->
    // ...,
    ownKeys(target){<!-- -->
        // If it is an array, use the length attribute instead of ITERATE_KEY
        track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
        return Reflect.ownKeys(target)
    }
})

Unlike for...in traversal, for...of is used to traverse iterable objects.

Array lookup method

The array methods actually rely on the basic semantics of the object. In most cases, no special treatment is required to make these methods work as expected.

const arr = reactive([1, 2])

effect(() => {<!-- -->
    console.log(arr.includes(1)) // initially print true
})
arr[0] = 3 // re-execute printing false
  • Access the element value through proxy object, if the value can still be proxied, then the obtained value is the new proxy object instead of the original object. Through arr[0] and includes is a proxy object, because each call to the reactive function will create a new proxy object. Therefore, a mapping that stores the original object to the proxy object is needed to avoid multiple creations proxy object.
  • When judging whether the original object exists through includes, it should be true intuitively. But in fact, because the access is a proxy object, it will return false. Therefore, the array method needs to be rewritten…
const obj = {<!-- -->}
const arr = reactive([obj])
console.log(arr.includes(arr[0])) // false
console. log(arr. includes(obj)) // false

// 1: Use the map instance to store the mapping relationship to solve the first case
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. Override methods such as includes, and try to execute this method on the original object and proxy object respectively
const arrayInstrumentations = {<!-- -->};
['includes', 'indexOf', 'lastIndexOf'].forEach(method => {<!-- -->
    const originMethod = Array.prototype[method]
    arrayInstrumentations[method] = function(...args){<!-- -->
        let res = originMethod. apply(this, args)
        // If not found, check again in the original array
        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 it is an array, and it is the corresponding operation, return the rewritten value
            if(Array.isArray(target) & amp; & amp; arrayInstrumentations.hasOwnProperty(key)) {<!-- -->
                return Reflect.get(arrayInstrumentations, key, receiver)
            }
            //...
        }
    })
}

A prototype method that implicitly modifies the length of an array

Methods such as push/pop/shift/unshift will modify the original array and change the length of the array. For example, the push operation needs to insert an element at the end, which can both read and set lenght.

// Whether to allow tracking
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)
        // Tracking is allowed after the method is called, push reads length but prevents tracking at this time
        shouldTrack = true
        return res
    }
})

Proxy Set and Map

Collection type objects are very different from ordinary objects, and have their own unique properties and methods.

How to proxy Set and Map

Therefore, special handling is required for proxying.

const reactiveMap = new Map()

fucntion reactive(obj){<!-- -->
    const existenceProxy = reactiveMap. get(obj)
    if(existionProxy) return existenceProxy
    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 special treatment for collection objects, this points to the original object when accessing the size attribute
            if(key === 'size') {<!-- -->
                return Reflect. get(target, key, target)
            }
            // The map.delete(...) method is specially treated, and returns after binding the method to the original object
            return target[key].bind(target)
        }
    })
}

Build Response Link

When you understand the key to proxying, you can implement a responsive solution.

  • size attribute: When reading the size attribute, call the track function to establish a response link. Since adding and deleting operations will change the size attribute, it is necessary to establish a link between ITERATE_KEY and side effects.
  • At the same time, you need to implement custom Add and Delete methods, and change the return value target[key].bind(target) to mutableInstrumentations [key], execute trigger in the custom method.
  • In addition, when a collection object is added (if it already exists) or deleted (if it does not exist), it does not need to trigger a response!

Avoid polluting raw data

The behavior of setting responsive data to original data is data pollution, such as set of Map type, add of Set type, and write value of ordinary objects Operations, adding elements to arrays, etc., all need to avoid pollution.

The method to avoid pollution, when performing the above operations, if the value to be written is responsive data, then use the raw (Symbol type in the source code) attribute to obtain the original data, and then write the original data into target.

Process forEach

Different from the forEach method of the array method, its callback function has three parameters (value, key, map), and the Map object cares about both key and value. In addition to establishing a relationship with ITERATE_KEY, it also needs to handle set operations.
In short, the forEach methods of different objects have their own characteristics, and they all need to be processed according to the actual semantics.

Iterator methods

Summary

  • Vue3 responsive data is implemented based on Proxy proxy, where Reflect.* solves the problem pointed to by this.
  • Object proxy
  • Deep response and shallow response and Deep read-only and shallow read-only, the return value of deep response (read-only) needs to be wrapped.

…undone