5.响应式系统比对:手写 React 响应式状态库 Mobx
前言
我们从前几篇文章中学到了数据响应式的实现原理,虽然它们的实现方式并不相同,但本质原理都是一样的,都是在数据读取的时候进行依赖收集,在数据更改的时候触发依赖。我们知道在 React 的技术栈中也有一个状态管理库 —— Mobx 也是通过数据响应式的方式实现的,那么既然也是数据响应式,那么它的实现本质原理应该都跟 Vue 是一致的,但我们不应该它的代码设计方式改变了,就看不懂了,而恰恰相反正因为我们熟悉 Vue 的数据响应式原理,所以我们 Vue 技术栈的同学应该更容易理解 Mobx 的实现原理才对,不然你不能说你精通了 Vue 的数据响应式原理。
Mobx 与 Vue 的响应式数据的差异
具体来说就是如果在 Vue 中你创建了一个引用类型的响应式数据,你可以直接修改它:
const vueProxy = reactive({ name: 'Cobyte' })
// 直接修改
vueProxy.name = '掘金签约作者'
在 Vue 这种操作是很正常的,但在 Mobx 中这种行为却是不提倡的。那么在 Mobx 中需要怎么修改呢?在 Mobx 中你需要定义一个函数来进行修改:
const mobxProxy = observable({ name: 'Cobyte', update(value) { this.name = value }})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')
当然,在 Mobx 中你也可以像 Vue 那样操作数据,但 Mobx 并不提倡,所以既然你使用了 Mobx 那就就要遵循它的规则,并学习它的优秀设计原理,然后融化为你知识的一部分,在将来你设计代码架构的时候,你所学习到的知识将在无形中响应式着你。
Mobx 的设计原理
我们知道虽然 Vue2 和 Vue3 数据响应式部分的实现有所不同,但实现思路还是一致的。那么跟 Vue 相比同样是实现响应式数据的 Mobx 最大的区别是什么呢?那么要了解这个就去了解 Mobx 的设计原理了。Mobx 的最核心设计原则就是跟 React 的单向数据流设计一致,也同样是单向数据流。也正是基于这个原则导致 Mobx 的代码架构跟 Vue 的数据响应式部分差别比较大。当然 Vue 也是单向数据流设计,并且 Vue 官方也提倡单向数据流,但只是从 Vue 框架层限制了组件的 props 的第一层,而并没有从数据响应式的底层进行限制,而 Mobx 则是从数据响应式的底层就进行限制,所以 Mobx 的单向数据流更为彻底。
我们在 Vue 中创建了一个响应式数据,如果这个响应式数据是引用类型的话,你可以在组件及任何一个其后代组件任何一个角度去修改它,这种方式对于开发功能的人员来说是非常方便的,但对于维护人员来说很可能就是灾难,因为维护人员有时候需要监听数据的更新行为,可并不知道这个响应式数据都在什么地方进行更新。
而在 Mobx 中你创建了一个响应式数据,即便这个响应式数据也是引用类型,在 Mobx 中如果你直接对响应式数据进行修改的话,Mobx 会发出警告,因为在 Mobx 中你需要 React 那样通过一个函数来进行修改,这样就保证了单向数据流的使用规范。
默认情况下,不允许在 actions 之外改变 state。这有助于在代码中清楚地对状态更新发生的位置进行定位。
上述引用来自 Mobx 中文官网,那么怎么可以做到直接修改响应式数据的属性值就发出警告,而通过响应式数据的函数就不会呢?其实原理很简单,我们可以设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。在 Mobx 中会对修改函数进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。
Mobx 的初步实现(observable 实现)
我们知道 Mobx 是参考了 Vue 的数据响应式原理,那么最初肯定是只有参考 Vue2 了,那么根据 Vue2 的数据响应式原理,我们很清楚知道一个对象要观察它的数据变化,需要通过 Object.defineProperty 劫持每一个属性的 getter、setter 的操作,同时属性值需要通过闭包进行缓存,还需要通过发布订阅模式来实现依赖(订阅者)和响应式数据之间的通信,具体就是在 getter 的时候进行订阅,在 setter 的时候进行发布,那么在 Vue2 数据响应式中每一个属性所形成的闭包就是一个发布者。那么在 Mobx 的属性值是否也需要通过闭包进行缓存呢?
在 Vue2 中需要一个 Observer 的观察器类来管理响应式数据的相关操作,在 Mobx 中同样需要一个观察器类来管理响应式数据的相关操作,它就是 ObservableObjectAdministration。那么根据我们前面的所学的经验可以很快得到 ObservableObjectAdministration 类的基础代码。如下:
// 对象观察器类
class ObservableObjectAdministration{
constructor(target) {
// 原始值保存
this.target_ = target
// 订阅者存储中心
this.values_ = new Map()
}
}
根据 Vue2 的数据响应式原理我们知道需要通过一个 observe 的函数创建响应式数据,在 Mobx 中也提供了一个叫 observable API 来创建响应式数据。那么根据 Vue2 我们知道需要实例化一个观察器对象,并且把观察器实例对象设置到需要观察的数据上,这样该数据就是响应式数据了。
function observable(target) {
const adm = new ObservableObjectAdministration(target)
// 把观察器实例对象设置到需要观察的数据上
target.__ob__ = adm
return target
}
在 Vue2 中是在观察器内部进行初始化对被观察数据进行遍历其属性通过 Object.defineProperty 劫持每一个属性的 getter、setter 操作。同样 Mobx 也需要这样,但 Mobx 的设计是在外部进行遍历属性,而不是在观察器内部进行遍历。
function observable(target) {
const adm = new ObservableObjectAdministration(target)
// 把观察器实例对象设置到需要观察的数据上
target.__ob__ = adm
+ Object.keys(target).forEach(key => {
+ // 在这里通过 Object.defineProperty 劫持每一个属性的 `getter`、`setter` 操作
+ adm.defineObservableProperty_(key, target[key])
+ })
return target
}
// 对象观察器
class ObservableObjectAdministration{
constructor(target) {
// 原始值保存
this.target_ = target
// 订阅者存储中心
this.values_ = new Map()
}
+ // 劫持属性的 getter、setter
+ defineObservableProperty_(key, value) {
+ Object.defineProperty(this.target_, key, {
+ get: () => {
+ // 获取值
+ },
+ set: (val) => {
+ // 设置值
+ }
+ })
+ }
}
在 Vue2 中循环劫持响应式对象的属性时是通过闭包的方式的,即每一个属性都会形成自己的一个闭包,最后读取和设置的值都是闭包中的变量值。而 Mobx 中则把每一个属性的值都包装成一个对象,本质上是通过沙箱模式将每个属性值进行隔离。
那么下面就让我们来实现 Mobx 中的属性劫持吧。
// 对象观察器
class ObservableObjectAdministration{
constructor(target) {
// 原始值保存
this.target_ = target
// 订阅者存储中心
this.values_ = new Map()
}
// 劫持属性的 getter、setter
defineObservableProperty_(key, value) {
+ // 将属性值包装成响应式对象
+ const observable = new ObservableValue(value)
// 将每一个属性和属性值进行记录起来
+ this.values_.set(key, observable)
Object.defineProperty(this.target_, key, {
get: () => {
// 获取值
+ return this.values_.get(key).get()
},
set: (val) => {
// 设置值
+ this.values_.get(key).setNewVal(val)
}
})
}
}
+ // 将属性值包装成响应式对象
+ class ObservableValue {
+ constructor(value) {
+ this.value_ = value
+ }
+ get() {
+ // 在这里进行依赖收集
+ console.log('依赖收集')
+ return this.value_
+ }
+ setNewVal(val) {
+ this.value_ = val
+ // 在这里进行依赖触发
+ console.log('依赖触发')
+ }
+ }
通过上面的代码我们可以看到 Mobx 在通过 Object.defineProperty 劫持对象属性的时候会把属性值通过一个对象进行包裹,也就是 ObservableValue 的实例对象 observable,并且通过键值对的方式保存在 ObservableObjectAdministration 的 this.values_ 上,然后在 getter 的时候实际获取的是对应 key 的 observable 对象中的值。那么很容易看出来每一个 ObservableValue 的实例对象 observable 都是一个发布者,或者叫被观察者更为贴切一些,反正是一个被观察的对象。
接下来我们就可以进行测试了:
// 创建响应式对象
const mobxProxy = observable({ name: 'Cobyte' })
// 读取触发依赖收集
mobxProxy.name
// 设置值触发依赖
mobxProxy.name = '我是掘金签约作者'
打印结果如下:
![]()
小结
在前面的讲解 Vue2 的数据响应式原理的文章中,我们说其实每一个属性所形成的闭包就是一个发布者,可能大家还有点难以理解,那么在 Mobx 中每一个属性都通过一个沙箱对象进行包裹,那么这个沙箱对象就是一个发布者,而且代码结构和所谓传统发布订阅模式的代码结构也是比较相似。
在 Mobx 中实现发布订阅模式
那么根据上文我们知道 ObservableValue 是一个发布者,那么我们根据前面的所学的知识,可以很容易完善发布订阅模式的功能。代码如下:
+ // 全局属性
+ const globalState = {
+ trackingDerivation: null // Mobx 中的订阅者全局变量
+ }
// 将属性值包装成响应式对象
class ObservableValue {
constructor(value) {
this.value_ = value,
+ // 订阅者存储中心
+ this.observers_ = new Set()
}
get() {
// 在这里进行依赖收集
+ if (globalState.trackingDerivation) {
+ this.observers_.add(globalState.trackingDerivation)
+ }
return this.value_
}
setNewVal(val) {
this.value_ = val
// 在这里进行依赖触发
+ this.observers_.forEach(derivation => derivation())
}
}
我们经过上面的功能完善,我们从代码结构可以看得出 ObservableValue 是一个发布者。那么接下来我们就可以进行最简单的功能测试了。测试代码如下:
const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
console.log(`我是:${mobxProxy.name}`)
}
globalState.trackingDerivation = subscriber
subscriber()
globalState.trackingDerivation = null
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'
我们可以看到正确打印了我们期待的结果:
![]()
实现订阅者中介 Reaction
接下来我们继续完善订阅者功能,根据我们前面所学习的知识,我们知道需要一个订阅者中介类,在 Mobx 中同样存在一个订阅者中介类,也就是 Reaction,那么根据 Vue2 的 Watcher 功能我们很快可以实现如下代码:
class Reaction {
constructor(fn) {
this._fn = fn
this.get()
}
get() {
globalState.trackingDerivation = this
this._fn()
globalState.trackingDerivation = null
}
update() {
this._fn()
}
}
因为订阅者的功能修改了,所以同时需要修改一下 ObservableValue 类:
// 将属性值包装成响应式对象
class ObservableValue {
// 省略...
setNewVal(val) {
this.value_ = val
// 在这里进行依赖触发
- this.observers_.forEach(derivation => derivation())
+ this.observers_.forEach(derivation => derivation.update())
}
}
接着我们进行测试:
const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
console.log(`我是:${mobxProxy.name}`)
}
new Reaction(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'
我们可以看到也是正确打印了我们期待的结果:
![]()
但上述 Reaction 的实现是根据 Vue2 的 Watcher 类实现的,实现的特点是在初始化的时候进行传进来的副作用函数,并且进行依赖收集,在更新的时候则不再进行依赖收集。而 Mobx 中的实现并不是这样的,但基本原理是一致的,就是在初始化的时候进行依赖收集,更新的时候则不再进行依赖收集,所以我们根据 Mobx 中的实现重新改造一下 Reaction 类。
Reaction 改造如下:
class Reaction {
constructor(onInvalidate) {
this.onInvalidate_ = onInvalidate
}
track(fn) {
globalState.trackingDerivation = this
fn()
globalState.trackingDerivation = null
}
// 更新的时候执行
schedule_() {
this.onInvalidate_()
}
}
我们可以看到在 Reaction 初始化的时候会传进来一个回调函数,这个回调函数会在更新的时候进行,而依赖收集则在 track 函数中进行,看函数名都可以顾名思义了。
实现 autorun 函数
因为 Reaction 更新执行的函数变了,所以我们也需要修改 ObservableValue 类相关功能:
// 将属性值包装成响应式对象
class ObservableValue {
// 省略...
setNewVal(val) {
this.value_ = val
// 在这里进行依赖触发
- this.observers_.forEach(derivation => derivation.update())
+ this.observers_.forEach(derivation => derivation.schedule_())
}
}
那么接下来需要重新修改测试代码:
const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
console.log(`我是:${mobxProxy.name}`)
}
// 实例化订阅者中介
const reaction = new Reaction(
() => {
// 回调函数中执行依赖收集函数
reaction.track(subscriber)
}
)
// 立即执行
reaction.schedule_()
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'
重新执行也同样打印了正确的结果:
![]()
我们可以看到要像之前那样实现自动执行订阅者函数,需要在实例化 Reaction 的时候设置回调函数 onInvalidate,然后把依赖收集函数的执行放到 onInvalidate 函数中,然后需要开始的时候就立即执行更新方法。这部分相对 Vue2 的 Watcher 类的实现就没有那么容易理解,这主要是因为 Mobx 主要是服务于 React,受 React 的特点影响,所以才这么设计。在后续我们再详细讲解为什么这么设计。
其实上述对订阅者的实现方法,就是 Mobx 的 autorun API 的实现原理。我们将其进行封装实现。
function autorun(view) {
// 实例化订阅者中介
const reaction = new Reaction(
() => {
// 回调函数中执行依赖收集函数
reaction.track(view)
}
)
// 立即执行
reaction.schedule_()
}
然后我们的测试代码就可以修改成:
const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
console.log(`我是:${mobxProxy.name}`)
}
autoruo(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'
修改之后,同样打印了正确的结果:
![]()
实现使用 actions 更新 state
通过上文对 Mobx 的设计原理的讲解,我们知道为了帮助开发人员清楚地知道状态修改的位置,默认情况下,Mobx 不允许在 actions 之外改变状态。
Mobx 使用单向数据流,利用 action 改变 state ,进而更新所有受影响的 view
上述引用来自 Mobx 中文官网,所谓 action 其实就是一个函数,例如下面的例子:
const mobxProxy = observable({
name: 'Cobyte',
update(value) {
this.name = value
}
})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')
通过上文我们知道它的基本原理就是设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。其实对修改函数会进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。
我们知道每一个属性值都被封装成了一个 observable 对象,那么我们就可以在 ObservableValue 类中对包装的值进行处理,如果是函数的话,就封装成一个高阶函数(高阶函数(higher-order function)—— 如果一个函数接收的参数为或返回的值为函数,那么我们可以将这个函数称为高阶函数)。
首先我们添加一个全局开关变量:
const globalState = {
trackingDerivation: null,
+ // 是否允许修改状态的开关
+ allowStateChanges: false
}
那么我们就可以在 ObservableValue 类中对包装的值进行处理:
class ObservableValue {
constructor(value) {
+ let action
+ // 如果是函数则封装 action 高阶函数
+ if (typeof value === 'function') {
+ action = function(...agrs) {
+ // 在执行原始函数之前开启允许修改开关
+ globalState.allowStateChanges = true
+ // 通过 apply 执行原始函数
+ value.apply(this, agrs)
+ // 执行完原始函数后又关闭开关
+ globalState.allowStateChanges = false
+ }
+ }
- this.value_ = value
+ // 判断如果是函数则使用封装的 action 高阶函数
+ this.value_ = typeof value === 'function' ? action : value,
this.observers_ = new Set()
}
}
接着我们就可以设置值的时候进行判断了:
class ObservableValue {
setNewVal(val) {
+ if (!globalState.allowStateChanges) {
+ console.warn('Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed')
+ }
this.value_ = val
// 在这里进行依赖触发
this.observers_.forEach(derivation => derivation.schedule_())
}
}
这时我们就可以进行测试了:
const mobxProxy = observable({
name: 'Cobyte',
update(val) {
this.name = val
}
})
// 设置订阅者
const subscriber = function() {
console.log(`我是:${mobxProxy.name}`)
}
autorun(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'
这个时候我们就可以看到直接通过属性进行修改值会发出警告了,然后我们再通过函数修改,则不会了。
mobxProxy.update('掘金签约作者')
通过函数修改则不会发出警告了。
接下来,我们再对我们的代码进行重构一下,让代码结构更接近 Mobx 源码。
+ function createAction(fn) {
+ // 这里有一个需要注意的点,返回函数需要使用 function 进行声明会比较方便获取原生对象的上下文,这里涉及到 this 的问题
+ function res() {
+ // 最后通过 executeAction 执行
+ return executeAction(fn, this, arguments)
+ }
+ return res
+ }
+ function executeAction(fn, scope, args) {
+ // 在执行原始函数之前开启允许修改开关
+ globalState.allowStateChanges = true
+ // 因为是用户写的函数,可能会存在错误,所以使用 try
+ try {
+ // 通过 apply 执行原始函数
+ return fn.apply(scope, args)
+ } catch (err) {
+ throw err
+ } finally {
+ // 执行完原始函数后又关闭开关
+ globalState.allowStateChanges = false
+ }
+ }
+ function deepEnhancer(value) {
+ // 如果是函数则封装 action 高阶函数
+ if (typeof value === 'function') {
+ return createAction(value)
+ }
+ // todo
+ // 如果是 observable 对象就返回,不处理
+ // 如果是对象进行递归处理
+ // 如果是数组也进行数组的递归处理
+ return value
+ }
class ObservableValue {
constructor(value) {
+ // 通过 deepEnhancer 处理 value 值
+ this.value_ = deepEnhancer(value)
this.observers_ = new Set()
}
setNewVal(val) {
+ // 设置值之前进行判断是否允许修改
+ checkIfStateModificationsAreAllowed(this)
this.value_ = val
// 在这里进行依赖触发
this.observers_.forEach(derivation => derivation.schedule_())
}
}
+ function checkIfStateModificationsAreAllowed(atom) {
+ if (!globalState.allowStateChanges) {
+ console.warn('Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed')
+ }
+ }
经过上面的重构我们的代码结构就更接近 Mobx 源码了,所以重构是我们日常编程中非常重要的组成部分。
实现 makeAutoObservable
我们知道 Redux 是函数式编程的推崇者,API 的设计对喜欢函数式编程的开发者非常友好,而 Mobx 的设计则更多偏向于面向对象编程(OOP),在 Mobx 中 class 是一等公民,这对喜欢 OOP 思想的开发者则非常友好。甚至于在 Mobx 的官网给出的实例代都是 OOP 实现的。
Mobx 官网 OOP 例子:
import { makeAutoObservable } from "mobx"
class Timer {
secondsPassed = 0
constructor() {
makeAutoObservable(this)
}
increase() {
this.secondsPassed += 1
}
reset() {
this.secondsPassed = 0
}
}
const myTimer = new Timer()
我们通过上面的例子可以看到 class 对象的响应式是通过 makeAutoObservable 这个 API 实现的,我们有了上述实现的 Mobx 基本原理的代码基础,再去实现 makeAutoObservable API 是很容易的。
在实现之前,我们需要对 ES class 的基础知识复习一下,class 中的属性在实例化是在实例化对象上的,而 class 的方法则是在原型上的,也就是说上述例子的实现等同于下面的实现:
class Timer {
secondsPassed = 0
constructor() {
makeAutoObservable(this)
}
}
Timer.prototype.increase = function() {
this.secondsPassed += 1
}
Timer.prototype.reset = function(){
this.secondsPassed = 0
}
这些是属于 JavaScript 的面向对象与继承部分的基础知识,这里不作过度深入说明。
通过上文我们知道在 Mobx 中实现数据响应式跟 Vue2 中的基本原理是一样的,也就是遍历要实现响应式的对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。但 通过 class 实例化的对象除了要获取自身的属性之外,还要获取原型对象上的属性,因为 class 中的方法是设置在原型上的。那么理解了这些之后我们就可以实现 makeAutoObservable API 了。
接下来我们实现一下:
function makeAutoObservable(target) {
const adm = new ObservableObjectAdministration(target)
target.__ob__ = adm
// 获取实例的原型对象
const proto = Object.getPrototypeOf(target)
// 同时获取实例对象上的 key 和 原型对象上的 key,才能完整获取 class 中的属性和方法,同时通过 Set 进行去重
const keys = new Set([...Reflect.ownKeys(target), ...Reflect.ownKeys(proto)])
// 删除不需要监听的属性
keys.delete("constructor")
keys.delete('__ob__')
// 遍历所有属性进行监听
keys.forEach(key => {
adm.defineObservableProperty_(key, target[key])
})
return target
}
我们可以看到有了之前实现 Mobx 的基础,再实现 makeAutoObservable 是非常容易的。相比较上面实现的 observable,makeAutoObservable 的实现最大的不同就是属性的获取,因为 makeAutoObservable 是应用在 class 类上的,所以除了获取对象自身上的属性之外,还要获取原型对象上的属性才能完整获取 class 中的属性和方法,同时还需要对所获取的属性和方法进行去重,最后去掉不需要监听的属性。
接下来我们就可以进行测试了:
class Timer {
secondsPassed = 0
constructor() {
makeAutoObservable(this)
}
increase() {
this.secondsPassed += 1
}
reset() {
this.secondsPassed = 0
}
}
const myTimer = new Timer()
// 设置订阅者
const subscriber = function() {
console.log(`现在的秒数:${myTimer.secondsPassed}`)
}
autorun(subscriber)
// 每秒更新一次
setInterval(() => {
myTimer.increase()
}, 1000)
打印结果如下:
![]()
可以看到我们实现的 makeAutoObservable 方法可以正确应用在 class 上了。
将手写的 Mobx 应用到 React 上
这小结对 React 不太熟悉的同学也没关太大关系,跟着敲就可以了。首先我们通过 create-react-app 这个脚手架快速创建一个 React 项目。
npx create-react-app react-app
我们把上面实现的 Mobx 功能内容设置到 ./src/mini-mobx.js 中,并且把使用到的函数进行导出。
接着我们把 App.js 文件的内容修改如下:
import { makeAutoObservable, observer } from "./mini-mobx"
class Timer {
secondsPassed = 0
constructor() {
makeAutoObservable(this)
}
increaseTimer() {
this.secondsPassed += 1
}
}
const myTimer = new Timer()
const TimerView = observer(({ timer }) => <span>Seconds passed: {timer.secondsPassed}</span>)
function App() {
return (
<TimerView timer={myTimer}></TimerView>
);
}
setInterval(() => {
myTimer.increaseTimer()
}, 1000)
export default App;
我们看到上述的例子其实就是 Mobx 官网的例子,我们把 Mobx 官网的例子跑起来,就说明我们手写的 Mobx 功能是成功的了。上述例子中,我们还需要实现一个函数 observer,我们可以参考上面实现过的 autorun 函数。
我们可以看到 observer 接受的是一个函数组件,返回的也是一个函数组件,那么这就是一个典型的高阶组件,所谓高阶组件,也就是高阶函数,因为函数组件本质就是一个函数。那么我们根据这些特点,我们很容易就构造出 observer 函数基础架构。代码如下:
export function observer(baseComponent) {
return (props) => {
return baseComponent(props)
}
}
页面正常渲染出来了,但还不能自动更新。
![]()
从发布订阅的角度来说在 React 应用 Mobx 后,所写的函数组件就是一个订阅者,那么根据我们上面实现的 autorun 函数,我们先要实例化一个 Reaction 对象,而不管在 Vue 中还是 React 中函数组件在更新的时候,都是重新执行整个函数组件的,所以我们实例化的 Reaction 对象需要保存起来,那么在 React 里面有提供了一个 useRef 的 Hook,它可以创建一个 mutable ref 对象,在组件的整个生命周期内该对象保持不变。简单来说就是 useRef 可以创建一个可以保存状态的 Hook,即使组件重新渲染,其内部的值也不会变化。
import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
return (props) => {
const admRef = useRef(null)
if (!admRef.current) {
// 实例化订阅者中介
const reaction = new Reaction(
() => {
// 回调函数中执行依赖收集函数
reaction.track(baseComponent)
}
)
admRef.current = reaction
}
const reaction = admRef.current
// 立即执行
reaction.schedule_()
}
}
页面显示如下:
![]()
我们根据 autorun 的实现原理初步实现了上述功能,但报错了,原因是组件的 props 没有传进去,所以我们进行以下修改:
import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
return (props) => {
const admRef = useRef(null)
if (!admRef.current) {
// 实例化订阅者中介
const reaction = new Reaction(
() => {
// 回调函数中执行依赖收集函数
+ reaction.track(() => {
+ baseComponent(props)
+ })
}
)
admRef.current = reaction
}
const reaction = admRef.current
// 立即执行
reaction.schedule_()
}
}
页面显示如下:
![]()
我们发现不报错了,但页面并没有渲染,没渲染的原因是我们并没有把函数组件执行的内容返回,所以我们继续进行以下修改:
import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
return (props) => {
+ let renderResult
const admRef = useRef(null)
if (!admRef.current) {
// 实例化订阅者中介
const reaction = new Reaction(
() => {
// 回调函数中执行依赖收集函数
reaction.track(() => {
+ renderResult = baseComponent(props)
})
}
)
admRef.current = reaction
}
const reaction = admRef.current
// 立即执行
reaction.schedule_()
+ return renderResult
}
}
修改后页面显示如下:
![]()
经过上面修改,我们的页面可以渲染出来了,但又遇到新的问题了,页面并没有更新。按理来说,我们上面的 observer 是已经根据 autorun 的实现方式进行实现了。我们可以在 Reaction 的回调函数中进行打印,
export function observer(baseComponent) {
return (props) => {
if (!admRef.current) {
const reaction = new Reaction(
() => {
// 回调函数中执行依赖收集函数
reaction.track(() => {
renderResult = baseComponent(props)
+ console.log('renderResult', renderResult)
})
}
)
admRef.current = reaction
}
+ console.log('outer')
}
}
打印显示如下:
![]()
我们发现其实我们的 Reaction 的回调函数已经重新执行了,但整个组件函数并没有重新执行,所以并没重新渲染内容。所以我们现在只要考虑把整个组件实现重新渲染就可以了。那么熟悉 React 的同学可能会知道在 React 函数组件中可以通过 useState 改变 state 值来触发组件的重新渲染。这个也是 Vue 和 React 区别非常大的一个地方。那么我们可以在 Reaction 的回调函数中执行更新函数,把依赖收集的相关代码放到外面执行。
代码修改如下:
import { useRef, useState } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
return (props) => {
+ const [, setState] = useState()
let renderResult
const admRef = useRef(null)
if (!admRef.current) {
// 实例化订阅者中介
const reaction = new Reaction(
() => {
+ // 执行更新
+ setState(Symbol())
}
)
admRef.current = reaction
}
const reaction = admRef.current
+ // 执行依赖收集函数
+ reaction.track(() => {
+ renderResult = baseComponent(props)
+ })
return renderResult
}
}
页面渲染如下:
![]()
我们重新修改后,可以正常如期执行了。至此我们手写的 Mobx 也实现了在真实 React 环境中执行了。
总结
本文通过相对比较简洁的代码实现了 Mobx 的核心原理,同时对比了同时响应式的 Vue 和 Mobx 的最大设计区别,在 Vue 中创建的响应式数据,是可以随意在任何地方通过普通属性访问器进行修改的,但 Mobx 中则不提倡这种可以随意修改 state 的方式,在 Mobx 中希望开发者通过 actions 来改变 state,本质是像 React 那样通过一个函数来修改 state,或者说是遵循 Flux 和 Redux 的单向数据流思想。同时 Mobx 中的订阅者中介 Reaction 和 Vue 中的订阅者中介实现则有比较大的区别,主要是因为 Mobx 主要的设计受 React 的影响,在更新的时候需要特别的设置,而不像 Vue 那样直接重新运行副作用函数就可以了,这个说到底也是因为 React 不是靠依赖追踪来实现响应式的缘故。
那么具体 Mobx 的 Reaction 的要这样设计,而不能像 Vue 那样简洁呢,我们下一篇文章中继续探讨。
上述文章写于:2023 年,由于个人原因今年 2026 年发布。
我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。