60 lines of code to implement a MobX
We will implement the main features of MobX:
- observable
- autoRun
- computed
As far as decorators are concerned and more dependent on the new ES features, there is no more analysis here
The characteristics of MobX
MobX: Simple, scalable State Management tool
Let’s take a look at some of MobX’s features and uses
import { observable, autorun, computed } from 'mobx'
const todoStore = observable({
/* Some observations */
todos: [],
/* Derive the value */
get completedCount() {
return this.todos.filter(todo= > todo.completed).length
}
})
/* Derive the value */
const finished = computed((a)= > {
return todoStore.todos.filter(todo= > todo.completed).length
})
/* A function of observing state changes */
autorun(function() {
console.log('Completed %d of %d items', finished, todoStore.all)
})
/ *.. And some state changing actions */
todoStore.todos[0] = {
title: 'Take a walk'.completed: false
}
// -> Print 'Completed 0 of 1 items'
todoStore.todos[0].completed = true
// -> Print 'Completed 1 of 1 items'
Copy the code
Let’s take a look at what MobX did:
-
1. Encapsulate Observable: Listens for changes in properties and values of objects. This process is usually intercepted through Object.defineProperty and getters and setters. Or Proxy intercepts. If you have learned vUE, then VUe2.0 uses the former, while the latest Vue3.0 (Vue-Next) uses the latter Proxy.
-
2. Dependency collection: What is the process of using autoRun for dependency collection? A = {collect: 1, noCollect: AutoRun (()=>console.log(a.collect))), a.collect++ However, when I call A.Collect ++, noCollect does not perform a dependency collection, so the run output will not be executed.
-
3. Automatically computed Computed: That is, automatically executes code const finished = computed(() => (todoStore.todos.filter(todo => todo.pleted).length)) when todos Automatically updates the finished variable value when changes occur.
Principle of inquiry
Having said so much, except for the first one, I may have heard a little, but the other feelings are not quite strange, in fact, the principle is relatively simple. The implementation of the whole big program can be divided into three main parts.
- Observer Model (DEP)
- The interceptor (Proxy)
- Object original value (symbol.toprimitive)
What is the Observer Mode (EventBus)
For one-to-many relationships, the Observer Pattern is used. As long as all dependent objects are notified and automatically updated when the state of an object changes, the coupling of the functions between the subject object and the observer is solved, that is, the change of state of one object notifies other objects.
A simple observer model
const dep = {
event: {},
on(key, fn) {
this.event[key] = this.event[key] || []
this.event[key].push(fn)
},
emit(key, args) {
if (!this.event[key]) return
this.event[key].forEach(fn= > fn(args))
}
}
dep.on('print', args => console.log(args))
dep.emit('print'.'hello world')
// output: hello world
Copy the code
Careful contrast
Dep. on('print', args => console.log(args)) dep.emit('print', 'hello world') // MobX autorun(() => console.log(todoStore.todos.length')) todoStore.todos[0] = { title: 'Take a walk', completed: false }Copy the code
Isn’t that very familiar, just an explicit trigger, an implicit trigger. So how do you implicitly trigger?
1. Proxy
DefineProperty There is another way to select Object.defineProperty instead of Proxy. Let’s look at how object.defineProperty is implemented.
const px = {}
let val = ' '
Object.defineProperty(px, 'proxy', {
get() {
console.log('get', val)
// dep.on('proxy', fn)
return val
},
set(args) {
console.log('set', args)
// dep.emit('proxy')
val = args
}
})
px.proxy = 1
// output set 1
console.log(px.proxy)
// output get 1
// output 1
Copy the code
That’s right. Registration and triggering are implicitly registered and triggered by get set. But Object.defineProperty has some drawbacks.
- Not friendly to array support
- Relatively complex encapsulation
Proxy
We have rewritten the above code as a Proxy, and the registration and trigger locations are still used for GET sets
const printFn = (a)= > console.log('emit print key')
const handler = {
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
// dep.emit(key, target) events
if (key === 'key') dep.emit('key')
return result
},
get(target, key, value, receiver) {
if (key === 'key') {
// Register events
dep.on(key, printFn)
}
return Reflect.get(target, key, value, receiver)
}
}
// Encapsulate the Proxy recursively
const observable = obj= > {
Object.entries(obj).forEach(([key, value]) = > {
if (typeofvalue ! = ='object' || value === null) return
obj[key] = observable(value)
})
return new Proxy(obj, handler)
}
const obj = observable({})
obj.key // Run the get method to register printFn
obj.key = 'print' // Run the set trigger event to execute printFn
// output 'emit print key'
Copy the code
At this point we are done with the automatic response run. This is where autoRun comes in.
2. Rely on collection
If you look at the code above, the registration method (printFn) is written dead, but for the actual scenario, we need to have a registry, like autoRun.
const printFn = (a)= > console.log('emit print key')
// Very simple
const autoRun = (key, fn) = > {
dep.on(key, fn)
}
// Simply modify our proxy
const handler = {
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
dep.emit(key)
return result
},
get(target, key, value, receiver) {
return Reflect.get(target, key, value, receiver)
}
}
// Encapsulate the Proxy recursively
const observable = obj= > {
Object.entries(obj).forEach(([key, value]) = > {
if (typeofvalue ! = ='object' || value === null) return
obj[key] = observable(value)
})
return new Proxy(obj, handler)
}
const obj = observable({})
autoRun('key', printFn)
obj.key = 'print' // Run the set trigger event autoRun to execute printFn
// output emit print key
Copy the code
At this point, you may ask, the registration method is still through the key to complete ah, said the dependency collection? What about automatic enrollment?
When we run a piece of code, how do we know what variables are used in the code? How many times did you use the variable? How do I associate methods with variables? For example: How do you associate ob.name with autoRun methods
const ob = observable({})
autoRun((a)= > {
console.log(`print ${ob.name}`)
})
ob.name = 'hello world'
// print hello world
Copy the code
Dependency collection principle: With global variables and run (type blackboard) we changed the above code.
// Globally unique ID
let obId = 0
const dep = {
event: {},
on(key, fn) {
if (!this.event[key]) {
this.event[key] = new Set()}this.event[key].add(fn)
},
emit(key, args) {
const fns = new WeakSet(a)const events = this.event[key]
if(! events)return
events.forEach(fn= > {
if (fns.has(fn)) return
fns.add(fn)
fn(args)
})
}
}
// Global variables
let pendingDerivation = null
// Rely on collection
const autoRun = fn= > {
pendingDerivation = fn
fn()
pendingDerivation = null
}
const handler = {
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
dep.emit(`${target.__obId}${key}`)
return result
},
get(target, key, value, receiver) {
if (target && key && pendingDerivation) {
dep.on(`${target.__obId}${key}`, pendingDerivation)
}
return Reflect.get(target, key, value, receiver)
}
}
const observable = obj= > {
obj.__obId = `? obj${++obId}__ `
Object.entries(obj).forEach(([key, value]) = > {
if (typeofvalue ! = ='object' || value === null) return
obj[key] = observable(value)
})
return new Proxy(obj, handler)
}
Copy the code
Looking at the code above, there are probably two key changes:
// Global variables
let pendingDerivation = null
// Collect depends on step 1
const autoRun = fn= > {
pendingDerivation = fn
fn()
pendingDerivation = null
}
// Collect depends on step 2
const handler = {
get(target, key, value, receiver) {
if (target && key && pendingDerivation) {
dep.on(`${target.__obId}${key}`, pendingDerivation)
}
return Reflect.get(target, key, value, receiver)
}
}
Copy the code
Validation of variables and event registration in observer mode via global variables and immediate execution
When using Autorun, the provided function is always fired immediately once and then again each time its dependency changes. –MobX
When autoRun fn is executed, it will trigger the get method of each attribute in Proxy. At this time, attributes and methods are mapped through global variables.
3. Computed: Original Value of objects (symbol.toprimitive)
In fact, the implementation of computed in MobX is triggered by events, but when I read the source code, I wondered if Symbol. ToPrimitive could also be used to implement computed.
const computed = fn= > {
return {
_computed: fn,
[Symbol.toPrimitive]() {
return this._computed()
}
}
}
Copy the code
The code is simple: encapsulate a method with computed, then return an object directly that caches the method by overwriting symbol.toprimitive and then runs it on get.
The complete code
The code just combs through the main logic and lacks code detail
let obId = 0
let pendingDerivation = null
const dep = {
event: {},
on(key, fn) {
if (!this.event[key]) {
this.event[key] = new Set()}this.event[key].add(fn)
},
emit(key, args) {
const fns = new WeakSet(a)const events = this.event[key]
if(! events)return
events.forEach(fn= > {
if (fns.has(fn)) return
fns.add(fn)
fn(args)
})
}
}
const autoRun = fn= > {
pendingDerivation = fn
fn()
pendingDerivation = null
}
const handler = {
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
dep.emit(target.__obId + key)
return result
},
get(target, key, value, receiver) {
if (target && key && pendingDerivation) {
dep.on(target.__obId + key, pendingDerivation)
}
return Reflect.get(target, key, value, receiver)
}
}
const observable = obj= > {
obj.__obId = `__obId${++obId}__ `
Object.entries(obj).forEach(([key, value]) = > {
if (typeofvalue ! = ='object' || value === null) return
obj[key] = observable(value)
})
return new Proxy(obj, handler)
}
const computed = fn= > {
return {
computed: fn,
[Symbol.toPrimitive]() {
return this.computed()
}
}
}
// demo
const todoObs = observable({
todo: [],
get all() {
return this.todo.length
}
})
const compuFinish = computed((a)= > {
return todoObs.todo.filter(t= > t.finished).length
})
const print = (a)= > {
const all = todoObs.all
console.log(`print: finish ${compuFinish}/${all}`)
}
autoRun(print)
todoObs.todo.push({
finished: false
})
todoObs.todo.push({
finished: true
})
// print: finish 0/0
// print: finish 0/1
// print: finish 1/2
Copy the code
This code, minus the demo, is only 60 lines of code. Review the flow chart again.