React 组件通信与插槽完全指南:从 Props、Children 到组件设计实战
React + TypeScript 学习笔记 · 组件通信 · 组件复用 · 由浅入深
前言
学习 React 时,经常会遇到几个问题:
- 父组件如何给子组件传递数据?
- 子组件如何通知父组件修改数据?
- 兄弟组件之间如何共享数据?
- React 有没有类似 Vue 的
slot插槽? props、children、Context分别什么时候使用?
其实,这些问题都属于 React 的两个核心知识点:
组件通信:解决组件之间如何传递数据和事件。
组件插槽:解决组件内部的内容如何由外部决定。
本文使用 React 函数组件和 TypeScript,从最简单的例子逐步深入。
一、先理解 React 组件是什么
在 React 中,组件可以简单理解为:一个返回 JSX 的函数。
例如:
javascript
function UserCard() {
return (
<div>
<h3>张三</h3>
<p>年龄:25</p>
</div>
);
}
然后在另一个组件中使用:
javascript
function App() {
return (
<div>
<UserCard />
<UserCard />
</div>
);
}
这样就相当于复用了两次 UserCard 组件。
但有个问题:两个组件都显示张三。
如果希望不同组件显示不同用户,就需要组件通信。
二、父传子:Props(最基础)
Props 是 React 中最常用的组件通信方式。
一句话理解:
父组件通过属性给子组件传递数据,子组件通过 props 接收。
2.1 最简单的例子
子组件:
typescript
// UserCard.tsx
interface UserCardProps {
name: string;
age: number;
}
function UserCard(props: UserCardProps) {
return (
<div>
<h3>{props.name}</h3>
<p>年龄:{props.age}</p>
</div>
);
}
export default UserCard;
父组件:
javascript
// App.tsx
import UserCard from "./UserCard";
function App() {
return (
<div>
<UserCard name="张三" age={25} />
<UserCard name="李四" age={30} />
</div>
);
}
效果:
核心关系:

需要注意:
name和age是父组件传入的属性。UserCardProps定义属性的 TypeScript 类型。- 子组件只能读取 props,不应该直接修改 props。
2.2 Props 解构写法
上面的写法:
javascript
function UserCard(props: UserCardProps) {
return <h3>{props.name}</h3>;
}
实际开发中更常使用解构:
javascript
function UserCard({ name, age }: UserCardProps) {
return (
<div>
<h3>{name}</h3>
<p>年龄:{age}</p>
</div>
);
}
这里的:
{ name, age }: UserCardProps
是 JavaScript 的对象解构,加上 TypeScript 类型声明。
2.3 Props 可以传递什么?
不仅能传递字符串、数字,还可以传递对象、数组、函数。
typescript
interface User {
id: number;
name: string;
}
interface UserCardProps {
user: User;
tags: string[];
active: boolean;
}
function UserCard({ user, tags, active }: UserCardProps) {
return (
<div>
<h3>{user.name}</h3>
<p>{active ? "在线" : "离线"}</p>
{tags.map(tag => (
<span key={tag}>{tag}</span>
))}
</div>
);
}
父组件:
ini
<UserCard
user={{ id: 1, name: "张三" }}
tags={["管理员", "开发者"]}
active={true}
/>
小结:Props 负责把数据从父组件传给子组件,是 React 最基础的通信方式。
三、子传父:回调函数(重点)
现在假设有一个需求:
父组件维护任务列表,子组件展示任务。
用户点击子组件中的"删除"按钮,希望父组件删除对应任务。
问题是:子组件怎么通知父组件?
React 通常使用回调函数实现。
核心思想:
父组件把函数传给子组件,子组件调用这个函数,把信息传回父组件。
3.1 基础例子
子组件:
typescript
interface ChildProps {
onMessage: (message: string) => void;
}
function Child({ onMessage }: ChildProps) {
return (
<button onClick={() => onMessage("你好,父组件!")}>
发送消息
</button>
);
}
父组件:
javascript
import { useState } from "react";
function Parent() {
const [message, setMessage] = useState("");
const handleMessage = (msg: string) => {
setMessage(msg);
};
return (
<div>
<h3>收到消息:{message}</h3>
<Child onMessage={handleMessage} />
</div>
);
}
交互演示
父组件 Parent
收到消息:暂无消息
子组件 Child
点击向父组件发送消息
整个流程:
scss
父组件 Parent
│
│ 传递 handleMessage 函数
▼
子组件 Child
│
│ 用户点击按钮
│
│ onMessage("你好")
▼
父组件 handleMessage()
│
│ setMessage()
▼
父组件重新渲染
这里需要理解:
typescript
onMessage: (message: string) => void;
表示 onMessage 是一个函数类型,接收 string 参数,返回 void。
3.2 实战:子组件删除父组件中的任务
这是一个很典型的业务场景。
typescript
import { useState } from "react";
interface Task {
id: number;
name: string;
}
interface TaskItemProps {
task: Task;
onDelete: (id: number) => void;
}
// 子组件
function TaskItem({ task, onDelete }: TaskItemProps) {
return (
<div>
<span>{task.name}</span>
<button onClick={() => onDelete(task.id)}>
删除
</button>
</div>
);
}
// 父组件
function TaskList() {
const [tasks, setTasks] = useState<Task[]>([
{ id: 1, name: "视频回查" },
{ id: 2, name: "文章回扫" }
]);
const handleDelete = (id: number) => {
setTasks(prev => prev.filter(task => task.id !== id));
};
return (
<div>
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onDelete={handleDelete}
/>
))}
</div>
);
}
这里同时存在两种通信:
task={task}:父组件向子组件传数据。onDelete={handleDelete}:父组件向子组件传回调,子组件通过调用它通知父组件。
子传父本质上并不是子组件直接修改父组件的数据,而是子组件调用父组件提供的函数。
四、兄弟组件通信:状态提升
假设有两个组件:
markdown
Parent
/ \
Search UserList
Search:输入搜索关键词。UserList:根据关键词展示用户。
它们是兄弟组件,应该怎么通信?
最常见的方案是:把共享状态放在共同的父组件中。
这叫做 Lifting State Up(状态提升)。
4.1 完整示例
typescript
import { useState } from "react";
interface SearchProps {
keyword: string;
onKeywordChange: (value: string) => void;
}
function Search({ keyword, onKeywordChange }: SearchProps) {
return (
<input
value={keyword}
onChange={e => onKeywordChange(e.target.value)}
placeholder="搜索用户"
/>
);
}
interface UserListProps {
keyword: string;
}
function UserList({ keyword }: UserListProps) {
const users = ["张三", "李四", "王五"];
return (
<ul>
{users
.filter(name => name.includes(keyword))
.map(name => (
<li key={name}>{name}</li>
))}
</ul>
);
}
function Parent() {
const [keyword, setKeyword] = useState("");
return (
<div>
<Search
keyword={keyword}
onKeywordChange={setKeyword}
/>
<UserList keyword={keyword} />
</div>
);
}
交互演示

