This article has participated in the third “topic writing” track of the Denver Creators Training Camp. For details, check out: Digg Project | Creators Training Camp third is ongoing, “write” to make a personal impact
According to the response process and simplified version of the source code to analyze the responsive system
reactivity
Reactivity in VUe3 is a standalone package that can be used completely outside of vUE and can theoretically be used anywhere.
Let’s start by looking at the use of the ReActivity package
-
In the project root directory, run yarn dev reactivity, and then enter the packages/reactivity find output dist/directory reactivity. Global. Js file.
-
Create a new index.html and write the following code:
<script src="./dist/reactivity.global.js"></script> <script> const { reactive, effect } = VueObserver const origin = { count: 0,}const state = reactive(origin) const fn = () = > { const count = state.count console.log(`set count to ${count}`) } effect(fn) </script> Copy the code
-
Open the file in your browser and execute state.count++ on the console to see the output Set count to 1.
In the example above, we use reactive() to convert the Origin object to the Proxy object state; Use the effect() function to call fn() as a reactive callback. Fn () is triggered when state.count changes. Next we will combine the above flow chart, with a simplified version of the source code, to explain how this responsive system is running.
Initialization phase
During the initialization phase, two main things are done:
- the
origin
Object is converted to a reactive Proxy objectstate
. - The function
fn()
As a reactive effect function.
Let’s start with the first thing.
Vue3 uses Proxy Proxy objects, rewriting setter and getter operations for objects to implement dependency collection and response triggering. In the initialization phase, we won’t go into the implementation of the setter and getter, but we’ll look at what reactive does.
export function reactive<T extends object> (target: T) {
return createReactiveObject(target, handler)
}
export function createReactiveObject(target: object, handler: ProxyHandler<any>) {
const observed = new Proxy(target, handler)
return observed
}
Copy the code
You can see that reactive simply proxies the object we pass in and passes it to handler.
Let’s look at the second thing
When an ordinary function fn() is wrapped with effect(), it becomes a reactive effect function, and fn() is executed immediately (not if it is set to lazy). After fn is executed, if a Proxy object is used, The getter collection dependency is triggered.
This effect is also pushed into the effectStack for subsequent calls.
const effectStack: ReactiveEffect[] = []
let activeEffect: ReactiveEffect | undefined
export function effect<T = any> (fn: () => T) {
const effect = createReactiveEffect(fn)
effect() // Execute immediately
return effect
}
function createReactiveEffect<T = any> (fn: () => T) :ReactiveEffect<T> {
const effect = function reactiveEffect() :unknown {
if(! effectStack.includes(effect)) { cleanup(effect)// prevent different dependencies caused by if statements in fn(). So every time the function is executed, the dependency is updated again.
try {
effectStack.push(effect) // Push this effect onto the effect stack
activeEffect = effect
// execute fn() immediately
// fn() performs dependency collection, using track
return fn()
} finally {
// Effect is pushed off the stack after execution
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]}}}as ReactiveEffect
effect.deps = [] // Collect the corresponding DEP and clean effect from the DEP when cleanup
return effect
}
Copy the code
There are a few minor issues:
-
Since before execution
effectStack.push(effect)
After, the implementation ofeffectStack.pop()
. Then why judgeeffectStack.includes(effect)
What about this case?The solution is to change state in fn(), for example
effect(() = > state.num++) Copy the code
The normal logic would have triggered the listener function continuously, but with effectStack.includes(effect), the recursive loop is automatically avoided.
The tigger function also makes this determination
if(effect ! == activeEffect) { effects.add(effect) }Copy the code
Dependencies that are being collected are not triggered, preventing circular calls.
-
Why do you need to clear the previous round of dependencies before collecting them
This is done to handle cases with branch processing. Because in the listener function, the dependency data may be different due to if and other conditional judgment statements. So every time the function is executed, the dependency is updated again. Hence the logic: cleanup.
Dependency collection phase
This phase is triggered when an effect is executed and its internal FN () fires the getter for the Proxy object. Simply put, whenever a statement like state.count is executed, the getter for state is triggered.
const get = createGetter()
export const handler = {
get,
}
function createGetter() {
return function get(target: object, key: string | symbol, receiver: object) :any {
const res = Reflect.get(target, key, receiver)
// If it is a built-in method of JS, do not do dependency collection
if (isSymbol(key) && builtInSymbols.has(key)) {
return res
}
track(target, TrackOpTypes.GET, key)
return isObject(res) ? reactive(res) : res
}
}
Copy the code
We can see that in the getter we call the track function for dependency collection. How track works will be discussed later.
Let’s start by explaining why built-in methods don’t do dependency collection.
Let’s say a listener function looks like this
const origin = {
a(){},}const observed = reactive(origin)
effect(() = > {
console.log(observed.a.toString())
})
Copy the code
Obviously, when the origin. A changes, observed. A.tostring () should also change, so why not use monitoring? This is simple because observed.a.tostring () has triggered the getter, so there is no need to collect dependencies repeatedly. Hence the similar built-in method, return directly.
Another minor detail is that we call reactive when the property is an object. Because the Proxy can only hijack one layer, it cannot hijack nested objects. Therefore, the source code uses lazy mode. Calling Reactive allows for deep responsiveness, which also avoids circular references.
In the dependency collection stage, we need to collect a “dependency collection table”, namely the targetMap in the figure, where key is the object after Proxy and value is the depsMap corresponding to the object.
DepsMap is a Map. The key value is the property value when the getter is triggered (count), and the value is the effect for which the property value is triggered.
TargetMap is defined as follows:
type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()
Copy the code
Here’s an example:
const state = reactive({
count: 0.age: 18,})const effect1 = effect(() = > {
console.log("effect1: " + state.count)
})
const effect2 = effect(() = > {
console.log("effect2: " + state.age)
})
const effect3 = effect(() = > {
console.log("effect3: " + state.count, state.age)
})
Copy the code
The targetMap should look like this:
The {target -> key -> deP} correspondence is established and the dependency collection is complete.
So let’s see how track works,
export function track(target: object.type: TrackOpTypes, key: string | symbol) {
if (activeEffect === undefined) {
return
}
let depsMap = targetMap.get(target)
if(! depsMap) { targetMap.set(target, (depsMap =new Map()))}let dep = depsMap.get(key)
if(! dep) { depsMap.set(key, (dep =new Set()))}if(! dep.has(activeEffect)) {// Add activeEffect to the deP for trigger to call
dep.add(activeEffect)
// Also push the effects collection dep in the effect deps for cleanup to remove dependencies from the previous round and prevent this round from triggering redundant dependencies
activeEffect.deps.push(dep)
}
}
Copy the code
The depsMap of targetMap contains the effect set DEP, which in turn contains the effect set DEP… At first glance, it seems a little confusing, and why two-way storage?
Effect removes itself from the response-dependent map before it is executed. It then executes its original function fn, triggers get of data, triggers track, and then adds this effect to the corresponding Set. Each time you remove yourself from the dependency map before execution, you add yourself back in during execution. Make sure every dependency is up to date.
The response phase
When a property value of an object is modified, the corresponding setter is fired.
const set = createSetter()
export const handler = {
set,
}
function createSetter() {
return function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object.) :boolean {
const hadKey = hasOwn(target, key)
const oldValue = (target as any)[key]
// Trigger must be executed first. There may be operations in effect that depend on the object after the set. Setting first ensures that the function in effect executes the correct result
const result = Reflect.set(target, key, value, receiver)
if(! hadKey) { trigger(target, TriggerOpTypes.ADD, key) }else if(value ! == oldValue) { trigger(target, TriggerOpTypes.SET, key) }return result
}
}
Copy the code
The trigger() function in the setter finds the dePs for the current property from the dependency collection table, pushes them into effects, and then executes the effects one by one through scheduleRun().
export function trigger(target: object.type: TriggerOpTypes, key? : unknown) {
const depsMap = targetMap.get(target)
if(! depsMap) {return
}
Const effect = depmap.get (key) const effect = depmap.get (key)
// If the effect function is executed, the track dependency will also be executed in this trigger round, resulting in an infinite loop
const effects = new Set<ReactiveEffect>()
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) = > {
if (effectsToAdd) {
effectsToAdd.forEach(effect= > {
// Do not add your current effect, otherwise effect(() => foo.value++) will cause an infinite loop
if(effect ! == activeEffect) { effects.add(effect) } }) } }// SET | ADD
if(key ! = =undefined) {
// Add effect for key
add(depsMap.get(key))
}
// iteration key on ADD
switch (type) {
case TriggerOpTypes.ADD:
// Adding an array element changes the array length
if (isArray(target) && isIntegerKey(key)) add(depsMap.get("length"))}// A simplified scheduleRun executes effects one by one
effects.forEach(effect= > {
effect()
})
}
Copy the code
The complete code
Small details
Avoid multiple tiggers
When the proxy object is an array, the push operation triggers multiple set executions, as well as a GET operation
let data = [1.2.3]
let p = new Proxy(data, {
get(target, key, receiver) {
console.log("get value:", key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log("set value:", key, value)
return Reflect.set(target, key, value, receiver)
},
})
p.unshift("a")
// get value: unshift
// get value: length
// get value: 2
// set value: 3 3
// get value: 1
// set value: 2 2
// get value: 0
// set value: 1 1
// set value: 0 a
// set value: length 4
Copy the code
As you can see, get and set are triggered multiple times when unshift is performed on the array. If you look at the output carefully, it is not difficult to see that get first takes the last index of the array, creates a new index 3 to store the original last value, and then moves the original value back and sets the index 0 to the value A of unshift, thus triggering multiple set operations.
This is obviously bad for notifying external operations, assuming that the console in the set is the render function that triggers the external render, the unshift operation will render multiple times.
How does VUE3 solve this problem
export const hasOwn = (val: object.key: string | symbol): key is keyof typeof val =>
Object.prototype.hasOwnProperty.call(val, key)
function createSetter() {
return function set(
target: object,
key: string | symbol,
value: unknown,
receiver: object.) :boolean {
console.log(target, key, value)
const hadKey = hasOwn(target, key)
const oldValue = (target as any)[key]
const result = Reflect.set(target, key, value, receiver)
if(! hadKey) {console.log("trigger ... is a add OperationType")
trigger(target, TriggerOpTypes.ADD, key)
} else if(value ! == oldValue) {console.log("trigger ... is a set OperationType")
trigger(target, TriggerOpTypes.SET, key)
}
return result
}
}
Copy the code
When I change the length of the array
const state = reactive([1])
state.push(1)
Copy the code
State.push (1) triggers set twice, once for the value itself 1 and once for the length attribute change.
When we set the value itself as an add operation, hasOwn(target, key) obviously returns false,
HasOwn (target, key) returns true and oldValue === value, so no trigger is triggered.
Therefore, the type of trigger can be determined by checking whether the key is a target property and setting whether val is equal to target[key], and avoiding redundant triggers.
Depth response
Another detail is that a Proxy can only represent one layer, for example
let data = { foo: "foo".bar: { key: 1 }, ary: ["a"."b"]}let p = new Proxy(data, {
get(target, key, receiver) {
console.log("get value:", key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log("set value:", key, value)
return Reflect.set(target, key, value, receiver)
},
})
p.bar.key = 2
// get value: bar
Copy the code
After executing the code, you can see that instead of triggering the output of set, get is triggered because the bar property is accessed during set. It can be seen that proxy objects can only be proxy to the first layer, and the deep detection of objects needs to be realized by developers themselves. The same is true for arrays inside objects.
p.ary.push("c")
// get value: ary
Copy the code
So vue3 uses lazy to implement deep responsiveness
function createGetter() {
return function get(target: object, key: string | symbol, receiver: object) :any {
const res = Reflect.get(target, key, receiver)
track(target, TrackOpTypes.GET, key)
return isObject(res) ? reactive(res) : res
}
}
Copy the code
If res is an object, reactive is reconfigured and the object is stored in reactiveMap to improve performance.
// A proxy already exists
const existingProxy = reactiveMap.get(target)
if (existingProxy) {
return existingProxy
}
Copy the code
Refer to the article
A diagram illustrates Vue 3.0’s responsive system
Data detection in Vue3