Implement a simple MobX

takeaway

Mobx is the state management library used by React.

But reading Mobx’s official documentation for the first time, concepts such as Actions, Derivations, State, and decorations are confusing.

Here’s an example from the website:

import React from "react"
import ReactDOM from "react-dom"
import { makeAutoObservable } from "mobx"
import { observer } from "mobx-react"

// Model the application state.
class Timer {
  secondsPassed = 0
  constructor() {
    makeAutoObservable(this)}increase() {
    this.secondsPassed += 1
  }
  reset() {
    this.secondsPassed = 0}}const myTimer = new Timer()

const TimerView = observer(({ timer }) = > (
  <button onClick={()= > timer.reset()}>Seconds passed: {timer.secondsPassed}</button>
))

ReactDOM.render(<TimerView timer={myTimer} />.document.body)

setInterval(() = > {
  myTimer.increase()
}, 1000)
Copy the code

In this example, the makeAutoObservable method is used in the Timer class to make myTimer an observable. And an Observer method was wrapped around the TimerView to listen for the observable used.

Thus, TimerView can automatically render updates when changing the value of myTimer in the timer.

This is not a good example, because MobX is framework-independent and doesn’t necessarily need to be tied to React.

To capture Mobx’s subtlety, we need to cut through the fog and take a look at a lower-level example:

import { observable, autorun } from 'mobx';

const store = observable({a: 1.b: 2});

autorun(() = > {
  console.log(store.a);
});

store.a = 5;
Copy the code

The running results are as follows:

1, 5Copy the code

The neat thing here is: why do methods in Autorun automatically execute when assigning values to store.a?

And Mobx is smart enough to only update when the values it uses change. Consider the following example:

import { observable, autorun } from 'mobx';

const store = observable({a: 1.b: 2});

autorun(() = > {
  console.log(store.a);
});

store.a = 5; // Update the method in autorun after the assignment
store.b = 10; // The method in Autorun does not use store.b, so the assignment is not updated
Copy the code

The running results are as follows:

1, 5Copy the code

The nice thing about this is that developers don’t have to control the granularity of the update scope at all, and smart Mobx does it for us.

Mobx also supports nested observations, as shown in the following example:

import { observable, autorun } from 'mobx';

const store = observable({ a: 1.b: { c: 2}}); autorun(() = > {
  console.log(store.b.c);
});

store.b.c = 10;
Copy the code

The running results are as follows:

2
10
Copy the code

Here we can think of the Autorun method as the Observer method in the first example, making changes as the observable changes.

The core of Mobx is the Observable and Autorun methods. By understanding them, we can understand the core principles of Mobx.

This brings us to the purpose of this article: to implement a simple MobX

How to do that?

In order to ensure the reading effect, it is recommended that readers read while hands-on operation, click here to download the source code.

In order to facilitate readers’ better reading experience, the author will implement a working MobX step by step with multiple demos.

1. Mobx vs. subscription publishing

Take a closer look at the example we just wrote:

import { observable, autorun } from 'mobx';

const store = observable({a: 1.b: 2});

autorun(() = > {
  console.log(store.a);
});

store.a = 5;
Copy the code

It’s a bit like a subscription publishing model:

const em = new EventEmitter();

const store = {a: 1.b: 2};

// autorun
em.on('store.a'.() = > console.log(store.a));

// set value
store.a = 5;
em.emit('store.a');
Copy the code

It’s just that Mobx automatically subscribs in Autorun and then triggers the subscription automatically on assignment without making an explicit call.

With this in mind, our Mobx infrastructure can be implemented using EventEmitter.

So, before we do that, we’ll implement a simple EventEmitter. See utils/ event-emitters.

export default class EventEmitter {
  list = {};
  on(event, fn) {
    let target = this.list[event];
    if(! target) {this.list[event] = [];
      target = this.list[event];
    }
    if (!target.includes(fn)) {
      target.push(fn);
    }
  };
  emit(event, ... args) {
    const fns = this.list[event];
    if (fns && fns.length > 0) {
      fns.forEach(fn= >{ fn && fn(... args); }); }}};Copy the code

2, use,definePropertyImplicitly invoke subscription publishing

DefineProperty can add extra logic when an object is assigned or evaluated, so we can use defineProperty to hide calls to methods like on, emit, etc.

Look at the following code:

import EventEmitter from '.. /utils/event-emitter'
const em = new EventEmitter();

const store = { a: 1.b: 2 };

// autorun
const fn = () = > console.log(store.a)

// observable
Object.defineProperty(store, 'a', {
  get: function () {
    em.on('store.a', fn);
    return 100;
  },
  set: function () {
    em.emit('store.a'); }});// Collect dependencies
fn();

// set state
store.a = 2
Copy the code

In the code above, we encapsulated the ON and emit methods into defineProperty without exposing too much detail externally, which already has some Mobx flavor.

In the following sections we’ll further encapsulate, exposing only observable and Autorun methods.

3, implementation,observableautorunmethods

Observable and Autorun methods are implemented with the following considerations:

  1. Set an internal key to store the original value of the current object, unlike the example above where store.a always returns 100.

  2. Channels for subscribing to publications can be duplicated, so a mechanism is needed to ensure that each object’s key has a unique channel.

  3. Subscribe only on Autorun.

With the above points in mind, we can design the first version of Mobx by referring to demo01/mobx.ts:

import EventEmitter from '.. /utils/event-emitter';

const em = new EventEmitter();
let currentFn;
let obId = 1;

const autorun = (fn) = > {
  currentFn = fn;
  fn();
  currentFn = null;
};

const observable = (obj) = > {
  // use Symbol as key; So it's not enumerated, it's just used for value storage;
  const data = Symbol('data');
  obj[data] = JSON.parse(JSON.stringify(obj));

  Object.keys(obj).forEach(key= > {
    // Each key generates a unique channel ID
    const id = String(obId++);
    Object.defineProperty(obj, key, {
      get: function () {
        if (currentFn) {
          em.on(id, currentFn);
        }
        return this[data][key];
      },
      set: function (v) {
        // Does not fire if the value is unchanged
        if (this[data][key] ! == v) {this[data][key] = v; em.emit(id); }}}); });return obj;
};
Copy the code

Try running the following code, referring to demo01/index.ts:

import { observable, autorun } from './mobx';

const store = observable({ a: 1.b: 2 });

autorun(() = > {
  console.log(store.a);
});

store.a = 5;
store.a = 6;
Copy the code

The result is:

1
5
6
Copy the code

We found that the results were consistent with native Observable and Autorun.

You can run YARN Demo01 to view the result

4. Support nesting

The observable implementation above has a few problems. It doesn’t support nested observations.

For example:

import { observable, autorun } from './mobx';

const store = observable({ a: 1.b: { c: 1}}); autorun(() = > {
  console.log(store.b.c);
});

store.b.c = 5;
store.b.c = 6;
Copy the code

The method in Autorun is not triggered when the assignment is made.

Therefore, the following optimization is made based on Demo01 to support nesting.

import EventEmitter from '.. /utils/event-emitter'; const em = new EventEmitter(); let currentFn; let obId = 1; const autorun = (fn) => { currentFn = fn; fn(); currentFn = null; }; Const Observable = (obj) => {// Use Symbol as key; So it's not enumerated, it's just used for value storage; const data = Symbol('data'); obj[data] = JSON.parse(JSON.stringify(obj)); Object.keys(obj).forEach(key => {+ if (typeof obj[key] === 'object') {
+ observable(obj[key]);
+ } else {
      // 每个 key 都生成唯一的 channel ID
      const id = String(obId++);
      Object.defineProperty(obj, key, {
        get: function () {
          if (currentFn) {
            em.on(id, currentFn);
          }
          return obj[data][key];
        },
        set: function (v) {
          // 值不变时不触发
          if (obj[data][key] !== v) {
            obj[data][key] = v;
            em.emit(id);
          }
        }
      });
+}
  });
  return obj;
};
Copy the code

You can run YARN Demo02 to view the result

Optimization of dependency collection

Support for observables that support nested observations also has a serious bug. Refer to the following scenario:

import { observable, autorun } from './mobx';

const store = observable({ a: 1.b: { c: 1}}); autorun(() = > {
  if (store.a === 2) {
    console.log(store.b.c); }}); store.a =2
store.b.c = 5;
store.b.c = 6;
Copy the code

The mobx printout we implemented in Demo02 looks like this:

1
Copy the code

The printed result of referencing native Mobx is:

1
5
6
Copy the code

Why is that?

Notice the method in autorun in the code above, which contains a conditional statement. The first time autorun did a dependency collection, the conditional statement was not established, resulting in the store. B.c dependency was not collected.

So that even if the following conditional statement is true, it cannot respond to changes in store.b.c.

To fix this, you need to change the dependency collection strategy. The previous strategy was to do dependency collection only on Autorun.

In fact, dependency collection is required for both Autorun and observable value changes.

How is that supposed to happen? In fact, it is very simple. Based on Demo03, we made the following optimization to support dependency collection in conditional judgment.

Refer to the following code:

import EventEmitter from '.. /utils/event-emitter'; const em = new EventEmitter(); let currentFn; let obId = 1;+ const autorun = (fn) => {
+ const warpFn = () => {
+ currentFn = warpFn;
+ fn();
+ currentFn = null;
+}
+ warpFn();
+};

- const autorun = (fn) => {
- currentFn = fn;
- fn();
- currentFn = null;
-};Const Observable = (obj) => {// Use Symbol as key; Const data = Symbol('data'); const data = Symbol('data'); obj[data] = JSON.parse(JSON.stringify(obj)); Object.keys(obj).forEach(key => { if (typeof obj[key]=== 'object') {observable(obj[key]); } else {// generate a unique channel ID for each key const ID = String(obId++); Object.defineProperty(obj, key, { get: function () { if (currentFn) { em.on(id, currentFn); } return obj[data][key]; }, set: function (v) {if (obj[data][key]! == v) { obj[data][key] = v; em.emit(id); }}}); }}); return obj; };Copy the code

The above changes essentially encapsulate the method in Autorun and automatically collect dependencies each time the method is triggered.

You can run Yarn Demo03 to view the operation result

6. Implementation of Proxy Version (extended reading)

The previous Mobx was implemented based on defineProperty.

In this section, we will implement a simple Mobx based on ES6 Proxy.

Before that, let’s make a simple comparison between defineProperty and Proxy:

// defineProperty
Object.keys(obj).forEach(key= > {
  // Each key generates a unique channel ID
  const id = String(obId++);
  Object.defineProperty(obj, key, {
    get: function () {
      em.on(id, fn);
      return 100;
    },
    set: function (v) { em.emit(id); }}); });// Proxy
new Proxy(obj, {
  get: (target, propKey) = > {
    em.on(channelId, fn);
    return target[propKey];
  },
  set: (target, propKey, value) = >{ em.emit(channelId); }});Copy the code

As you can see, using defineProperty, we can define unique channels for all keys of the observable at definition time. To accurately collect dependencies.

This is not possible in a Proxy. We need a mechanism to ensure that each key has a unique channel.

The smart reader might imagine that we could use the current key table to determine the unique channel, something like this:

new Proxy(obj, {
  get: (target, propKey) = > {
    em.on(propKey, fn);
    return target[propKey];
  },
  set: (target, propKey, value) = >{ em.emit(propKey); }});Copy the code

However, this has its limitations, and bugs can occur once the same key is encountered.

The intelligent reader may also think that we can maintain a list of maps externally for recording channels, and the map key is the object that needs to be recorded.

Something like this:

const store = observable({ a: 5.b: 10 });

// After executing the code above, the map data is as follows:
/ / {
// '[store object]':{
// a: 'channel-1'
// b: 'channel-2'
/ /}
// }
Copy the code

But there is a technical difficulty: when you assign a target object as a key to an ordinary object, the target object is implicitly converted to a string

Refer to the following code:

const map = {};
const key = {};
map[key] = "hello";
console.log(map);
// out:
/ / {
// "[object Object]": "1"
// }
Copy the code

That brings us to the savior of the problem: WeakMap. Looking at the relevant MDN documentation, we learned that a WeakMap key must be an object and the value can be arbitrary.

That’s great. It fits the bill.

With this in mind, we can easily write a Proxy version of Mobx code:

import EventEmitter from '.. /utils/event-emitter';

const em = new EventEmitter();
let currentFn;
let obId = 1;

const autorun = (fn) = > {
  const warpFn = () = > {
    currentFn = warpFn;
    fn();
    currentFn = null;
  }
  warpFn();
};

const map = new WeakMap(a);const observable = (obj) = > {
  return new Proxy(obj, {
    get: (target, propKey) = > {
      if (typeof target[propKey] === 'object') {
        return observable(target[propKey]);
      } else {
        if (currentFn) {
          if(! map.get(target)) { map.set(target, {}); }const mapObj = map.get(target);
          const id = String(obId++);
          mapObj[propKey] = id;
          em.on(id, currentFn);
        }
        returntarget[propKey]; }},set: (target, propKey, value) = > {
      if(target[propKey] ! == value) { target[propKey] = value;const mapObj = map.get(target);
        if(mapObj && mapObj[propKey]) { em.emit(mapObj[propKey]); }}return true; }}); };Copy the code

It runs exactly as it did in the defineProperty version.

You can run YARN Demo04 to view the result

7, optimizationEventEmitter

Proxy MobX still has a few minor issues: the em.list gets bigger and bigger with autorun calls.

This is because we only have the subscribe operation, but no unsubscribe operation.

The core reason is that in the previous code we used the increment ID to determine the unique channel, which was problematic.

How do you solve it? We can transform EventEmitter by referring to the idea of Proxy and treating objects as keys.

The modified code is as follows, refer to./utils/event-emitter- with-Weakmap. ts:

export default class EventEmitter {
  list = new WeakMap(a);on(obj, event, fn) {
    let targetObj = this.list.get(obj);
    if(! targetObj) { targetObj = {};this.list.set(obj, targetObj);
    }
    let target = targetObj[event];
    if(! target) { targetObj[event] = []; target = targetObj[event]; }if (!target.includes(fn)) {
      target.push(fn);
    }
  };
  emit(obj, event, ... args) {
    const targetObj = this.list.get(obj);
    if (targetObj) {
      const fns = targetObj[event];
      if (fns && fns.length > 0) {
        fns.forEach(fn= >{ fn && fn(... args); }); }}}};Copy the code

/demo05/mobx.ts: /demo05/mobx.ts:

import EventEmitter from '.. /utils/event-emitter-with-weakmap';

const em = new EventEmitter();
let currentFn;

const autorun = (fn) = > {
  const warpFn = () = > {
    currentFn = warpFn;
    fn();
    currentFn = null;
  }
  warpFn();
};

const observable = (obj) = > {
  return new Proxy(obj, {
    get: (target, propKey) = > {
      if (typeof target[propKey] === 'object') {
        return observable(target[propKey]);
      } else {
        if (currentFn) {
          em.on(target, propKey, currentFn);
        }
        returntarget[propKey]; }},set: (target, propKey, value) = > {
      if(target[propKey] ! == value) { target[propKey] = value; em.emit(target, propKey); }return true; }}); };Copy the code

In the code above, we completely removed the use of increment ids to determine unique channels. MobX code is also very clean by encapsulating WeakMap in EventEmitter.

You can run YARN Demo05 to view the result

By the way, we can also optimize Mobx defineProperty to remove the increment ID, see./demo06/mobx.ts:

import EventEmitter from '.. /utils/event-emitter-with-weakmap';

const em = new EventEmitter();
let currentFn;

const autorun = (fn) = > {
  const warpFn = () = > {
    currentFn = warpFn;
    fn();
    currentFn = null;
  }
  warpFn();
};

const observable = (obj) = > {
  // use Symbol as key; This will not be enumerated and will only be used for value storage
  const data = Symbol('data');
  obj[data] = JSON.parse(JSON.stringify(obj));

  Object.keys(obj).forEach(key= > {
    if (typeof obj[key] === 'object') {
      observable(obj[key]);
    } else {
      Object.defineProperty(obj, key, {
        get: function () {
          if (currentFn) {
            em.on(obj, key, currentFn);
          }
          return obj[data][key];
        },
        set: function (v) {
          // Does not fire if the value is unchanged
          if(obj[data][key] ! == v) { obj[data][key] = v; em.emit(obj, key); }}}); }});return obj;
};
Copy the code

You can run Yarn Demo06 to view the result

conclusion

In this tutorial, we implemented Mobx with two core features, Observable and Autorun.

And observables support nesting, automatic collection of dependencies.

In addition, we implemented the Mobx using defineProperty and Proxy writing respectively.

Hopefully, this tutorial will give readers a deeper understanding of Mobx’s underbelly.

The full code of these examples can be viewed here. If you feel that these demos are well written, you can give the author a STAR. Thank you for reading.

reading

Mobx document

MobX principle

MobX source code exploration

MobX concise tutorial

Manage React application state using Mobx + Hooks

Mobx React — Best practices

Hooks & Mobx only need to know two additional Hooks to experience such a simple approach to development