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 从这次变化中引出渲染。

常见问题

  1. 我可以在一个组件中使用多个 Hooks 吗?
    可以。在顶层无条件地调用 Hooks,并在每次渲染中保持相同的顺序,React 就能正确管理它们各自的状态槽位。

  2. 什么时候 useReducer 比 useState 更好?
    当下一个状态以多种方式依赖上一个状态、多个相关的值一起变化、或者你需要显式的 dispatch 触发器时,优先选择 useReducer。对于相互独立的简单值,useState 就够了。

  3. useEffect 到底是用来做什么的?
    与外部系统同步——获取数据、订阅、定时器、手动 DOM 操作——以及之后的清理工作。不应该用它来请求额外的渲染。

  4. 我可以在不改变状态的情况下强制重新渲染吗?
    Hooks 没有提供这样的 API,而且把状态设置为当前值会让 React 跳过渲染。useReducer 计数器之所以有效,恰恰因为每次 dispatch 都交给 React 一个新值。

  5. forceUpdate() 有 Hooks 版本吗?
    没有——它是类组件方法。对于那些确实需要它的罕见场景,useReducer 计数器模式是被认可的函数组件替代方案。