Skip to content

Latest commit

 

History

History
782 lines (598 loc) · 18.5 KB

README.md

File metadata and controls

782 lines (598 loc) · 18.5 KB

实现一个简单的 MobX

导读

Mobx 是 React 常用的状态管理库。

但是初次读 Mobx 的官方文档,概念很多,例如:ActionsDerivationsState,还有各种各样相关的装饰器,让人摸不着头脑。

官网提供的例子是这样的:

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)

这个例子中,在 Timer 类中使用了 makeAutoObservable 方法使 myTimer 变成一个可观察对象。并且在 TimerView 外曾包了一个 observer 方法,来监听用到的可观察对象。

于是,在定时器中改变 myTimer 中的值时,TimerView 可以自动的渲染更新。

这不是一个好的例子,因为 MobX 是框架无关的,并不一定需要和 React 绑定使用。

为了体现出 Mobx 的精妙,我们需要拨开层层迷雾,来看一个更底层的例子:

import { observable, autorun } from 'mobx';

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

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

store.a = 5;

运行结果为:

1
5

这里的精妙之处在于:为什么在给 store.a 赋值时,可以自动执行 autorun 中的方法?

而且 mobx 还很聪明,只会在自己用到的值变化时才更新。参考下面这个例子:

import { observable, autorun } from 'mobx';

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

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

store.a = 5; // 赋值后更新 autorun 的中的方法
store.b = 10; // 由于 autorun 的中的方法没有用到 store.b,所以赋值后没有更新

运行结果为:

1
5

这样的好处是,开发者完全不需要去控制更新范围的粒度,聪明的 Mobx 都帮我们做好了。

另外,Mobx 还支持嵌套观察,参考下面的例子:

import { observable, autorun } from 'mobx';

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

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

store.b.c = 10;

运行结果为:

2
10

这里我们可以把 autorun 方法看作第一个例子中的 observer 方法,可观察对象变化后,做出相应的改变。

这里的核心是observableautorun 这两个方法,吃透它们,我们就能了解 Mobx 的核心原理。

这就引出来这篇文章的的目的:实现一个简单的 MobX

如何实现?

为了保证阅读效果,建议读者边阅读边动手实操,点击这里可以下载源码。

为了方便读者更好的阅读体验,笔者将循序渐进的分多个demo来实现一个可用的 MobX。

1、Mobx 与 订阅发布模式 对比

仔细观察刚才的例子:

import { observable, autorun } from 'mobx';

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

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

store.a = 5;

它有些像订阅发布模式:

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');

只不过 Mobx 是在 autorun 中自动地进行订阅,然后在赋值时自动地触发订阅,并没有进行显式的调用。

有了这个思路,我们的 Mobx 底层可以用 EventEmitter 来实现。

所以,在这之前,我们先实现一个简单的 EventEmitter,参考 utils/event-emitter.ts

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);
      });
    }
  }
};

2、使用 defineProperty 隐式调用订阅发布

defineProperty 可以在对象赋值或者取值的时候添加额外的逻辑,所以我们可以用 defineProperty 来隐藏 onemit 等方法的调用。

查看下面的代码:

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');
  },
});

// 收集依赖
fn();

// set state
store.a = 2

上面的代码中,我们将 onemit 方法封装进了 defineProperty 中,外部没有暴露过多的细节,这已经有了一些 Mobx 的味道。

在下面我们会做进一步的封装,只对外暴露 observableautorun 方法。

3、实现 observableautorun 方法

实现 observableautorun 方法时,需要注意以下三点:

  1. 设置一个内部key将当前对象的原始值储存起来,而不像上面例子中 store.a 永远返回 100。

  2. 订阅发布的信道有可能会重复,所以需要一个机制来确保每一个对象的 key 都有唯一的信道。

  3. 只在 autorun 时进行进行订阅操作。

有了上面的这些注意点,我们可以设计出第一版的 mobx,参考 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) => {
  // 用 Symbol 当 key;这样就不会被枚举到,仅用于值的存储;
  const data = Symbol('data');
  obj[data] = JSON.parse(JSON.stringify(obj));

  Object.keys(obj).forEach(key => {
    // 每个 key 都生成唯一的 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) {
        // 值不变时不触发
        if (this[data][key] !== v) {
          this[data][key] = v;
          em.emit(id);
        }
      }
    });
  });
  return obj;
};

尝试运行如下代码,参考 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;

结果为:

1
5
6

我们发现运行结果和使用原生的 observableautorun 运行结果一致。

感兴趣的同学可以运行 yarn demo01 查看运行效果

4、支持嵌套

上面实现的 observable 还有一些问题,不支持嵌套观察。

例如下面的代码:

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;

赋值时并没有触发 autorun 中的方法。

所以基于 demo01 做了如下的优化,来支持嵌套。

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) => {
  // 用 Symbol 当 key;这样就不会被枚举到,仅用于值的存储;
  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;
};

感兴趣的同学可以运行 yarn demo02 查看运行效果

5、对依赖收集的优化

支持支持嵌套观察的 observable 还有一个严重的 bug。参考下面的场景:

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;

我们在 demo02 中的实现的 mobx 打印结果是:

1

而引用原生的 mobx 的打印结果是:

1
5
6

这是为什么?

注意上面代码中 autorun 里的方法,它里面有一个条件判断语句。在第一次 autorun 做依赖收集时,条件语句不成立,导致 store.b.c 的依赖没有收集上。

以至于后面条件语句即使成立了,也没法对 store.b.c 的改变作出响应。

为了修复这个问题,需要改变依赖收集的策略。之前的策略是:只在 autorun 时做依赖收集。

而实际上 autorun 以及对可观察对象的值修改时都要需要做依赖收集

那怎么该?其实也很简单,基于 demo03 ,我们做了如下的优化,来支持条件判断中的依赖收集。

参考下面的代码:

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) => {
  // 用 Symbol 当 key;这样就不会被枚举到,仅用于值的存储
  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;
};

上面的修改本质上是对 autorun 中的方法做一层封装,每次触发该方法时,都可以自动的收集依赖。

感兴趣的同学可以运行 yarn demo03 查看运行效果

6、Proxy 版的实现(扩展阅读)

前面的 Mobx 是基于 defineProperty 来实现的。

这一小节,我们将基于 ES6 中 Proxy 来实现一个简易的 Mobx;

在这之前,先简单对比一下 definePropertyProxy 各自的基础写法:

// defineProperty
Object.keys(obj).forEach(key => {
  // 每个 key 都生成唯一的 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);
  }
});

可以看到,使用 defineProperty 时,我们在定义时就能为可观察对象的所有的 key 都确定好唯一的信道。从而准确地收集依赖。

而这一点在 Proxy 中无法做到。我们需要一个机制来确定每个 key 都有唯一的信道。

聪明的读者可能会想到,我们可以用当前的 key 表来确定唯一的信道,类似这样:

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

但是这有它的局限性,一旦遇到相同的key,就会出现bug。

聪明的读者可能又会想到,我们可以在外部维护一个 map 列表,用于记录信道,这个 map 的 key 就是需要记录的对象。

类似这样:

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

// 上面的代码执行后 map 中的数据如下:
// {
//   '[store object]':{
//     a: 'channel-1'
//     b: 'channel-2'
//   }
// }

但是这里有一个技术难题:把目标对象当作key赋给普通对象时,目标对象会被隐式转换为字符串

参考下面的代码:

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

这就引出了解决这个问题的救星:WeakMap。查看相关的 MDN文档,我们了解到 WeakMap 的 key 必须是对象,而值可以是任意的。

太棒了,刚好符合上面的需求。

有了上面的思路,我们就能轻松写出 Proxy 版 的 Mobx 代码:

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();

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);
        }
        return target[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;
    }
  });
};

运行的效果和 defineProperty 版的完全一致。

感兴趣的同学可以运行 yarn demo04 查看运行效果

7、优化 EventEmitter

Proxy 版的 MobX 还是会有一些小问题: em.list 的长度会随着 autorun 的调用越来越大

这是因为我们只有订阅操作,但是没有取消订阅的操作。

核心原因是 之前的代码中我们用自增ID来确定唯一的信道,这是有问题的。

怎么解决呢?我们可以参考 Proxy 的思路,把对象当作 key,来改造 EventEmitter

改造后的代码如下,参考 ./utils/event-emitter-with-weakmap.ts

export default class EventEmitter {
  list = new WeakMap();
  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);
        });
      }
    }
  }
};

基于 demo04, 我们再重构一下 Mobx 代码,参考 ./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);
        }
        return target[propKey];
      }
    },
    set: (target, propKey, value) => {
      if (target[propKey] !== value) {
        target[propKey] = value;
        em.emit(target, propKey);
      }
      return true;
    }
  });
};

上面代码中,我们完全移除了使用自增ID来确定唯一信道。并且将 WeakMap 封装在 EventEmitter 中,MobX的代码也变得非常清爽。

感兴趣的同学可以运行 yarn demo05 查看运行效果

顺带的,我们也可以优化一下 defineProperty 版的 Mobx,移除其中的自增ID,参考 ./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) => {
  // 用 Symbol 当 key;这样就不会被枚举到,仅用于值的存储
  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) {
          // 值不变时不触发
          if (obj[data][key] !== v) {
            obj[data][key] = v;
            em.emit(obj, key);
          }
        }
      });
    }
  });
  return obj;
};

感兴趣的同学可以运行 yarn demo06 查看运行效果

总结

这篇教程中,我们实现了包含 observableautorun 两个核心功能的 Mobx

并且可观察对象支持嵌套、自动收集依赖。

另外,我们分别用 definePropertyProxy 写法实现了这个 Mobx

希望通过这篇教程,读者能够对 Mobx 的底层有更深刻的认识。

这些例子的完整代码可以点这里查看,如果感觉这些 demo 写的不错,可以给笔者一个 star,谢谢大家阅读。

相关阅读