5.响应式系统比对:手写 React 响应式状态库 Mobx

前言

我们从前几篇文章中学到了数据响应式的实现原理,虽然它们的实现方式并不相同,但本质原理都是一样的,都是在数据读取的时候进行依赖收集,在数据更改的时候触发依赖。我们知道在 React 的技术栈中也有一个状态管理库 ------ Mobx 也是通过数据响应式的方式实现的,那么既然也是数据响应式,那么它的实现本质原理应该都跟 Vue 是一致的,但我们不应该它的代码设计方式改变了,就看不懂了,而恰恰相反正因为我们熟悉 Vue 的数据响应式原理,所以我们 Vue 技术栈的同学应该更容易理解 Mobx 的实现原理才对,不然你不能说你精通了 Vue 的数据响应式原理。

Mobx 与 Vue 的响应式数据的差异

具体来说就是如果在 Vue 中你创建了一个引用类型的响应式数据,你可以直接修改它:

javascript 复制代码
const vueProxy = reactive({ name: 'Cobyte' })
// 直接修改
vueProxy.name = '掘金签约作者'

在 Vue 这种操作是很正常的,但在 Mobx 中这种行为却是不提倡的。那么在 Mobx 中需要怎么修改呢?在 Mobx 中你需要定义一个函数来进行修改:

javascript 复制代码
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 劫持每一个属性的 gettersetter 的操作,同时属性值需要通过闭包进行缓存,还需要通过发布订阅模式来实现依赖(订阅者)和响应式数据之间的通信,具体就是在 getter 的时候进行订阅,在 setter 的时候进行发布,那么在 Vue2 数据响应式中每一个属性所形成的闭包就是一个发布者。那么在 Mobx 的属性值是否也需要通过闭包进行缓存呢?

在 Vue2 中需要一个 Observer 的观察器类来管理响应式数据的相关操作,在 Mobx 中同样需要一个观察器类来管理响应式数据的相关操作,它就是 ObservableObjectAdministration。那么根据我们前面的所学的经验可以很快得到 ObservableObjectAdministration 类的基础代码。如下:

javascript 复制代码
// 对象观察器类
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
}

根据 Vue2 的数据响应式原理我们知道需要通过一个 observe 的函数创建响应式数据,在 Mobx 中也提供了一个叫 observable API 来创建响应式数据。那么根据 Vue2 我们知道需要实例化一个观察器对象,并且把观察器实例对象设置到需要观察的数据上,这样该数据就是响应式数据了。

javascript 复制代码
function observable(target) {
    const adm = new ObservableObjectAdministration(target)
    // 把观察器实例对象设置到需要观察的数据上
    target.__ob__ = adm
    return target
}

在 Vue2 中是在观察器内部进行初始化对被观察数据进行遍历其属性通过 Object.defineProperty 劫持每一个属性的 gettersetter 操作。同样 Mobx 也需要这样,但 Mobx 的设计是在外部进行遍历属性,而不是在观察器内部进行遍历。

diff 复制代码
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 中的属性劫持吧。

diff 复制代码
// 对象观察器
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,并且通过键值对的方式保存在 ObservableObjectAdministrationthis.values_ 上,然后在 getter 的时候实际获取的是对应 keyobservable 对象中的值。那么很容易看出来每一个 ObservableValue 的实例对象 observable 都是一个发布者,或者叫被观察者更为贴切一些,反正是一个被观察的对象。

接下来我们就可以进行测试了:

javascript 复制代码
// 创建响应式对象
const mobxProxy = observable({ name: 'Cobyte' })
// 读取触发依赖收集
mobxProxy.name
// 设置值触发依赖
mobxProxy.name = '我是掘金签约作者'

打印结果如下:

小结

在前面的讲解 Vue2 的数据响应式原理的文章中,我们说其实每一个属性所形成的闭包就是一个发布者,可能大家还有点难以理解,那么在 Mobx 中每一个属性都通过一个沙箱对象进行包裹,那么这个沙箱对象就是一个发布者,而且代码结构和所谓传统发布订阅模式的代码结构也是比较相似。

