preface

Vue3’s response-based proxy-based approach provides better support for intercepting new objects and arrays than Vue2’s Object.definedProperty approach.

Vue3’s responsiveness is a separate system that can be used in isolation, so how does it work?

You know there’s a Getter and a Setter, so what are the major operations that go on in the Getter and Setter to get reactive?

With these questions in mind, this article will implement a complete responsive system step by step.

start

Observer-util the observer-util library is written in the same vein as Vue3, where the implementation is more complex and starts from a pure-play library (I won’t admit it because there are some unread things in Vue3, no).

According to the example on the official website:

import { observable, observe } from '@nx-js/observer-util';

const counter = observable({ num: 0 });
const countLogger = observe(() = > console.log(counter.num));

// this calls countLogger and logs 1
counter.num++;
Copy the code

These are similar to Reactive in Vue3 and normal reactive.

Objects after Observable are added to the proxy, and the response function added in Observe is called once when the dependent property changes.

A little thought

The rough idea here is a subscribing and publishing model, in which the object behind the Observable proxy builds a publisher repository, observe subscribes to counter.num, and calls back and back when the subscribed content changes.

Pseudo code:

// Add a listener
xxx.addEventListener('counter.num'.() = > console.log(counter.num))
// Change the content
counter.num++
// Send notifications
xxx.emit('counter.num', counter.num)
Copy the code

The core of responsiveness is this, adding listeners and sending notifications automatically via Observables and Observes.

Code implementation

With that thought in mind, in the Getter we need to add the callback from Observe to the subscription repository.

An Observable adds a handler to the observable, In the handler of Getter had a registerRunningReactionForOperation ({target, key, receiver, type: ‘get’})

const connectionStore = new WeakMap(a)// reactions can call each other and form a call stack
const reactionStack = []

// register the currently running reaction to be queued again on obj.key mutations
export function registerRunningReactionForOperation (operation) {
  // get the current reaction from the top of the stack
  const runningReaction = reactionStack[reactionStack.length - 1]
  if (runningReaction) {
    debugOperation(runningReaction, operation)
    registerReactionForOperation(runningReaction, operation)
  }
}
Copy the code

This function will get out of a reaction (i.e., and observe the callback), and through registerReactionForOperation preservation.


export function registerReactionForOperation (reaction, { target, key, type }) {
  if (type === 'iterate') {
    key = ITERATION_KEY
  }

  const reactionsForObj = connectionStore.get(target)
  let reactionsForKey = reactionsForObj.get(key)
  if(! reactionsForKey) { reactionsForKey =new Set()
    reactionsForObj.set(key, reactionsForKey)
  }
  // save the fact that the key is used by the reaction during its current run
  if(! reactionsForKey.has(reaction)) { reactionsForKey.add(reaction) reaction.cleaners.push(reactionsForKey) } }Copy the code

A Set is generated and the reaction is added to the Set based on the key used in the actual business get. The structure looks like this:

connectionStore<weakMap>: {
    // target eg: {num: 1}
    target: <Map> {num: (reaction1, reaction2...) }}Copy the code

Notice the reaction, const runningReaction = reactionStack[stack.leng-1] by its global variable.

export function observe (fn, options = {}) {
  // wrap the passed function in a reaction, if it is not already one
  const reaction = fn[IS_REACTION]
    ? fn
    : function reaction () {
      return runAsReaction(reaction, fn, this.arguments)}// save the scheduler and debugger on the reaction
  reaction.scheduler = options.scheduler
  reaction.debugger = options.debugger
  // save the fact that this is a reaction
  reaction[IS_REACTION] = true
  // run the reaction once if it is not a lazy one
  if(! options.lazy) { reaction() }return reaction
}

export function runAsReaction (reaction, fn, context, args) {
  // do not build reactive relations, if the reaction is unobserved
  if (reaction.unobserved) {
    return Reflect.apply(fn, context, args)
  }

  // only run the reaction if it is not already in the reaction stack
  // TODO: improve this to allow explicitly recursive reactions
  if (reactionStack.indexOf(reaction) === -1) {
    // release the (obj -> key -> reactions) connections
    // and reset the cleaner connections
    releaseReaction(reaction)

    try {
      // set the reaction as the currently running one
      // this is required so that we can create (observable.prop -> reaction) pairs in the get trap
      reactionStack.push(reaction)
      return Reflect.apply(fn, context, args)
    } finally {
      // always remove the currently running flag from the reaction when it stops execution
      reactionStack.pop()
    }
  }
}
Copy the code

In runAsReaction, the const reaction = function() {runAsReaction(reaction)} is pushed onto the stack and fn, Fn is the function that we want to automatically respond to, to execute that function is going to trigger get, and that reaction is going to be in its reaction, in its reaction, in its reaction. If fn has asynchronous code in it, the try finally is executed in this order:

// Execute the contents of a try,
// If there is a return, but it does not return, finally returns, there is no blocking.

function test() {
    try { 
        console.log(1); 
        const s = () = > { console.log(2); return 4; }; 
        return s();
    } finally { 
        console.log(3)}}// 1, 2, 3, 4
console.log(test())

Copy the code

So if the asynchronous code blocks and executes before the Getter, the dependency will not be collected.

imitation

