组件模型:函数组件与类组件的本质差异
第2章:组件模型:函数组件与类组件的本质差异
React 16.8 引入 Hooks 后,函数组件和类组件之间的差异不再是 API 能力的差别,而是关于"值如何在时间中存在"的根本性语义差异。
本章核心问题:函数组件和类组件的核心差异到底是什么?为什么函数组件最终胜出? 读完本章你将理解:
- 函数组件的闭包语义——每次渲染捕获独立的值快照
- 类组件的
this可变性带来的隐患与过时闭包陷阱 - React 19 Server Components 如何进一步强化函数组件的优势
Level 1 · 你需要知道的(1-3年经验)
渲染的本质:函数调用 vs 实例方法
理解函数组件与类组件差异的起点,是理解 React 如何"渲染"一个组件。
对于类组件,React 会:
- 实例化这个类(
new MyComponent(props)),创建一个对象实例 - 调用实例的
render()方法获取 React 元素树 - 将实例保存在 Fiber 节点上,后续更新时复用同一个实例,只更新其
props和state
对于函数组件,React 会:
- 直接调用这个函数(
MyComponent(props)),函数返回 React 元素树 - 每次需要更新时,再次调用这个函数
- 没有实例,每次调用都是一个独立的函数调用
这意味着:函数组件的每次渲染是独立的函数调用,每次调用都有自己独立的作用域和闭包。
闭包语义:函数组件捕获渲染时的值
让我们通过一个具体例子来理解这个差异。假设我们有一个计数器,点击按钮后延迟 3 秒显示当前值:
函数组件版本:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setTimeout(() => {
alert(`You clicked ${count} times`);
}, 3000);
}
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={handleClick}>Show after 3s</button>
<p>Count: {count}</p>
</div>
);
}
类组件版本:
class Counter extends React.Component {
state = { count: 0 };
handleClick = () => {
setTimeout(() => {
alert(`You clicked ${this.state.count} times`);
}, 3000);
};
render() {
return (
<div>
<button onClick={() => this.setState(s => ({ count: s.count + 1 }))}>+</button>
<button onClick={this.handleClick}>Show after 3s</button>
<p>Count: {this.state.count}</p>
</div>
);
}
}
现在执行以下操作:点击"+"按钮若干次,然后立刻点击"Show after 3s",在 3 秒内继续点击"+"。
- 函数组件:alert 显示的是你点击"Show after 3s"那一刻的 count 值。
- 类组件:alert 显示的是 3 秒后最新的 count 值。
这不是 bug,是设计。函数组件中的 handleClick 是在某次特定的渲染中创建的闭包,它捕获了那次渲染时 count 的值——这是一个具体的数字,不是对某个可变容器的引用。类组件中的 this.state.count 是对 this 这个可变实例的属性读取,this 始终指向同一个实例,所以总是读到最新值。
组件身份与 key
React 使用组件在树中的位置来决定是更新现有组件还是卸载并重新挂载。当一个组件在相同的位置渲染相同类型的组件时,React 复用现有实例(或 Fiber)并传入新 props。
key prop 是这个机制的显式控制开关。相同 key 意味着"这是同一个组件",不同 key 意味着"这是新的组件,应该销毁旧的"。
// 使用 key 强制重置
<UserProfile key={selectedId} userId={selectedId} />
当 selectedId 变化时,key 变化,React 会卸载旧组件、挂载新组件,所有内部状态都会被重置为初始值。这是一种干净、可预测的状态重置方式。
Level 2 · 它是怎么运行的(3-5年经验)
实例语义:类组件持有可变的 this
类组件的核心是 this——一个在整个组件生命周期内持续存在的可变对象。this.state、this.props、this.setState 都是通过这个对象访问的。
这带来了一个根本性的问题:this 是共享的、可变的,而时间是线性流动的。当你在一个异步操作中读取 this.props 或 this.state 时,你读到的不一定是触发该操作时的值,而是执行时的当前值。
// 类组件中捕获快照的手动方式
handleClick = () => {
const count = this.state.count; // 手动快照
setTimeout(() => {
alert(`You clicked ${count} times`);
}, 3000);
};
函数组件将这个"捕获快照"变成了默认行为——你无需做任何额外的事,每次渲染的数据自动是不可变的快照。
过时闭包:函数组件的陷阱
函数组件的闭包语义虽然在大多数情况下是正确的默认值,但在使用 Hooks 时,如果不理解这个语义,会踩入"过时闭包"(stale closure)的陷阱。
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 陷阱:count 永远是 0
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖数组:effect 只运行一次
return <div>Count: {count}</div>;
}
正确的解法:
方案一:使用函数式更新(推荐)
setCount(c => c + 1); // 使用更新函数,不依赖闭包中的 count
方案二:useRef 作为逃生舱
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
useRef 返回的对象在组件整个生命周期内是同一个对象(类似类组件的 this),所以读取 .current 总能得到最新值。
Level 3 · 规范怎么定义的(资深)
为什么函数组件最终胜出
函数组件并不是因为"更简洁"或"代码量更少"而胜出。它们胜出是因为它们的语义与 React 的数据模型更加一致。
React 的核心命题是:UI = f(state)。UI 是状态的纯函数。类组件通过 this 引入了一个贯穿时间的可变容器,使这个等式变得不纯粹——同样的 state 值,在不同时刻调用 render(),可能因为 this 上其他字段的变化而产生不同结果。
函数组件强制执行了这个等式:每次渲染就是用当前 props 和 state 调用函数,结果完全由输入决定。这使得组件更容易推理、更容易测试、更容易被编译器优化。
React 19 对组件模型的改进
React 19 引入了 Server Components 作为正式稳定特性,这对组件模型产生了影响。Server Components 是只在服务器上渲染的函数组件——它们没有状态,没有生命周期,但可以直接访问数据库和文件系统,结果作为 JSON 流传输到客户端。
// 这是一个 Server Component
async function UserList() {
const users = await db.query('SELECT * FROM users');
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Server Components 彻底不可能是类组件——它们是异步函数,完全符合"函数组件 = 纯函数"的哲学的延伸。
此外,React 19 的 Compiler 进一步强化了函数组件的优势:编译器可以静态分析函数组件,自动识别哪些值在渲染间不会改变,并自动插入记忆化。类组件因为 this 的存在,其可变性难以静态分析,无法从编译器获益。
Level 4 · 边界与陷阱(所有人)
陷阱一:在类组件中使用异步回调读取 this.props
class ProfilePage extends React.Component {
showMessage = () => {
// 3 秒后读取的 this.props 可能已经变了(父组件传了新 props)
setTimeout(() => alert(`Hello ${this.props.user}`), 3000);
};
}
如果用户在 3 秒内切换了 profile 页面,alert 会显示新用户的名字而非触发时的用户。解法:在事件处理开始时将 this.props.user 存为局部变量。
陷阱二:误以为 useRef 能替代 useState 触发渲染
function Bad() {
const countRef = useRef(0);
function handleClick() {
countRef.current++; // UI 不会更新!
}
return <p>{countRef.current}</p>;
}
useRef 的变更不触发重渲染。需要触发 UI 更新的数据必须用 useState。
陷阱三:函数组件不等于无状态组件
在 Hooks 出现之前,"函数组件"几乎等同于"无状态组件"(stateless component)。但 React 16.8 之后,函数组件可以拥有完整的状态和生命周期能力。在面试或技术讨论中,仍然混用这两个概念是不准确的。函数组件不是暂时的流行趋势,而是 React 长期演进方向的必然选择。