Store 之间的依赖

Kerros Store 本质上是 React Hook,所以一个 Store 可以直接调用另一个 Store。

下面用“任务依赖当前账户”作为例子。

创建账户 Store

先创建被依赖的账户 Store:

import { createStore } from '@violetflux/kerros'
import { useState } from 'react'

interface User {
  id: string
  name: string
}

function useAccountStoreValue() {
  const [user, setUser] = useState<User | null>(null)

  return { user, setUser }
}

export const [useAccount, AccountProvider] = createStore(useAccountStoreValue)

在任务 Store 中读取账户

任务 Store 直接调用 useAccount,用法和组件中完全一样:

interface Task {
  id: string
  title: string
  assigneeId: string
}

function useTaskStoreValue() {
  const { user } = useAccount(s => ({ user: s.user }))
  const [tasks, setTasks] = useState<Task[]>([])

  const addTask = (title: string) => {
    if (!user)
      return

    setTasks(v => [...v, {
      id: crypto.randomUUID(),
      title,
      assigneeId: user.id,
    }])
  }

  return { tasks, addTask }
}

export const [useTask, TaskProvider] = createStore(useTaskStoreValue)

账户切换后,任务 Store 会收到新的 user。任务组件不需要再单独读取账户 Store。

按依赖顺序挂 Provider

TaskProvider 创建时要调用 useAccount,所以它必须放在 AccountProvider 内部:

function Providers({ children }: PropsWithChildren) {
  return (
    <AccountProvider>
      <TaskProvider>
        {children}
      </TaskProvider>
    </AccountProvider>
  )
}

可以把 Provider 顺序直接理解为依赖顺序:

Account → Task

左边的 Store 先创建,右边的 Store 可以读取左边。

不要形成循环依赖

如果任务 Store 已经读取账户 Store,就不要再让账户 Store 读取任务 Store:

Account → Task → Account  // 错误:无法安排 Provider 顺序

遇到循环时,不要用额外全局变量绕过去。应该重新判断状态属于谁:

  • 登录用户属于 Account
  • 任务列表属于 Task
  • 两边都需要的纯计算可以提取成普通函数
  • 同时控制多个 Store 的操作可以放到更外层组件或新的上层 Store

三层依赖

多个 Store 按同样规则继续嵌套:

<AccountProvider>
  <TaskProvider>
    <EditorProvider>
      <App />
    </EditorProvider>
  </TaskProvider>
</AccountProvider>

对应关系是:

Account → Task → Editor

Editor 可以读取 TaskAccountTask 可以读取 Account,但依赖不能反向。

在项目中组合 Provider

composeProviderswithProps 不是 Kerros API。Provider 较多时,可以在自己的项目里添加下面这个通用工具:

utils/context.tsx
import type { ComponentType, ReactNode } from 'react'

type Provider = ComponentType<{ children: ReactNode }>

export function composeProviders(providers: Provider[]) {
  return function Providers({ children }: { children: ReactNode }) {
    return providers.reduceRight(
      (tree, Provider) => <Provider>{tree}</Provider>,
      children,
    )
  }
}

export function withProps<TProps extends object>(
  Component: ComponentType<TProps & { children: ReactNode }>,
  props: TProps,
): Provider {
  return function Provider({ children }) {
    return <Component {...props}>{children}</Component>
  }
}

然后按相同的依赖顺序写成数组:

import { composeProviders } from '@/utils/context'

export const AppProvider = composeProviders([
  StreamProvider,
  ThreadProvider,
  NavigationProvider,
  SenderProvider,
])
<AppProvider>
  <App />
</AppProvider>

数组中的第一个 Provider 在最外层,最后一个在最内层。因此这段代码仍然表示:

Stream → Thread → Navigation → Sender

如果某个 Provider 需要固定 props,使用项目内的 withProps 绑定:

import { composeProviders, withProps } from '@/utils/context'

export const AppProvider = composeProviders([
  withProps(ApiProvider, { baseUrl: '/api' }),
  StreamProvider,
  ThreadProvider,
  NavigationProvider,
  SenderProvider,
])

withProps 适合在模块作用域绑定不会变化的配置。动态 props 直接传给 Provider,不要在组件渲染期间反复创建新的包装组件。这两个工具不会进入 Kerros 包,也不会增加 Kerros 的运行时代码。

Provider props 与 key

createStore 会根据 Store Hook 的参数推导 Provider props:

interface ThreadProps {
  threadId: string
}

function useThreadStoreValue({ threadId }: ThreadProps) {
  const [draft, setDraft] = useState('')
  return { threadId, draft, setDraft }
}

export const [useThread, ThreadProvider] = createStore(useThreadStoreValue)

ThreadProvider 现在必须接收 threadId

<ThreadProvider threadId={threadId}>
  <Thread />
</ThreadProvider>

Provider 也是普通 React 组件,所以支持 React 的 key

<ThreadProvider key={threadId} threadId={threadId}>
  <Thread />
</ThreadProvider>

threadId prop 会传给 Store Hook;key 不会作为 prop 传入,它只由 React 使用。key 改变时,旧 Provider 会卸载并创建全新的 Store 实例,因此上例会同时重置 draft 等内部状态。不加 key 时,Provider props 更新会重新运行 Store Hook,但已有的 React state 会继续保留。

对于大型应用,优先拆成多个职责明确的小 Store,再让 Provider 顺序清楚地表达依赖关系。