preface

The @vue3 / reActivity module is a module that can be used independently of the Vue3 framework as a reactive tool library for any project, even Node projects. The underlying principle is implemented based on Proxy. Internet Explorer 11 and the following cannot be used. For details, see caniuse.com/proxy.

It is worth noting that the @vue3 / ReActivity module only provides some basic apis, not completely equivalent to Vue3’s REactivity API, Vue3’s related API is actually provided by the most basic API encapsulation module.

This article will implement a rudimentary version of the ReActivity module step by step, implementing its key apis and functions. The full code is at the end of the article.

Language conventions, @vue3 / reActivity will be referred to as a “reactive module” for short, passing in functions from the Effect API called “side effects”. Variables returned by ref are called “ref variables”, and variables returned by reactive are called “reactive variables”.

Usage, idea

The simplest way to use it

import {effect, reactive} from @vue/reactivity let a= {count: 0} let value = reactive(a); effect(() => { console.log('effect', a.value); }) setTimeout(() => {value.count++}, 1000) // effect: 0 // effect: 1Copy the code

In this example, a reactive variable value is declared, and the side effect function passed in is immediately run. When the count of value changes, the side effect function is run again.

Reactive modules intuitively expose the use of the two most basic apis: Reactive accepts an object and returns a reactive object. The effect function takes a function that is re-run when the properties of the reactive object used in the function change.

The basic idea

Those who know Vue2 know that the response formula principle of Vue2 can be summarized in the following figure:

As we know from the figure, during rendering, the corresponding dependencies are collected through the getter. When the corresponding dependencies change, re-rendering is triggered.

Vue3’s reactive module is based on a similar idea: Reactive functions are used to get and set operations on proxy variables to collect dependencies and notify updates. The runtime collects the corresponding dependencies for incoming side effects and re-runs the side effects when the corresponding dependencies change. The essence is a publish and subscribe model

Implementation Lite

Based on the above ideas, we tried to implement a simple version of the responsive module, run through the simplest example, solve some problems, and then gradually expand to explain some core functions.

To realize the reactive

From the above example, we know that a reactive function is a proxy for the operations passed to its variables. Basically, get collects dependencies for target, and set triggers dependency updates for target.

The code is as follows:

function reactive(target) { if (! isObject(target)) { console.error("target must be an object"); return; } const proxyValue = new Proxy(target, { get: (target, key) => { track(target, key); // Collection depends on const result = reflect.get (target, key); return result; }, set: (target, key, value) => { const oldValue = target[key]; const result = Reflect.set(target, key, value); if (oldValue ! == value) { trigger(target, key); } return result; }}); return proxyValue; }Copy the code

The track function is called when get to collect dependencies, and the trigger function is called when set to trigger updates.

Then realize trigger, track and effect

Collect dependencies, then you need to establish a relationship between dependencies and corresponding side effects. The source code uses such data structure: target -> key -> DEP, target corresponds to the object to be collected, key corresponds to the key of the object, DEP corresponds to the set of side effects function. The type definition of TS might be more intuitive:

type Dep = Set<ReactiveEffect>;
type KeyToDepMap = Map<any, Dep>;
const targetMap = new WeakMap<any, KeyToDepMap>();
Copy the code

The ReactiveEffect is the type of side effect function.

Therefore, the implementation idea of Track is to establish such a mapping relationship:

function track(target, key) { if (! activeEffect) { return; } let keyToDepMap = targetMap.get(target); if (! KeyToDepMap) {// If the targe has not already collected dependencies, create a new one. keyToDepMap = new Map(); targetMap.set(target, keyToDepMap); } let effects = keyToDepMap.get(key); if (! Effects) {// If the corresponding key has not already collected dependencies, create a new one. effects = new Set(); keyToDepMap.set(key, effects); } effects.add(activeEffect); }Copy the code

You may have noticed that there is an activeEffect inside the track function, which represents the current side effect function. Since we do not know which side effect function the current variable is running in when collecting dependencies, we rely on an external variable to record the current side effect function.

After implementing track, implementing trigger is relatively easy: find the set of side effects that correspond to the dependency and run them all once.

function trigger(target, key) { let keyToDepMap = targetMap.get(target); if (! keyToDepMap) { return; } let effects = keyToDepMap.get(key); if (! effects) { return; } effects.forEach((effect) => { effect(); }); }Copy the code

Finally, we implement the effect function, if only to run through the simplest example, it is actually used to point to the latest effect function, and then automatically run the function passed in:

function effect(curEffect) {
  activeEffect = curEffect;
  activeEffect();
  activeEffect = undefined;
}
Copy the code

Problem 1: The deep structure is not becoming responsive.

const value = reactive({ foo: { bar: 1 } }); effect(() => { console.log("count:", value.foo.bar); }); value.foo.bar = 2; // Does not trigger the side effect function.Copy the code

The cause of the problem is also obvious, we directly return result when the agent get operation, but the value returned is not responsive (there is no agent GET and set operation, that is, there is no call to track, that is, there is no call to trigger the side effect function). Modify the code as follows:

function reactive(target) { if (! isObject(target)) { console.error("target must be an object"); return; } const proxyValue = new Proxy(target, { get: (target, key) => { track(target, key); // Collection depends on const result = reflect.get (target, key); // If the result of get is an object, make it responsive and return. if (isObject(result)) { return reactive(result); } return result; }, set: (target, key, value) => { const result = Reflect.set(target, key, value); if (result ! == value) { trigger(target, key); } return result; }}); return proxyValue; }Copy the code

We all know that defineProperty is the principle to achieve responsivity in VUe2. When initializing, all attribute descriptors of objects are defined recursively. When the object hierarchy is deep, this must bring certain performance problems. In the implementation of the new reactive module, the reactive proxy is always “lazy” and will only proxy operations on that part of the structure until it actually gets the response structure.

After the change, the substructure is also responsive:

const value = reactive({ foo: { bar: 1 } }); const { foo } = value; // The resulting structure is still responsive. effect(() => { console.log("effect:", foo.bar); }); foo.bar = 2;Copy the code

Another question: What if a substructure is not an object?

// Const value = reactive({foo: 1}); const { foo } = value; // The structure is unresponsive.Copy the code

If the inner structure is an object, we get the inner deconstruction is still responsive, but if the inner deconstruction is a primitive type, we don’t delegate. So how do primitive types implement responsiveness? Since value passing for simple types in JS copies the value directly, the new value has nothing to do with the original value. The ref can only refer to simple values without losing the original values.

Realize the ref

The ref API is provided in the reactive module to solve this problem. Simple usage:

const count = ref(0);
effect(() => {
  console.log("effect:", count.value)
})
setTimeout(() => {
  count.value = count.value + 1
}, 1000)
Copy the code

The ref wrapper always gets/sets the original value via a value reference. Get, set value:

function ref(rawValue) { let _value = isObject(rawValue) ? reactive(value) : rawValue; const refValue = { get value() { track(refValue, 'value'); return _value; }, set value(_newValue) { if (_newValue ! == _value) {// Actually compare whether the original value is the same, not the proxy. _value = _newValue; trigger(refValue, 'value'); } } } return refValue; }Copy the code

As you can see in the code, if the value passed in is an object, it is converted to an object of Reactive. Track and trigger functions are called when the agent gets /set value to collect and trigger dependencies.

To solve the problem

So if the value is not an object, if it’s a primitive type, we can take it and do the ref conversion ourselves.

const value = reactive({ foo: 1 }); const { foo } = value; // The structure is unresponsive. const fooRef = ref(foo); // ref wrap. effect(() => { console.log('effect', fooRef.value) }) fooRef.value = 2Copy the code

So the responsive module provides toRef, toRefs API. ToRef converts the value of a responsive object’s specified key into a REF object, and toRefs converts all key values into a REF object. The internal implementation is very simple. Determine if the current value is a Ref object, and if not, convert. Interested can go to see the source code, here is not a realization.

Value type problem

So far, there are reactive types, REF types, and normal types…… In normal use, especially the use and conversion of ref types, reactive variable destructions require special care. Although the module provides toRaw, toRef and other related type conversion and judging API, but still need to pay attention to the correct use and understanding of its principle. So there is a certain cost of learning and the mental burden of using.

The concept of Vu3 is an incremental framework, with incremental costs to use and learn. So the new API is still in the “advanced Usage guide”, which offers the possibility of being more flexible and reusable.

Problem 2: Array push explodes the stack.

const arr = reactive([]);

effect(() => {
    arr.push(1)
})
console.log(arr);
Copy the code

Array push: get arr. Length

Set arr. Length = length + 1Copy the code

Set arr. Length (); set arr. Length (); set arr. BOOM!

If the length attribute does not collect dependencies, the length attribute should not collect dependencies.

Suppose the change code is as follows:

/ /... If (array.isarray (target) &&key === "length") {// If it is the length property of the Array, the corresponding value is returned. Return reflect. get(target, key); } track(target, key); / /...Copy the code

While this seems fine, in JS, the length of an array can be changed directly, for example

arr = [1, 2, 3, 4]; arr.length = 2; Arr.splice (arr.length-2, arr.length-2)Copy the code

So apparently ignored length collection is unreasonable, not only such, length change, should have collected the array depends on all side effects function run again (actually the source for array, Map, Set etc. There are a lot of special treatment, no specific treatment, just met here, simple mention)

Add the following code:

function trigger(target, key) { let keyToDepMap = targetMap.get(target); if (! keyToDepMap) { return; } if (Array.isArray(target) && key === "length") { keyToDepMap.forEach((effects) => { effects.forEach((effect) => { effect(); }); }); return; } / /... }Copy the code

Going back to the previous question, we need a mechanism to prevent dependency collection. The problem itself could be that dependencies should not be collected while pushing. Modify the code as follows:

Function wrappedPush(... args) { shouldTrack = false const result = Array.prototype.push.apply(this, Args) shouldTrack = true return result} // get: (target, key) => {// If it is an array push method, return the wrapped function directly. // To simplify, other functions include 'push', 'pop', 'shift', 'unshift', 'splice', etc. If (array.isarray (target) &&key === 'push') {return wrappedPush} function track(target, key) { if (! shouldTrack) { return }Copy the code

Not just push, but ‘push’, ‘pop’, ‘shift’, ‘unshift’, ‘splice’, etc. This simplifies the corresponding code!

Control the dependency collection process

We introduced an external variable shouldTrack to indicate whether dependency collection is being done. We can provide apis that give us the flexibility to control which code does not need dependency collection.

let shouldTrack = true const trackStack = [] function pauseTracking() { trackStack.push(shouldTrack) shouldTrack = false  } function enableTracking() { trackStack.push(shouldTrack) shouldTrack = true } function resetTracking() { const last =  trackStack.pop() shouldTrack = last === undefined ? true : last }Copy the code

TrackStack is a data deconstruction of a stack containing shouldTrack. Why provide this stack data deconstruction instead of directly changing shouldTrack?

The enableTracking API is provided. It is necessary to provide such an API for a function that has internal dependency control but does not know the external dependency collection state when it is run.

// const aRef = ref('a'); const bRef = ref('b'); function foo() { enableTracking() console.log(bRef.value) resetTracking() } function fn() { pauseTracking() foo() Console.log (aref.value) resetTracking()} effect(() => {fn()}) setTimeout(() => {// Will trigger an update bref.value = 'changeB' // Aref. value = 'changeA'}, 1000)Copy the code

Problem 3: Excessive collection of dependencies

let a = reactive({ foo: true, bar: 1 }); let dummy; effect(() => { dummy = a.foo ? a.bar : 999; console.log("run!" , dummy); }); a.foo = false; a.bar = 2; // Does not change the result but still fires the side effect functionCopy the code

When we change Foo, the dependency changes and the side effect function reruns. However, when bar is changed later, dummy value is not affected, but the side effect function is restarted. Although in this case, it doesn’t matter if the side effect function runs multiple times. But you can imagine that vue3, when compiling the template and then running the render function, is actually collecting dependencies and re-rendering them when the corresponding dependencies change. So the problem here is that if the branch condition changes and the previously collected dependencies change, the view will still be updated, which is obviously not what we want.

To solve this problem, it is very simple to collect dependencies again before running the side effect function, and remove all previous dependencies. To remove the dependency of the side effect function before running it, we need to add a layer of mapping from the side effect function to the side effect set. It might be more intuitive to just look at the TS type.

export interface ReactiveEffect<T = any> {
  (): T
  raw: () => T
  deps: Array<Dep>
         ...
}

type Dep = Set<ReactiveEffect>type 
KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()
Copy the code

The side effect functions passed in to effect are now wrapped as ReactiveEffect functions, which collect a collection of DEPs, or dependency mapping side effect functions.

Before we actually run the ReactiveEffect function, we take the DEPS and delete ourselves from each collection… It may be a bit tricky, but look directly at the code:

Function effect(fn) {function reactiveEffect() {activeEffect = reactiveEffect; // Clear dependencies before running. const { deps } = activeEffect; if (deps) { deps.forEach((dep) => { dep.delete(activeEffect); }); } const result = fn(); ActiveEffect = null; return result; } // source code with array optimization space, here simply use set. reactiveEffect.deps = new Set(); reactiveEffect(); return effect; }Copy the code

Then add the dependency to the dePS when track first creates the dependency:

function track(target, key) { ... // Create bidirectional mapping. activeEffect.deps.add(effects) effects.add(activeEffect); }Copy the code

Note that the trigger function needs to be modified:

function trigger(target, key) { ... / / get triggered when the side effects of function, prevent subsequent repeat adding lead to infinite loop effects [...] forEach ((effect) = > {/ / function to prevent the side effects of change depend on, trigger itself, the burst stack. if (effect === activeEffect) { return; } effect(); }); }Copy the code

At this point we have solved the problem of over-collection of dependencies by creating a bidirectional mapping between side effects and side effects, clearing dependencies before run time and re-collecting dependencies at run time.

Complete data structure

Problem 4: Implementing computed based on Effect

const fooRef = ref(1); const computedValue = computed(() => { console.log("computed:", fooRef.value); return fooRef.value; }); console.log(computedValue.value) // computed: 1 fooRef.value = 2; fooRef.value = 3; Console. log(computedValue.value) // Computed :3 is printed hereCopy the code

A computed function is always lazy; it does not actively run a function until its value is accessed, and its dependencies do not change; it returns values directly from the cache.

We want the effect function not to be reruned when the dependency does not change (because it is usually quite time-consuming), so we add optional parameters to the effect function. We add lazy and scheduler options. On the first run, the collection dependency is not initialized to determine if lazy is present. After the first GET, the corresponding dependencies are collected. Later, if the dependency has not changed, the value is fetched directly from the cache. If the dependency changes, rerun the side effects function to update the cache on the next GET.

function computed(fn) { let obj = {}; let cache; let dirty = true; Const effectFn = effect(fn, {lazy: true, scheduler: () => {// Dependencies change, update flag. dirty = true; trigger(obj, "value") }, }); Object.defineProperty(obj, "value", { get: () => { if (dirty) { dirty = false; Cache = effectFn(); } track(obj, "value"); return cache; }}); return obj; } function effect(fn, options = {}) { function reactiveEffect() { ... } reactiveeffect.options = options; if (! Options. lazy) {// Do not automatically run a collection dependency once. reactiveEffect(); } return reactiveEffect; } function trigger(target, key) { ... [...effects].forEach(effect => { if(effect ! == activeEffect){if (effect.options.scheduler) {// If there is a scheduler, then run effect.options.scheduler(); } else { effect() } } }); }Copy the code

The scheduler option

Vue3 does not expose effect directly, but provides apis such as computed, Watch, and watchEffect. These apis are exposed by Run-time core, which means that the basic APIS are reexposed in conjunction with the run-Timecore implementation. The side effects function is bound to the instance of the component for easy deletion when the component is destroyed.

This focuses on watch and watchEffect implementations (see documentation for usage: vue3js.cn/docs/zh/api…). , they are all implemented in the source code through the same function doWatch. It is worth noting that in Vue side effects may not be run immediately after a dependency change, and functions may be run asynchronously. Take a look at the source code:

let scheduler if (flush === 'sync') { scheduler = job } else if (flush === 'post') { scheduler = () => queuePostRenderEffect(job, instance && instance.suspense) } else { // default: 'pre' scheduler = () => { if (! instance || instance.isMounted) { queuePreFlushCb(job) } else { // with 'pre' option, the first call must happen before // the component is mounted so it is called synchronously. job() } } } const runner = effect(getter, { lazy: true, onTrack, onTrigger, scheduler })Copy the code

In the source code, convert the watchEffect side effect function passed in, and the watch array passed in to watch variable changes into getters. As can be seen from the code, the scheduler option allows for flexible scheduling of functions.

Problem 5: Nested computed and effect.

const nums = reactive({ num1: 1, num2: 2, num3: 3 })

const count = computed(() => nums.num1 + nums.num2 + nums.num3)
effect(() => {
        console.log('count:'count)
})
nums.num1 = -1;
Copy the code

Example: shows side effect functions and calculated values nested, cross-reference (run) examples. In fact, a change in the dependency of the value of the calculated attribute in the side effect function may re-generate new values, and we expect the side effect function to re-run as well.

When we implemented computed above, we called track to collect dependencies when we got value, and when the dependencies changed, we not only updated dirty, but also called trigger to trigger notification. This allows us to delegate computed operations so that they can collect dependencies and fire just like normal responsive variables. However, running the above example does not trigger the effect function change.

The reason is that after effect is run, functions in computed are run lazily only when GET Value is run, and activeEffect is changed first when reactiveEffect is run. At this time, computed dependencies are collected, but after the collection is completed, activeEffect is set to NULL, and then track is triggered. Because activeEffect is null at this time, dependency collection fails. So changing the value of nums.num1 does not trigger a reruning of the side effect function. Simply put, activeEffect is assigned multiple times, so that it does not really express the side effect function that currently needs to collect dependencies.

To solve this problem, it is necessary to restore the ativeEffect after collecting the dependencies in the inner layer. Remember shouldTrack’s previous solution to nesting, which was to increase the stack structure, is the same here: use the effectStack to record the stack of side effects, and then activeEffect always points to the top of the stack.

let effectStack = [] function effect(fn, options = {}) { function reactiveEffect() { activeEffect = reactiveEffect; Effectstack. push(activeEffect); . const result = fn(); // When finished, push out effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; return result } ... }Copy the code

The crazy nesting doll structure also works.

const nums = reactive({ num1: 1, num2: 2, num3: 3 });
let dummy1;
dummy1 = computed(() => 1 + nums.num1); // 2
let dummy2;
dummy2 = computed(() => dummy1.value + nums.num2); // 4
const fn = effect(() => {
    console.log("fn", dummy2.value + nums.num3); // 7
});
effect(() => {
    fn();
});
nums.num1 = 3;
Copy the code

conclusion

The basic idea behind the design of this module code is the subscription publishing model. Its internal data structure is shown above. We know the realization of internal Reactive, ref and effect, the control mechanism of dependency collection and the solution of nested problems are all based on the stack data structure, and the scheduler option can flexibly control the execution timing of functions.

The appendix

IO /s/bold-star… Two Vue libraries:

  • VueUse: github.com/antfu/vueus… .

  • Vue – composable: github.com/pikax/vue-c…