The goal is to implement Observable and Observe and computed in the derived Vue.

Using the idea of Vue3, the operation of GET is called track, the operation of set is called trigger, and the callback is called effect.

Let’s start with a map:

function createObserve(obj)  {
    
    let handler = {
        get: function (target, key, receiver) {
            let result = Reflect.get(target, key, receiver)
            track(target, key, receiver)            
            return result
        },
        set: function (target, key, value, receiver) {
            let result = Reflect.set(target, key, value, receiver)
            trigger(target, key, value, receiver)        
            return result
        }
    }

    let proxyObj = new Proxy(obj, handler)

    return proxyObj
}

function observable(obj) {
    return createObserve(obj)
}
Copy the code

Here we have only one layer of Proxy encapsulation, like in Vue we would have done a recursive encapsulation.

The difference is that only one layer of encapsulation can detect the outer = operation, and the inner layer, such as array.push, or nested substitutions, cannot pass through set and get.

Realize the track

In track, we will push the currently triggered effect, that is, the content of Observe or other contents, into the relationship chain so that this effect can be invoked when trigger.

const targetMap = new WeakMap(a)let activeEffectStack = []
let activeEffect

function track(target, key, receiver?) {
    let depMap = targetMap.get(target)

    if(! depMap) { targetMap.set(target, (depMap =new Map()))}let dep = depMap.get(key)

    if(! dep) { depMap.set(key, ( dep =new Set()))}if(! dep.has(activeEffect)) { dep.add(activeEffect) } }Copy the code

TargetMap is a weakMap. The advantage of using weakMap is that when there is no other reference to the observable object, it will be garbage collected correctly. This chain is an extra content that we build, and should not continue to exist if the original object does not exist.

This will eventually lead to one:

TargetMap = {<Proxy or Object>observeable: <Map>{<observeable key>key: (observe, observe, observe...) }}Copy the code

ActiveEffectStack and activeEffect are two global variables used for data exchange. In GET, we will add the current activeEffect to the Set generated by the key of get and save it. Let the set operation take the activeEffect and call it again, making it reactive.

To realize the trigger

function trigger(target, key, value, receiver?) {
    let depMap = targetMap.get(target)

    if(! depMap) {return
    }

    let dep = depMap.get(key)

    if(! dep) {return
    }

    dep.forEach((item) = > item && item())
}
Copy the code

Trigger here implements a minimal content according to the idea, and only calls the effects added in get one by one.

Realize the observe

In Observe we need to push the function passed into the activeEffectStack and call function once to trigger get.

function observe(fn:Function) {
    const wrapFn = () = > {

        const reaction = () = > {
            try {
                activeEffect = fn     
                activeEffectStack.push(fn)
                return fn()
            } finally {
                activeEffectStack.pop()
                activeEffect = activeEffectStack[activeEffectStack.length-1]}}return reaction()
    }

    wrapFn()

    return wrapFn
}
Copy the code

Function can go wrong, and the code in finally ensures that the corresponding one in the activeEffectStack is removed correctly.

test

let p = observable({num: 0})
let j = observe(() = > {console.log("i am observe:", p.num);)
let e = observe(() = > {console.log("i am observe2:", p.num)})

// i am observe: 1
// i am observe2: 1
p.num++
Copy the code

To realize the computed

One useful thing in Vue is computed attributes, which generate new values based on other attributes and change automatically when other values it depends on change.

We have achieved more than half of computed after ovServe.

class computedImpl {
    private _value
    private _setter
    private effect

    constructor(options) {
        this._value = undefined
        this._setter = undefined
        const { get, set } = options
        this._setter = set

        this.effect = observe(() = > {
            this._value = get()
        })
    }

    get value() {
        return this._value
    }

    set value (val) {
        this._setter && this._setter(val)
    }
}

function computed(fnOrOptions) {

    let options = {
        get: null.set: null
    }

    if (fnOrOptions instanceof Function) {
        options.get = fnOrOptions
    } else {
        const { get, set } = fnOrOptions
        options.get= get
        options.set = set
    }

    return new computedImpl(options)
}
Copy the code

For computed, there are two ways, one is computed(function), which is a get, and you can set setters, which are more like a callback and have nothing to do with the other properties that you depend on.

let p = observable({num: 0})
let j = observe(() = > {console.log("i am observe:", p.num); return `i am observe: ${p.num}`})
let e = observe(() = > {console.log("i am observe2:", p.num)})
let w = computed(() = > { return 'I'm computed 1:' + p.num })
let v = computed({
    get: () = > {
        return 'test computed getter' + p.num
    },

    set: (val) = > {
        p.num = `test computed setter${val}`
    }
})

p.num++
// i am observe: 0
// i am observe2: 0
// i am observe: 1
// i am observe2: 1
// I am computed 1:1
console.log(w.value)
v.value = 3000
console.log(w.value)
// i am observe: test computed setter3000
// i am observe2: test computed setter3000
// I am computed 1:test computed Setter3000
w.value = 1000
// There is no setter set for w so it does not take effect
// I am computed 1:test computed Setter3000
console.log(w.value)
Copy the code

Request praise time ~

Ahem, good luck to those who get a “like” from a pretty sister.

Complete the code on Github, or put it together yourself.