Responsiveness is familiar to anyone who has used Vue or RxJS. Responsiveness is also one of the core functional features of Vue, so we must have a deep understanding of responsiveness if we are to master Vue. Next, I’ll start with the observer pattern and take you through the principles of responsiveness with the observer-util library.
Observer mode
Observer mode, which defines a one-to-many relationship in which multiple observer objects listen to a topic object at the same time. When the topic object’s state changes, all observer objects are notified so that they can automatically update themselves. There are two main roles in the Observer pattern: Subject and Observer.
Pay attention to “the road to immortal in full stack” to read the original 4 free e-books (22,000 + downloads) and more than 50 “re-learn TS” tutorials.
Because the observer mode supports simple broadcast communication, all observers are automatically notified when a message is updated. Let’s take a look at how to implement observer mode in TypeScript:
1.1 define ConcreteObserver
interface Observer {
notify: Function;
}
class ConcreteObserver implements Observer{
constructor(private name: string) {}
notify() {
console.log(`The ${this.name} has been notified.`); }}Copy the code
1.2 Defining the Subject class
class Subject {
private observers: Observer[] = [];
public addObserver(observer: Observer): void {
this.observers.push(observer);
}
public notifyObservers(): void {
console.log("notify all the observers");
this.observers.forEach(observer= >observer.notify()); }}Copy the code
1.3 Example
// ① Create the subject object
const subject: Subject = new Subject();
// add an observer
const observerA = new ConcreteObserver("ObserverA");
const observerC = new ConcreteObserver("ObserverC");
subject.addObserver(observerA);
subject.addObserver(observerC);
// ③ Inform all observers
subject.notifyObservers();
Copy the code
For the above example, there are three main steps: (1) Creating the subject object, (2) adding observers, and (3) notifying observers. After the above code runs successfully, the console will output the following:
notify all the observers
ObserverA has been notified.
ObserverC has been notified.
Copy the code
In most front-end scenarios, what we observe is data. When the data changes, the page can be automatically updated. The corresponding effect is shown in the figure below:
To achieve automatic update, we need to meet two conditions: one is to achieve accurate update, and the other is to detect changes in the data. Accurate updates require the collection of update functions (observers) that are interested in the data anomalies. After the collection is complete, the corresponding update functions can be notified when the anomaly is detected.
The above description may seem confusing, but in order to achieve automatic update, we need to automate (1) creating the subject object, (2) adding observers, and (3) notifying observers. This is the core idea of implementing responsiveness. Let’s take a concrete example:
The code in the figure above will be familiar to those familiar with the responsive principle of Vue2, where the second step is also known as collecting dependencies. By using the Object.defineProperty API, we can intercept reading and modifying data.
If a data is read in the function body, it indicates that the function is interested in the data. When the data is read, the defined getter function fires, and the observer of the data can be stored. When the data changes, we can notify all observers in the observer list and perform the corresponding update operation.
Vue3 uses Proxy API to implement responsiveness. What are the advantages of Proxy API over Object. DefineProperty API? I’m not going to go into this, but I’m going to write a dedicated article on the Proxy API. Here’s where Apogo begins introducing the main character of this article, observer-util:
Transparent reactivity with 100% language coverage. Made with ❤️ and ES6 Proxies.
Github.com/nx-js/obser…
The library also uses ES6’s Proxy API internally to implement responsiveness, but before we go into how it works, let’s take a look at how to use it.
An introduction to observer-util
The observer-util library is also easy to use. The observable and Observe functions make it easy to make data responsive. Here’s a simple example:
2.1 Known Attributes
import { observable, observe } from '@nx-js/observer-util';
const counter = observable({ num: 0 });
const countLogger = observe(() = > console.log(counter.num)); / / 0
counter.num++; / / output 1
Copy the code
In the above code, we import the Observables and Observe functions from the @nx-js/observer-util module, respectively. The observable function is used to create observable objects, and the observe function is used to register the observer function. When the above code executes successfully, the console will print a 0 and a 1 in sequence. In addition to known properties, observer-util also supports dynamic properties.
2.2 Dynamic Properties
import { observable, observe } from '@nx-js/observer-util';
const profile = observable();
observe(() = > console.log(profile.name));
profile.name = 'abao'; / / output 'abao'
Copy the code
After successful execution of the above code, the console outputs undefined and abao in turn. In addition to supporting normal objects, observer-util also supports arrays and collections in ES6, such as maps, sets, and so on. Here we take the usual array example and see how to make an array object reactive.
2.3 an array
import { observable, observe } from '@nx-js/observer-util';
const users = observable([]);
observe(() = > console.log(users.join(', ')));
users.push('abao'); / / output 'abao'
users.push('kakuqo'); // Output 'abao, kakuqo'
users.pop(); / / output 'abao,'
Copy the code
Here are just a few simple examples, but those interested in other examples using observer-util can read the project’s readme.md documentation. Next, We’ll take a look at how the observer-util library is implemented in a responsive way, using the simplest of examples.
If you want to run the above example locally, you can modify the index.js file in the debug/index.js directory and then run the NPM run debug command in the root directory.
3. Observer-util principle analysis
First, let’s go back to the earliest example:
import { observable, observe } from '@nx-js/observer-util';
const counter = observable({ num: 0 }); // A
const countLogger = observe(() = > console.log(counter.num)); // B
counter.num++; // C
Copy the code
In line A, we create an observable counter object using the Observable function. The object’s internal structure is as follows:
Looking at the figure above, you can see that the counter variable refers to a Proxy object with three Internal slots. How does the Observable function transform our {num: 0} object into a Proxy object? In the project SRC/Observable.js file, we find the definition of this function:
// src/observable.js
export function observable (obj = {}) {
Return obj if it is already an Observable or should not be wrapped
if(proxyToRaw.has(obj) || ! builtIns.shouldInstrument(obj)) {return obj
}
// If obj already has an Observable, return it. Otherwise create a new Observable
return rawToProxy.get(obj) || createObservable(obj)
}
Copy the code
The proxyToRaw and rawToProxy objects are defined in SRC /internals.js:
// src/internals.js
export const proxyToRaw = new WeakMap(a)export const rawToProxy = new WeakMap(a)Copy the code
These two objects store the mapping between proxy => RAW and RAW => proxy. Raw indicates the original object and proxy indicates the wrapped proxy object. Clearly for the first time executes, proxyToRaw. From the (obj) and rawToProxy. Get (obj) will return false and undefined respectively, so will perform | | operators on the right side of the logic.
Let’s examine the shouldInstrument function, which is defined as follows:
// src/builtIns/index.js
export function shouldInstrument ({ constructor }) {
const isBuiltIn =
typeof constructor= = = 'function'&&constructor.name in globalObj &&
globalObj[constructor.name= = =constructor
return !isBuiltIn || handlers.has(constructor)}Copy the code
Inside the shouldInstrument function, we use the constructor of the argument obj to determine whether it is a built-in object. For {num: 0} objects, it is the constructor of the ƒ Object () {} [native code], so isBuiltIn value is true, so they will continue to execute | | operators on the right side of the logic. The handlers object is a Map object:
// src/builtIns/index.js
const handlers = new Map([[Map, collectionHandlers],
[Set, collectionHandlers],
[WeakMap, collectionHandlers],
[WeakSet, collectionHandlers],
[Object.false],
[Array.false],
[Int8Array.false],
[Uint8Array.false].// Omit part of the code
[Float64Array.false]])Copy the code
After looking at the handlers, it’s obvious! BuiltIns. ShouldInstrument (obj) expression to false results. So next, our focus is the createObservable function:
function createObservable (obj) {
const handlers = builtIns.getHandlers(obj) || baseHandlers
const observable = new Proxy(obj, handlers)
// Save raw => proxy, proxy => Mapping between raw
rawToProxy.set(obj, observable)
proxyToRaw.set(observable, obj)
storeObservable(obj)
return observable
}
Copy the code
Observable ({num: 0}) returns a Proxy object after calling the observable({num: 0}) function. For the Proxy constructor, it supports two arguments:
const p = new Proxy(target, handler)
Copy the code
- Target: To use
Proxy
The wrapped target object (which can be any type of object, including a native array, a function, or even another proxy); - Handler: An object that usually has functions as properties, each of which defines a proxy for performing various operations
p
Behavior.
In our example, target refers to the {num: 0} object, and the handlers value returns different handlers depending on the type of obj:
// src/builtIns/index.js
export function getHandlers (obj) {
return handlers.get(obj.constructor) // [Object, false],
}
Copy the code
BaseHandlers are objects that contain “traps” such as get, has, and set:
export default { get, has, ownKeys, set, deleteProperty }
Copy the code
After the observable is created, it saves the mapping between raw => proxy, proxy => raw, and then calls storeObservable to store it. The storeObservable function is defined in the SRC /store.js file:
// src/store.js
const connectionStore = new WeakMap(a)export function storeObservable (obj) {
Key -> reaction Is used to save the mapping between obj.key -> reaction
connectionStore.set(obj, new Map()}Copy the code
Introducing so much, Brother A baoge with a picture to summarize the previous content:
What about proxyToRaw and rawToProxy objects? After reading the following code, you will know the answer.
// src/observable.js
export function observable (obj = {}) {
Return obj if it is already an Observable or should not be wrapped
if(proxyToRaw.has(obj) || ! builtIns.shouldInstrument(obj)) {return obj
}
// If obj already has an Observable, return it. Otherwise create a new Observable
return rawToProxy.get(obj) || createObservable(obj)
}
Copy the code
Let’s start with line B:
const countLogger = observe(() = > console.log(counter.num)); // B
Copy the code
The observe function is defined in the SRC /observer.js file as follows:
// src/observer.js
export function observe (fn, options = {}) {
// const IS_REACTION = Symbol('is reaction')
const reaction = fn[IS_REACTION]
? fn
: function reaction () {
return runAsReaction(reaction, fn, this.arguments)}// Omit part of the code
reaction[IS_REACTION] = true
// If it is not lazy, run it directly
if(! options.lazy) { reaction() }return reaction
}
Copy the code
In the above code, we first determine if fn is a reaction function, and if so, we use it directly. If not, it wraps the passed FN function as reaction and then calls that function. Inside the reaction function, another function is called, runAsReaction, which, as the name suggests, is used to run the reaction function.
The runAsReaction function is defined in the SRC /reactionRunner. Js file:
// src/reactionRunner.js
const reactionStack = []
export function runAsReaction (reaction, fn, context, args) {
// Omit part of the code
if (reactionStack.indexOf(reaction) === -1) {
// Release (obj -> key -> reactions) link and reset the sweeper link
releaseReaction(reaction)
try {
// Put it on the reaction stack so that the link between observable. Prop -> reaction can be established in the get trap
reactionStack.push(reaction)
return Reflect.apply(fn, context, args)
} finally {
// Remove the reaction function that was executed from the reaction stack
reactionStack.pop()
}
}
}
Copy the code
Within the body of the runAsReaction function, the currently executing reaction function is pushed onto the reaction stack, and the passed fn function is called using the Reflect. Apply API. When fn executes, the console.log(counter.num) statement is executed, in which the num property of the counter object is accessed. The counter object is a Proxy object that triggers get traps in baseHandlers when a property of that object is accessed:
// src/handlers.js
function get (target, key, receiver) {
const result = Reflect.get(target, key, receiver)
// Register and save (Observable. Prop -> runningReaction)
registerRunningReactionForOperation({ target, key, receiver, type: 'get' })
const observableResult = rawToProxy.get(result)
if (hasRunningReaction() && typeof result === 'object'&& result ! = =null) {
// Omit part of the code
}
return observableResult || result
}
Copy the code
In the above function, registerRunningReactionForOperation function used to hold observables. Prop – > runningReaction the mapping relationship between. It’s just adding an observer to a given property of an object, and that’s a very important step. So let’s focus on analysis registerRunningReactionForOperation function:
// src/reactionRunner.js
export function registerRunningReactionForOperation (operation) {
// Get the reaction currently executing from the top of the stack
const runningReaction = reactionStack[reactionStack.length - 1]
if (runningReaction) {
debugOperation(runningReaction, operation)
registerReactionForOperation(runningReaction, operation)
}
}
Copy the code
In registerRunningReactionForOperation function, is first obtained from the reactionStack stack running reaction function, Then again registerReactionForOperation function called registration for the current operation reaction function, specific processing logic is as follows:
// src/store.js
export function registerReactionForOperation (reaction, { target, key, type }) {
// Omit part of the code
const reactionsForObj = connectionStore.get(target) // A
let reactionsForKey = reactionsForObj.get(key) // B
if(! reactionsForKey) {// C
reactionsForKey = new Set()
reactionsForObj.set(key, reactionsForKey)
}
if(! reactionsForKey.has(reaction)) {// D
reactionsForKey.add(reaction)
reaction.cleaners.push(reactionsForKey)
}
}
Copy the code
When an observable is created by calling the Observable (obj) function, it is stored in the connectionStore (ConnectionStore.set (obj, new Map())) object with the obj object as the key. The Po brother registerReactionForOperation internal processing logic function is divided into four parts:
- A reaction will return A reactionsForObj (Map) object if the reaction store (WeakMap) object is used to obtain the target value; A reactionsForObj (Map) object will return A reactionsForObj (Map) object.
- (B) : The reaction will get the key value from the reaction object, and will return undefined if it does not exist;
- (C) : if the reaction for key is undefined, a Set will be created and stored as value in the reactionsForObj (Map) object;
- (D) : Determine whether the reaction function is present in the reaction sforkey (Set), and if it does not, add the current reaction function to the reaction sforkey (Set).
In order to better understand the content of this part, A Baoge continues to summarize the above content by drawing:
Since each attribute in an object can be associated with multiple reactions, to avoid duplication, we use a Set object to store the reactions associated with each attribute. An object can contain multiple properties, so observer-util internally uses a Map object to store the relationship between each property and the reaction function.
In addition, in order to support the ability to turn multiple objects into Observable objects and timely reclaim memory when the original object is destroyed, observer-util defines a connectionStore object of type WeakMap to store the link relationship of the object. For the current example, the internal structure of the connectionStore object looks like this:
Finally, let’s analyze counter. Num++; This line of code. For the sake of simplicity, We only analyze the core processing logic. Those who are interested in the complete code can read the source code of the project. When performing counter. Num++; This line of code triggers the set trap that has been set:
// src/handlers.js
function set (target, key, value, receiver) {
// Omit part of the code
const hadKey = hasOwnProperty.call(target, key)
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if(! hadKey) { queueReactionsForOperation({ target, key, value, receiver,type: 'add'})}else if(value ! == oldValue) { queueReactionsForOperation({ target, key, value, oldValue, receiver,type: 'set'})}return result
}
Copy the code
For our example, will call queueReactionsForOperation function:
// src/reactionRunner.js
export function queueReactionsForOperation (operation) {
// iterate and queue every reaction, which is triggered by obj.key mutation
getReactionsForOperation(operation).forEach(queueReaction, operation)
}
Copy the code
In internal queueReactionsForOperation function will continue to call getReactionsForOperation function gets the current key corresponding reactions:
// src/store.js
export function getReactionsForOperation ({ target, key, type }) {
const reactionsForTarget = connectionStore.get(target)
const reactionsForKey = new Set(a)if (type === 'clear') {
reactionsForTarget.forEach((_, key) = > {
addReactionsForKey(reactionsForKey, reactionsForTarget, key)
})
} else {
addReactionsForKey(reactionsForKey, reactionsForTarget, key)
}
// Omit part of the code
return reactionsForKey
}
Copy the code
After successfully obtaining the reactions corresponding to the current key, each reaction is iterated over the object to execute each reaction. The actual processing logic is defined in queueReaction:
// src/reactionRunner.js
function queueReaction (reaction) {
debugOperation(reaction, this)
// queue the reaction for later execution or run it immediately
if (typeof reaction.scheduler === 'function') {
reaction.scheduler(reaction)
} else if (typeof reaction.scheduler === 'object') {
reaction.scheduler.add(reaction)
} else {
reaction()
}
}
Copy the code
Because our example does not configure the Scheduler parameter, we simply execute the else branch, executing the reaction() statement.
Okay, so the core logic of how the observer-util library transforms ordinary objects into observables is gone. For ordinary objects, observer-util internally provides get and set traps via the Proxy API to automatically add observers (add reaction) and notify observers (execute reaction).
The definition of targetMap in the ReActivity module in Vue3 should be understandable if you’ve read this article:
// vue-next/packages/reactivity/src/effect.ts
type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()
Copy the code
In addition to ordinary objects and arrays, observer-util also supports collections in ES6, such as maps, sets, weakMaps, and so on. When dealing with these objects, the collectionHandlers object is used instead of the baseHandlers object when the Proxy object is created. This part of the content, brother will not expand the introduction, interested partners can read the relevant code.
Pay attention to the “full stack of the road to repair the immortal” to read a baoge original 4 free e-books (accumulated download 21,000 +) and 10 source code analysis tutorial series.
Iv. Reference Resources
- what-is-an-internal-slot-of-an-object-in-javascript
- MDN-Proxy
- MDN-Reflect