HOWTO · React
使用 React Hooks 强制重新渲染
React Hooks 的引入改变了我们编写和构建 React 组件的方式。在本文中,我们将探讨如何使用 React Hooks 强制重新渲染。
本页内容
React 函数组件没有 forceUpdate() 方法,但这个问题背后的需求是真实的:某些东西变了,界面就应该把它显示出来。Hooks 给出的答案是:你永远不需要直接推动一次渲染——你更新状态,React 来调度渲染。实际做法就是:当显示的数据发生变化时使用 useState;当你确实需要一个背后没有真实数据的显式触发器时,用一个 useReducer 计数器。
本文解释了渲染的触发原因,演示了可用的 useState 和 useReducer 模式,并澄清了为什么 useEffect 和仅属于类组件的 forceUpdate() 不是函数组件的答案。
什么会导致 React 组件渲染
自 React 16.8 起,Hooks 让函数组件能够持有状态并响应更新。函数组件会在其输入之一发生变化时重新渲染:
useState的 setter 或useReducer的 dispatch 提供了一个新的状态值——React 用Object.is将它与之前的状态比较,不同则重新渲染。- 父组件重新渲染,于是组件函数带着新的(或相同的)props 再次运行。
- 组件读取的 context 值发生变化。
对「强制重新渲染」这个问题,有两点细节很重要。第一,渲染并不等同于改动 DOM——React 先渲染,然后只把差异提交到页面。第二,由于 React 在新状态完全相同时可以跳过渲染,把当前值传回 setter 并不是获得再一次渲染的可靠办法;触发器必须产生一个新值。
当渲染的数据发生变化时,使用 state
当屏幕上显示的信息变了,就用 state 把它表达出来。useState 是最直接的工具:
本节中的代码以及显示的标签或输出仍为英文。
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
function RerenderExample() {
const [count, setCount] = React.useState(0);
return (
<div>
<p>Current count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>
Refresh count
</button>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root'))
.render(<RerenderExample />);
</script>
</body>
</html>
本文中的每个 React 示例都是一个完整的浏览器页面:把它保存为 .html 文件并打开,CDN 脚本会加载 React 18 和 Babel,让 text/babel 中的 JSX 按原样运行。这些页面以静态浏览器源代码的形式呈现——本文的运行环境不会执行它们,因此不声称有任何控制台输出。第一次渲染时,段落显示 Current count: 0(示例中的界面标签保持英文不变);每次点击都会调度一次计数加一的渲染。更新函数形式 c => c + 1 基于最新状态构建,而不是基于创建事件处理器时捕获的值,这样快速连续点击也不会丢失递增。
这几乎覆盖了所有真实的「强制重新渲染」场景:界面过时只是因为状态还没有被更新而已。
用 reducer 实现显式的渲染触发器
偶尔,显示的数据并没有变化,但你仍然想让 React 再运行一次组件——这是 Hooks 中最接近强制更新的等价物,也是 React Hooks FAQ 推广过的模式。useReducer 可以做到:每次 dispatch 都返回一个全新的状态值,所以变更检查总是通过,渲染随之而来。
本节中的代码以及显示的标签或输出仍为英文。
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'reset':
return initialState;
default:
return state;
}
}
function RerenderWithReducer() {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>
Rerender
</button>
<button onClick={() => dispatch({ type: 'reset' })}>
Reset
</button>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root'))
.render(<RerenderWithReducer />);
</script>
</body>
</html>
每次 dispatch increment 都会返回一个新的 { count: ... } 对象,正是这个新值让 React 再次渲染。当 dispatch 了一个未知的 action 时,从 default 分支返回当前的 state 可以让界面保持不变,而不是抛出错误。
reducer 只是普通的 JavaScript,所以它的状态迁移可以直接验证。运行这个示例:
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
default:
return state;
}
}
let state = { count: 0 };
state = reducer(state, { type: 'increment' });
state = reducer(state, { type: 'increment' });
console.log(`Count: ${state.count}`);
Count: 2
当更新是多步骤的、依赖之前的状态,或者你需要的正是那个显式的计数器触发器时,选择 useReducer。对于单个变化的值,useState 依然更简单。
为什么不应该用 effect 强制渲染
useEffect 用于把组件与 React 之外的事物同步——获取的资源、订阅、定时器或手动管理的 DOM——并且它在渲染提交之后才运行。它不是渲染触发器。一个唯一职责就是调用状态 setter 来换取再一次渲染的 effect,例如 useEffect(() => { setTick(t => t + 1); }),白白付出一个额外的渲染-提交周期却什么新东西也没产出,而且如果它的依赖不断变化,还会无限循环下去。
这个模式的正当用法是:外部数据真正到达时才设置状态。这里的 subscribeProfile 充当对外部系统的获取或订阅的替身,使页面保持自足:
本节中的代码以及显示的标签或输出仍为英文。
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
function subscribeProfile(userId, onData) {
const timer = setTimeout(
() => onData({ name: 'Ada Lovelace' }), 500);
return () => clearTimeout(timer);
}
function ProfileCard({ userId }) {
const [profile, setProfile] = React.useState(null);
React.useEffect(() => {
return subscribeProfile(userId, setProfile);
}, [userId]);
return <p>{profile ? profile.name : 'Loading…'}</p>;
}
ReactDOM.createRoot(document.getElementById('root'))
.render(<ProfileCard userId={1} />);
</script>
</body>
</html>
这里渲染发生的原因是 setProfile 递交了新数据,而不是 effect 请求了一次渲染。返回退订函数可以防止迟到的数据在组件卸载或 effect 重跑之后仍然写入状态——这与在 fetch 周围使用 cancelled 标志的作用相同。当一个值可以从现有的 props 或 state 计算出来时,就在渲染函数体里计算并完全跳过 effect——渲染期间推导总是比渲染两次更便宜。
forceUpdate() 何时适用于类组件
forceUpdate() 只存在于类组件上。调用 this.forceUpdate() 会在跳过 shouldComponentUpdate 的情况下重新运行 render():
本节中的代码以及显示的标签或输出仍为英文。
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
class Refreshable extends React.Component {
render() {
return <p>Rendered at {Date.now()}</p>;
}
}
const refreshableRef = React.createRef();
function App() {
return (
<div>
<Refreshable ref={refreshableRef} />
<button onClick={
() => refreshableRef.current.forceUpdate()
}>
Force update
</button>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root'))
.render(<App />);
</script>
</body>
</html>
这里的按钮通过 ref 在类实例上调用 forceUpdate(),所以每次点击都会重新运行 render(),并在下一次绘制时显示新的时间戳(示例中的界面文本保持英文不变)。这个 API 被有意设计为最后手段:它绕过了 React 的正常数据流,因此 React 文档把它保留给非常规场景,例如输出依赖于位于 React 之外的可变数据。只要变化能用 state 表达,类组件里 setState 就是更好的工具——而函数组件根本没有 forceUpdate(),这正是上面的 useReducer 计数器成为旧 force-update 代码迁移到 Hooks 的常用替代方案的原因。
选择正确的更新模式
| 场景 | 选用 |
|---|---|
| 显示的数据发生了变化 | useState —— 设置新值 |
| 多步骤的状态迁移,或确实需要显式触发器 | useReducer —— 每次 dispatch 都返回新状态 |
| 与外部系统同步 | useEffect —— 渲染跟随它所设置的状态 |
| 新值等于当前状态 | 没有可靠手段 —— React 可能跳过这次渲染 |
| 类组件 | setState;forceUpdate() 仅用于外部可变数据 |
简短版本:在函数组件中,你不是推动一次渲染,而是更新数据,让 React 从这次变化中引出渲染。
常见问题
-
我可以在一个组件中使用多个 Hooks 吗?
可以。在顶层无条件地调用 Hooks,并在每次渲染中保持相同的顺序,React 就能正确管理它们各自的状态槽位。 -
什么时候
useReducer比useState更好?
当下一个状态以多种方式依赖上一个状态、多个相关的值一起变化、或者你需要显式的 dispatch 触发器时,优先选择useReducer。对于相互独立的简单值,useState就够了。 -
useEffect到底是用来做什么的?
与外部系统同步——获取数据、订阅、定时器、手动 DOM 操作——以及之后的清理工作。不应该用它来请求额外的渲染。 -
我可以在不改变状态的情况下强制重新渲染吗?
Hooks 没有提供这样的 API,而且把状态设置为当前值会让 React 跳过渲染。useReducer计数器之所以有效,恰恰因为每次 dispatch 都交给 React 一个新值。 -
forceUpdate()有 Hooks 版本吗?
没有——它是类组件方法。对于那些确实需要它的罕见场景,useReducer计数器模式是被认可的函数组件替代方案。