Mobx 是一个经过战火洗礼的库,它通过透明的函数响应式编程(transparently applying functional reactive programming - TFRP)使得状态管理变得简单和可扩展.
Mobx
上面这段话引自 Mobx 的官方文档,说明了 Mobx 是一个应用了函数响应式的状态管理库。所谓的响应式就是事件监听,也是 Mobx 背后的哲学:
任何源自应用状态的东西都应该自动地获得
这里说的 “应用状态” 就是 state,在 Mobx 的世界里叫 observable;源自应用状态的 “东西” 叫做 derivations,derivations 可以分为两大类:computed 和 reaction 。
computed 表示从应用状态派生出来的新状态,也就是派生值。比如你定义了两个 state 分别叫做 a 和 b,它们的和叫做 total,而 total 可以通过 a + b 得到,你没必要定义一个新的 state,这个 total 就叫做 computed。
reaction 表示从应用状态派生出来的副作用,也就是派生行为。比如有一个分页选择器:你用一个叫做 index 的 state 表示当前页码,初始值是 1,当你改变这个 index 值为 2 的时候,就需要触发一个跳转到第2页的行为,这个行为是由 index 派生出来的,就叫做 reaction。
Mobx 的核心概念其实就是这三个:observable、computed 和 reaction。
依赖收集
任何源自应用状态的东西都应该自动地获得
上面这句话还有很重要的一点没有讲到,就是 Mobx 哲学所声明的 “自动”,用高大上一点的术语讲就是依赖收集。我们可以举个栗子:
let message = observable({
title: "Foo"
})
autorun(() => {
console.log(message.title)
})
// 输出:
// "Foo"
message.title = "Bar"
// 输出:
// "Bar"
我们声明了一个 observable 的 message 对象,并调用了一个 autorun 函数用来输出 message 的 title 属性,这时候控制台马上输出 "Foo"。嗯,一切都在掌控之中。
接下来我们尝试修改了 message 的 title 属性为 "Bar"。这时候神奇的事情发生了,autorun 里面传入的函数又自动执行了一遍,控制台输出了新的 title 值 "Foo",到底发生了什么?
我们先看下官方给我们的解释:
MobX 会对在追踪函数执行过程中读取现存的可观察属性做出反应。
嗯,看不懂。
接下来官方又对上面这句话做了解释:
- “读取”是对象属性的间接引用,可以用过
.
(例如user.name
) 或者[]
(例如user['name']
) 的形式完成。- “追踪函数”是
computed
表达式、observer 组件的render()
方法和when
、reaction
和autorun
的第一个入参函数。- “过程(during)”意味着只追踪那些在函数执行时被读取的 observable 。这些值是否由追踪函数直接或间接使用并不重要。
嗯,好像有点懂了,让我们重新分析下上面的代码:
// 这是可观察对象
let message = observable({
title: "Foo"
})
// autorun 是“追踪函数”
autorun(() => {
// message.title 是“读取”操作
// 这次读取操作在函数执行“过程”中
console.log(message.title)
})
// 输出:
// "Foo"
message.title = "Bar"
// 输出:
// "Bar"
我们声明的 message 是一个可观察对象,我们注册了一个 autorun 作为追踪函数,在这个追踪函数中我们传入一个函数参数,这个函数进行了一次 message.title 的读取操作,且这次操作在函数执行过程中。满足所有条件,bingo!!!
但是你真的懂了吗?
我再举几个例子,大家可以根据上面的规则自己再判断一下:
例1.
let message = observable({
title: "Foo"
})
autorun(() => {
console.log(message.title)
})
// 输出:
// "Foo"
message = { title: "Bar" }
上面我把 message.title = "Bar" 的赋值操作改为了直接修改 message 对象:message = { title: "Bar" },这时候 autorun 会执行吗?
例2.
let message = observable({
title: "Foo"
})
let title = message.title
autorun(() => {
console.log(title)
})
// 输出:
// "Foo"
message.title = "Bar"
例2我们新定义了一个 title = message.title 的变量,然后在 autorun 中输出这个变量。
例3.
let message = observable({
title: "Foo"
})
autorun(() => {
console.log(message)
})
message.title = "Bar"
例3我们在 autorun 中直接输出了 message 对象。
上面3个例子都是不能在 message 的 title 变更的时候正常响应的:
- 例1因为 autorun 追踪的是 message 对 title 属性的读取操作,但是我们变更的是 message 引用,原 message 对象的 title 属性并没有发生变更,所以 autorun 不会自动执行;
- 例2因为 autorun 里面并没有 “读取” 操作,所以不会追踪 message.title 的变更;
- 例3可能难理解一些,因为 console.log(message) 其实是 “读取” 了 message 对象的所有属性并输出到控制台上的,所以这里满足了 “追踪函数” 和 “读取” 两个条件,既然还有问题,那肯定是没有满足 “过程” 这个条件。原因就是 console.log 函数是异步的,它并没有在函数的执行过程中立即调用。
是不是发现事情开始并得复杂了起来?
现在让我们放慢一下脚步,停止对 Mobx 官方解释的过度理解,这些只是 Mobx 实现者的文字游戏,他们并没有告诉我们事情的本质。
依赖收集的实现
让我们换一个角度,思考一下 Mobx 的依赖收集到底是如何实现的?
还是上文的例子,这一次让我们剖析一下这段代码的实现原理:
1 let message = observable({
2 title: "Foo"
3 })
4
5 autorun(() => {
6 console.log(message.title)
7 })
8 // 输出:
9 // "Foo"
10
11 message.title = "Bar"
12 // 输出:
13 // "Bar"
上面的 1 到 3 行代码我们声明了一个 message 对象,并且用 Mobx 的 observable 进行了封装。这里 observable 的意思就是让 message 对象变成可观察对象,observable 做的事情就是用 ES6 Proxy 代理了 { title: "Foo" } 这个普通对象并返回代理对象给 message。这样 Mobx 就有能力去监听 message 的变更了,我们可以自己实现一个 observable:
function observable(origin) {
return new Proxy(origin, {
// 监听取值操作
get: function (target, propKey, receiver) {
// ...
return Reflect.get(target, propKey, receiver);
},
// 监听赋值操作
set: function (target, propKey, value, receiver) {
// ...
return Reflect.set(target, propKey, value, receiver);
}
})
}
第 5 到 7 行我们传入了一个函数参数调用了 autorun,函数参数只是简单输出 message 的 title 属性到控制台。经过这一步以后我们在 11 行修改了 message 的 title 属性,autorun 的注册函数就会自动执行,在控制台输出最新的 message.title 信息。
再重新看一下上面的代码,思考一个问题:autorun 为什么会知道它需要去关心 message 对象的 title 属性?我们没有传类似 ["message", "title"] 这样明确的参数给他,它接受的唯一参数只是一个执行函数,看起来就好像它自动去解析了执行函数的函数体内容,这就像个魔术一样。
Mobx 的执行确实像魔术一样神奇,但是就像很多魔术的原理都很简单,Mobx 的依赖收集原理也很简单。解开这个魔术的钥匙就是 “全局变量”。
联系一下上面提供的几个线索:
- message 对象是一个 Proxy 对象;
- autorun 注册了一个执行函数,执行函数内部有 message.title 的 get 操作;
- 对 message.title 进行 set,autorun 的注册函数自动运行;
让我们解开 autorun 的秘密:
function autorun(trigger) {
window.globalState = trigger
trigger()
window.globalState = null
}
autorun 函数先将接收的执行函数挂载到 globalState 的全局变量上,接下来立即触发一次执行函数,最后将 globalState 重置为 null。
我们再改写一下我们的 observable 函数:
function observable(origin) {
let listeners = {}
return new Proxy(origin, {
// 监听取值操作
get: function (target, propKey, receiver) {
if(window.globalState) {
listeners[propKey] = listeners[propKey] || []
listeners[propKey] = [...listeners, window.globalState]
}
return Reflect.get(target, propKey, receiver);
},
// 监听赋值操作
set: function (target, propKey, value, receiver) {
listeners[propKey].forEach((fn) => fn())
return Reflect.set(target, propKey, value, receiver);
}
})
}
新的 observable 函数维护了一个事件队列,在每次对象属性的取值操作时去检查全局的 globalState 属性,如果发现当前取值操作是在一个追踪函数内执行的,就将 globalState 的值放入事件队列中;在每次对象的赋值操作发生时执行一遍事件队列。
上面的 observable 和 autorun 只用于解释基本原理,不代表 Mobx 的真实实现。
现在我们对 Mobx 的依赖收集有了更深刻的理解,再让我们回过头去看一下比较难理解的例3:
let message = observable({
title: "Foo"
})
autorun(() => {
console.log(message)
})
message.title = "Bar"
这里的关键在于 console.log 是一个异步的函数,将它代入 autorun:
function autorun(trigger) {
window.globalState = trigger
trigger()
window.globalState = null
}
autorun(() => {
console.log(message)
})
让我们解构一下函数执行:
window.globalState = () => console.log(message)
// async
console.log(message)
window.globalState = null
假设有一个 print 函数可以在控制台同步输出信息,因为 console.log 是异步的,上面的代码执行会变成:
window.globalState = () => console.log(message)
window.globalState = null
print(`{ message: ${ message.title } }`)
虽然 message.title 做了一次 get 操作,但这时候的 globalState 已经变成 null 了,message 对象的事件队列当然不能注册到这个执行函数。下次遇到类似的问题,你都可以试着把执行函数代入到 autorun 中分析一下,结果就能一目了然了。
Mobx 对于 autorun 的说明也从侧面验证了我们上面的实现:
当使用autorun
时,所提供的函数总是立即被触发一次,然后每次它的依赖关系改变时会再次被触发。
What does mobx react to?
那么 Mobx 对于什么会做出响应,你现在比以前更清楚一些了吗?
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。