阅读书架包含多个重复部分:标题、搜索框、书籍条目,以及选中条目后显示的说明。直接操作 DOM 时,我们逐步指示程序寻找及修改元素。使用 React 时,我们描述当前数据对应的界面,并在事件发生时更新数据。React 计算新的界面描述,再把所需变更提交到浏览器 DOM。
当多个元素依赖同一个选择时,这种组织方法尤其清晰。选中书籍的按钮文字、按下状态及说明应该一致;一个状态值就能共同决定这些输出。
组件描述数据的呈现方式
定义
组件、props 与 state
组件是描述界面某一部分的可复用单位。Props 是父组件传入的值。State 是 React 在多次渲染之间为组件保留的数据。一次渲染会按照当次可用的 props 与 state,计算界面描述。
函数组件返回 React 元素。组件名称以大写字母开头,让 JSX 能把它与 p、button 等内置元素区分。
function ShelfHeading({ title, total }) {
return (
<header>
<h1>{title}</h1>
<p>{total} books available</p>
</header>
);
}
function ShelfIntro() {
return <ShelfHeading title="Reading shelf" total={3} />;
}
ShelfIntro 传入两个 props,ShelfHeading 通过参数解构读取它们。字符串放在引号内;数字则以大括号内的 JavaScript 表达式传入。同一个组件定义可以产生两个具有不同 props 的 ShelfHeading,复用结构并显示不同内容。
组件应把 props 视为只读输入。以 prop 传入的对象仍然是 JavaScript 对象,改动其字段会修改共享数据。组件应通过数据拥有者传入的回调函数提出变更请求。明确数据由谁拥有,界面的变化就更容易追踪。
React 是描述界面的库;更完整的应用程序可以在它周围加入路由、数据访问及构建工具。组件复用或虚拟树结构本身并不保证每种实现都快。首先要建立的是数据与显示结果之间清晰的关系。
JSX 是描述元素的 JavaScript 语法
JSX 外观近似 HTML,但普通浏览器执行之前,必须先把它转换成 JavaScript。转换结果是描述 React 元素的值,而非 HTML 源码字符串。项目的构建过程可以完成转换,并解析已安装 React 包的导入。
本单元的示例是在已配置 JSX 转换的 React 项目内使用的模块。客户端渲染的入口可以把导出的 ReadingShelf 组件挂载到已存在、内容为空且 ID 为 app 的 div:
import { createRoot } from "react-dom/client";
import ReadingShelf from "./ReadingShelf.jsx";
const container = document.getElementById("app");
const root = createRoot(container);
root.render(<ReadingShelf />);
入口执行前,容器必须已存在。对这个根节点调用一次 createRoot,随后由组件管理内部内容。如果已有 React 在服务器生成的 HTML,应采用 hydration,而非这个空容器挂载过程。较早的 ReactDOM.render 属于旧版入口 API,React 19 已将其移除。
即使很小的组件,也涉及多项 JSX 规则:
| 规则 | 示例 | 原因 |
|---|---|---|
| 把相邻 JSX 元素组合起来 | <>...</> | 一个 return 表达式产生一个值。Fragment 能组合子元素而不额外加入 DOM 包装。 |
| 闭合每个元素 | <input /> | 即使是 HTML 的 void element,JSX 也要求闭合语法。 |
| 使用 JSX 属性名称 | className、htmlFor、onClick | 属性遵循 React DOM 接口;aria-* 与 data-* 保留连字符。 |
| 把 JavaScript 表达式放入大括号 | {book.title} | 括号内按 JavaScript 求值。if 等语句应放在表达式外或函数内。 |
| 以对象提供样式 | style={{ maxWidth: "36rem" }} | 外层大括号进入 JavaScript;内层大括号建立对象。样式属性名采用 camelCase。 |
用大括号插入的普通字符串会显示为文本。例如,书名包含 <draft> 时,界面会显示这些字符。对象本身不适合作为文本子节点;应渲染 book.title,而非 {book}。
由一个组件拥有书架的选择状态
例题
把小组件组合成完整界面
在 React 项目内把以下模块存为 ReadingShelf.jsx。所有数据及交互都保留在浏览器当前的组件树内。
import { useState } from "react";
const books = [
{ id: "markup", title: "Meaningful markup", note: "Describe content relationships." },
{ id: "events", title: "Event traces", note: "Follow one action through its state changes." },
{ id: "layout", title: "Flexible layouts", note: "Adapt presentation to available space." },
];
function BookEntry({ book, selected, onChoose }) {
return (
<li>
<h2>{book.title}</h2>
<button
type="button"
aria-pressed={selected}
onClick={() => onChoose(book.id)}
>
{selected ? "Hide discussion note" : "Show discussion note"}
</button>
{selected && <p>{book.note}</p>}
</li>
);
}
export default function ReadingShelf() {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState(null);
const search = query.trim().toLowerCase();
const visibleBooks = books.filter(book =>
book.title.toLowerCase().includes(search)
);
function chooseBook(id) {
setSelectedId(previous => previous === id ? null : id);
}
return (
<main style={{ maxWidth: "36rem" }}>
<h1>Reading shelf</h1>
<label htmlFor="reading-query">Search title</label>
<input
id="reading-query"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<p>{visibleBooks.length} matching books</p>
{visibleBooks.length === 0 ? (
<p>No titles match this search.</p>
) : (
<ul>
{visibleBooks.map(book => (
<BookEntry
key={book.id}
book={book}
selected={selectedId === book.id}
onChoose={chooseBook}
/>
))}
</ul>
)}
</main>
);
}
从初始状态开始,追踪搜索与选择的变化:
| 动作 | query | selectedId | 可见结果 |
|---|---|---|---|
| 初次渲染 | "" | null | 显示全部三个书名,没有讨论说明。 |
输入 event | "event" | null | 只显示 Event traces,匹配数量是 1。 |
| 选中该条目 | "event" | "events" | 显示其说明 “Follow one action through its state changes.”。 |
把搜索改成 layout | "layout" | "events" | 只显示 Flexible layouts,没有说明;已选中的书被筛掉。 |
| 清除搜索 | "" | "events" | 全部三个书名再次出现,Event traces 的说明也再次显示。 |
书架拥有 query 与 selectedId。BookEntry 接收书籍对象、布尔选择值及回调函数。按下按钮时,回调接收书籍 ID。书架更新自己的状态,下一次渲染就为每个可见条目计算新的 props。搜索只改变 query,不会清除 selectedId,因此产生表格最后两行的结果。
说明文字、按钮标签及 aria-pressed 都由同一个 selected prop 推导,因此一致地表达同一选择,无须分别保留三个 state。同样,visibleBooks 及其长度由 books 与 query 计算;另外保留一个独立更新的“匹配数量”,反而会增加维持数据一致的工作。
数据通过 props 从书架流向各条目。事件则通过传入的函数到达数据拥有者。这并非让子组件直接改写父组件状态;回调如何处理事件仍由父组件决定。
思考检查
沿组件树追踪一次选择
起初 selectedId 是 null。依次点击 ID 为 events 的条目、ID 为 layout 的条目,再点击一次 layout。每次点击后,哪段说明会显示?
解答 · 追踪父组件状态与推导出的 props
状态先变成 events,所以只有该条目接收 selected=true。接着变成 layout,events 说明消失,layout 说明显示。最后一次更新发现前一个 ID 已是 layout,于是返回 null,两段说明都隐藏。一个选中的 ID 决定所有可见条目的状态。
状态更新会安排另一次渲染
useState(initialValue) 返回当次渲染的状态值及 setter 函数。调用 setter 是提出更新请求,不会改写正在执行的事件处理函数已捕获的局部变量。当新状态依赖前一状态时,更新函数可以明确表达这个依赖。
例题
追踪两个待处理的状态更新
假设组件显示 pages,用 useState(4) 初始化它,并在按钮被点击时调用以下处理函数一次:
function recordTwoPages() {
setPages(previous => previous + 1);
setPages(previous => previous + 1);
}
如果起初 pages = 4,第一个更新函数产生 5;第二个接收这个待处理结果,产生 6。如果同一次渲染的处理函数改为调用两次 setPages(pages + 1),两次都请求用 5 替换状态。在该处理函数内,即使已调用 setter,立即读取捕获到的 pages 仍会得到 4。
因此,两个更新函数使下一次提交显示 6;两个替换请求则使它显示 5。递增幅度取决于队列中的操作,并非只看 setter 调用了多少次。
| 阶段 | 当时可用的数据 | 结果 |
|---|---|---|
| 事件处理函数 | 所属渲染的状态快照 | 可以计算值并把更新放入队列。 |
| 渲染 | 更新后的状态及当前 props | React 调用组件,计算下一个界面。 |
| 提交 | 已计算的变更 | React 更新浏览器 DOM,使它反映结果。 |
渲染应计算界面,不应在其中改动无关对象或建立计时器。React 可能再次调用渲染逻辑,因此放在其中的可观察动作可能比作者预期执行更多次。用户按钮动作放在事件处理函数;与外部浏览器功能的同步工作则放在适当的生命周期位置。
常见错误
改写变量或直接修改对象,并不会提出预期的状态更新
函数组件每次执行都会重新建立局部变量;给它赋值不会请求 React 渲染。同样,直接修改已存储在 state 的对象,再传回同一个对象引用,可能无法触发预期更新。应建立下一个对象或数组,再使用 setter,例如 setBook(previous => ({ ...previous, read: true }))。
事件、条件与受控字段
onClick={chooseBook} 传入函数;onClick={chooseBook(id)} 则在渲染期间立即调用函数,再把返回值放入事件 prop。要在点击发生时才传入参数,可以写 onClick={() => chooseBook(id)}。
条件表达式 selected ? a : b 选择其中一个结果。selected && <p>...</p> 在 selected 为真时产生段落,为假时则产生不显示的布尔值。数量判断应写成 count > 0 && ... 等布尔条件;count && ... 可能显示 0,因为零是可以渲染的数字。
搜索输入框是受控字段。它显示的值来自 query,而变更处理函数把新输入的文本存入该状态。
例题
从输入到列表,追踪一次搜索编辑
书架起初的 query 是空字符串,三本书全部匹配,输入框为空,匹配数量是 3。假设一次编辑把输入文本改成 event。
- 变更处理函数读取
event.target.value,其值是字符串"event"。 setQuery请求用该字符串作为下一状态。- 下一次渲染计算
search = "event",再筛选书名。只有Event traces包含这段搜索文本。 - 提交后显示一本书及数量
1,并把"event"作为value传回输入框。
字段与列表都由 query 推导,因此两者现在一致。如果处理函数没有更新状态,受控字段会继续接收原先的空值,列表也仍然显示全部三本书。
受控文本字段应以字符串初始化,并在整个生命周期保持字符串值。受控复选框使用 checked,从 event.target.checked 读取选中状态,而非读取文本值。
Key 在多次渲染之间识别条目
map 把可见数据转成条目元素。React 使用每个 key,把不同渲染中的同级条目配对。书籍原有的 ID 能识别该书,即使另一本书被移除或顺序改变也一样。Key 是协调元素身份的特殊数据;如果子组件本身需要 ID,也须通过 book 等普通 prop 传入。
以数组索引作为 key,识别的是位置。如果位置零原先是 markup 书,后来改成 events 书,React 可能把该 key 位置上的状态用于另一条目。稳定 ID 能在筛选及排序后保留原本的条目身份。每次渲染产生随机 key 则相反:它每次都赋予条目新的身份。
本例的选择状态由父组件拥有,筛选不会改动 selectedId。因此,用搜索隐藏选中书籍时,父组件仍保留选择;清除搜索后,该书的说明再次出现。如果状态由书籍条目自己拥有,从渲染树移除条目就会卸载它;以后重新加入时,会初始化一个新实例。
阅读 class 组件与生命周期工作
函数组件与 class 组件都把输入、保留状态及渲染分开。Class 组件继承 Component,读取 this.props,初始化 this.state,并通过 this.setState 更新状态。其 render 方法返回界面描述。例如:
import { Component } from "react";
class PageCounter extends Component {
state = { pages: 0 };
recordPage = () => {
this.setState(previous => ({ pages: previous.pages + 1 }));
};
render() {
return (
<button type="button" onClick={this.recordPage}>
Pages recorded: {this.state.pages}
</button>
);
}
}
箭头函数字段保留实例的 this。普通方法如果作为回调并访问实例,就需要合适的绑定。Class 的 setState 会把对象更新作浅层合并;useState setter 则替换该 Hook 的整个值。如果在构造函数内用 props 建立 class 状态,使用 this 前应先调用 super(props)。
挂载会把组件加入树中;更新会提供改变后的 props 或 state;卸载会把它移除。Class 生命周期方法包括 componentDidMount、componentDidUpdate 及 componentWillUnmount。较早的 componentWillMount、componentWillReceiveProps 及 componentWillUpdate 属于旧式 API,不宜作为新组件代码的起点。
函数组件需要与外部系统同步时,可以使用 Effect。计时器是一个简单的本地示例:
例题
从挂载到卸载,追踪计时器
考虑一个状态从零开始的新 ReadingClock,先追踪没有额外开发模式 Strict Mode 检查的正常生命周期:
import { useEffect, useState } from "react";
function ReadingClock() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setSeconds(previous => previous + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <p>Elapsed timer ticks: {seconds}</p>;
}
把 setup 建立的 interval 称为 A。更新函数避免捕获过时的 seconds 值,因此每次触发会作用于前一个待处理状态。
| 时刻 | 状态与显示结果 | Effect 与 interval |
|---|---|---|
| 初次渲染及提交 | seconds 是 0,段落显示 0。 | Setup 尚未建立 interval。 |
| Effect setup | 状态与文本仍是 0。 | Setup 建立 interval A。 |
| 第一次触发、更新及提交 | 更新函数把 0 改成 1,段落显示 1。 | [] 依赖未改变,A 继续运行。 |
| 状态未变的另一次渲染提交 | 段落仍显示 1。 | 不会建立新的 interval。 |
| 卸载 | 段落被移除。 | Cleanup 对 A 调用 clearInterval,不再有 interval 运行。 |
这里 setup 没有读取会改变的 props 或 state,因此空依赖数组适用。如果省略依赖参数,Effect 会在此组件每次渲染提交后先清理再设置,包括由计时器触发更新的那次。只要保留 cleanup,就仍然只有一个 interval,但其调度会不断重新开始。如果 Effect 读取会改变的 props 或 state,就需要在依赖中反映那些响应式值。
如果在开发模式下于应用程序根部启用 Strict Mode,初次 Effect 也会经历 setup → cleanup → setup 检查。Interval A 会在替代计时器启动前取消,最后保留一个运行中的 interval。这是在检查设置与清理是否平衡,并非要求第二个永久计时器。浏览器调度延误时,计时器触发次数也未必等于精确的实际秒数。
练习
思考检查
练习 1:预测两次排队的替换更新
某次渲染的 pages 为 7,其事件处理函数调用两次 setPages(pages + 1)。两次请求的替换值是多少?请改写成按照前一个待处理值增加两次。
解答 · 练习 1 解答
两次都计算 7 + 1,因此都请求 8。改为调用两次 setPages(previous => previous + 1),就会把两次递增放入队列,得到 9。更新函数的参数是前一个待处理状态;捕获到的 pages 则属于处理函数所在的渲染。
思考检查
练习 2:解释无法编辑的搜索框
组件渲染 <input value={query} />,其中 query 是初始化为空字符串的 state。为什么输入无法形成可编辑的状态驱动字段?请补上缺少的 prop。
解答 · 练习 2 解答
组件控制了显示值,却没有提供更新该值的处理函数。加入 onChange={event => setQuery(event.target.value)};每次编辑就会请求新状态,让下一次渲染把它作为 value 传回输入框。
思考检查
练习 3:保留书籍身份
三本书的 ID 是 markup、events 及 layout,筛选移除了第一本。解释以数组索引作 key 时,筛选前后各识别什么,再选择合适的 key。
解答 · 练习 3 解答
索引零原先识别 markup 的位置,随后识别 events 的位置。这可能保留位置上的组件状态,而非对应书籍的状态。使用 key={book.id},events 书在筛选前后就保留同一 ID。
思考检查
练习 4:找出数据拥有者与清理工作
选中 layout 书后,用搜索把它隐藏,再清除搜索。解释书架为什么保留选择。另外,如果每次渲染都建立一个计时器却从不取消,会发生什么?
解答 · 练习 4 解答
selectedId 属于书架,搜索只改变 query。因此隐藏期间它仍是 layout,条目再出现时就接收 selected=true。每次渲染都建立计时器会累积多个独立 interval。计时器 Effect 应配对 setup 与 cleanup,使同步停止后不会留下仍在运行的 interval。
同样的状态与事件推理,也可从普通浏览器对象开始理解,见DOM 事件与对象状态。