类组件的时代
React 0.14 版本之前,组件的主流写法是类组件(class component)。那时候写一个带状态的组件是这样的:
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<button onClick={this.handleClick}>
点击 {this.state.count}
</button>
);
}
}
这种写法在当时是完全正常的,所有 React 文档和开源项目都采用这种模式。但它有几个显著的痛点。
类组件的几个问题
this 的问题。 类组件的方法需要手动绑定 this,不然在回调里调用时 this 会变成 undefined。this.handleClick = this.handleClick.bind(this) 这行代码在每个类组件里都要写,不写就会出错。这是一个纯粹的样板代码,没有任何业务价值。
重复的模板代码。 构造函数要写,super(props) 要调用,state 初始化要写在 constructor 里。这几行代码在每个类组件里都要重复,组件一多就显得冗余。
逻辑复用困难。 在 Hooks 出现之前,类组件之间的逻辑复用只有两种方式:render props 和高阶组件。这两种模式都能工作,但都会产生"包装地狱"——组件树里嵌套了一层又一层的提供者或者渲染函数,实际业务逻辑埋在回调深处,可读性很差。
生命周期函数的复杂性。 类组件的生命周期函数是按"时机"组织的,而不是按"关注点"组织的。比如 componentDidMount 和 componentWillUnmount 通常处理同一个订阅逻辑,但它们在代码里是分开的。如果只在 componentDidMount 里订阅而忘了在 componentWillUnmount 里取消订阅,就会产生内存泄漏。Hooks 出现之前,这类 bug 很常见。
函数组件的特点
React 0.14 引入了函数组件(functional component),但那时候的函数组件是"无状态的"——它们只是一个接收 props 返回 JSX 的函数,不能有自己的 state。
到了 React 16.8,Hooks 出现了。Hooks 机制让函数组件可以拥有状态和生命周期能力,同时保留了函数组件的简洁性。
用 Hooks 重写上面的计数器:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
点击 {count}
</button>
);
}
不需要构造函数、不需要绑定 this、不需要 class 关键字。代码行数少了三分之二,意图也更清晰了。
为什么Hooks能让函数组件有状态
理解这个问题的关键,是理解 React 怎么知道"每次渲染时哪个 state 对应哪个变量"。
React 给每个组件维护一个"状态队列"。当你调用 useState 时,React 会从队列里取出一个值返回,同时把下一个值放入队列。第一次渲染时队列是初始值,后续渲染时队列里的值来自上一次渲染的快照。
这意味着:同一个组件的多次渲染之间,React 通过队列机制维护了状态的连续性。
这不只是一个实现技巧,它的设计思路是:把"状态"看成组件的一个属性,而不是类的实例属性。函数组件每次渲染时,虽然函数会重新执行,但 React 在底层维护了状态的持久化,所以看起来像是"状态跟组件绑定在一起"。
从类组件到函数组件的迁移
Hooks 出现之后,社区花了几年时间从类组件迁移到函数组件。这个迁移不是单纯因为"函数组件更新",而是因为 Hooks 解决了类组件的几个核心问题。
逻辑复用方式改变了。 Hooks 允许把逻辑抽到一个自定义 Hook 里:
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
这个 Hook 在任何函数组件里都能用,而且使用时不需要任何包装组件。逻辑复用变得干净多了。
相关逻辑不用分散在不同生命周期函数里了。 同样一个订阅逻辑,在类组件里要分散在 componentDidMount 和 componentWillUnmount 里。在函数组件里,只需要一个 useEffect:
useEffect(() => {
const subscription = subscribe(handleChange);
return () => subscription.unsubscribe();
}, []);
这个 return 的函数就是清理函数,它在组件卸载时和 Effect 重新运行之前都会被调用,相关逻辑自然地组织在一起。
函数组件带来的新心智模型
函数组件 + Hooks 提供了一个和类组件完全不同的心智模型。
类组件的心智模型是:组件是一个有生命周期的小机器,状态是它的内部属性,props 是它的配置参数。
函数组件 + Hooks 的心智模型是:组件是一个状态的函数,状态随着渲染而持久化,副作用随着依赖变化而运行。
后者的思考方式更直接:给定这样的 props 和 state,应该渲染出这样的 UI。如果有副作用,用 useEffect 包装起来,并声明它的依赖。如果有多个状态,用多个 useState,或者用 useReducer 把它们组织在一起。
这个心智模型比类组件的"生命周期"模型更容易理解,也更容易推导——给定输入和状态,输出自然就确定了。
函数组件不是银弹
函数组件解决了类组件的很多痛点,但也带来了新的问题。
闭包陷阱。 函数组件里容易写出闭包陷阱。比如:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 这里 count 永远是 0
}, 1000);
return () => clearInterval(id);
}, []);
return <div onClick={() => setCount(count + 1)}>{count}</div>;
}
这个 setInterval 的回调里引用了 count,但因为 useEffect 的依赖数组是空的,这个回调捕获的是首次渲染时的 count 值,也就是 0。这个 bug 在类组件里不会发生,因为类组件的方法里的 this.state 永远是最新的。
解决方式是使用函数式更新 setCount(prev => prev + 1),或者把 count 放到依赖数组里。
useEffect 的依赖数组容易写错。 这是函数组件最常见的 bug 来源。依赖数组里漏了某个值,Effect 就不会在那个值变化时重新运行,可能导致使用了过期的值。这个问题 ESLint 的 exhaustive-deps 规则能帮上忙,但根本上还是需要对 Hooks 的执行时机有清晰的理解。
这一章想说的
函数组件成为主流不是因为它"新",而是因为 Hooks 机制解决了类组件的几个核心问题:样板代码多、this 绑定繁琐、逻辑复用困难、生命周期函数导致相关逻辑分散。
但函数组件和 Hooks 也带来了新的心智负担和新的 bug 模式。闭包陷阱和依赖数组问题是最常见的两个,需要对 Hooks 的执行时机有清晰的理解才能避免。
选择用类组件还是函数组件(现代 React 显然倾向函数组件),需要理解这两种范式的权衡,而不是盲目跟从趋势。