实现一个简单的 MobX
实现一个简单的 MobX
导读
Mobx 是 React 常用的状态管理库。
但是初次读 Mobx 的官方文档,概念很多,例如:、、,还有各种各样相关的装饰器,让人摸不着头脑。
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 }) => (
))
ReactDOM.render( , document.body)
setInterval(() => {
myTimer.increase()
}, 1000)
这个例子中,在 类中使用了 方法使 变成一个可观察对象。并且在 外曾包了一个 方法,来监听用到的可观察对象。
于是,在定时器中改变 中的值时, 可以自动的渲染更新。
这不是一个好的例子,因为 是框架无关的,并不一定需要和 绑定使用。
为了体现出 Mobx 的精妙,我们需要拨开层层迷雾,来看一个更底层的例子:
import { observable, autorun } from 'mobx';
const store = observable({a: 1,b: 2});
autorun(() => {
console.log(store.a);
});
store.a = 5;
运行结果为:
1
5
这里的精妙之处在于:**为什么在给 赋值时,可以自动执行 中的方法?**
而且 还很聪明,只会在自己用到的值变化时才更新。参考下面这个例子:
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
这样的好处是,**开发者完全不需要去控制更新范围的粒度,聪明的 都帮我们做好了。**
另外, 还支持嵌套观察,参考下面的例子:
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
这里我们可以把 方法看作第一个例子中的 方法,可观察对象变化后,做出相应的改变。
这里的核心是、 这两个方法,吃透它们,我们就能了解 的核心原理。
这就引出来这篇文章的的目的:实现一个简单的 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');
只不过 是在 中自动地进行订阅,然后在赋值时自动地触发订阅,并没有进行显式的调用。
有了这个思路,我们的 底层可以用 来实现。
所以,在这之前,我们先实现一个简单的 ,参考 :
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` 隐式调用订阅发布
可以在对象赋值或者取值的时候添加额外的逻辑,所以我们可以用 来隐藏 、 等方法的调用。
查看下面的代码:
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
上面的代码中,我们将 和 方法封装进了 中,外部没有暴露过多的细节,这已经有了一些 Mobx 的味道。
在下面我们会做进一步的封装,只对外暴露 和 方法。
3、实现 `observable` 和 `autorun` 方法
实现 和 方法时,需要注意以下三点:
有了上面的这些注意点,我们可以设计出第一版的 ,参考 :
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;
};
尝试运行如下代码,参考 :
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
我们发现运行结果和使用原生的 、 运行结果一致。
感兴趣的同学可以运行 查看运行效果
4、支持嵌套
上面实现的 还有一些问题,不支持嵌套观察。
例如下面的代码:
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;
赋值时并没有触发 中的方法。
所以基于 做了如下的优化,来支持嵌套。
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;
};
感兴趣的同学可以运行 查看运行效果
5、对依赖收集的优化
支持支持嵌套观察的 还有一个严重的 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;
我们在 中的实现的 打印结果是:
1
而引用原生的 的打印结果是:
1
5
6
这是为什么?
注意上面代码中 里的方法,它里面有一个条件判断语句。在第一次 做依赖收集时,条件语句不成立,导致 的依赖没有收集上。
以至于后面条件语句即使成立了,也没法对 的改变作出响应。
为了修复这个问题,需要改变依赖收集的策略。之前的策略是:只在 时做依赖收集。
而实际上 **在 以及对可观察对象的值修改时都要需要做依赖收集**。
那怎么该?其实也很简单,基于 ,我们做了如下的优化,来支持条件判断中的依赖收集。
参考下面的代码:
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;
};
上面的修改本质上是对 中的方法做一层封装,每次触发该方法时,都可以自动的收集依赖。
感兴趣的同学可以运行 查看运行效果
6、Proxy 版的实现(扩展阅读)
前面的 是基于 来实现的。
这一小节,我们将基于 ES6 中 来实现一个简易的 ;
在这之前,先简单对比一下 与 各自的基础写法:
// 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);
}
});
可以看到,使用 时,我们在定义时就能为可观察对象的所有的 key 都确定好唯一的信道。从而准确地收集依赖。
而这一点在 中无法做到。我们需要一个机制来确定每个 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"
// }
这就引出了解决这个问题的救星:。查看相关的 ,我们了解到 的 key 必须是对象,而值可以是任意的。
太棒了,刚好符合上面的需求。
有了上面的思路,我们就能轻松写出 版 的 代码:
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;
}
});
};
运行的效果和 版的完全一致。
感兴趣的同学可以运行 查看运行效果
7、优化 `EventEmitter`
版的 还是会有一些小问题: em.list 的长度会随着 autorun 的调用越来越大。
这是因为我们只有订阅操作,但是没有取消订阅的操作。
核心原因是 之前的代码中我们用自增ID来确定唯一的信道,这是有问题的。
怎么解决呢?我们可以参考 的思路,把对象当作 key,来改造 。
改造后的代码如下,参考 :
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);
});
}
}
}
};
基于 , 我们再重构一下 代码,参考 :
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来确定唯一信道。并且将 封装在 中,的代码也变得非常清爽。
感兴趣的同学可以运行 查看运行效果
顺带的,我们也可以优化一下 版的 ,移除其中的自增ID,参考 :
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;
};
感兴趣的同学可以运行 查看运行效果
总结
这篇教程中,我们实现了包含 、 两个核心功能的 。
并且可观察对象支持嵌套、自动收集依赖。
另外,我们分别用 和 写法实现了这个 。
希望通过这篇教程,读者能够对 的底层有更深刻的认识。
这些例子的完整代码可以查看,如果感觉这些 demo 写的不错,可以给笔者一个 star,谢谢大家阅读。