React 基础体系 · 第 4/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 组件与 Props:纯渲染、组合、Children 和 API 契约

React 组件最核心的职责,是把输入映射为用户界面:

UI=F(props,state,context)UI = F(props, state, context)

其中:

  • 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>
  );
}

这里的契约包括:

  1. variant 只能取三个字符串;
  2. loading 缺省时是 false
  3. onClick 使用按钮事件类型;
  4. children 必须存在,但可以是任意可渲染 React 内容;
  5. loadingtrue 时,按钮被禁用且显示加载文本。

如果组件实现把 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(p,s,c)=R(p,s,c)R(p, s, c) = R(p, s, c)

其中 R 表示一次 render,psc 分别表示 Props、state 和 context。

还需要满足一个更重要的条件:渲染不应改变组件外部可观察状态。若 E 表示外部环境,则应满足:

Eafter render=Ebefore renderE_{\text{after render}} = E_{\text{before render}}

这里的“外部状态”包括:

  • 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>
  );
}

这个流程的因果关系是:

  1. query 改变;
  2. React 提交新的 UI;
  3. Effect 启动请求;
  4. 请求完成后调用 setResults
  5. React 根据新 state 重新渲染;
  6. 如果 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 管理,通常通过 defaultValueref 读取:

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>
  );
}

valuedefaultValue 不是同义词:

  • value 表示 React 持续控制当前值;
  • defaultValue 只提供初始值,之后由 DOM 自行维护。

把同一个输入在两种模式间来回切换,通常会产生受控/非受控警告,也会让数据来源难以推断。


五、组合:用组件边界表达结构,而不是复制内部实现

1. 组合和继承解决的问题不同

React 组件通常通过组合复用结构:

function Page() {
  return (
    <Layout>
      <Header />
      <Main />
      <Footer />
    </Layout>
  );
}

Layout 不需要知道 HeaderMainFooter 的内部实现。它只负责布局边界:

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>
    </>
  }
/>

这里的 titlebodyfooter 是“插槽”或命名区域。它比让 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 />}

isAdminfalse 时,表达式结果是 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;
};

不要依赖遍历结果推断业务状态。


七、keyref 与普通 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 不会自动提供给 Rowkey 是 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 契约。下面这些问题很常见:

  1. children 可能是字符串、数组、Fragment 或 null
  2. 目标组件可能不接受你注入的属性;
  3. 注入的 className 可能覆盖调用方值;
  4. 注入 onClick 时可能覆盖原回调;
  5. 组件包装层级改变后,注入逻辑可能失效;
  6. 不能通过它可靠地修改任意后代组件;
  7. keyref 的覆盖可能改变节点身份或引用行为。

例如,直接覆盖事件是危险的:

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;
};

但这个类型允许同时传 valuedefaultValue,语义并不完整。可以通过联合类型表达互斥关系:

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 仍然是输入:

UI=F(props,state,context)UI = F(props, state, 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、注册浏览器事件或访问 windowdocument,应放在客户端边界内。服务端组件的 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 串行移动。

诊断时应检查:

  1. key 是否来自业务稳定 ID;
  2. 是否在列表重新排序、插入、删除后仍保持同一项同一 key;
  3. 是否错误地使用数组索引;
  4. 是否在 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 和空节点语义;
  • 固定区域:使用 headerbodyfooter 等显式 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. 用 useMemomemo 掩盖不纯渲染

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>

可以逐项推导这个契约:

  1. title 是必需的,因为无标题时语义结构可能不完整;
  2. actions 是可选的,因为有些面板没有操作;
  3. children 是主体内容,不限制调用方使用列表、表单或其他组件;
  4. footer 是命名插槽,避免要求调用方按 children 位置猜测;
  5. Panel 不拥有成员列表状态,也不处理邀请动作;
  6. Panel 只负责布局和语义结构;
  7. MemberList、邀请按钮和 footer 的状态由各自所有者管理;
  8. 由于这些内容是 React node,它们可以继续组合其他组件;
  9. 如果该 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 进行调度。组件的责任仍然可以严格描述为:

Panel(props)ReactNodePanel(props) \rightarrow ReactNode

其中 props.children 只是 props 中一个约定俗成的字段。

组合的本质是把“内容如何生成”与“内容放在哪里”分离:

调用方决定内容
      │
      ▼
组件决定边界、布局和语义
      │
      ▼
React 根据 element/Fiber 协调并提交

纯渲染保证这个过程可重试;Props 保证输入边界明确;children 提供默认组合通道;命名插槽和 render prop 在结构更复杂时把隐含关系显式化;key 则保证同级动态内容的身份稳定。


十七、最终检查一个组件契约是否完整

设计或评审一个组件时,可以用以下问题验证其契约,而不是只看 Props 数量:

  1. 组件接收的是数据、React node、element,还是组件类型?
  2. 每个 Props 的必需性、默认值和允许值是否准确?
  3. 组件是否在 render 中修改了外部状态?
  4. 状态由父组件、子组件、DOM 还是 Context 拥有?
  5. 子组件如何把用户意图传回状态所有者?
  6. children 是任意内容、单个 element,还是固定插槽?
  7. 是否需要 key,并且 key 是否来自稳定身份?
  8. 是否错误地依赖 props.key 或把 ref 当普通属性?
  9. 是否使用 cloneElement 注入了未声明的行为?
  10. 是否存在服务端到客户端边界,Props 是否可序列化?
  11. 网络、事件和异步失败由谁捕获并显示?
  12. API 变更会影响行为、语义结构、类型还是边界传输?

一个组件真正稳定,不是因为它的实现代码短,而是因为输入、状态所有权、渲染阶段、组合方式和失败行为都能从 API 中推导出来。React 的组件模型因此不是“把 HTML 拆成函数”,而是用纯渲染函数和明确契约构造一棵可调度、可组合、可协调的 UI 树。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。