createContext 还是 useSyncExternalStore?一文讲清场景与选型

这是一篇系统性的选型文章,帮助你在 React 项目中判断:什么时候应该使用 createContext/Provider 模式,什么时候应该使用 useSyncExternalStore(完整拼写,不简写)。文章覆盖两者的定位、适用场景、使用原因、常见陷阱、优化策略和落地示例,并给出最终的决策清单。

1. 两种模式的定位

  • createContext/Provider(以下简称"Context")

    • 定位:树内作用域化的数据传递(scoped configuration),强调就近覆盖、嵌套生效和与渲染同步的读取。
    • 典型:主题 Token、国际化、方向、组件尺寸/禁用状态、局部业务配置、交互实例注入(如 antd 的 App.useApp)。
  • useSyncExternalStore

    • 定位:并发渲染下对"外部可变状态源"的一致性订阅 API,解决"撕裂"(tearing)问题,确保一次提交中所有消费者看到同一版本快照。
    • 典型:Redux/Zustand/Jotai 等外部状态库、自建全局 store、浏览器环境状态(媒体查询、网络状态、可见性、时钟、WebSocket 推送等)。

两者并非对立,而是面向不同问题域,常在同一个项目里互补使用。

2. 适用场景与使用原因

  • 优先使用 Context 的场景

    • 树内可覆盖的 UI 配置:主题/Design Token、国际化、方向、prefixCls、全局尺寸与禁用。

    • 低频变化、读多写少的配置型数据。

    • 需要作用域隔离与嵌套覆盖(同页多主题、局部禁用)。

    • 需要与渲染时序严格对齐(尤其 CSS-in-JS、SSR 水合一致性)。

    • 局部业务配置(如当前页面/表单的业务规则)。

    • 使用原因:

      • 作用域语义天然吻合;
      • 与渲染同步,SSR 友好;
      • 低频更新配合拆分 Context 与 useMemo 已足够高效;
      • 工程心智与实现复杂度低。
  • 优先使用 useSyncExternalStore 的场景

    • 外部可变全局状态:跨页面共享、更新中高频、可能被非 React 事件驱动。

    • 并发渲染(Transition/Suspense/重试)下,必须避免撕裂。

    • 希望进行选择性订阅(仅订阅切片)以获得更细粒度性能优化。

    • 浏览器/系统环境订阅,如媒体查询、online/offline、可见性、时间源、WebSocket。

    • 使用原因:

      • 让 React 协调"外部快照",避免同一提交内的版本不一致;
      • 订阅管理契约清晰,适配并发特性;
      • 便于封装 selector,做到按需更新。

3. 核心对比(帮助你把握边界)

  • 数据来源与归属

    • Context:树内数据(由 Provider 提供),属于 React 的数据流。
    • useSyncExternalStore:树外可变源(全局单例或外部库),不受 React 调度直接管理。
  • 作用域与覆盖

    • Context:天然作用域隔离与嵌套覆盖,非常适合"就近覆盖"配置。
    • useSyncExternalStore:默认是全局单例订阅;若要支持子树差异,需要额外实例与封装。
  • 并发一致性与撕裂

    • Context:React 自带一致性保障,不会 tearing。
    • useSyncExternalStore:专门解决外部源在并发下的 tearing。
  • 选择性订阅

    • Context:默认"全量值变化即所有消费者更新";可用多 Context 拆分或 use-context-selector 优化。
    • useSyncExternalStore:天然易于封装 selector,只让订阅的切片变化时更新。
  • SSR/样式系统

    • Context:渲染期读取,SSR 与 CSS-in-JS 注入顺序易于对齐。
    • useSyncExternalStore:需提供稳定的 getServerSnapshot,避免水合不一致。
  • 复杂度与体积

    • Context:最小心智成本;但要注意 value 引用稳定与拆分粒度。
    • useSyncExternalStore:需要订阅契约、快照可比较、选择器与缓存等样板。

4. 反模式与常见陷阱

  • Context 侧

    • value 每次 render 都新建对象/函数:导致所有消费者无谓重渲染。应 useMemo/useCallback 稳定引用。
    • 大而全的 Context:把无关字段塞在一个 value 内,任何字段变化都会触发所有消费者更新。应拆分多 Context 或使用 use-context-selector。
    • 指望 React.memo 抵消 useContext 触发的更新:做不到。优化要点在"订阅粒度",不是组件记忆。
  • useSyncExternalStore 侧

    • getSnapshot 每次返回新对象:会被判定为"始终变化",导致频繁重渲染。需要结构共享或缓存,保持未变切片的引用不变。
    • subscribe 通知时序不规范:应在状态更新完成后再通知,确保快照一致。
    • 忽略 SSR:未提供稳定的 getServerSnapshot,导致水合不一致。

5. 实战示例

  • Context:主题与交互实例(典型于 antd v5)
tsx 复制代码
import { ConfigProvider, App as AntdApp, theme } from 'antd'
import zhCN from 'antd/locale/zh_CN'

export function Bootstrap() {
  return (
    <ConfigProvider
      locale={zhCN}
      theme={{ token: { colorPrimary: '#1677ff' } }}
    >
      <AntdApp>
        <App />
      </AntdApp>
    </ConfigProvider>
  )
}

