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.