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。这是清晰、稳定、可维护的组合策略。
相关推荐
带着梦想扬帆启航14 小时前
UniApp 多个异步开关控制教程
前端·javascript·uni-app
小高00715 小时前
JavaScript 内存管理是如何工作的?
前端·javascript
是大林的林吖15 小时前
解决 elementui el-cascader组件懒加载时存在选中状态丢失的问题?
前端·javascript·elementui
鹏仔工作室15 小时前
elemetui中el-date-picker限制开始结束日期只能选择当月
前端·vue.js·elementui
一 乐15 小时前
个人博客|博客app|基于Springboot+微信小程序的个人博客app系统设计与实现(源码+数据库+文档)
java·前端·数据库·spring boot·后端·小程序·论文
sTone8737515 小时前
Android Room部件协同使用
android·前端
晴殇i15 小时前
前端代码规范体系建设与团队落地实践
前端·javascript·面试
用户740546399430915 小时前
Vite 库模式输出 ESM 格式时的依赖处理方案
前端·vite
开发者小天15 小时前
React中使用useParams
前端·javascript·react.js
lichong95115 小时前
Android studio release 包打包配置 build.gradle
android·前端·ide·flutter·android studio·大前端·大前端++