核心思路:
markdown
Search 输入关键词
│
▼
Parent 更新 keyword
│
▼
UserList 接收新 keyword
│
▼
列表重新渲染
记住:兄弟组件通常不直接互相通信,而是通过共同父组件共享状态。
五、React 插槽:children(重点)
前面讲的都是组件之间传递数据。
现在换一个需求。
假设你要开发一个公共弹窗组件 Modal,但是每个弹窗展示的内容都不一样。
例如:
- 删除弹窗:展示"确定删除吗?"
- 用户弹窗:展示用户信息。
- 任务弹窗:展示任务执行详情。
难道每个弹窗都要重新写一遍吗?
不需要。
这就引出了**插槽(Slot)**的概念。
5.1 什么是插槽?
一句话理解:
插槽就是组件预留一个位置,允许外部组件把内容放进来。
React 没有完全对应 Vue slot 的内置语法,最常见的实现方式是 props.children。
先看一个例子:
typescript
import type { ReactNode } from "react";
interface CardProps {
children: ReactNode;
}
function Card({ children }: CardProps) {
return (
<div className="card">
{children}
</div>
);
}
使用:
javascript
function App() {
return (
<Card>
<h2>用户信息</h2>
<p>姓名:张三</p>
<p>年龄:25</p>
</Card>
);
}
渲染效果:
用户信息

这里:
xml
<Card>
<h2>用户信息</h2>
<p>姓名:张三</p>
</Card>
<Card> 标签中间的内容,会作为 children 传递给 Card 组件。
相当于:
xml
<Card
children={
<>
<h2>用户信息</h2>
<p>姓名:张三</p>
</>
}
/>
其中 <>...</> 是 React Fragment,用来组合多个元素,不额外创建 DOM 节点。
插槽原理

