React 基础体系 · 第 4/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 组件与 Props:纯渲染、组合、Children 和 API 契约
React 组件最核心的职责,是把输入映射为用户界面:
其中:
props是父组件传入的输入;state是组件自身管理的状态;context是通过 Context 提供的环境输入;F是组件的渲染逻辑;UI是 React 元素树,而不是已经创建好的 DOM。
这条公式不是说组件必须没有状态,而是说明:一次渲染应该根据当次可见输入计算结果,不能依赖渲染过程中的隐藏副作用。Props、纯渲染、组合和 children,都可以看成这条关系在组件 API 设计中的不同表现。
一、先区分组件、元素、DOM 和 Fiber
1. 组件是用于计算 UI 的函数或类
现代 React 中最常见的是函数组件:
type GreetingProps = {
name: string;
};
export function Greeting({ name }: GreetingProps) {
return <h1>Hello, {name}</h1>;
}
Greeting 是组件定义。它本身不是页面节点,也不是 <h1> DOM 元素。
当 JSX 写成:
<Greeting name="Ada" />
JSX 会创建一个描述组件调用的 React element。可以把它抽象成:
{
type: Greeting,
props: {
name: "Ada"
}
}
实际对象包含内部字段,不能依赖其完整结构;上面的形式只用于理解。
React 随后根据这个 element 调用 Greeting,得到:
<h1>Hello, Ada</h1>
React 再继续处理这个 <h1> element,并在提交阶段创建或更新真实 DOM。
因此存在三层不同概念:
| 概念 | 作用 |
|---|---|
| 组件 | 计算 UI 的代码 |
| React element | 描述“要渲染什么”的不可变对象 |
| DOM 节点 | 浏览器中的实际节点 |
Fiber 是 React 内部用于保存工作单元、组件状态、父子关系和更新信息的数据结构。组件函数并不会直接操作 Fiber;组件返回 element,React 内部再通过 Fiber 完成协调和提交。
2. JSX 标签大小写决定含义
小写标签表示宿主元素:
<button>Save</button>
大写标签表示 JavaScript 中的组件变量:
<Button>Save</Button>
下面的代码不会调用自定义的 button 变量,而会被当作 HTML 标签:
function button() {
return <div />;
}
// 这里是原生 button,而不是上面的函数
<button />
组件名称使用大写,不只是风格约定,而是 JSX 语法区分宿主元素和组件的机制。
二、Props 是组件的输入,也是组件 API 的契约
1. Props 从父组件流向子组件
type UserCardProps = {
name: string;
email: string;
disabled?: boolean;
};
export function UserCard({
name,
email,
disabled = false,
}: UserCardProps) {
return (
<article aria-disabled={disabled}>
<h2>{name}</h2>
<p>{email}</p>
</article>
);
}
调用方使用:
<UserCard
name="Ada Lovelace"
email="ada@example.com"
/>
此时可以把 UserCard 看成:
父组件 --{ name, email, disabled }--> UserCard
Props 的方向是单向的。子组件不能通过修改 props.name 反过来修改父组件中的变量。
function BadCard(props: { name: string }) {
// ❌ TypeScript 通常会报错;即使绕过类型,也不应这样做
// props.name = "Other";
return <h2>{props.name}</h2>;
}
更重要的是,Props 不是“组件内部可变配置”。React 在渲染时向组件提供输入,组件应当读取它们并返回结果。
2. Props 的类型定义了公开 API
对外暴露组件时,Props 类型就是 API 契约的一部分:
type ButtonProps = {
variant?: "primary" | "secondary" | "danger";
loading?: boolean;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
children: React.ReactNode;
};
export function Button({
variant = "primary",
loading = false,
onClick,
children,
}: ButtonProps) {
return (
<button
type="button"
data-variant={variant}
disabled={loading}
onClick={onClick}
>
{loading ? "Loading…" : children}
</button>
);
}
这里的契约包括:
variant只能取三个字符串;loading缺省时是false;onClick使用按钮事件类型;children必须存在,但可以是任意可渲染 React 内容;loading为true时,按钮被禁用且显示加载文本。
如果组件实现把 variant 直接用于 CSS 类名:
className={`button-${variant}`}
那么将 variant 从 "primary" 改成 "brand" 不只是改名,而是会破坏调用方和样式系统之间的契约。组件库中的 Props 需要同时考虑类型、行为、可访问性和视觉语义。
3. 可选属性、默认值和 undefined
下面三种调用对默认参数而言通常等价:
function Panel({ title = "Untitled" }: { title?: string }) {
return <h2>{title}</h2>;
}
<Panel />;
<Panel title={undefined} />;
但空字符串不会触发默认值:
<Panel title="" />
此时 title 是 ""。如果业务上禁止空字符串,应使用更严格的类型或运行时校验,而不能依赖默认参数。
需要区分:
type Props = {
title?: string;
};
和:
type Props = {
title: string | undefined;
};
前者表示属性可以不传;后者表示属性必须存在,但值可以是 undefined。在启用 TypeScript exactOptionalPropertyTypes 时,这种差别会更加明确。
4. 不要把 React.FC 当成组件契约的必要条件
函数组件可以直接写成:
type AvatarProps = {
src: string;
alt: string;
};
export function Avatar({ src, alt }: AvatarProps) {
return <img src={src} alt={alt} />;
}
不需要使用:
const Avatar: React.FC<AvatarProps> = ...
直接标注参数通常更清楚,也不会隐式表达额外的 API 意图。组件是否接收 children,应由 Props 类型明确决定:
type IconProps = {
name: string;
// 没有 children,调用 <Icon>content</Icon> 应被视为 API 设计错误
};
三、纯渲染:组件函数必须像数学函数一样计算结果
1. 纯渲染的形式化条件
对于相同的输入,组件的渲染结果应当相同:
其中 R 表示一次 render,p、s、c 分别表示 Props、state 和 context。
还需要满足一个更重要的条件:渲染不应改变组件外部可观察状态。若 E 表示外部环境,则应满足:
这里的“外部状态”包括:
- DOM;
- 全局变量;
- 网络请求;
- 日志系统;
localStorage;- 计时器;
- 订阅;
- 随机数或当前时间带来的不可控变化。
例如,下面的组件不是纯渲染:
let renderCount = 0;
function BadCounter() {
renderCount += 1; // ❌ render 改变了模块级外部状态
return <p>{renderCount}</p>;
}
它的问题不是“计数器不能递增”,而是递增发生在渲染阶段。React 可能因为预览、重试、并发调度、优先级切换或开发模式检查而多次调用渲染逻辑,渲染次数不等于用户看到的提交次数。
2. 纯函数不等于没有 state
下面的组件仍然遵守纯渲染:
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount((n) => n + 1)}>
{count}
</button>
);
}
点击后,setCount 请求 React 更新 state。React 重新调用组件:
第一次 render: count = 0 -> <button>0</button>
点击事件: setCount(n => n + 1)
第二次 render: count = 1 -> <button>1</button>
commit: 更新按钮文本
组件在每次 render 内仍然只是读取当前 count 并计算 JSX。状态变化由 React 管理,而不是通过直接修改变量实现:
let count = 0;
function BrokenCounter() {
return (
<button
onClick={() => {
count += 1; // ❌ React 不知道需要重新渲染
}}
>
{count}
</button>
);
}
3. 不要在 render 中执行副作用
副作用是与渲染计算无关、会影响外部环境或需要与外部系统同步的操作。
错误示例:
function BadSearch({ query }: { query: string }) {
fetch(`/api/search?q=${encodeURIComponent(query)}`); // ❌ render 中请求
return <p>Searching…</p>;
}
React 可能在尚未提交 UI 时就调用多次 render。这样会产生重复请求,甚至在最终 UI 没有显示时仍然发送请求。
客户端组件需要与外部系统同步时,通常使用 Effect:
import { useEffect, useState } from "react";
type Result = {
id: string;
title: string;
};
export function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setError(null);
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as Result[];
})
.then(setResults)
.catch((reason: unknown) => {
if (reason instanceof DOMException && reason.name === "AbortError") {
return;
}
setError(
reason instanceof Error ? reason : new Error("Search failed"),
);
});
return () => {
controller.abort();
};
}, [query]);
if (error) {
return <p role="alert">{error.message}</p>;
}
return (
<ul>
{results.map((result) => (
<li key={result.id}>{result.title}</li>
))}
</ul>
);
}
这个流程的因果关系是:
query改变;- React 提交新的 UI;
- Effect 启动请求;
- 请求完成后调用
setResults; - React 根据新 state 重新渲染;
- 如果
query再次改变,清理函数取消旧请求。
请求取消不能保证服务器已经撤销处理,但可以避免旧响应继续更新当前组件状态。
4. StrictMode 下的额外调用不是生产渲染契约
开发环境的 StrictMode 可能故意重新调用某些逻辑,以发现不纯的渲染和不正确的 Effect 清理。它不应被当作“React 会在生产中固定渲染两次”的承诺。
如果组件在这种检查下出现:
- 重复写入全局数组;
- 重复注册事件;
- 重复请求且没有清理;
- 计数结果异常;
通常说明组件把副作用放错了阶段,或清理函数不完整,而不是应该通过布尔变量“阻止第二次执行”。
四、Props 不可变,事件回调负责把意图传回去
React 的数据流通常是:
父组件 state
│
├── props.down
▼
子组件 UI
│
└── event callback up
▲
│
父组件更新 state
完整例子:
import { useState } from "react";
type EditorProps = {
value: string;
onChange: (nextValue: string) => void;
};
function NameEditor({ value, onChange }: EditorProps) {
return (
<label>
Name
<input
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</label>
);
}
export function ProfileForm() {
const [name, setName] = useState("");
return (
<form>
<NameEditor value={name} onChange={setName} />
<p>Preview: {name || "Anonymous"}</p>
</form>
);
}
NameEditor 不拥有这份名字数据。它接收当前 value,在用户输入时调用 onChange。真正的状态更新由 ProfileForm 决定。
这种设计称为受控组件。它的代价是父组件需要维护状态,收益是数据来源唯一,校验、提交和其他视图都能读取同一份值。
相反,非受控组件把当前值交给 DOM 管理,通常通过 defaultValue 和 ref 读取:
function UncontrolledNameForm() {
return (
<form
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
console.log(data.get("name"));
}}
>
<input name="name" defaultValue="Ada" />
<button type="submit">Save</button>
</form>
);
}
value 与 defaultValue 不是同义词:
value表示 React 持续控制当前值;defaultValue只提供初始值,之后由 DOM 自行维护。
把同一个输入在两种模式间来回切换,通常会产生受控/非受控警告,也会让数据来源难以推断。
五、组合:用组件边界表达结构,而不是复制内部实现
1. 组合和继承解决的问题不同
React 组件通常通过组合复用结构:
function Page() {
return (
<Layout>
<Header />
<Main />
<Footer />
</Layout>
);
}
Layout 不需要知道 Header、Main、Footer 的内部实现。它只负责布局边界:
type LayoutProps = {
children: React.ReactNode;
};
function Layout({ children }: LayoutProps) {
return <div className="layout">{children}</div>;
}
这与通过继承创建“特殊 Layout 子类”不同。组件组合让调用方把内容注入边界,减少了基类需要理解的具体类型。
2. children 是最常见的组合 API
下面的 Card 只约定外壳,不限制内容:
type CardProps = {
title?: string;
children: React.ReactNode;
};
function Card({ title, children }: CardProps) {
return (
<section className="card">
{title !== undefined && <h2>{title}</h2>}
<div className="card__body">{children}</div>
</section>
);
}
调用:
<Card title="Account">
<p>Signed in as Ada.</p>
<button type="button">Sign out</button>
</Card>
渲染结果的结构是:
Card
└── children
├── <p>...</p>
└── <button>...</button>
Card 不需要复制调用方的内容,也不需要知道其中有哪些按钮或段落。
3. 多个插槽应显式建模
当组件需要固定的多个区域时,单个 children 可能不够清楚:
type DialogProps = {
title: React.ReactNode;
body: React.ReactNode;
footer?: React.ReactNode;
};
function Dialog({ title, body, footer }: DialogProps) {
return (
<section role="dialog" aria-labelledby="dialog-title">
<header>
<h2 id="dialog-title">{title}</h2>
</header>
<div>{body}</div>
{footer !== undefined && <footer>{footer}</footer>}
</section>
);
}
调用方的职责非常明确:
<Dialog
title="Delete account"
body={<p>This action cannot be undone.</p>}
footer={
<>
<button type="button">Cancel</button>
<button type="button">Delete</button>
</>
}
/>
这里的 title、body、footer 是“插槽”或命名区域。它比让 Dialog 猜测 children 的第一个元素是标题、第二个元素是正文更稳定。
4. 组件作为 Props 传入
如果容器需要决定某个区域使用哪个组件,可以传入组件类型:
type PageProps = {
Sidebar: React.ComponentType;
};
function Page({ Sidebar }: PageProps) {
return (
<div className="page">
<aside>
<Sidebar />
</aside>
<main>Content</main>
</div>
);
}
function Navigation() {
return <nav>Navigation</nav>;
}
<Page Sidebar={Navigation} />;
这里传入的是组件函数 Navigation,由 Page 在内部创建 <Sidebar />。
这与传入已经创建好的 element 不同:
type PageWithElementProps = {
sidebar: React.ReactNode;
};
function PageWithElement({ sidebar }: PageWithElementProps) {
return (
<div className="page">
<aside>{sidebar}</aside>
<main>Content</main>
</div>
);
}
<PageWithElement sidebar={<Navigation />} />;
区别在于:
Sidebar: React.ComponentType是“如何创建内容”的能力;sidebar: React.ReactNode是“已经创建好的内容”。
如果容器需要向侧栏传递自己的数据,组件类型通常更合适:
type UserSidebarProps = {
userId: string;
};
type UserPageProps = {
Sidebar: React.ComponentType<UserSidebarProps>;
userId: string;
};
function UserPage({ Sidebar, userId }: UserPageProps) {
return <Sidebar userId={userId} />;
}
5. Render prop:让调用方决定每一项如何渲染
有时容器拥有遍历、加载或状态逻辑,但不应决定每项 UI:
type DataListProps<T> = {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
};
function DataList<T>({ items, renderItem }: DataListProps<T>) {
return <ul>{items.map(renderItem)}</ul>;
}
<DataList
items={[{ id: 1, name: "Ada" }]}
renderItem={(item) => <li key={item.id}>{item.name}</li>}
/>;
renderItem 是函数型 Props,也称 render prop。它不是普通的 React node,因为组件需要在渲染时调用它,并把当前项传入。
函数型 Props 有边界:
- 在客户端组件之间传递没有问题;
- 在服务端组件到客户端组件的边界上,通常不能直接传递,因为函数不是可序列化数据;
- 如果父组件每次 render 都创建新函数,接收方的引用比较可能发生变化,但这不是自动的性能错误,只有在具体优化依赖引用稳定时才需要处理。
六、children 的真实类型和处理方式
1. children 是特殊名称,但本质上仍是 Props
下面两种写法等价:
<Card>
<p>Hello</p>
</Card>
<Card children={<p>Hello</p>} />
React 会把 JSX 标签之间的内容放到 props.children。
children 的类型通常使用:
children: React.ReactNode;
React.ReactNode 可以表示多种可渲染内容,包括:
- React element;
- 字符串;
- 数字;
null;undefined;- 布尔值;
- 数组或可迭代的 React 节点;
- React 运行时支持的其他节点形式。
布尔值通常用于条件渲染,本身不会产生文本:
{isAdmin && <AdminPanel />}
当 isAdmin 为 false 时,表达式结果是 false,React 不渲染它。
如果 API 只接受一个 React element,应使用更窄的类型:
type IconButtonProps = {
icon: React.ReactElement;
children: React.ReactNode;
};
如果需要一个组件类型,而不是 element,则使用:
type Props = {
Icon: React.ComponentType<{ size: number }>;
};
三者不能混用:
ReactNode 已经可以被 React 渲染的节点值
ReactElement 某个具体 JSX element 对象
ComponentType 可以被调用来创建 element 的组件类型
2. 为什么不能直接对 children 使用数组方法
下面的写法不可靠:
function BadList({ children }: { children: React.ReactNode }) {
// ❌ children 不保证是数组
// return <ul>{children.map(...)}</ul>;
return <ul>{children}</ul>;
}
单个子节点是 element,不存在 .map;没有子节点时还可能是 null。
React 提供 Children API 处理不透明的 children 数据结构:
import { Children } from "react";
function List({ children }: { children: React.ReactNode }) {
return (
<ul>
{Children.map(children, (child, index) => (
<li key={index}>{child}</li>
))}
</ul>
);
}
Children.map 会遍历 React 认可的 children 结构,但它不等于“深度遍历最终 DOM”。例如:
function Example() {
return (
<List>
<>
<span>A</span>
<span>B</span>
</>
</List>
);
}
对 List 而言,传入的直接 child 可能是一个 Fragment element。Children API 不会把组件或 Fragment 的内部实现完全展开为最终 DOM 后再遍历。它处理的是当前组件收到的 children 数据,而不是递归执行所有子组件。
这正是为什么“根据 children 的位置自动识别标题、正文、按钮”容易脆弱:组件看到的是 React 节点结构,不是已经完成渲染的 DOM 语义树。
3. Children.count 和空节点
import { Children } from "react";
function Debug({ children }: { children: React.ReactNode }) {
return <p>count: {Children.count(children)}</p>;
}
Children.count 的计数语义面向 React children 节点,而不是“最终可见 DOM 元素数量”。null、条件表达式结果等情况不应简单等同于实际屏幕上的元素数量。
如果需求是验证“是否有实际内容”,更好的做法通常是把这个语义做成显式 Props,例如:
type EmptyStateProps = {
isEmpty: boolean;
children: React.ReactNode;
};
不要依赖遍历结果推断业务状态。
七、key、ref 与普通 Props 不同
1. key 用于协调,不会自动进入 Props
function Row(props: { id?: string; children: React.ReactNode }) {
console.log(props.id);
return <div>{props.children}</div>;
}
<Row key="user-1" id="user-1">
Ada
</Row>;
这里 props.id 是 "user-1",但 props.key 不会自动提供给 Row。key 是 React 用于识别同级节点身份的特殊字段。
正确写法是显式传两份:
<Row key="user-1" id="user-1">
Ada
</Row>
key 的作用发生在父节点对子节点列表进行协调时:
items.map((item) => (
<Row key={item.id} id={item.id}>
{item.name}
</Row>
))
假设旧列表为:
[A(id=a), B(id=b)]
新列表在开头插入 C:
[C(id=c), A(id=a), B(id=b)]
使用稳定的 id 作为 key 时,React 可以识别:
c 是新增节点
a 仍是原来的节点
b 仍是原来的节点
如果使用索引:
items.map((item, index) => (
<Row key={index} id={item.id}>
{item.name}
</Row>
))
旧索引 0 对应 A,新索引 0 却对应 C。如果 Row 内部有输入框或 state,React 可能把原本属于 A 的局部状态复用于 C,造成输入内容“跟错行”。
因此,key 不是性能装饰,而是列表项身份契约。它应当在列表的直接映射位置提供,并在同一列表作用域内稳定、唯一。
2. ref 是命令式访问通道
ref 也不是普通 Props。它通常用于获取 DOM 节点或组件暴露的命令式句柄。
在 React 19 的函数组件模型中,ref 可以作为 Props 使用;但具体 TypeScript 类型、JSX 类型定义和框架配置仍应以项目使用的 React 类型版本为准。示例:
import { useRef } from "react";
type InputProps = {
ref?: React.Ref<HTMLInputElement>;
};
function SearchInput({ ref }: InputProps) {
return <input ref={ref} type="search" />;
}
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<SearchInput ref={inputRef} />
<button type="button" onClick={() => inputRef.current?.focus()}>
Focus
</button>
</>
);
}
ref 不应成为组件的主要数据通信机制。对于值、状态和业务动作,优先使用普通 Props 与事件回调;ref 适合聚焦、测量、滚动、播放媒体等必须命令式操作的场景。
为了限制暴露的命令式 API,可以使用 useImperativeHandle:
import { forwardRef, useImperativeHandle, useRef } from "react";
type SearchInputHandle = {
focus: () => void;
};
type SearchInputProps = {
placeholder?: string;
};
const SearchInput = forwardRef<SearchInputHandle, SearchInputProps>(
function SearchInput({ placeholder }, ref) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus() {
inputRef.current?.focus();
},
}),
[],
);
return <input ref={inputRef} placeholder={placeholder} />;
},
);
这里父组件只能调用 focus,不能直接依赖内部 DOM 结构。这是更窄的命令式契约。
八、不要随意克隆和修改 children
1. cloneElement 的工作方式
cloneElement 可以基于已有 element 创建一个带新 Props 的 element:
import { cloneElement, isValidElement } from "react";
type ColorizeProps = {
children: React.ReactNode;
};
function Colorize({ children }: ColorizeProps) {
if (!isValidElement<{ className?: string }>(children)) {
return children;
}
return cloneElement(children, {
className: `${children.props.className ?? ""} colored`.trim(),
});
}
它不会修改原 element,而是创建一个新的 element 描述。
2. cloneElement 的边界
它只适合非常明确、可控的 element 契约。下面这些问题很常见:
children可能是字符串、数组、Fragment 或null;- 目标组件可能不接受你注入的属性;
- 注入的
className可能覆盖调用方值; - 注入
onClick时可能覆盖原回调; - 组件包装层级改变后,注入逻辑可能失效;
- 不能通过它可靠地修改任意后代组件;
key或ref的覆盖可能改变节点身份或引用行为。
例如,直接覆盖事件是危险的:
cloneElement(child, {
onClick: handleParentClick, // 可能丢失 child 原本的 onClick
});
如果确实要合并,应显式定义顺序:
const originalOnClick = child.props.onClick;
return cloneElement(child, {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
originalOnClick?.(event);
if (!event.defaultPrevented) {
handleParentClick(event);
}
},
});
但这要求你确定 child 真的是按钮,并且原回调签名兼容。很多情况下,显式 render prop 或命名插槽比 clone 更容易维护:
type ToolbarProps = {
renderButton: (props: {
type: "button";
onClick: () => void;
}) => React.ReactNode;
};
function Toolbar({ renderButton }: ToolbarProps) {
return (
<div>
{renderButton({
type: "button",
onClick: () => console.log("save"),
})}
</div>
);
}
组合 API 的原则不是“永远不能 cloneElement”,而是:当组件需要注入行为时,必须把注入对象、覆盖规则和类型边界定义清楚。
九、让组件保持纯渲染的并发原因
React 的渲染和提交不是同一个阶段:
输入变化
↓
render:计算新的 element/Fiber
↓
可能暂停、重启、放弃或重新计算
↓
commit:把结果应用到 DOM
↓
effect:与外部系统同步
React 可以在 render 阶段准备一个结果,但不一定立即提交。并发渲染的价值之一,就是允许 React 根据优先级安排工作。因此,render 逻辑必须允许被重新执行。
下面的代码把 DOM 操作错误地放在 render 中:
function BadTitle({ title }: { title: string }) {
document.title = title; // ❌ render 阶段修改外部系统
return <h1>{title}</h1>;
}
客户端组件可以这样同步:
import { useEffect } from "react";
function PageTitle({ title }: { title: string }) {
useEffect(() => {
const previous = document.title;
document.title = title;
return () => {
document.title = previous;
};
}, [title]);
return <h1>{title}</h1>;
}
不过,Effect 不是所有计算的默认位置。只依赖当前输入的派生值应直接在 render 中计算:
function Price({ amount, taxRate }: { amount: number; taxRate: number }) {
const total = amount * (1 + taxRate);
return <span>{total.toFixed(2)}</span>;
}
不应为了保存这个同步派生值再加一层 Effect 和 state:
// ❌ 多一次 render,且存在短暂不同步状态
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(amount * (1 + taxRate));
}, [amount, taxRate]);
判断标准是:如果没有外部系统,只是从当前 Props/state 计算另一个值,就直接计算;如果需要与 DOM、网络、订阅、浏览器 API 等外部系统同步,才考虑 Effect 或事件处理器。
十、组件 API 契约应表达“谁拥有状态”
组合 API 的设计,核心不是 Props 越多越灵活,而是明确状态所有权。
1. 受控与非受控 API
一个可复用输入组件常见的契约是:
type TextFieldProps = {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
};
但这个类型允许同时传 value 和 defaultValue,语义并不完整。可以通过联合类型表达互斥关系:
type ControlledTextFieldProps = {
value: string;
onChange: (value: string) => void;
defaultValue?: never;
};
type UncontrolledTextFieldProps = {
defaultValue?: string;
value?: never;
onChange?: (value: string) => void;
};
type TextFieldProps =
| ControlledTextFieldProps
| UncontrolledTextFieldProps;
这样,调用方只能选择一种模式:
<TextField value={name} onChange={setName} />
<TextField defaultValue="Ada" />
下面的写法会被 TypeScript 拒绝:
<TextField
value={name}
defaultValue="Ada"
onChange={setName}
/>
类型系统表达了运行时必须遵守的状态所有权规则:受控模式中,当前值由父组件拥有;非受控模式中,初始值交给子组件或 DOM。
2. 回调命名要区分事件与状态变更
原生事件风格:
onClick?: React.MouseEventHandler<HTMLButtonElement>;
组件值变更风格:
onValueChange?: (value: string) => void;
onValueChange 通常表示组件已经从事件中提取出业务值;调用方不需要依赖具体 DOM 事件对象。契约越接近组件的语义,替换内部实现时越不容易破坏调用方。
3. 不要无条件透传所有 Props
下面的写法看似方便:
function Box(props: Record<string, unknown>) {
return <div {...props} />;
}
它会模糊组件 API,还可能把不应出现在 DOM 上的字段透传出去,例如业务属性、对象值或事件名称。
更可靠的方式是区分自有 Props 和 DOM Props:
type BoxProps = {
padding?: number;
} & React.ComponentPropsWithoutRef<"div">;
function Box({ padding = 0, style, ...divProps }: BoxProps) {
return (
<div
{...divProps}
style={{
padding,
...style,
}}
/>
);
}
这里要明确覆盖顺序:
- 组件默认提供
padding; - 调用方传入的
style其他字段保留; - 如果调用方也传入
padding,当前实现中的组件值会覆盖style.padding,因为padding被单独写入; data-*、aria-*等标准 DOM 属性可以通过divProps传递。
如果需要支持 className 合并,也应使用明确的合并函数,而不是随意覆盖。
十一、Context 不是 Props 的替代品,而是隐式输入
Props 适合表达显式的父子契约:
<Button variant="primary" />
Context 适合一棵子树共享环境信息,例如主题、当前用户或国际化配置:
const ThemeContext = createContext<"light" | "dark">("light");
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button data-theme={theme}>Save</button>;
}
从纯渲染角度看,Context 仍然是输入:
使用 Context 的代价是依赖变得隐式。看到 ThemedButton 的 Props 类型,无法知道它还依赖 ThemeContext。因此:
- 组件个性化配置通常放在 Props;
- 稳定的环境级配置可以放在 Context;
- 不要把所有数据都塞进 Context,以免组件边界和更新来源难以追踪。
Context value 的引用变化还会影响消费者重新渲染:
<ThemeContext.Provider value={{ mode: "dark" }}>
如果每次父组件 render 都创建新对象,消费者会观察到新的 context value。是否需要 useMemo 取决于实际渲染成本和依赖关系,不能仅凭“对象新建”就断言一定有性能问题。
十二、服务端与客户端边界会改变 Props 契约
现代框架可能支持 React Server Components(RSC)。这不是所有 React 应用的默认能力,而是由框架和构建工具提供的架构能力。
1. 服务端组件可以生成 UI,但不能使用客户端交互能力
典型边界如下:
// 服务端组件
import ClientButton from "./ClientButton";
export default async function ProductPage() {
const product = await loadProduct();
return (
<section>
<h1>{product.name}</h1>
<ClientButton productId={product.id} />
</section>
);
}
// ClientButton.tsx
"use client";
import { useState } from "react";
type ClientButtonProps = {
productId: string;
};
export default function ClientButton({
productId,
}: ClientButtonProps) {
const [pending, setPending] = useState(false);
async function handleClick() {
setPending(true);
try {
const response = await fetch("/api/cart", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ productId }),
});
if (!response.ok) {
throw new Error(`Add to cart failed: ${response.status}`);
}
} finally {
setPending(false);
}
}
return (
<button type="button" disabled={pending} onClick={handleClick}>
{pending ? "Adding…" : "Add to cart"}
</button>
);
}
服务端组件可以负责读取数据和生成初始 UI;客户端组件负责 state、事件和浏览器 API。
2. 跨边界 Props 通常必须可序列化
从服务端组件传给客户端组件的 Props,通常必须能被框架的 RSC 传输机制序列化。字符串、数字、布尔值、数组、普通对象等通常适合作为数据输入;函数、任意 class 实例、DOM 节点和不可序列化资源通常不能直接作为边界数据。
下面通常不适合作为服务端到客户端的普通 Props:
<ClientButton
onClick={() => console.log("clicked")} // ❌ 函数跨 RSC 边界通常不可直接传递
/>
应改成传输数据,让客户端组件自己绑定行为:
<ClientButton productId="p-1" />
children 也不能绕过边界规则。服务端生成的可传输 React 内容可以作为客户端组件的内容组合,但一个服务端组件不能把任意服务端闭包当作客户端回调传递。
3. 不能在服务端组件中使用浏览器状态 API
下面的代码需要客户端环境:
"use client";
import { useEffect, useState } from "react";
如果组件要调用 useState、注册浏览器事件或访问 window、document,应放在客户端边界内。服务端组件的 Props API 因此不仅要考虑 TypeScript 类型,还要考虑运行时传输协议。
十三、组合组件中的错误路径
组件 API 只描述正常输入还不够,还要定义失败行为。
1. 事件错误不会自动被普通渲染捕获
渲染期间抛出的错误可以由错误边界处理;但事件处理器中的异常不会自动由错误边界捕获:
function DangerousButton() {
return (
<button
type="button"
onClick={() => {
throw new Error("event failed");
}}
>
Run
</button>
);
}
对于事件中的异步操作,应显式捕获并把失败状态纳入渲染:
function SaveButton() {
const [status, setStatus] = useState<"idle" | "saving" | "error">("idle");
async function save() {
setStatus("saving");
try {
const response = await fetch("/api/save", { method: "POST" });
if (!response.ok) {
throw new Error(`Save failed: ${response.status}`);
}
setStatus("idle");
} catch {
setStatus("error");
}
}
return (
<>
<button type="button" disabled={status === "saving"} onClick={save}>
{status === "saving" ? "Saving…" : "Save"}
</button>
{status === "error" && <p role="alert">保存失败,请重试。</p>}
</>
);
}
错误状态由 state 驱动 UI,而不是在 render 期间修改外部变量。
2. Error Boundary 的范围
错误边界主要用于捕获其子树渲染、生命周期和部分组件初始化过程中的错误。它不能替代事件处理器中的 try/catch,也不能自动处理任意异步回调的拒绝。
组合组件库可以在高风险子树外围提供错误边界,但应避免吞掉错误:
class WidgetBoundary extends React.Component<
{ children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
reportError(error, info);
}
render() {
if (this.state.failed) {
return <p role="alert">此区域暂时不可用。</p>;
}
return this.props.children;
}
}
错误边界的 fallback 本身也应是稳定、可渲染的 UI;日志上报则应放在 componentDidCatch 等错误处理路径,而不是普通 render 中。
十四、常见失败模式及诊断方法
1. 把组件当作普通函数直接调用
错误:
function App() {
return Greeting({ name: "Ada" }); // ❌ 不要这样调用组件
}
正确:
function App() {
return <Greeting name="Ada" />;
}
直接调用会绕过 React 对组件身份、Hooks 调用位置、Fiber 和生命周期的管理。尤其是组件内部使用 Hooks 时,直接调用可能导致 Hooks 规则错误或状态归属混乱。
2. 在循环中使用错误的 key
失败表现包括:
- 输入值显示在错误的列表项中;
- 动画或焦点跳到错误节点;
- 删除一项后其他项的局部 state 串行移动。
诊断时应检查:
- key 是否来自业务稳定 ID;
- 是否在列表重新排序、插入、删除后仍保持同一项同一 key;
- 是否错误地使用数组索引;
- 是否在 render 中随机生成 key。
下面的 key 会让每次 render 都像创建新节点:
<li key={Math.random()}>{item.name}</li>
这会破坏组件身份保持,导致 state 重置和不必要的 DOM 重建。
3. 错把 children 当作固定数组
失败表现包括:
<Card>
<p>Only one child</p>
</Card>
在内部执行 children.map 时直接报错。
诊断方法是先问清楚 API 需要什么:
- 任意内容:
ReactNode; - 单个 element:
ReactElement; - 多个直接项:使用
Children.map,同时定义 Fragment 和空节点语义; - 固定区域:使用
header、body、footer等显式 Props; - 带数据的动态内容:使用 render prop。
4. Props 类型允许了实现无法处理的值
例如:
type Props = {
icon: React.ReactNode;
};
function IconButton({ icon }: Props) {
return cloneElement(icon, { size: 16 }); // ❌ icon 可能是字符串、null 或数组
}
如果实现要求一个 element,应改为:
type Props = {
icon: React.ReactElement<{ size?: number }>;
};
或者先用 isValidElement 做运行时判断。TypeScript 类型和运行时校验分别保护不同场景:类型保护开发期调用方,运行时保护外部数据、JavaScript 调用方和边界反序列化结果。
5. 用 useMemo 或 memo 掩盖不纯渲染
memo 可能减少某些 Props 没有变化时的重新渲染,但它不修复:
- render 中的网络请求;
- 模块级变量累加;
- 错误的 key;
- 直接修改 Props;
- 缺少 Effect 清理。
优化前先验证正确性和实际瓶颈。纯渲染是 React 模型要求;memo 是可能的性能优化,不是行为保证。
十五、从一个完整组件 API 推导设计取舍
下面设计一个可组合的 Panel:
type PanelProps = {
title: React.ReactNode;
actions?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
};
export function Panel({
title,
actions,
children,
footer,
}: PanelProps) {
return (
<section className="panel">
<header className="panel__header">
<h2 className="panel__title">{title}</h2>
{actions !== undefined && (
<div className="panel__actions">{actions}</div>
)}
</header>
<div className="panel__body">{children}</div>
{footer !== undefined && (
<footer className="panel__footer">{footer}</footer>
)}
</section>
);
}
调用:
<Panel
title="Members"
actions={
<button type="button" onClick={openInviteDialog}>
Invite
</button>
}
footer={<small>Updated a minute ago</small>}
>
<MemberList members={members} />
</Panel>
可以逐项推导这个契约:
title是必需的,因为无标题时语义结构可能不完整;actions是可选的,因为有些面板没有操作;children是主体内容,不限制调用方使用列表、表单或其他组件;footer是命名插槽,避免要求调用方按children位置猜测;Panel不拥有成员列表状态,也不处理邀请动作;Panel只负责布局和语义结构;MemberList、邀请按钮和 footer 的状态由各自所有者管理;- 由于这些内容是 React node,它们可以继续组合其他组件;
- 如果该 Panel 跨服务端到客户端边界,传入内容还必须满足框架的边界约束。
如果未来需要让 Panel 控制操作按钮的 loading 状态,就不应偷偷修改 actions 内部 element,而应增加明确契约:
type PanelActionsRenderProps = {
busy: boolean;
};
type AsyncPanelProps = {
renderActions: (props: PanelActionsRenderProps) => React.ReactNode;
children: React.ReactNode;
};
这是 API 演进的重要原则:把新增的控制关系显式写入 Props,而不是通过子节点结构推断或克隆隐式注入。
十六、纯渲染、组合和 Children 的统一模型
可以把一个组件抽象成:
type Component<P> = (props: P) => React.ReactNode;
对于:
<Panel title="Members">
<MemberList members={members} />
</Panel>
其数据结构可以理解为:
Panel({
title: "Members",
children: MemberList({ members })
})
但实际执行并不是普通 JavaScript 的立即递归调用,而是由 React 根据 element 和 Fiber 进行调度。组件的责任仍然可以严格描述为:
其中 props.children 只是 props 中一个约定俗成的字段。
组合的本质是把“内容如何生成”与“内容放在哪里”分离:
调用方决定内容
│
▼
组件决定边界、布局和语义
│
▼
React 根据 element/Fiber 协调并提交
纯渲染保证这个过程可重试;Props 保证输入边界明确;children 提供默认组合通道;命名插槽和 render prop 在结构更复杂时把隐含关系显式化;key 则保证同级动态内容的身份稳定。
十七、最终检查一个组件契约是否完整
设计或评审一个组件时,可以用以下问题验证其契约,而不是只看 Props 数量:
- 组件接收的是数据、React node、element,还是组件类型?
- 每个 Props 的必需性、默认值和允许值是否准确?
- 组件是否在 render 中修改了外部状态?
- 状态由父组件、子组件、DOM 还是 Context 拥有?
- 子组件如何把用户意图传回状态所有者?
children是任意内容、单个 element,还是固定插槽?- 是否需要
key,并且 key 是否来自稳定身份? - 是否错误地依赖
props.key或把ref当普通属性? - 是否使用
cloneElement注入了未声明的行为? - 是否存在服务端到客户端边界,Props 是否可序列化?
- 网络、事件和异步失败由谁捕获并显示?
- API 变更会影响行为、语义结构、类型还是边界传输?
一个组件真正稳定,不是因为它的实现代码短,而是因为输入、状态所有权、渲染阶段、组合方式和失败行为都能从 API 中推导出来。React 的组件模型因此不是“把 HTML 拆成函数”,而是用纯渲染函数和明确契约构造一棵可调度、可组合、可协调的 UI 树。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React JSX 与渲染模型:元素、Fiber、Reconciliation 和 Key
- 下一篇:React State 与 Hooks:useState、useReducer、批处理和状态快照
- 延伸:React 组件库与设计系统:组合 API、Token、主题和版本治理
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论