Skip to main content

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 实现批量更新,优化多个状态变化下的性能。

附录

MobX Github

MobX Official

React Redux Official

Hooks & Mobx 只需额外知道两个 Hook,便能体验到如此简单的开发方式

Redux 的 Store 概念延伸出很多的业务规范,比如:

  • Redux Thunk 通过中间件处理业务
  • Reselect 带缓存的 Store 选择工具,其中用到 LRU 缓存
  • Redux Saga 与 Redux Thunk 类似,但是用 ES6 Generator 格式编写业务