function CardX() {
  const { token } = theme.useToken()
  const { message } = AntdApp.useApp()
  return (
    <div style={{ color: token.colorText }} onClick={() => message.success('OK')}>
      Hello
    </div>
  )
}
  • Context 优化:拆分 Context + useMemo 稳定引用
tsx 复制代码
const ThemeContext = React.createContext<'light'|'dark'>('light')
const LangContext = React.createContext<'zh'|'en'>('zh')

function Providers({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = React.useState<'light'|'dark'>('light')
  const [lang, setLang] = React.useState<'zh'|'en'>('zh')
  const themeVal = React.useMemo(() => theme, [theme])
  const langVal = React.useMemo(() => lang, [lang])
  return (
    <ThemeContext.Provider value={themeVal}>
      <LangContext.Provider value={langVal}>
        {children}
      </LangContext.Provider>
    </ThemeContext.Provider>
  )
}
  • useSyncExternalStore:极简外部 store 与订阅
tsx 复制代码
type State = { count: number }
let state: State = { count: 0 }
const listeners = new Set<() => void>()

const store = {
  getSnapshot: () => state,
  subscribe: (l: () => void) => (listeners.add(l), () => listeners.delete(l)),
  set(next: Partial<State>) {
    state = { ...state, ...next }
    listeners.forEach(l => l())
  }
}

function useCount() {
  return React.useSyncExternalStore(
    store.subscribe,
    () => store.getSnapshot().count,
    () => 0 // SSR fallback
  )
}

function Counter() {
  const count = useCount()
  return <button onClick={() => store.set({ count: count + 1 })}>{count}</button>
}
  • useSyncExternalStore + 选择器封装
tsx 复制代码
function useStoreSelector<T>(selector: (s: State) => T) {
  const getSnap = React.useCallback(() => selector(store.getSnapshot()), [selector])
  return React.useSyncExternalStore(store.subscribe, getSnap, getSnap)
}

function CountOnly() {
  const count = useStoreSelector(s => s.count)
  return <span>{count}</span>
}
  • 想保留 Context 又要"选择性订阅":use-context-selector
ts 复制代码
import { createContext, useContextSelector } from 'use-context-selector'

type Ctx = { user: { name: string } | null; lang: 'zh' | 'en' }
const Ctx = createContext<Ctx>({ user: null, lang: 'zh' })

function Provider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = React.useState<Ctx['user']>(null)
  const [lang, setLang] = React.useState<Ctx['lang']>('zh')
  const value = React.useMemo(() => ({ user, lang }), [user, lang])
  return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}

function UserName() {
  const name = useContextSelector(Ctx, v => v.user?.name ?? 'Guest')
  return <span>{name}</span>
}

6. 性能优化清单

  • 使用 Context 时

    • 拆分 Context,降低"无关字段变化"造成的重渲染。
    • Provider 的 value 用 useMemo,回调用 useCallback。
    • 避免在 value 中放每次都新建的对象/数组/函数。
  • 使用 useSyncExternalStore 时

    • 确保 getSnapshot 返回可比较、稳定的值;对复杂切片做结构共享/缓存。
    • 订阅通知的时序保持"先更新,再通知"。
    • 需要选择器时封装 useStoreSelector,避免在组件层每次创建选择逻辑。
    • SSR 提供稳定 getServerSnapshot,防止水合差异。

7. 决策树(选型速查)

  • 你是否需要"树内就近覆盖/嵌套覆盖"的作用域?

    • 是 → createContext/Provider。
  • 状态是否来自"树外可变全局源",且更新频繁或由外部事件驱动?

    • 是 → useSyncExternalStore 或采用内置它的状态库(Redux/Zustand/Jotai)。
  • 是否只在局部组件内、短生命周期使用?

    • 是 → useState/useReducer。
  • 想保留 Context,又要按字段订阅降低无关渲染?

    • 用 use-context-selector。
  • 是否有严格的 SSR 与 CSS-in-JS 样式注入顺序要求?

    • 是 → Context(更易保障渲染-样式一致性)。

8. 总结

  • createContext/Provider 解决"树内作用域化配置"的问题,简单、可覆盖、SSR 友好。对于主题、国际化、尺寸/禁用与局部业务配置,应优先选择它,并通过"拆分 Context + useMemo"获得足够的性能。
  • useSyncExternalStore 解决"树外可变源在并发渲染下的一致性(防撕裂)",适用于外部全局状态与环境订阅;若你使用 Redux/Zustand/Jotai 等,它们已在内部适配了 useSyncExternalStore。
  • 在同一项目中,两者通常互补:UI 配置走 Context,业务状态走 useSyncExternalStore(或状态库),局部状态走 useState/useReducer。这是清晰、稳定、可维护的组合策略。
相关推荐
wyiyiyi22 分钟前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip44 分钟前
vite和webpack打包结构控制
前端·javascript
excel1 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国1 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼1 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy2 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT2 小时前
promise & async await总结
前端
Jerry说前后端2 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天2 小时前
A12预装app
linux·服务器·前端
7723892 小时前
解决 Microsoft Edge 显示“由你的组织管理”问题
前端·microsoft·edge