Redux Mobx 源码分析: 状态管理库在渲染引擎上的适配工作
目录
我经常在 React 框架业务里面混合用 Redux 和 MobX。比如 Redux 用来管理全局登录态,用 MobX 对细分的业务逻辑建模,而用 React Context注入公用的上下文(axios、Facade、etc),由此构建一个完整的前端项目。
Redux、MobX 是独立的跨平台状态管理库,他们本身对平台没有关联,所以要在特定的UI引擎项目里面配合使用,就要用到它们的适配包:
react-redux
Redux React 适配包mobx-react-lite
MobX React 适配包
这两个适配库虽然有各自不同的实现,但是它们有共同的任务目标:把状态管理嵌入 React 组件生命周期,做到跟随组件生成,处理业务、释放资源。
现在以源码解读试着分析它们的适配原理。
Redux: Context + useSyncExternalStore
TLDR:Redux 利用React Context 特性传输 store上下文,状态事件pub sub用React 对外提供的 useSyncExternalStore 实现。
Redux Store
Redux Store的模型非常简单,是一个由当前状态对象、状态处理器组成的事件源。对外提供以下方法:
- getState() 获取当前状态
- dispatch(Action) 修改状态的唯一方法,执行后必定通知所有听众subscription
- subscribe(): unsubscribe 收听后返回一个解除收听的执行方法
Provider 和钩子: React Context 功能
Provider 作为 Redux Store 的分发源,App 往外包一层 Provider ,所有业务即可用 useStore()、useDispatch() 拿到全局 Store 和它的功能接口。本质上,它用了 React Context 进行 store 分发,而钩子都是 React useContext 的变体。
// src/components/Provider 简化部分
function Provider({ store, children, serverState }) {
const contextValue = useMemo(() => {
const subscription = createSubscription(store)
return {
store,
subscription,
getServerState: serverState ? () => serverState : undefined,
}
}, [store, serverState])
// ...
return <Context.Provider value={contextValue}>{children}</Context.Provider>
}
下方的代码可以看出,React Redux 的 useStore()、useDispatch() 钩子,本质上是运用 React Context 的功能。
// src/hooks/useReduxContext
export function useReduxContext() {
return useContext(ReactReduxContext)
}
// src/hooks/useStore
export function useStore() {
const { store } = useReduxContext()!
return store
}
// src/hooks/useDispatch
export function useDispatch() {
const store = useStore()!
return store.dispatch
}
useSelector: 使用 React 外部接口
Redux Store 是事件源,把 pub-sub 形式的状态对象转换为 React State 是这个库的关键,useSelector API 是它的实现,用了 React 的对外钩子 useSyncExternalStore ,这个钩用于监听一个事件源,签名和职责如下:
// module: use-sync-external-store/with-selector
useSyncExternalStoreWithSelector<State, Selection>(
// React 会传一个cb,应在状态变化时触发 cb,添加监听后后返回一个取消监听的方法
subscribe: (onStoreChange: () => void) => UnsubscribeFunction,
// 获取当前状态
getSnapshot: () => State,
// 获取服务器的状态,SSR 专用
getServerSnapshot: () => State,
// 选择所需的状态部分
selector: (state: State) => Selection,
// 状态选择前后的对等检查,减少不必要的重绘
isEqual?: (a: Selection, b: Selection) => boolean,
): Selection;
useSelector 直接用了这个接口,对等检查默认是内存比较。
// src/hooks/useSelector
export function useSelector(selector, equalityFn) {
const { store, subscription, getServerState } = useReduxContext()!
const selectedState = useSyncExternalStoreWithSelector(
subscription.addNestedSub,
store.getState,
getServerState || store.getState,
selector,
equalityFn
)
return selectedState
}
链表实现的 Subscription 管理
除了 Store 自带的 subscribe() 接口外,Redux 额外封装了一层 Subscription 管理器,用链表实现,可以在 React 渲染期间频繁的 subscribe、unsubscribe 调用中保持较好的数组增删性能。
依据链表的特性,获取所有监听时性能最差,需要遍历整条链,但这个方法用得不多。
// src/utils/Subscription
// 结构
type Listener = {
callback: () => void
next: Listener | null
prev: Listener | null
}
function subscribe(callback) {
const listener = { callback, next: null, prev: last }
// 入链
if (last) { last.next = listener } else { first = listener }
return function unsubscribe() {
if (!first) return
// 断链和重接
if (listener.next) {
listener.next.prev = listener.prev
} else {
last = listener.prev
}
if (listener.prev) {
listener.prev.next = listener.next
} else {
first = listener.next
}
}
}
Batch: 批量操作
批量通知 Listeners 用到了 batch() 方法,并借用了 React 的批量更新方法 unstable_batchedUpdates(),执行期间所有的 setState() 会暂存,是性能优化的解决方案。
// src/utils/Subscription
// import { unstable_batchedUpdates } from 'react-native'
import { unstable_batchedUpdates } from 'react'
// Subscription 链表遍历通知
function notify() {
unstable_batchedUpdates(() => {
let listener = first
while (listener) {
listener.callback() // callback 可能调用 setState()
listener = listener.next
}
})
}
服务端渲染(SSR)处理
React 提供同步 useLayoutEffect 和异步的 useEffect,DOM 环境下用 useLayoutEffect 可以同步初始化重要的 Store,而 SSR 环境只能用 useEffect。
// src/utils/useIsomorphicLayoutEffect
export const canUseDOM = !!(
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
)
export const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffect
另外,Store 的事件触发用了 React 给第三方库的更新接口 useSyncExternalStoreWithSelector(),这个接口主要给一些第三方的状态管理库使用。
Mobx: useEffect + Timeout GC
TLDR:Mobx 让 Reaction 跟随组件的生命周期,利用 React setState (forceUpdate)通知所在组件更新。但 MobX 处理了组件的首次渲染和 Effect 期间的真空期,并用超时机制清理了泄漏的 Reaction ,以内存换取了性能。
MobX 的 autorun (Reaction)会对状态变化产生反应并执行回调,我在另一篇文章里面解读了 MobX 的原理和它的名词概念。
组件真空期
MobX 没有完全跟随组件的生命周期,它在组件首次渲染(first render)时创建了 Reaction 然后开始监听状态变化,此时组件还未进入生命周期,所以这个 Reaction 没办法通过 Effect 部署一个dispose 取消监听,可能会出现内存泄漏。
// src/useObserver 有删减
// 注意从 reaction.track(renderFn) 开始监听
// 但是没有地方调用 reaction.dispose(),可能会出现泄漏
function useObserver(renderFn: Function, componentName: string) {
const reactionTrackingRef = React.useRef<IReactionTracking | null>(null)
if (!reactionTrackingRef.current) {
reactionTrackingRef.current = new Reaction(...)
}
const { reaction } = reactionTrackingRef.current!
reaction.track(() => {
try {
rendering = renderFn()
} catch (e) {
exception = e
}
})
return rendering
}
超时 GC: 处理泄漏的对象
MobX 把可能泄漏的 Reaction 队列到一个清理器上,如果一段时间后发现 Reaction 所在组件仍然未进入 Effect 生命周期,则会取消 Reaction 的监听任务并释放资源,根据源码定义的常数,这个超时设定为10秒。
其实组件从首次渲染到进入 Effect 的真空期很短,但是真空期是没有 dispose 回调,所以必须处理可能的泄漏。
// src/utils/reactionCleanupTrackingCommon 有删减
export interface ReactionCleanupTracking {
// 把 Reaction 队列到清理列表
addReactionToTrack(
reactionTrackingRef,
reaction: Reaction
): IReactionTracking
// 取消对该 Reaction 的清理任务
recordReactionAsCommitted(reactionRef): void
}
// src/useObserver 有删减
function useObserver(renderFn: Function, componentName: string) {
...
if (!reactionTrackingRef.current) { // 这段是真空期
const newReaction = new Reaction(...)
addReactionToTrack(reactionTrackingRef, newReaction, ...) // 队列到清理列表
}
...
}
处于真空期的 Reaction 收到状态变化不能直接 setState(会发生递归调用导致爆栈),Reaction 选择把这段变化暂存,直到组件进入 Effect。
Effect: 生命周期和误杀处理
当组件顺利进入 Effect 挂载,Reaction 就可以把 dispose 部署到卸载任务上了,这时候 Reaction 就跟随了组件的生命周期,不会出现泄漏问题,所以 Effect 期间要把 Reaction 从清理器上移除。此外还会处理之前暂存的变化,通过 setState 刷新页面。
// src/useObserver 有删减
function useObserver(renderFn: Function, componentName: string) {
...
if (!reactionTrackingRef.current) { // 这段是真空期
const newReaction = new Reaction(...)
addReactionToTrack(reactionTrackingRef, newReaction, ...) // 队列到清理列表
}
useEffect(() => {
recordReactionAsCommitted(reactionTrackingRef) // 一旦挂载就立即取消这个清理任务
if (reactionTrackingRef.current) {
...
if (reactionTrackingRef.current.changedBeforeMount) {
reactionTrackingRef.current.changedBeforeMount = false
forceUpdate() // 如果真空期有状态改变,则刷新暂存的变化
}
...
}
})
...
}
如果进入 Effect 挂载前,清理器把 Reaction 干掉了。那么组件也会在 Effect 期间重新生成一个 Reaction 并跟随它的生命周期。
function useObserver(renderFn: Function, componentName: string) {
...
useEffect(() => {
...
if (reactionTrackingRef.current) {
...
} else {
// 看这里!reactionTrackingRef 没有值,说明 Reaction 被清理器干掉了
// 所以要重开一个 Reaction
// 当然这里是 Effect,所以 Reaction 有反应直接 forceUpdate 即可,不需要暂存
reactionTrackingRef.current = new Reaction(baseComponentName, () => {
forceUpdate()
})
}
return () => {
// 跟随组件一起卸载,释放资源
reactionTrackingRef.current!.reaction.dispose()
reactionTrackingRef.current = null
}
})
...
}
QA: 为什么不用 useLayoutEffect 代替?
因为 useLayoutEffect 之前也执行了 render,即 First render - useLayoutEffect 之间仍然有真空期。useLayoutEffect 只是保证了在 DOM 操作生效之前能够执行并修改预设的 State。只要有真空期就可能会出现泄漏。
QA: 为什么不在 useLayoutEffect 中启用 Reaction,而是首次渲染就开始跟踪?
通过 useLayoutEffect 控制 Reaction,虽然不需要处理真空期的泄漏,但现在面临 2 种情况:
-
如果真空期不渲染,直到 Effect 之后渲染,那么渲染方法里面不能用钩子,否则会出现前后钩子不一致,相当于用 if 分支了钩子。
-
如果真空期渲染一次,那么 Effect 也会再触发渲染一次,虽然能达到预期的效果,但与现在的 GC 处理方式相比,法2 为了应对真空期保持钩子顺序而多进行一次无关渲染,造成性能损失。
这段 Github Gist 代码段实现了第2种情况,它会在 mobx-react-lite 单元测试时出错,主要是「发现组件多渲染了一次」。
接管 React useState
MobX 导出了 useLocalObservable 可以当 useState() 使用。
MobX 的特性 observable 的 get、set 陷阱会触发 Reaction 的反应并执行 forceUpdate() 重绘,所以不需要 setState。
setState 虽然是异步的,但是在异步回调中它是同步的,而 MobX Action 可以批量操作,避免重复触发渲染。
关于批量操作
和 React Redux 类似, MobX 也用了 React 内部方法 unstable_batchUpdate 实现批量更新,优化多个状态变化下的性能。
附录
Hooks & Mobx 只需额外知道两个 Hook,便能体验到如此简单的开发方式
Redux 的 Store 概念延伸出很多的业务规范,比如:
- Redux Thunk 通过中间件处理业务
- Reselect 带缓存的 Store 选择工具,其中用到 LRU 缓存
- Redux Saga 与 Redux Thunk 类似,但是用 ES6 Generator 格式编写业务