第 06 课 · 组件通信
Vue 的通信工具箱是 props / emit / v-model / provide-inject / defineExpose / Pinia;React 没有这么多关键字,但每一项都有明确对应物,核心只有一句话:数据往下走靠 props,往上走靠回调函数 props,跨层靠 Context,命令式兜底靠 ref。
🎯 本课目标
- 画出 React 通信方案全景图,给「父子 / 跨层 / 兄弟 / 全局」四类场景选对工具
- 用回调 props 实现子传父,理解它就是
defineEmits + $emit的对应物 - 三步搭好 Context(createContext / Provider / useContext),并用拆分 Context 控制重渲染粒度
- 会用
useImperativeHandle暴露子组件方法,知道 React 19 可以把 ref 当普通 prop 传 - 树立企业态度:能 props/回调解决的就不用 Context,能用 Context 的就不用 ref
1. 通信方案全景图
Vue 里你这样写
| 场景 | Vue 工具 |
|---|---|
| 父 → 子 | props |
| 子 → 父 | defineEmits + $emit |
| 跨层 | provide / inject |
| 兄弟 | 状态提升到共同父组件 / Pinia |
| 全局 | Pinia |
React 里这样写
| 场景 | React 工具 | 对应 Vue |
|---|---|---|
| 父 → 子 | props(数据、JSX、组件都可以传) | props |
| 子 → 父 | 回调函数 props(onXxx) |
emit |
| 跨层 | Context + useContext |
provide / inject |
| 兄弟 | 状态提升(lift state up) | 同 Vue |
| 全局 | Zustand(第 10 课) / TanStack Query(第 11 课) | Pinia |
关键差异与注意点
- React 没有 emit 这套机制:事件就是「父传下来的函数,子去调用」,本质仍是 props。
- 插槽也是 props (
children/ 传 JSX),Vue 里slot的灵活度在 React 里全靠「把 JSX 当参数」,这是第 2 课的内容。 - 选型原则从上往下试:先 props → 不够再状态提升 → 跨层太多上 Context → 全局高频变化才上状态库。跳级直接上 Zustand 是常见过度设计。
2. 子传父 = 回调函数 props(对照 defineEmits)
Vue 里你这样写
vue
<!-- 子组件 TodoItem.vue -->
<script setup lang="ts">
defineProps<{ todo: { id: number; text: string; done: boolean } }>()
const emit = defineEmits<{ toggle: [id: number]; remove: [id: number] }>()
</script>
<template>
<input type="checkbox" :checked="todo.done" @change="emit('toggle', todo.id)" />
<button @click="emit('remove', todo.id)">删除</button>
</template>
<!-- 父组件:<TodoItem :todo="t" @toggle="handleToggle" @remove="handleRemove" /> -->
React 里这样写
tsx
import { useState } from 'react'
interface Todo { id: number; text: string; done: boolean }
// 子组件:只负责展示 + 上报,自己从不改数据
function TodoItem({ todo, onToggle, onRemove }: {
todo: Todo
onToggle: (id: number) => void
onRemove: (id: number) => void
}) {
return (
<li>
<input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
{todo.text}
<button onClick={() => onRemove(todo.id)}>删除</button>
</li>
)
}
// 父组件:数据集中在这里,回调负责改
export default function TodoList() {
const [todos, setTodos] = useState<Todo[]>([{ id: 1, text: '学 Context', done: false }])
function handleToggle(id: number) {
setTodos(prev => prev.map(t => (t.id === id ? { ...t, done: !t.done } : t)))
}
function handleRemove(id: number) {
setTodos(prev => prev.filter(t => t.id !== id))
}
return (
<ul>
{todos.map(t => (
<TodoItem key={t.id} todo={t} onToggle={handleToggle} onRemove={handleRemove} />
))}
</ul>
)
}
关键差异与注意点
- 回调命名铁律
onXxx:onToggle/onRemove/onChange/onSubmit。约定成俗到 Ant Design 全库如此,写成toggleClick、del会被 review 打回。 - 子组件永远不自己改数据(props 是只读的,改了也不生效),只调用回调「上报意图」,改数据集中在父组件 ------ 和 Vue 的单向数据流一模一样。
emit('toggle', todo.id)→onToggle(todo.id),参数一模一样,心智可平移。- 需要反向传数据给子组件时(受控子组件),把 state 和
onXxx一起传下去,就是「受控模式」------上一课的表单就是这个原理。
3. Context + useContext(对照 provide / inject)
Vue 里你这样写
ts
// 祖先组件
provide('theme', { theme, toggleTheme })
// 任意后代
const { theme, toggleTheme } = inject('theme')
React 里这样写(三步)
tsx
import { createContext, useContext, useState, type ReactNode } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextValue { theme: Theme; toggleTheme: () => void }
// ① 创建:单独放 theme-context.ts,谁都用得到
export const ThemeContext = createContext<ThemeContextValue | null>(null)
// 企业习惯:包一层自定义 hook,没包 Provider 时立刻抛错,而不是拿到 null 悄悄崩
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme 必须在 ThemeProvider 内使用')
return ctx
}
// ② 提供方:value 变化时,所有消费组件都会重渲染
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light')
return (
<ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => (t === 'light' ? 'dark' : 'light')) }}>
{children}
</ThemeContext.Provider>
)
}
// ③ 任意深度的后代消费,中间层完全不用接 props
function ThemedButton() {
const { theme, toggleTheme } = useTheme()
return <button onClick={toggleTheme}>{theme === 'dark' ? '深色' : '浅色'}</button>
}
关键差异与注意点
- 三步记牢:createContext → Provider 包住子树 → useContext 消费。中间隔多少层都无所谓,中间层零改动。
- Context 的性能模型和 Vue 不同 :Provider 的
value引用一变,所有消费组件全部重渲染 (用Object.is比较)。Vue 的 provide/inject 靠响应式按字段追踪,粒度更细。所以 React 里要么 value 用原始值、要么拆分 Context、要么记忆化(下一节)。 - 拆分 Context(数据与更新分开) :
StateContext放数据、ActionsContext放方法包(useMemo固定引用)。只需要改数据的组件(按钮)就不会因为数据变化而重渲染。 - 何时不该用 Context :高频变化的全局数据(输入框内容、鼠标位置、每秒轮询的行情)会让大量消费组件跟着抖动,这类放 Zustand(store 订阅粒度可控)或 TanStack Query;Context 适合低频的横切关注点:主题、语言、当前登录用户、权限。
- 默认值
createContext(null)+ 自定义 hook 抛错,是企业标准写法:比「忘了包 Provider 还能跑出诡异默认值」好查得多。
4. useImperativeHandle:父调用子的方法(对照 defineExpose)
Vue 里你这样写
vue
<!-- 子组件 -->
<script setup lang="ts">
function focus() { /* ... */ }
function validate(): string | null { return null }
defineExpose({ focus, validate })
</script>
<!-- 父组件:searchRef.value?.focus() -->
React 里这样写
tsx
import { forwardRef, useImperativeHandle, useRef, useState } from 'react'
interface SearchBoxHandle {
focus: () => void
clear: () => void
validate: () => string | null
}
// 经典写法(React ≤18 标准,存量代码大量存在)
const SearchBox = forwardRef<SearchBoxHandle>(function SearchBox(_props, ref) {
const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
// 不写它,父拿到的是 DOM;写了,父拿到的是这个方法包
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => { setValue(''); inputRef.current?.focus() },
validate: () => (value.trim() ? null : '搜索词不能为空'),
}))
return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} />
})
// React 19:ref 就是普通 prop,不用再包 forwardRef
function SearchBox19({ ref }: { ref?: React.Ref<SearchBoxHandle> }) {
const inputRef = useRef<HTMLInputElement>(null)
useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus() }))
return <input ref={inputRef} />
}
关键差异与注意点
useImperativeHandle(ref, () => 方法包)让ref.current变成你定义的方法集合 ,而不是 DOM 节点------这就是defineExpose的对应物,能暴露什么由子组件说了算。- React 19 变化 :函数组件的
ref可以直接当 prop 接收({ ref }: { ref?: Ref<Handle> }),不再需要forwardRef。存量代码里forwardRef仍然到处都是,两种都要看得懂。 - 企业态度:能 props / 回调解决的,就别用 ref 调方法 。命令式调用绕过了单向数据流,让「谁改了什么」变难追踪。合理场景:聚焦输入框、滚动定位、播放/暂停视频、表单的
validate()/submit()。 - 第三参数是依赖数组(同 useEffect 语义),方法包里用到响应式值时按需填写,默认每次渲染重建也无妨。
5. 兄弟通信 = 状态提升
Vue 里你这样写
两个兄弟组件要共享数据时,你会把状态放到共同父组件里,再分别用 props/emit 传下去------Pinia 出现前就是这么干的。
React 里这样写
tsx
import { useState } from 'react'
function FilterBar({ keyword, onChange }: { keyword: string; onChange: (v: string) => void }) {
return <input value={keyword} onChange={e => onChange(e.target.value)} />
}
function UserList({ keyword }: { keyword: string }) {
return <p>按「{keyword}」筛选(列表自己不发请求,等父组件处理)</p>
}
export default function UserPage() {
// 状态提升:keyword 放在两个兄弟的共同父级
const [keyword, setKeyword] = useState('')
return (
<>
<FilterBar keyword={keyword} onChange={setKeyword} />
<UserList keyword={keyword} />
</>
)
}
关键差异与注意点
- 兄弟之间不直接通信,共同父组件持有关键状态,一路 props 下去、回调收上来------和 Vue 的思路完全一致。
- 状态提升会让中间层多出「搬运 props」的代码,层数深了就是 props 钻井;两条出路:Context (跨层共享)或 children 组合(把组件当参数传,让中间层不必认识那些 props,第 7 课细讲)。
- 提升到哪一层?「所有需要读写它的组件的最小公共祖先」,别一上来就提到 App 根组件。
📊 Vue ↔ React 对照速查表
| Vue 3 写法 | React 写法 | 一句话差异 |
|---|---|---|
defineProps |
参数解构 ({ todo }) / props 对象 |
一样是父传子 |
defineEmits + emit('remove', id) |
回调 props onRemove(id) |
emit 变成「调用父传下来的函数」 |
@remove="handleRemove" |
onRemove={handleRemove} |
监听和传值是同一件事 |
provide('theme', v) |
<ThemeContext.Provider value={v}> |
提供方包住子树 |
inject('theme') |
useContext(ThemeContext) |
任意后代消费 |
defineExpose({ focus }) |
useImperativeHandle(ref, () => ({ focus })) |
父拿子的方法包 |
ref 拿子实例 |
useRef<Handle>(null) + ref.current?.focus() |
React 19 里 ref 可当普通 prop |
| 兄弟通信:状态提升 / Pinia | 状态提升 / Zustand | 思路一致 |
| 全局状态 Pinia | Zustand(第 10 课) | 后面专门讲 |
slot |
children / JSX props |
插槽即参数 |
🏢 企业实战要点
- 回调命名
onXxx铁律 :内部处理函数handleXxx,对外暴露的回调onXxx。一个组件里onChange既是原生事件名又是自定义回调,注意别混淆------企业里常用onValueChange这类名字错开。 - Provider 的 value 必须记忆化 :
value={{ theme, setUser }}这种内联对象每次渲染都是新引用,消费组件全体陪跑。数据放原始值/拆分 Context,方法包用useMemo(第 7 课),review 必查。 - Context 文件单独放 :
context/user-context.ts放createContext+UserProvider+useUser(),业务组件只 import hook,不 import Context 本体------避免任意组件拿到 Context 乱塞数据。 - 避免 props 钻井的两条路:横切数据(主题/用户/权限)用 Context;纯结构复用(布局、卡片壳)用 children 组合把「未知的下层」当参数传进来。别用「层层透传 + 中间层改别名」糊。
- 拆 Context 的粒度:按「变化的频率和消费方」拆,不是按业务模块拆。变化快的(表单草稿)和几乎不变的(配置、翻译文案)绝不放同一个 Context。
useImperativeHandle少用:出现在 code review 里时,先问「为什么不用 props/回调」。真正合理的场景集中在焦点、滚动、媒体控制、表单命令式方法。- 组件 props 类型显式 interface :回调函数的参数和返回值必须标全(
onRemove: (id: number) => void),不标类型的高级组件在 TS 严格模式下会传染出一片any。 - 真实坑 :在子组件里直接改 props 对象属性(
props.todo.done = true)不报错但不生效;父组件重建value对象导致下级疯狂重渲染;忘写key导致兄弟组件状态串位。
💼 面试高频
1. React 组件之间有哪些通信方式? 父子用 props 和回调 props;跨层用 Context(createContext + Provider + useContext);兄弟用状态提升;复杂全局状态用 Zustand/Redux,服务端状态用 TanStack Query;兜底的命令式通信用 useImperativeHandle。另外 JSX 本身可以当 props 传(children 组合),能减少很多透传。
2. Context 有什么性能问题?怎么规避? Provider 的 value 引用变化会让所有消费组件重渲染,且无法按字段细粒度订阅。规避:value 用原始值;拆分 Context(数据与更新分开,方法包用 useMemo 固定引用);把状态下放缩小 Provider 范围;高频变化的数据改用 Zustand 或 Query 等外部 store(订阅粒度可控)。
3. useImperativeHandle 的作用和使用时机? 它把 ref.current 从「DOM 节点」替换成你定义的方法包,实现父调用子方法(Vue 的 defineExpose 对应物)。时机:只在真正需要命令式操作时用------聚焦、滚动、播放控制、表单 validate/submit。能用 props 和回调表达的状态变化就不该用它,否则数据流不可追踪。
4. React 的 Context 和 Vue 的 provide/inject 有什么差异? 用法几乎一一对应(createContext/Provider/useContext 对 provide/inject)。差异在更新粒度:Vue 基于响应式系统,inject 的对象字段变化能精确更新用到它的地方;React 以「value 的引用是否变化」为单位,value 引用一变全部消费者重渲染,所以 React 里要自己设计拆分和记忆化。
5. React 19 在组件通信上有什么新特性? 函数组件的 ref 可以作为普通 prop 接收和传递,不再需要 forwardRef 包裹,也更容易在自定义 hook 里透传;同时 ref 作为 prop 后清理逻辑更统一。存量代码里 forwardRef 仍是主流写法,新代码可以逐步切换。
📌 小结 + 下节预告
这一课把 Vue 的通信全家桶翻译完毕:emit → 回调 props(命名 onXxx),provide/inject → Context 三步(注意 value 引用与拆分),defineExpose → useImperativeHandle(React 19 里 ref 直接当 prop),兄弟通信依旧是状态提升。选型顺序:props → 提升 → Context → 状态库,命令式 ref 只做兜底。
但 Demo 3 已经埋了伏笔:为什么 Provider 的 value 要 useMemo、为什么传给子组件的函数要 useCallback、为什么按钮组件「只用了 setCount」也会重渲染?下一课讲 React 性能优化三件套:memo / useMemo / useCallback------没有 Vue 那套自动依赖追踪之后,你需要手动管理「引用稳定性」,这是企业 code review 必查项。