文章

跨组件状态共享方案深度解析

从 Context API 到 provide/inject,从事件总线到状态提升,系统对比 React 和 Vue 中跨组件状态共享的各种方案与适用场景

跨组件状态共享方案深度解析

一句话概括

跨组件共享状态的本质矛盾是:要直接,还是要解耦。props 层层传递最简单但最耦合,Context/provide-inject 跳过了中间层但容易滥用,事件总线最解耦但也最难追踪——不存在银弹,只有「按状态的性质选方案」。

核心知识点

1. props 逐层传递:简单场景的最佳方案

1
App → Dashboard → Panel → Detail → Button

props 逐层传的问题(props drilling):Button 需要一个回调,中间 3 层组件被迫承载了不关心的 props。但它的好处也最明确——数据流完全可见、TypeScript 完美支持、调试时有清晰的追溯链。

判断标准:穿过 2 层以内用 props,超过 2 层考虑升级方案。不需要「为了不用 props 而不用 props」。

2. Context API(React):共享但别滥用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 创建 Context
const ThemeContext = createContext<'light' | 'dark'>('light')

// Provider 包裹需要共享的范围
function App() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  return (
    <ThemeContext.Provider value={theme}>
      <Header />   {/* 深层子组件 */}
      <Content />  {/* 深层子组件 */}
    </ThemeContext.Provider>
  )
}

// 任意深度的子组件都能读到
function DeepChild() {
  const theme = useContext(ThemeContext)
  return <div className={theme}>...</div>
}

Context 的致命问题:Provider 的 value 一变,所有 useContext 的组件都会重渲染——即使它只读了 value 的某个字段。这就是为什么 Context 只适合「低频变化」(主题、语言、用户信息),不适合「高频变化」(表单状态、计数器)。

改进方案:拆成多个 Context(一个 Context 一个字段),或者用 useMemo 稳定 value 引用。

3. provide/inject(Vue):和 Context 类似但天然更优

1
2
3
4
5
6
7
8
9
10
11
<!-- 祖先组件提供 -->
<script setup>
import { provide, ref } from 'vue'
provide('theme', ref('light'))
</script>

<!-- 任意深层子组件注入 -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
</script>

比 Context 更好的点:Vue 的响应式系统让 provide/inject 天然就是「按需更新」——只有用了 inject('theme') 的组件才会在 theme 变化时更新,因为依赖追踪是属性级的。不像 React Context 把整个 Provider 子树都拉下水。

4. 事件总线:极致解耦,但也难以调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 简易事件总线实现(5 行)
class EventBus {
  private listeners = new Map<string, Set<Function>>()
  on(event: string, fn: Function) {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set())
    this.listeners.get(event)!.add(fn)
    return () => this.listeners.get(event)?.delete(fn)  // 返回 unsubscribe
  }
  emit(event: string, data?: any) {
    this.listeners.get(event)?.forEach(fn => fn(data))
  }
}

// 使用
const bus = new EventBus()

// 组件 A(发送方)
bus.emit('notification', { type: 'success', text: '保存成功' })

// 组件 B(接收方,无论在哪一层)
useEffect(() => {
  return bus.on('notification', (data) => showToast(data))
}, [])

事件总线的优势是彻底的解耦——通信双方完全不知道彼此存在。代价也是彻底的不可见:事件流没有类型约束(TypeScript 难做)、没有调用链、调试时找不到「谁发了这个事件」。

适用场景:跨路由的全局通知(toast、消息推送)、WebSocket 消息分发、触发类型的事件型通信(不是持续性的状态共享)。

5. 方案选择决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
你要共享的是什么?
├── 短暂的一次性事件(toast、通知)
│   └── 事件总线 或 全局状态中的 toast store
│
├── 持久但变化少的配置(主题、语言、权限)
│   ├── React: Context(注意拆分避免无谓渲染)
│   └── Vue: provide/inject(天然不触发多余更新)
│
├── 跨路由的共享状态(用户信息、购物车)
│   ├── React: Zustand / Redux(选择器精准订阅)
│   └── Vue: Pinia(setup store 写法)
│
├── 高频变更的 UI 状态(表单临时值、展开/折叠)
│   └── 状态提升到最近的公共祖先组件
│
└── 服务端数据(API 返回的列表、详情)
    └── TanStack Query / SWR / Vue Query(有缓存、有去重、有重新验证)

其实你每天都在用

  1. useContext(ThemeContext) 导致整个页面重渲染:theme 一换,所有使用 Context 的组件都重跑——但你只换了颜色,列表数据根本没变。这就是高频状态不适合 Context 的直观例子。
  2. Vue 的 provide('key', ref(0)) 可以在 devtools 里看到:Vue DevTools 的 Provide / Inject 面板直接展示了祖先提供了什么、后代注入了什么——Context 没有这样的可视化。
  3. 事件总线就是 Node.js 的 EventEmitter 在前端的投影:on/emit/off 三件套学了 Node 就会用——但也继承了 Node 事件模式的缺点:内存泄漏(忘记 off)。
  4. 状态提升(Lifting State Up)是最被低估的方案:逻辑相关的几个组件放到同一个父组件管理状态,比引入状态管理库更简单——很多时候不需要 store,只是状态放错了位置。

常见误解(FAQ)

❌ 误区:「Context 可以做状态管理,不需要 Redux/Zustand」

Context 是依赖注入机制,不是状态管理方案。它没有选择器、没有中间件、没有 DevTools 集成、没有持久化。小项目可以凑合用,但一旦需要「只订阅部分状态」或「跨 React 树外使用状态」,就必须上状态管理库。

❌ 误区:「Vue 的 provide/inject 比 React Context 强,所以 Vue 不需要状态管理库」

provide/inject 解决了数据传递问题,但没解决状态管理的工程问题——测试性、模块化、中间件、持久化。Vue 同样需要 Pinia 来管理跨模块、跨路由的复杂状态。

❌ 误区:「事件总线就是全局变量,不安全」

不是「不安全」,是「不可追踪」。事件是 fire-and-forget 的,没有返回值、没有错误处理、没有类型检查。工程上可以通过严格的命名规范和 TypeScript 事件类型来约束,但天然的不可见性需要靠团队纪律弥补。

一句话总结

跨组件状态共享不存在最优解——状态的性质(事件 vs 值、低频 vs 高频、局部 vs 全局)决定了通信机制的性质,先分类再选型,比「无脑上状态管理库」重要一百倍。

本文由作者按照 CC BY 4.0 进行授权