在 Mobx 中实现发布订阅模式

那么根据上文我们知道 ObservableValue 是一个发布者,那么我们根据前面的所学的知识,可以很容易完善发布订阅模式的功能。代码如下:

diff 复制代码
+ // 全局属性
+ 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 是一个发布者。那么接下来我们就可以进行最简单的功能测试了。测试代码如下:

javascript 复制代码
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 功能我们很快可以实现如下代码:

javascript 复制代码
class Reaction {
    constructor(fn) {
        this._fn = fn
        this.get()
    }
    get() {
        globalState.trackingDerivation = this
        this._fn()
        globalState.trackingDerivation = null
    }
    update() {
        this._fn()
    }
}

因为订阅者的功能修改了,所以同时需要修改一下 ObservableValue 类:

diff 复制代码
// 将属性值包装成响应式对象
class ObservableValue {
    // 省略...
    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
-        this.observers_.forEach(derivation => derivation())
+        this.observers_.forEach(derivation => derivation.update())
    }
}

接着我们进行测试:

javascript 复制代码
const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}

new Reaction(subscriber)

// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

我们可以看到也是正确打印了我们期待的结果:

但上述 Reaction 的实现是根据 Vue2 的 Watcher 类实现的,实现的特点是在初始化的时候进行传进来的副作用函数,并且进行依赖收集,在更新的时候则不再进行依赖收集。而 Mobx 中的实现并不是这样的,但基本原理是一致的,就是在初始化的时候进行依赖收集,更新的时候则不再进行依赖收集,所以我们根据 Mobx 中的实现重新改造一下 Reaction 类。

Reaction 改造如下:

javascript 复制代码
class Reaction {
    constructor(onInvalidate) {
        this.onInvalidate_ = onInvalidate
    }

    track(fn) {
        globalState.trackingDerivation = this
        fn()
        globalState.trackingDerivation = null
    }
    // 更新的时候执行
    schedule_() {
        this.onInvalidate_()
    }
}

我们可以看到在 Reaction 初始化的时候会传进来一个回调函数,这个回调函数会在更新的时候进行,而依赖收集则在 track 函数中进行,看函数名都可以顾名思义了。

实现 autorun 函数

因为 Reaction 更新执行的函数变了,所以我们也需要修改 ObservableValue 类相关功能:

diff 复制代码
// 将属性值包装成响应式对象
class ObservableValue {
    // 省略...
    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
-        this.observers_.forEach(derivation => derivation.update())
+        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

那么接下来需要重新修改测试代码:

javascript 复制代码
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 的实现原理。我们将其进行封装实现。

javascript 复制代码
function autorun(view) {
    // 实例化订阅者中介
    const reaction = new Reaction(
        () => {
            // 回调函数中执行依赖收集函数
            reaction.track(view)
        }
    )
    // 立即执行
    reaction.schedule_()  
}

然后我们的测试代码就可以修改成:

javascript 复制代码
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 其实就是一个函数,例如下面的例子:

javascript 复制代码
const mobxProxy = observable({ 
    name: 'Cobyte',
    update(value) { 
        this.name = value 
    }
})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')

通过上文我们知道它的基本原理就是设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。其实对修改函数会进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。

我们知道每一个属性值都被封装成了一个 observable 对象,那么我们就可以在 ObservableValue 类中对包装的值进行处理,如果是函数的话,就封装成一个高阶函数(高阶函数(higher-order function)------ 如果一个函数接收的参数为或返回的值为函数,那么我们可以将这个函数称为高阶函数)。

首先我们添加一个全局开关变量:

diff 复制代码
const globalState = {
    trackingDerivation: null,
+    // 是否允许修改状态的开关
+    allowStateChanges: false
}

那么我们就可以在 ObservableValue 类中对包装的值进行处理:

diff 复制代码
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()
    }
}

接着我们就可以设置值的时候进行判断了:

diff 复制代码
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_())
    }
}

这时我们就可以进行测试了:

javascript 复制代码
const mobxProxy = observable({ 
    name: 'Cobyte',
    update(val) {
        this.name = val
    }
})
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
autorun(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

这个时候我们就可以看到直接通过属性进行修改值会发出警告了,然后我们再通过函数修改,则不会了。

javascript 复制代码
mobxProxy.update('掘金签约作者')

通过函数修改则不会发出警告了。

接下来,我们再对我们的代码进行重构一下,让代码结构更接近 Mobx 源码。

diff 复制代码
+ 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 例子:

javascript 复制代码
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 的方法则是在原型上的,也就是说上述例子的实现等同于下面的实现:

javascript 复制代码
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 了。

接下来我们实现一下:

javascript 复制代码
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 是非常容易的。相比较上面实现的 observablemakeAutoObservable 的实现最大的不同就是属性的获取,因为 makeAutoObservable 是应用在 class 类上的,所以除了获取对象自身上的属性之外,还要获取原型对象上的属性才能完整获取 class 中的属性和方法,同时还需要对所获取的属性和方法进行去重,最后去掉不需要监听的属性。

接下来我们就可以进行测试了:

javascript 复制代码
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 项目。

lua 复制代码
npx create-react-app react-app

我们把上面实现的 Mobx 功能内容设置到 ./src/mini-mobx.js 中,并且把使用到的函数进行导出。

接着我们把 App.js 文件的内容修改如下:

javascript 复制代码
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 函数基础架构。代码如下:

javascript 复制代码
export function observer(baseComponent) {
    return (props) => {
        return baseComponent(props)
    }
}

页面正常渲染出来了,但还不能自动更新。

从发布订阅的角度来说在 React 应用 Mobx 后,所写的函数组件就是一个订阅者,那么根据我们上面实现的 autorun 函数,我们先要实例化一个 Reaction 对象,而不管在 Vue 中还是 React 中函数组件在更新的时候,都是重新执行整个函数组件的,所以我们实例化的 Reaction 对象需要保存起来,那么在 React 里面有提供了一个 useRef 的 Hook,它可以创建一个 mutable ref 对象,在组件的整个生命周期内该对象保持不变。简单来说就是 useRef 可以创建一个可以保存状态的 Hook,即使组件重新渲染,其内部的值也不会变化。

javascript 复制代码
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 没有传进去,所以我们进行以下修改:

diff 复制代码
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_()
    }
}

页面显示如下:

我们发现不报错了,但页面并没有渲染,没渲染的原因是我们并没有把函数组件执行的内容返回,所以我们继续进行以下修改:

diff 复制代码
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 的回调函数中进行打印,

diff 复制代码
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 的回调函数中执行更新函数,把依赖收集的相关代码放到外面执行。

代码修改如下:

diff 复制代码
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 应用开发。

相关推荐
鹓于1 小时前
PPT VBA随机选题系统实现详解
java·前端·javascript
前端双越老师2 小时前
OpenClaw 实战记录:前端 VS 全栈 招聘岗位分析
前端·agent·全栈
Bigger2 小时前
第八章:我是如何剖析 Claude Code 里的“电子宠物”彩蛋的
前端·ai编程·源码阅读
qq_12084093712 小时前
Three.js 模型加载与线上稳定性实战:路径、跨域、缓存与降级全链路指南
开发语言·javascript·缓存·vue3
码界奇点2 小时前
基于Spring Boot与Vue的教务管理系统设计与实现
vue.js·spring boot·后端·java-ee·毕业设计·源代码管理
qq_364371722 小时前
NestJS + LangChain SSE 流式输出 + 前端实时渲染打字机效果
前端·langchain
qq_12084093712 小时前
Vue3 + Three.js 实战入门:从零搭建可交互3D场景(含模型加载与性能优化)
javascript·3d·vue3·交互
1314lay_10072 小时前
axios的Post方法和Delete方法的参数个数和位置不同,导致415错误
前端·javascript·vue.js·elementui
LXXgalaxy2 小时前
HTML头部元信息避坑指南
前端·html