函数组件:把 UI 写成状态的函数
本章学习目标
- 用费曼学习法真正理解函数组件:能向外行解释,也能在工程中正确使用。
- 掌握函数组件的正式定义、渲染模型、Hooks、闭包与快照。
- 识别常见误区,并知道如何写出可维护、可测试、符合 React 心智模型的函数组件。
费曼路线图:先讲简单,再补漏洞
费曼学习法的核心不是“把话说得浅”,而是:
- 选择一个概念:函数组件。
- 用外行能懂的话讲一遍。
- 发现讲不通的地方:状态从哪来?副作用放哪?为什么不能条件调用 Hooks?
- 回到正式概念修补漏洞。
- 再用一句话总结。
本章就按这个循环展开。
给外行的 60 秒解释
函数组件就是一个函数:你给它输入,它返回一段界面描述。React 拿这段描述去更新屏幕。
如果只能记住一句话:
UI = f(props, state)
props:外部传进来的只读参数。state:组件自己记住的数据。f:函数组件。UI:React 元素,不是真实 DOM,而是“我想要屏幕长这样”的描述。
你可以把它想成自动售货机:投入硬币和按钮选择,它吐出商品。函数组件不自己动手改装机器,它只描述“应该出现什么”。真正把描述变成 DOM 的,是 React。
但这个类比有漏洞:函数每次渲染都会重新执行,局部变量会消失。那状态为什么还在?事件处理为什么能记住旧值?副作用又放在哪里?接下来修补。
正式定义:函数组件到底是什么
在 React 中,函数组件是满足以下条件的 JavaScript 函数:
- 名称首字母大写,例如
Welcome、Counter,以便 JSX 区分组件和普通 HTML 标签。 - 接收一个
props对象,通常写成解构形式。 - 返回可渲染内容:React 元素、
null、字符串、数组、Fragment、Portal 等。 - 渲染期间应当是纯的:不修改外部变量,不直接操作 DOM,不发请求,不订阅。
- 可以调用 Hooks,但必须遵守 Hooks 规则。
示例:
function Welcome({ name }) {
return <h1>Hello, {name}</h1>;
}
JSX 只是语法糖,接近:
const element = React.createElement(Welcome, { name: "Ada" });
element 是一个普通对象,描述“这里应该有一个 Welcome 组件,props 是 { name: 'Ada' }”。React 会协调这棵树,最终更新真实 DOM。
函数组件与类组件的根本差异
| 维度 | 类组件 | 函数组件 |
|---|---|---|
| 定义 | class extends React.Component | 普通函数 |
| 状态 | this.state | useState / useReducer |
| 生命周期 | componentDidMount 等 | useEffect 等 Hooks |
this | 需要绑定,易出错 | 没有 this |
| 逻辑复用 | HOC、render props | 自定义 Hook |
| 心智模型 | 实例生命周期 | 渲染快照 + 状态函数 |
| 并发渲染 | 较难契合 | 更契合,但要求渲染纯 |
函数组件不是“类组件的简化版”。它更接近 React 的原生心智模型:UI 是状态的函数。类组件围绕“实例生命周期”组织代码,函数组件围绕“每次渲染的快照”组织代码。
渲染、提交与快照
React 渲染函数组件时,大致经历:
- 渲染阶段:React 调用函数组件,收集返回的 React 元素。
- 协调阶段:比较新旧元素树,计算最小变更。
- 提交阶段:把变更应用到 DOM,然后运行
useEffect等副作用。
关键点:函数组件每次渲染都会重新执行一次。局部变量、函数、JSX 都会重新创建。你这一次渲染里看到的 props 和 state,是这一次渲染的快照。
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(prev => prev + 1)}>
点击了 {count} 次
</button>
);
}
点击按钮时,setCount 不会立刻改变当前这次渲染里的 count。它只是告诉 React:“请用新的状态再渲染一次。”下一次渲染中,count 才是新值。
Hooks:让函数拥有记忆与副作用
函数组件本身没有实例,状态由 React 在组件树中按位置保存。Hooks 是函数组件与 React 运行时协作的原语。
useState:状态快照
const [count, setCount] = useState(0);
count:当前渲染的状态快照。setCount:调度一次新渲染。- 更新对象或数组时,要创建新对象,不要直接修改旧状态。
setUser(prev => ({ ...prev, name: "Ada" }));
useEffect:同步副作用
副作用是“与外部系统同步”的行为:请求、订阅、定时器、DOM 操作、日志等。
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
- 依赖数组决定 effect 何时重新运行。
- 清理函数在下次 effect 前和组件卸载时运行。
- 空数组表示只在挂载后运行一次,但仍会在开发环境 StrictMode 下被双调用以暴露问题。
其他常用 Hooks
useRef:保存可变值,不触发渲染。useContext:读取跨层上下文。useReducer:复杂状态逻辑。useMemo/useCallback:性能优化,不是语义保证。- 自定义 Hook:以
use开头,复用逻辑,但每次使用都有独立状态。
function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, []);
return online;
}
闭包与快照:函数组件最容易踩的坑
每次渲染都有独立的函数作用域。事件处理、useEffect、定时器都会捕获当次渲染的变量。
错误示例:
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 捕获旧快照 count = 0
}, 1000);
return () => clearInterval(id);
}, []); // 依赖数组缺少 count
修复方式一:函数式更新。
setCount(prev => prev + 1);
修复方式二:正确声明依赖。
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]);
但更好的做法通常是问:这个 effect 真的需要依赖 count 吗? 很多情况下,函数式更新或重构状态能消除依赖。
Hooks 规则:为什么不能条件调用
Hooks 必须:
- 只在函数组件或自定义 Hook 中调用。
- 只在顶层调用,不能放在条件、循环、嵌套函数中。
- 命名以
use开头,方便 lint 检查。
错误:
if (isLoggedIn) {
useState(); // 禁止
}
原因:React 依赖稳定的调用顺序来匹配每个 Hook 对应的状态槽位。条件调用会打乱顺序,导致状态错位。
使用 eslint-plugin-react-hooks 可以在编码阶段发现大多数问题。
组合、性能与并发
组合优于继承
函数组件通过 props、children、自定义 Hook 组合逻辑。
function Layout({ children }) {
return <main className="layout">{children}</main>;
}
性能优化
React.memo:浅比较 props,跳过不必要的重渲染。useMemo/useCallback:缓存计算结果或函数引用。- 状态下放:让状态尽量靠近使用它的组件。
- 稳定
key:列表项使用唯一 ID,不要用索引。 - 不要过早优化:先测量,再优化。
const Child = React.memo(function Child({ value, onChange }) {
return <button onClick={onChange}>{value}</button>;
});
并发渲染
React 18+ 的并发渲染可能中断、重启或丢弃渲染结果。因此,函数组件的渲染阶段必须是纯的。副作用必须放在事件处理或 useEffect 中。StrictMode 在开发环境双调用组件和 effect,就是为了暴露不纯的渲染。
常见误区清单
- 直接修改 state:
user.name = "Ada"不会触发更新。 - 认为
setState同步:它调度新渲染,当前快照不变。 - 依赖数组撒谎:遗漏依赖会导致闭包旧值。
- 在渲染中发请求、订阅、操作 DOM:这些应放在 effect 或事件处理中。
- 用索引作为 key:排序、插入、删除时会导致状态错位。
- 滥用
useMemo/useCallback:它们不是免费午餐。 - 把自定义 Hook 当成状态共享:每次调用都有独立状态。
- 忘记清理副作用:定时器、订阅、请求取消都需清理。
费曼自测:检验你是否真的理解
请尝试回答:
- 用自动售货机类比解释函数组件,并指出类比的三个局限。
- 为什么说函数组件每次渲染都是一次新的函数调用?
state为什么被称为“快照”?- 为什么 Hooks 不能条件调用?
- 修复下面代码的闭包陷阱:
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
- 什么时候需要
React.memo、useMemo、useCallback?什么时候不需要? - 为什么并发渲染要求渲染阶段必须是纯的?
如果你能不用术语向外行讲清楚,再回到代码中正确使用,你就完成了费曼循环。
本章小结
函数组件是 React 的默认组件形态。它的核心公式是:
UI = f(props, state)
它接收 props,读取 state,返回 React 元素。React 负责协调和提交,把描述变成真实 DOM。Hooks 让函数组件拥有状态、副作用和逻辑复用能力。每次渲染都是一次快照,闭包会捕获当次渲染的值。渲染必须纯,副作用必须隔离。遵守 Hooks 规则,理解闭包与快照,你就能写出符合 React 心智模型的函数组件。
延伸阅读
React 官方文档:Describing the UI、Adding Interactivity、Managing State、Escape Hatches、Rules of Hooks。
推荐实践:You Might Not Need an Effect。