Card 组件
children 插入的位置
<UserInfo />
你可以把 children 理解成:
父组件把一块 JSX 内容交给子组件,子组件决定这块内容放在哪里。
5.2 children 可以传入什么?
children 不局限于 HTML 标签,也可以是字符串、数字、组件或多个 JSX 元素。
例如:
xml
<Card>你好</Card>
xml
<Card>
<UserInfo />
</Card>
xml
<Card>
<h2>标题</h2>
<TaskList />
<button>提交</button>
</Card>
在 TypeScript 中,通常使用:
typescript
import type { ReactNode } from "react";
interface CardProps {
children: ReactNode;
}
ReactNode 是能够作为 React 渲染内容的类型。
5.3 React 插槽和 Vue 插槽对比
如果以后看到 Vue 的代码,可以这样理解:
Vue
xml
<!-- Card.vue -->
<template>
<div>
<slot />
</div>
</template>
使用:
xml
<Card>
<h2>用户信息</h2>
</Card>
React
javascript
function Card({ children }) {
return (
<div>
{children}
</div>
);
}
使用:
xml
<Card>
<h2>用户信息</h2>
</Card>
两者思想相似:
Vue 使用 <slot />,React 通常使用 {children}。
六、具名插槽:多个位置如何传入不同内容?
children 适合一个主要内容区域。
但有时候,一个组件需要多个插槽。
例如一个页面布局:

如果只有 children,怎么区分 Header、Sidebar 和 Footer?
React 常见的解决方式是:使用普通 props 接收不同位置的 ReactNode。
6.1 自定义多个插槽
css
import type { ReactNode } from "react";
interface LayoutProps {
header: ReactNode;
sidebar: ReactNode;
children: ReactNode;
footer?: ReactNode;
}
function Layout({
header,
sidebar,
children,
footer
}: LayoutProps) {
return (
<div>
<header>{header}</header>
<div className="layout-body">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
<footer>{footer}</footer>
</div>
);
}
使用:
javascript
function App() {
return (
<Layout
header={<h1>任务管理平台</h1>}
sidebar={<TaskMenu />}
footer={<p>Copyright 2026</p>}
>
<TaskList />
</Layout>
);
}
这里的:
css
header={<h1>任务管理平台</h1>}
就是把一段 JSX 作为属性传递给组件。
同理:
ini
sidebar={<TaskMenu />}
表示把 TaskMenu 组件的 React 元素传给 sidebar。
6.2 什么时候使用 children,什么时候使用具名插槽?
| 场景 | 推荐方式 |
|---|---|
| 卡片主要内容 | children |
| 弹窗主体 | children |
| 顶部标题 | title 或 header |
| 底部操作按钮 | footer |
| 侧边栏 | sidebar |
| 多个可定制区域 | 多个 ReactNode props |
核心:React 的具名插槽通常就是把 JSX 当作 props 传递。
七、进阶插槽:Render Props
你前面学习过 renderItem,其实它就是 Render Props 思想的典型应用。
这个概念的难点就一句话:普通插槽传的是 "现成的内容",Render Props 传的是 "一个函数"------ 数据还在子组件手里,但怎么画由你(父组件)决定。
普通插槽:
xml
<Card>
<h2>固定内容</h2>
</Card>
这里传入的是现成的 JSX 内容。
但如果子组件希望把内部的数据传给外部,让外部决定怎么渲染呢?
这时可以使用 Render Props。
7.1 最简单的 Render Props
typescript
import type { ReactNode } from "react";
interface User {
id: number;
name: string;
}
interface UserProviderProps {
render: (user: User) => ReactNode;
}
function UserProvider({ render }: UserProviderProps) {
const user: User = {
id: 1,
name: "张三"
};
return <div>{render(user)}</div>;
}
父组件:
javascript
function App() {
return (
<UserProvider
render={(user) => (
<h2>用户名:{user.name}</h2>
)}
/>
);
}
关键是:
javascript
render={(user) => (
<h2>{user.name}</h2>
)}
这次传入的不是 JSX,而是一个返回 JSX 的函数。
子组件内部执行:
scss
render(user)
把自己的数据传给父组件提供的渲染函数。
7.2 Render Props 和普通 children 的区别
| 对比 | children | Render Props |
|---|---|---|
| 传入内容 | JSX 元素 | 函数 |
| 能否接收子组件提供的数据 | 不能直接通过该元素接收 | 可以 |
| 典型写法 | <Card>内容</Card> |
render={(item) => ...} |
| 适用场景 | 布局、容器 | 列表渲染、数据驱动的内容定制 |
7.3 renderItem 实战
例如自己封装一个通用列表:
typescript
import type { ReactNode } from "react";
interface ListProps<T extends { id: number }> {
data: T[];
renderItem: (item: T, index: number) => ReactNode;
}
function MyList<T extends { id: number }>({
data,
renderItem
}: ListProps<T>) {
return (
<div>
{data.map((item, index) => (
<div key={item.id}>
{renderItem(item, index)}
</div>
))}
</div>
);
}
调用:
ini
const tasks = [
{ id: 1, name: "视频回查" },
{ id: 2, name: "文章回扫" }
];
<MyList
data={tasks}
renderItem={(item, index) => (
<div>
{index + 1}. {item.name}
</div>
)}
/>
可以理解为:
scss
data.map((item, index) => renderItem(item, index))
这就是 Render Props 最核心的思想:组件提供数据,外部提供渲染方式。
八、跨层级组件通信:Context
前面介绍的 Props 很适合父子组件之间通信。
但是假设组件结构是这样的:
css
App
└── Page
└── Layout
└── Header
└── UserAvatar
现在 App 中保存了用户信息,而 UserAvatar 需要读取它。
使用 Props 就需要:
css
App → Page → Layout → Header → UserAvatar
每个中间组件都要传一遍 user。
这种情况叫做 Props Drilling(属性逐层透传)。
React 提供了 Context 来解决这类问题。
8.1 Context 是什么?
一句话理解:
Context 允许组件共享数据,让深层子组件不必经过每一层 Props 传递,就能读取上层提供的值。
例如:
css
App
│
│ 提供 UserContext
▼
Page
│
Layout
│
Header
│
▼
UserAvatar
直接读取 Context
8.2 最简单的 Context 示例
javascript
import { createContext, useContext } from "react";
// 1. 创建 Context
const UserContext = createContext("游客");
// 2. 深层子组件
function UserAvatar() {
const username = useContext(UserContext);
return <div>当前用户:{username}</div>;
}
// 3. 中间组件
function Header() {
return <UserAvatar />;
}
// 4. 父组件
function App() {
return (
<UserContext.Provider value="张三">
<Header />
</UserContext.Provider>
);
}
最终显示:
当前用户:张三
注意:
ini
const username = useContext(UserContext);
即使没有通过 Props 一层层传递,UserAvatar 仍然能读取到上层 Provider 提供的值。
这里使用兼容 React 18 的 Provider 写法,React 19 也支持直接使用 <UserContext value="张三">。
8.3 Context 能结合 useState 使用吗?
当然可以,而且非常常见。
例如实现全局主题切换:
javascript
import {
createContext,
useContext,
useState,
type ReactNode
} from "react";
interface ThemeContextType {
theme: "light" | "dark";
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = () => {
setTheme(prev => prev === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function ThemeButton() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("ThemeButton 必须在 ThemeProvider 内使用");
}
return (
<button onClick={context.toggleTheme}>
当前主题:{context.theme}
</button>
);
}
function App() {
return (
<ThemeProvider>
<ThemeButton />
</ThemeProvider>
);
}
交互演示
主题设置

Context 的主要优势:
- 减少多层 Props 透传。
- 适合主题、当前用户、语言等跨层共享数据。
- 配合
useState或useReducer可以共享状态及更新方法。
但要注意:Context 不代表所有状态都应该放在全局。 如果数据只被父子两个组件使用,直接用 Props 往往更简单。
九、进阶:受控组件与双向交互
现在来看一个非常常见的组件设计模式。
假设你封装一个搜索框,希望:
- 父组件控制搜索框中的文本。
- 子组件展示该文本。
- 用户输入时通知父组件。
- 父组件更新状态,再传回子组件。
这叫做受控组件模式。
typescript
import { useState } from "react";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
}
// 子组件
function SearchInput({ value, onChange }: SearchInputProps) {
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
);
}
// 父组件
function App() {
const [keyword, setKeyword] = useState("");
return (
<div>
<SearchInput
value={keyword}
onChange={setKeyword}
/>
<p>搜索关键词:{keyword}</p>
</div>
);
}
执行流程:
scss
父组件 App
│
value={keyword}
│
▼
SearchInput
│
用户输入内容
│
onChange(newValue)
│
▼
setKeyword()
│
父组件重新渲染
│
▼
新 value 传回子组件
看起来像"双向绑定",但 React 的数据流本质仍然是单向的:
- 数据通过 Props 向下传递。
- 事件通过回调向上传递。
这种 value + onChange 模式在 React 组件库中非常普遍。
十、组件通信常见错误
10.1 子组件直接修改 Props
错误:
javascript
function Child({ user }: { user: User }) {
user.name = "李四"; // ❌ 不应该修改 Props 对象
return <div>{user.name}</div>;
}
正确方式是让父组件更新状态,再将新数据传给子组件。
typescript
interface ChildProps {
user: User;
onChangeName: (name: string) => void;
}
function Child({ user, onChangeName }: ChildProps) {
return (
<button onClick={() => onChangeName("李四")}>
修改 {user.name}
</button>
);
}
10.2 错把函数调用结果传给 onClick
错误:
xml
<button onClick={handleDelete(1)}>
删除
</button>
这样会在渲染时执行函数,而不是点击时执行(假设 handleDelete 不是返回事件处理函数的高阶函数)。
正确:
scss
<button onClick={() => handleDelete(1)}>
删除
</button>
10.3 混淆 children 和 renderItem
javascript
// 普通插槽:直接传 JSX
<Card>
<UserInfo />
</Card>
// Render Props:传函数
<MyList
data={users}
renderItem={(user) => <UserCard user={user} />}
/>
它们的核心区别就是:children 通常传现成的内容,renderItem 传生成内容的函数。
10.4 在多个兄弟组件中重复维护同一份状态
如果两个组件需要始终展示同一个状态,通常不要让它们各自维护独立副本。
更推荐把状态提升到共同父组件,确保有一个明确的数据来源。
十一、组件通信方式如何选择?
这是实际开发时最需要掌握的一张表。
| 场景 | 推荐方案 |
|---|---|
| 父组件向子组件传数据 | Props |
| 子组件通知父组件 | 回调函数 |
| 兄弟组件共享状态 | 状态提升 |
| 跨多层组件共享数据 | Context |
| 组件传入自定义内容 | children |
| 一个组件有多个内容区域 | 具名 ReactNode Props |
| 子组件提供数据,外部决定如何渲染 | Render Props |
| 复杂跨页面全局状态 | Context 或专门的状态管理库 |
如果项目更加复杂,还可能接触:
- Zustand:轻量全局状态管理。
- Redux Toolkit:适合需要集中管理复杂状态的项目。
- TanStack Query:侧重服务端数据获取、缓存与同步。
- 自定义 Hook:复用状态逻辑,而不一定共享同一份状态。
它们并不是 Props 和 Context 的替代品,而是针对不同问题的工具。
十二、综合实战:设计一个可复用的任务卡片
最后将 Props、回调函数、children 和 TypeScript 组合起来。
目标:封装一个任务卡片,支持自定义内容和删除操作。
typescript
import type { ReactNode } from "react";
interface Task {
id: number;
name: string;
status: string;
}
interface TaskCardProps {
task: Task;
children?: ReactNode;
footer?: ReactNode;
onDelete: (id: number) => void;
}
function TaskCard({
task,
children,
footer,
onDelete
}: TaskCardProps) {
return (
<div className="task-card">
<h3>{task.name}</h3>
<p>状态:{task.status}</p>
<div className="task-content">
{children}
</div>
<div className="task-footer">
{footer}
<button onClick={() => onDelete(task.id)}>
删除
</button>
</div>
</div>
);
}
父组件使用:
javascript
import { useState } from "react";
function App() {
const [tasks, setTasks] = useState<Task[]>([
{ id: 1, name: "视频回查", status: "running" },
{ id: 2, name: "文章回扫", status: "pending" }
]);
const handleDelete = (id: number) => {
setTasks(prev => prev.filter(task => task.id !== id));
};
return (
<div>
{tasks.map(task => (
<TaskCard
key={task.id}
task={task}
onDelete={handleDelete}
footer={<button>查看详情</button>}
>
<p>任务 ID:{task.id}</p>
</TaskCard>
))}
</div>
);
}
在这个例子中:
task={task}:父传子。onDelete={handleDelete}:子组件通过回调通知父组件。children:内容插槽。footer:具名插槽。useState:父组件管理数据。map:动态生成任务卡片。
一个可复用的 React 组件,基本就是这样设计出来的。
十三、总结:掌握这五个概念就够了

React 最核心的设计思想有两个:
第一,数据单向流动。 父组件通过 Props 向子组件传递数据,子组件通过回调函数通知父组件。
第二,组合优于继承。 通过 children、具名插槽和 Render Props,把组件组合成更复杂的页面,而不是依靠复杂的继承关系。
建议学习顺序:Props → 回调函数 → 状态提升 → children → Render Props → Context。
参考官方文档:React Passing Props、React Sharing State、React useContext。