一个只有「点我」两个字的按钮,用 class 写要多少行?构造函数、super()、初始化 state、绑定 this、定义方法、render 返回,一共六段,实际有效逻辑只有一行。这还只是一个按钮,真实项目里一个页面组件动辄七八个生命周期方法,同一件事的逻辑被拆在 componentDidMount 和 componentWillUnmount 两头。
Hooks 就是来把这套东西拆掉的。这篇按原文的顺序过一遍四个核心 Hook,每个都补上当年没讲的边界条件,最后单开一节说明 2019 到今天 React 19 之间这套 API 发生了什么变化。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 组件类到底重在哪,Hooks 想解决的是什么问题
useState的函数式更新、惰性初始化,以及它和 class 里setState不合并这个差异useContext共享状态的写法,和它带来的重渲染代价useReducer什么时候比useState划算,它和 Redux 的界限在哪useEffect的依赖数组语义、清理函数、以及请求竞态怎么处理- Hooks 的两条硬规则,以及 React 18/19 上的新变化
# 一、组件类的缺点
React 的核心是组件。v16.8 版本之前,组件的标准写法是类(class)。下面是一个简单的组件类:
import React, { Component } from "react";
export default class Button extends Component {
constructor() {
super();
this.state = { buttonText: "Click me, please" };
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState(() => {
return { buttonText: "Thanks, been clicked!" };
});
}
render() {
const { buttonText } = this.state;
return <button onClick={this.handleClick}>{buttonText}</button>;
}
}
这个组件类仅仅是一个按钮,但可以看到,它的代码已经很「重」了。真实的 React App 由多个类按照层级一层层构成,复杂度成倍增长。再加入 Redux,就变得更复杂。
代码量只是表面。真正难受的是两件事。
一是 this 的指向问题。handleClick 必须在构造函数里 bind,或者写成 class 属性配合箭头函数,忘了就是运行时报 Cannot read property 'setState' of undefined。二是逻辑复用没有好办法,高阶组件和 render props 都能做,但代价是组件树里多出一堆没有实际渲染意义的包装层,React DevTools 里翻五层才找到自己的组件。
还有一个更隐蔽的问题。同一件事的代码会被生命周期强行拆开:订阅写在 componentDidMount,退订写在 componentWillUnmount,中间隔着几百行;而不相干的事情反而被塞在同一个方法里。逻辑按「时机」组织,而不是按「关注点」组织。
# 二、Hook 的含义
React Hooks的意思是,组件尽量写成纯函数,如果需要外部功能和副作用,就用钩子把外部代码「钩」进来。React Hooks就是那些钩子- 你需要什么功能,就使用什么钩子。
React默认提供了一些常用钩子,你也可以封装自己的钩子 - 所有的钩子都是为函数引入外部功能,所以
React约定,钩子一律使用 use 前缀命名,便于识别。你要使用xxx功能,钩子就命名为usexxx
React 默认提供的四个最常用的钩子:
useState()useContext()useReducer()useEffect()
use 前缀不只是命名习惯,它是 ESLint 规则赖以工作的信号。eslint-plugin-react-hooks 靠函数名是不是 use 开头来判断这是不是一个 Hook,进而检查调用位置合不合法。名字写成 getUserState 而不是 useUserState,规则就管不到它了。
# 三、useState():状态钩子
useState() 用于为函数组件引入状态(state)。纯函数不能有状态,所以把状态放在钩子里面。
用户点击按钮会导致按钮的文字改变,文字取决于用户是否点击,这就是状态。使用 useState() 重写如下:
import React, { useState } from "react";
export default function Button() {
const [buttonText, setButtonText] = useState("Click me, please");
function handleClick() {
return setButtonText("Thanks, been clicked!");
}
return <button onClick={handleClick}>{buttonText}</button>;
}
对比一下上面的 class 版本,构造函数没了,bind 没了,this 没了,六段变成了三行。
useState() 这个函数接受状态的初始值作为参数,上例的初始值为按钮的文字。该函数返回一个数组,第一个成员是一个变量(上例是 buttonText),指向状态的当前值。第二个成员是一个函数,用来更新状态,约定是 set 前缀加上状态的变量名(上例是 setButtonText)。
# 3.1 三个当年没讲清楚的点
第一个是状态不合并。class 里的 this.setState({ a: 1 }) 会和已有 state 做浅合并,b 字段还在。useState 的 setter 是整体替换,你写 setUser({ name: 'x' }),原来的 age 就没了。想保留得自己展开:
setUser(prev => ({ ...prev, name: 'x' }))
第二个是函数式更新。setter 接受一个函数,参数是上一次的状态值。连续更新同一个状态时必须用这个形式:
// 这样写只会加 1,两次拿到的 count 是同一个快照
setCount(count + 1)
setCount(count + 1)
// 这样写才是加 2
setCount(c => c + 1)
setCount(c => c + 1)
原因是 count 是这次渲染闭包里的一个常量,在这次渲染的整个生命周期内它都不会变。这就是所谓的闭包陷阱,在 setTimeout、事件监听、异步回调里表现得最明显,因为它们捕获的可能是好几次渲染之前的值。
第三个是惰性初始化。useState(expensiveCalc()) 里的 expensiveCalc() 每次渲染都会被执行,只是返回值从第二次起被丢掉了。要避免这个浪费,把它包成函数传进去:
const [data, setData] = useState(() => expensiveCalc())
这三条里,第二条踩的人最多。
# 四、useContext():共享状态钩子
如果需要在组件之间共享状态,可以使用 useContext()。现在有两个组件 Navbar 和 Messages,我们希望它们之间共享状态:
<div className="App">
<Navbar/>
<Messages/>
</div>
第一步是使用 React Context API,在组件外部建立一个 Context:
const AppContext = React.createContext({});
组件封装代码如下:
<AppContext.Provider value={{
username: 'superawesome'
}}>
<div className="App">
<Navbar/>
<Messages/>
</div>
</AppContext.Provider>
AppContext.Provider 提供了一个 Context 对象,这个对象可以被子组件共享。
Navbar 组件的代码如下:
const Navbar = () => {
const { username } = useContext(AppContext);
return (
<div className="navbar">
<p>AwesomeSite</p>
<p>{username}</p>
</div>
);
}
useContext() 钩子函数用来引入 Context 对象,从中获取 username 属性。
Message 组件的代码也类似:
const Messages = () => {
const { username } = useContext(AppContext)
return (
<div className="messages">
<h1>Messages</h1>
<p>1 message for {username}</p>
<p className="message">useContext is awesome!</p>
</div>
)
}
上面这段代码有个隐患,value 写的是对象字面量。Provider 所在的组件每次重渲染,这个对象都是新引用,所有消费这个 Context 的组件都会跟着重渲染,哪怕 username 一个字都没变。
正确的做法是把 value 用 useMemo 稳住:
const value = useMemo(() => ({ username }), [username])
还有一个绕不过去的限制。Context 的更新粒度是整个 value 对象,你没法做到「只订阅 value 里的某个字段」。一个大 Context 里塞了用户信息、主题、语言三样东西,改语言会让读用户信息的组件也重渲染。所以实践中的做法是按变更频率拆分多个 Context,而不是塞进一个大对象。
这也是 Zustand、Jotai 这类库存在的理由之一,它们在订阅粒度上比 Context 细得多。
# 五、useReducer():action 钩子
- React 本身不提供状态管理功能,通常需要使用外部库。这方面最常用的库是 Redux
- Redux 的核心概念是,组件发出
action与状态管理器通信。状态管理器收到action以后,使用Reducer函数算出新的状态,Reducer函数的形式是(state, action) => newState useReducer()钩子用来引入Reducer功能
const [state, dispatch] = useReducer(reducer, initialState);
上面是 useReducer() 的基本用法,它接受 Reducer 函数和状态的初始值作为参数,返回一个数组。数组的第一个成员是状态的当前值,第二个成员是发送 action 的 dispatch 函数。
下面是一个计数器的例子。用于计算状态的 Reducer 函数如下: