preface
It’s been a while since vuE3’s beta release, and there are a number of changes in this major release, both in terms of TypeScript development and improvements to compilation and composition APIS. This time, our analysis of vue3 principle mainly focuses on the responsive principle and the implementation of Watch. The code is based on version “3.1.0-Beta.7”, which is the latest version when I checked the source code, and may have been updated several more versions, but the update of the small version will not affect the overall implementation and learning.
Vue2.0+ version of the source code believe most of the students have seen, responsive Object implementation logic is through object.defineproperty (), to achieve the hijacking of the Object set, get and other actions, and finally with Watcher class and Dep class to achieve the dependent collection and trigger. Because this article is for Vue3 to do analysis, Vue2 related source code implementation, interested can view.
Below I will use my own language to describe the implementation of the source logic, a small partner feel unclear where you can leave a message to discuss, common progress.
Responsive object
One big difference between Vue and React is that its data is responsive. This feature has always been present since version upgrade. Because of this feature, we only need to focus on the data layer when developing business, and DOM view will naturally be re-rendered according to data changes.
Reactive Implementation Principle
Vue3 initializes a responsive object, rather than the black-box method used in Vue2 to initialize data in data, props, and computed. He needs us to manually introduce reactive methods to initialize the data that we need to be responsive.
Usage:
<template>
<div>{{state.msg}}</div>
<button @click="toggle">switch</button>
</template>
<script>
import { reactive } from 'vue'
export default {
name: 'main'.setup() {
const state = reactive({
msg: 'Hello World! '
})
const toggle = function () {
state.msg = state.msg === 'Hello World! ' ? 'Hello reactive! ' : 'Hello World! '
}
return {
state,
toggle
}
}
}
</script>
Copy the code
We see in the example above that we manually pass the object that needs to be converted to reactive as a parameter to the reactive function to initialize the reactive data, and when we change the data the page changes. Take a look at how reactive is implemented.
reactive api
@reactivity/src/reactive.ts
export function reactive(target: object) {
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap
)
}
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>,
proxyMap: WeakMap<Target, any>
) {
// Is an object type, not a direct return
if(! isObject(target)) {if (__DEV__) {
console.warn(`value cannot be made reactive: The ${String(target)}`)}return target
}
// Initialization is a reactive object that returns itself directly
if( target[ReactiveFlags.RAW] && ! (isReadonly && target[ReactiveFlags.IS_REACTIVE]) ) {return target
}
// Cache optimization, initialized directly returns the proxy object
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// Determine that an invalid object is returned directly
const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
return target
}
// In our example, the object type is passed in, so the second argument to Proxy is baseHandlers.
const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
)
proxyMap.set(target, proxy)
return proxy
}
Copy the code
We see that reactive function execution makes a series of judgments on the incoming object, uses WeakMap cache to optimize the initialization object, and finally uses new Proxy to hijack the target object. In contrast to object.defineProperty in VUe2, the Proxy does not need to know the Object’s key in advance and can intercept any changes to the Object.
mutableHandlers
@reactivity/src/baseHandlers.ts
export const mutableHandlers: ProxyHandler<object> = {
get,
set,
deleteProperty,
has,
ownKeys
}
Copy the code
The mutableHandlers object is a parameter passed when the Proxy is initialized and internally hijabs five separate actions, covering everything that can be done to the data. Of course, these actions have different implementation logic for different objects, so we will analyze only GET and set. After looking at the internal implementation, you can clearly see what gets and sets do when they fire, and how the response is implemented.
Getter dependency collection
@reactivity/src/baseHandlers.ts
const get = /*#__PURE__*/ createGetter()
function createGetter(isReadonly = false, shallow = false) {
return function get(target: Target, key: string | symbol, receiver: object) {
// Proxy for the __v_isReactive attribute
if (key === ReactiveFlags.IS_REACTIVE) {
return! isReadonly }else if (key === ReactiveFlags.IS_READONLY) {
// Proxy for the __v_isReadonly attribute
return isReadonly
} else if (
key === ReactiveFlags.RAW &&
receiver ===
(isReadonly
? shallow
? shallowReadonlyMap
: readonlyMap
: shallow
? shallowReactiveMap
: reactiveMap
).get(target)
) {
// Proxy for the __v_raw attribute
return target
}
const targetIsArray = isArray(target)
// arrayInstrumentations is a pair of ['push', 'pop', 'shift', 'unshift', 'splice'] and ['includes', 'indexOf', 'lastIndexOf'] the functions on these arrays are overridden.
// If the object is an array and the key obtained is one of these overridden methods, then reflect.get returns the value returned after calling the function, ensuring the default behavior of GET. The dependency is then re-added based on the new value passed when the function is called, ensuring that the new value in the array is also a reactive object.
if(! isReadonly && targetIsArray && hasOwn(arrayInstrumentations, key)) {return Reflect.get(arrayInstrumentations, key, receiver)
}
const res = Reflect.get(target, key, receiver)
// The internal Symbol key attribute does not collect dependencies
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res
}
// A dependency on the current object's key collection if the object is not read-only.
// Following the mainline flow, the dependency here is the side effect function when the component executes setupRenderEffect at initialization (code location: @/ run-time core/ SRC /renderer.ts). After the function is executed, a global variable of activeEffect stores the latest side effect function. Track saves the current function inside. The detailed implementation of Track can be seen below.
if(! isReadonly) { track(target, TrackOpTypes.GET, key) }if (isObject(res)) {
// Execute reactive to initialize the returned object as a reactive object.
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
}
Copy the code
The whole process is quite clear. After reactive operations are performed on an object, its GET function will internally proxy some special properties and return specified property values. When target is an array, all methods of the array are propped up, and new values are initialized as responsive objects when the function is executed.
If the final return value is also an object, the object is called reactive again to initialize it as a reactive object. This operation is not quite the same as in VUe2 version 2.0. In VUe2, there is no way to know which property will be accessed at runtime, so the entire Object is initialized recursively, hijacking each property with Object.defineProperty. Therefore, the initialization process for data and computed complex components can be time-consuming and increase the performance overhead. However, in VUe3, proxy is used to proxy the entire object, and only when an object is acquired in the component, the object is initialized and responsive, instead of mindless recursion. This greatly improves component initialization speed and performance.
Next, track, the core function in the getter, collects dependencies in the getter so that it can be executed when a set fires.
track
@reactivity/src/effect.ts
export function track(target: object, type: TrackOpTypes, key: unknown) {
// No dependency collection is required or activeEffect is null
if(! shouldTrack || activeEffect ===undefined) {
return
}
let depsMap = targetMap.get(target)
if(! depsMap) {// Initialize a Map object as value with target as key
targetMap.set(target, (depsMap = new Map()))}let dep = depsMap.get(key)
if(! dep) {// Set depsMap key to a non-repeating data structure Set.
depsMap.set(key, (dep = new Set()))}if(! dep.has(activeEffect)) {// Store the activeEffect dependency of the target object in the DEP.
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
Copy the code
Looking at the track code, the entire logic is to store the ActiveEffects in scope according to the properties of the currently passed object (here, following the main flow, the ActiveEffects side function is the method of re-rendering the component). Use target as the key of the targetMap, value as a Map structure, and depsMap as a Map type. Use the attribute name as the key to store dependent functions in the Set. This way, when we want to trigger a dependency, we can find its dependent function to do the specified operation based on the object itself and the changed attribute key.
The basic analysis of the getter execution logic is completed. The core logic is the track dependency collection, which stores the current activeEffect into the relevant structure.
Setter sends notifications
@reactivity/src/baseHandlers.ts
const set = /*#__PURE__*/ createSetter()
function createSetter(shallow = false) {
return function set(target: object, key: string | symbol, value: unknown, receiver: object) :boolean {
const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver)
// don't trigger if target is something up in the prototype chain of original
if (target === toRaw(receiver)) {
if(! hadKey) {// If the key set is the new ADD that calls trigger
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
// If the key already exists, it indicates that the value is changed. If the original value is inconsistent with the value to be SET, the SET of trigger is called. Checking whether the value has changed filters out invalid triggers such as when the array changes and leegth changes.
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
}
}
Copy the code
Looking at the code logic of the setter, the main flow is relatively simple. The main flow is to send a notification to the modified responsive object property. Let’s take a look at what happens in the trigger.
trigger
The trigger function is triggered in the setter to notify the dependencies collected by the corresponding property in the getter to be executed in a responsive manner.
@reactivity/src/effect.ts
export function trigger(target: object, type: TriggerOpTypes, key? : unknown, newValue? : unknown, oldValue? : unknown, oldTarget? :Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
// depsMap is a dependency that stores the target as the key on track.
if(! depsMap) {// never been tracked
return
}
// The add function is the core of the whole logic. After passing in the dependency Set of the Set structure as parameters, the qualified side effects are added to the effects.
const effects = new Set<ReactiveEffect>()
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) = > {
if (effectsToAdd) {
effectsToAdd.forEach(effect= > {
if(effect ! == activeEffect || effect.allowRecurse) { effects.add(effect) } }) } }if (type === TriggerOpTypes.CLEAR) {
depsMap.forEach(add)
} else if (key === 'length' && isArray(target)) {
depsMap.forEach((dep, key) = > {
if (key === 'length' || key >= (newValue as number)) {
add(dep)
}
})
} else {
// schedule runs for SET | ADD | DELETE
// Check the current key! DepsMap == undefined; depsMap == depsMap
if(key ! = =void 0) {
add(depsMap.get(key))
}
// also run for iteration key on ADD | DELETE | Map.SET
switch (type) {
case TriggerOpTypes.ADD:
if(! isArray(target)) { add(depsMap.get(ITERATE_KEY))if (isMap(target)) {
add(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isIntegerKey(key)) {
// new index added to array -> length changes
add(depsMap.get('length'))}break
case TriggerOpTypes.SET:
if (isMap(target)) {
add(depsMap.get(ITERATE_KEY))
}
break}}const run = (effect: ReactiveEffect) = > {
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
effect()
}
}
// Effects is the dependency set corresponding to this key, where the set is traversed. If there is a scheduler scheduler function, the scheduler function is executed, otherwise the side effect function is executed directly.
effects.forEach(run)
}
Copy the code
Looking at the code above,trigger is basically a notification of a specified property in an object, performing the dependencies collected in the getter phase.
The process we analyze here is based on the component initialization scenario. MountComponent is executed to instantiate components and initialize components, including setupRenderEffect function to generate the global activeEffect side effect function. The internal logic of the function is that the Patch function converts the component template to the vNode type and generates DOM elements according to the data structure and type to add them to the page. During the mounting process, it will obtain all the used responsive objects and trigger their getter, and the getter internal logic will collect the current activeEffect. In this scenario, activeEffect corresponds to setupRenderEffect, which is the function that triggers the re-rendering of the component. When we change the reactive data in method, trigger logic in the setter will be executed, and notification will be sent to the specified reactive property to execute the callback. The callback for this scenario is the setupRenderEffect function, which rerenders the component. Of course, in Vue3, the rendering logic has also been optimized. It’s not that changing a value will cause the whole DOM to render, but you can take a look at the complex internal diff processing for yourself.
Responsive flow chart
Effect implementation Principle
In terms of reactive principles, reactive objects are generated from Reactive, and getters and setters are internally hijacked. Effect sets the current function to the top of the stack before execution and marks activeEffect as the current function. The internal logic of getters and setters does dependency collection and so on.
In my opinion, vue3’s Effect + Track + Trigger mode is easier to understand than VUE2’s Watcher+Dep mode, and the code is more logically friendly. Effect can be used for computed, watch, watchEffect and other logic after encapsulation independently. Effect function has its own system of clearing dependencies, suspending collection, resetting collection state, and sending notifications, so it can also be exported as a tool function.
Let’s look at the internal logic of Effect with the main process of initializing the component
@runtime-core/src/renderer.ts
const setupRenderEffect = () = > {
effect(function componentEffect() {
// do sth...
patch() // Render component})}Copy the code
Components create a componentEffect side effect function during initialization, and an internal set of logic performs rendering or updating the component.
@reactivity/src/effect.ts
export function effect<T = any> (fn: () => T, options: ReactiveEffectOptions = EMPTY_OBJ) :ReactiveEffect<T> {
if (isEffect(fn)) {
fn = fn.raw
}
const effect = createReactiveEffect(fn, options)
if(! options.lazy) { effect() }return effect
}
Copy the code
Look at the effect code above and take a function and a configuration object as parameters. If the object is also an initialized effect, the original function inside it is passed in as an argument. Effect is delayed if options has lazy:true, otherwise it is executed immediately. Lazy is generally used for computations. Let’s look at the createReactiveEffect function that actually generates an effect.
@reactivity/src/effect.ts
function createReactiveEffect<T = any> (fn: () => T, options: ReactiveEffectOptions) :ReactiveEffect<T> {
const effect = function reactiveEffect() :unknown {
// Effect that is not executing is first executed once.
if(! effect.active) {// Fn is componentEffect()
return fn()
}
// effectStack is a stack of side effects that are being executed
if(! effectStack.includes(effect)) {Activeeffect.deps.push (dep) // Empty all dependencies collected in the last getter
cleanup(effect)
try {
// Turn on the dependency collection switch
enableTracking()
effectStack.push(effect)
// Set current effect to activeEffect for easy collection
activeEffect = effect
// Execute the function and return the result of the function.
return fn()
} finally {
// Effect is removed from the stack after execution
effectStack.pop()
resetTracking()
// Take effect from the top of the stack as an activeEffect, because components are rendered from parent to child, and when the bottom child components are rendered, they pop out of the stack one by one, ensuring that the dependencies in each component are correct.
activeEffect = effectStack[effectStack.length - 1]}}}asReactiveEffect effect.id = uid++ effect.allowRecurse = !! options.allowRecurse effect._isEffect =true
effect.active = true
effect.raw = fn
effect.deps = []
effect.options = options
return effect
}
Copy the code
This function initializes some state properties. If it is executed for the first time, fn() will be executed and acctiveEffect = effect will be set. The fn() function executed here is the component rendering function. The rendering function will obtain all the used responsive data and trigger the getter logic of the corresponding property, so as to trigger the component rendering logic in the setter. So far, the analysis of the whole component reactive rendering logic process is finished.
As an important part of responsive implementation, Effect is very different from vue2 logic, and it is helpful for us to be familiar with the implementation of Watch and computed.