by: 小熊
一、引入
我们日常写代码时经常用到mobx,也遇到过一些奇奇怪怪的问题,所以今天让我们一起来了解一下mobx的运行机制解决这些奇奇怪怪的小问题吧(ps:文章中没提到的问题可以在评论区补充哦)~
首先让我们一起来看一个实际项目的案例,如下图所示,这是一个很常见的场景,页面是左右布局,左边是可以展开收起的侧边栏,右边上侧是图表下侧是表格。
常见问题
展开左侧菜单后,右侧内容区的图表因为宽度还是菜单展开前的宽度导致出现滚动条,图表也超出视口的展示区域。大家日常是怎么处理这个问题的呢?
熊的解决思路是通过mobx监听侧边栏展开收起状态的变化来触发右侧内容区的图表resize方法~
@observable viewState = { isSidebarCollapsed: false };
this.disposer = reaction(
() => viewState.isSidebarCollapsed,
() => {
chartResize();
}
);
大家有没有注意到熊写了一个 disposer,会在组件卸载时调用该方法,将该reaction清理掉~ 下面有一个关于reaction的优化建议,大家可以了解一下~
优化建议
永远要清理 reaction,不然只有当它们观察的对象都垃圾回收了,它们才会被垃圾回收。对于像 autorun 这样的 reaction 要棘手得多,因为它们可能观察到许多不同的 observable,并且只要其中一个仍在作用域内,reaction 将保持在作用域内,这意味着其使用的所有其他 observable 也保持活跃以支持将来的重新计算。 —— Mobx官方文档(永远要清理-reaction)
言归正传,接下来就让我们一起看看这几行mobx的代码究竟做了什么吧~ tips:因为源码较长为了文章更好的阅读体验我们这里会附上源码的核心逻辑,具体源码会附在链接里~
二、mobx运行机制
2.1 observable
先让我们一起来看看 observable 的源码,看看它做了什么事情~
【源码】observable
var observable: IObservableFactory = assign(createObservable, observableFactories);
从上面的源码,我们可以发现 observable 包含两部分,一部分 createObservable 函数,一部分是 observableFactories 对象。接下来让我们分别看看这两部分。
function createObservable(v: any, arg2?: any, arg3?: any) {
// @observable someProp;
if (isStringish(arg2)) {
storeAnnotation(v, arg2, observableAnnotation)
return
}
// already observable - ignore
if (isObservable(v)) {
return v
}
// plain object
if (isPlainObject(v)) {
return observable.object(v, arg2, arg3)
}
// Array
if (Array.isArray(v)) {
return observable.array(v, arg2)
}
// Map
if (isES6Map(v)) {
return observable.map(v, arg2)
}
// Set
if (isES6Set(v)) {
return observable.set(v, arg2)
}
// other object - ignore
if (typeof v === 'object' && v !== null) {
return v
}
// anything else
return observable.box(v, arg2)
}
我们可以发现 createObservable 其实是一个转发函数,它只是把传入的对象根据对象的类型转发到不同的转换函数。
const observableFactories: IObservableFactory = {
box(){},
array(){},
map(){},
set(){},
object<T = any>(props: T, decorators?: AnnotationsMap<T, never>, options?: CreateObservableOptions): T {
return extendObservable(
globalState.useProxies === false || options?.proxy === false
? asObservableObject({}, options)
: asDynamicObservableObject({}, options),
props,
decorators
)
},
ref: createDecoratorAnnotation(observableRefAnnotation),
shallow: createDecoratorAnnotation(observableShallowAnnotation),
deep: observableDecoratorAnnotation,
struct: createDecoratorAnnotation(observableStructAnnotation)
} as any
observableFactories 包含两部分,一部分与 createObservable 相似是各种类型的转换函数,如:box, array, map, set, object,另一部分是一些属性,这几个属性的含义如下:
ref
:不需要观察属性变化(属性是只读的),频繁变更引用时使用 ref。底层处理禁用自动的 observable 转换,只是创建一个 observable 引用。shallow
:只观察第一层数据。底层处理时不进行递归转换。deep
:是否开启深度观察。struct
:对象每次更新都会触发 reaction,但是有时只是 reference 更新了实际属性内容没变,这就是 struct 存在的意义。struct 会基于 property 做深比较。
为了方便讲解,我们选择一个转换函数继续往下看看这些转换函数做了什么,我们选取日常用到最多的 observable.object 来看一下~ 还是上面那段 observableFactories 的代码,我们可以发现 observable.object 方法实际是调用了 extendObservable 方法,我们顺着往下看看 extendObservable 做了什么。
function extendObservable<A extends Object, B extends Object>(
target: A,
properties: B,
annotations?: AnnotationsMap<B, never>,
options?: CreateObservableOptions
): A & B {
// Pull descriptors first, so we don't have to deal with props added by administration ($mobx)
const descriptors = getOwnPropertyDescriptors(properties)
const adm: ObservableObjectAdministration = asObservableObject(target, options)[$mobx]
startBatch()
try {
ownKeys(descriptors).forEach(key => {
adm.extend_(
key,
descriptors[key as any],
// must pass "undefined" for { key: undefined }
!annotations ? true : key in annotations ? annotations[key] : true
)
})
} finally {
endBatch()
}
return target as any
}
顺着源码调用我们来看一下 extendObservable 做了什么事情,它做了如下事情:
1 . 调用 asObservableObject
方法,给 target 挂载 $mobx 属性
2 . 遍历 target 的属性值,让每个属性经过 decorator 改造后重新挂载到 target 上。我们顺着代码看有这样一条调用链:
$mobx.extend_ → defineProperty_ → defineObservableProperty_ → getCachedObservablePropDescriptor
如下所示是 getCachedObservablePropDescriptor 的源码,我们发现它拦截了 target 每个属性的读取操作,并将操作映射到了 mobx 上。
【源码】getCachedObservablePropDescriptor
function getCachedObservablePropDescriptor(key) {
return (
descriptorCache[key] ||
(descriptorCache[key] = {
get() {
return this[$mobx].getObservablePropValue_(key)
},
set(value) {
return this[$mobx].setObservablePropValue_(key, value)
}
})
)
}
我们继续往下看看,getObservablePropValue 和 setObservablePropValue_ 究竟做了什么,也就是说我们对 observable 包装的对象做读取操作时它内部做了什么事呢 ~ 往下看我们可以发现这样一条调用链:
getObservablePropValue_ → get_ → has_ → get → reportObserved → queueForUnobservation
这条调用链的重点在 reportObserved 函数,所以我们重点看一下它的源码~
function reportObserved(observable: IObservable): boolean {
checkIfStateReadsAreAllowed(observable)
const derivation = globalState.trackingDerivation
if (derivation !== null) {
if (derivation.runId_ !== observable.lastAccessedBy_) {
observable.lastAccessedBy_ = derivation.runId_
// Tried storing newObserving, or observing, or both as Set, but performance didn't come close...
derivation.newObserving_![derivation.unboundDepsCount_++] = observable
if (!observable.isBeingObserved_ && globalState.trackingContext) {
observable.isBeingObserved_ = true
observable.onBO()
}
}
return true
} else if (observable.observers_.size === 0 && globalState.inBatch > 0) {
queueForUnobservation(observable)
}
return false
}
从读取的源码可以看出,一步步走下来读取会触发 reportObserved,最终将当前对象的属性包装成一个 ObservableValue 对象加入全局变量 isPendingUnobservation_ 中。
到这里我们悟了,这相当于观察者模式啊,对 observable 对象的读取操作就相当于在对应的被观察环境里做了一次依赖收集 ~ 那下面的写值操作会做什么事情我们心里也大概有数了,让我们简单看一下~ 下面是写值操作的调用链,具体源码点击对应函数可跳转至 Github 仓库查看:
setObservablePropValue_ → prepareNewValue_ → interceptChange → setNewValue_ → reportChanged + notifyListeners
在对 observable 对象进行写值操作时,先在写值前进行了拦截操作,在拦截器进行了一些处理。在写值后通知所有的观察者值被更新。emmmm,这里是典型的观察者模式~
小结一下,observable 源码一路看下来我们可以发现:
- observable 先采用策略模式根据对象的类型进行转发
- 转发到对应的转换函数后通过在对象上挂载 $mobx 属性代理对原对象的操作,这里用到了代理模式。
- 代理读操作时进行依赖收集,代理写操作时进行观察者的通知,这里用到了观察者模式
- 在写值前后安插了两次回调(这里用到了面向切面编程的思想-AOP),写值前的回调像是axios的拦截器(intercept),写值后的回调像是reaction对新值做出响应。
2.2 reaction
emmmm,看完了 observable 干得活,下面我们来一起看看看 reaction 都做了什么吧~
【源码】reaction:emmmm,这里太长了熊就不贴了大家自己跳转过去康康吧~
从 reaction 源码出发一路找下去,我们可以找到这样一条调用栈:
Reaction.schedule_ → runReactions → runReactionsHelper → runReaction_
我们阅读 runReaction_ 的实现可以发现,它主要做了以下几件事:
- 用 startBatch 和 endBatch 开启和结束一个事务,用于批量处理 reaction 的执行,避免不必要的重新计算(这里借鉴了数据库事务的概念)。
- 用 shouldCompute 判断是否继续执行,继续的话就执行 onInvalidate_
顺着 onInvalidate_ 往下找可以找到这样的调用栈:
onInvalidate_ → reactionRunner → track → trackDerivedFunction → bindDependencies
根据这条调用栈,我们可以发现onInvalidate_主要做了以下几件事:
- 跟踪任务(track):开启了一层新的事务,并将当前的 reaction 挂载到全局变量 globalState.trackingContext 上
- 执行任务(trackDerivedFunction):执行 reaction 的 callback,更新 newObserving_ 属性方便后面更新依赖关系
- 更新依赖(bindDependencies):用 newObserving_ 更新当前 derivation(reaction 运行环境的this)中的 observing_ 属性,完成依赖更新
下面插入一个小小的拓展知识点~
在依赖更新这里涉及到 observing_ 和 newObserving_ 两个数组,正常双层循环去判断的算法时间复杂度是O(n^2),但 mobx 通过3次循环 + diffValue 属性的辅助将复杂度降到了 O(n),该理解引用参考资料1的2.2.3.2节。
1 . 第一次循环遍历 newObserving,利用 diffValue 进行去重,diffValue 初始值为 0,如果 diffValue 为0则将其值修改为1,如果 diffValue 为1说明重复了直接去掉即可,第一次遍历完的结果如下:
这次遍历后,所有 最新的依赖 的 diffValue 值都是 1 了,而且去除了所有重复的依赖。
2 . 第二次循环遍历 observing, diffValue的值为0说明其不在更新后的依赖里面可以调用 removeObserver 直接去掉,diffValue 的值为1的话将其变为0.
这一次遍历之后,去除了 observing 中所有陈旧的依赖,且遗留下来的对象的 diffValue 值都是 0 了。
3 . 第三次循环遍历 newObserving,如果 diffValue 为 1,说明是新增的依赖,调用 addObserver 新增依赖,并将 diffValue 置为 0。
emmmm,看到这里我们简单的了解了 observable 和 reaction 的实现,但我们还不知道 observable 的改变如何触发 reaction 的运行~ 那下面就让我们看一下 mobx 是如何将两者关联到一起的吧~
observable 写值时会触发 reportChanged,reportChanged 内开启了一层新的事务随后调用 propagateChanged,propagateChanged 会遍历该 observable 的观察者列表(即做过 observable 读取操作的 reaction 们,在读取时被加入到 observable 的观察者队列),然后执行每个观察者的onBecomeStale_ 方法,onBecomeStale_ 方法直接调用 reaction 的 schedule_ 方法,也就是把我们上面讲的 reaction 的任务流程重新跑一边~ emmm,到此 observable 和 reaction 的流程就串起来拉~
三、总结
最后让我们一起来回顾一下本文的主要内容。本文开始我们从实际项目侧边栏展开右侧图表区域内容不会重新调整尺寸聊起,然后给出了 mobx 的解决方案,然后根据解决方案中的代码顺着源码往下读。了解了 mobx 的 observable 和 reaction 都做了什么事情。
observable
将传入数据包装成 ObservableValue 对象,并代理其读取和写入操作:
- 在读取时调用 reportObserved 方法进行依赖收集
(reportObserved 把 observable 放进了 derivation 的 newObserving_ 队列里,derivation 执行到 bindDependencies 时会调用 addObservable 和 removeObservale 更新依赖,addObservable 和 removeObservale 会更新 observable 的 observers_ 属性)
- 在写入时调用 reportChanged 方法进行通知发放
(reportChanged 调用 propagateChanged,在 propagateChanged 遍历 observable的观察者们-observers_,然后让它们执行各自的 onBecomeStale_ 方法,从onBecomeStale_方法走下去就是把reaction的执行流程全部重新跑一边)
reaction
通过 shouldCompute 判断有没有必要执行,shouldCompute主要做性能的优化,具体实现原理见该文章。如果有必要执行下述任务:
- 跟踪任务(track):开启了一层新的事务,并将当前的reaction挂载到全局变量 globalState.trackingContext 上
- 执行任务(trackDerivedFunction):执行reaction的callback,更新 newObserving_ 属性方便后面更新依赖关系
- 更新依赖(bindDependencies):用 newObserving_ 更新当前 derivation(reaction 运行环境的this)中的 observing_ 属性,完成依赖更新
emmmm,那现在看到这里的小伙伴们可以从 mobx
源码的角度给大家讲一下为什么最开始的代码可以实现侧边栏展开收起时右侧图表的内容重新调整大小嘛?
四、小测试
- 以下两道题目的输出是什么,为什么是这样的输出?
-
我们没讲 autorun,大家可以猜一猜 autorun 和 reaction 的关系嘛?
-
参考 action 源码 回答,为什么 mobx 严格模式要求对 mobx 值的变更必须包裹在 action 里?猜猜看 action 和 runInAction 有何不同?
-
下面这段代码里 SomeContainer 组件的 title 会更新嘛?如果不会请说明为什么不会,以及如何让它更新~
五、调试技巧
恭喜你做完了所有题目,熊最后附赠两个 mobx 调试的小技巧~
- 使用 track 监听页面的变化是由哪个 observable 变化导致的,具体操作参考文档,效果如下:
- MobX 附带的开发者工具 mobx-react-devtools 可以用来追踪应用的渲染行为和数据依赖关系,具体操作参考文档。
最后的最后,既然你不辞辛苦的读完了整片文章,那就奖励你在文档上随意提问的权力吧_(:3」∠)_
六、参考资料
招贤纳士
青藤前端团队是一个年轻多元化的团队,坐落在有九省通衢之称的武汉。我们团队现在由 20+ 名前端小伙伴构成,平均年龄26岁,日常的核心业务是网络安全产品,此外还在基础架构、效率工程、可视化、体验创新等多个方面开展了许多技术探索与建设。在这里你有机会挑战类阿里云的管理后台、多产品矩阵的大型前端应用、安全场景下的可视化(网络、溯源、大屏)、基于Node.js的全栈开发等等。
如果你追求更好的用户体验,渴望在业务/技术上折腾出点的成果,欢迎来撩~ yan.zheng@qingteng.cn