jotai
是一个管理 React 全局状态的第三方库。我在学习的时候发现它一个“缺陷”:即便是最简单的代码,也会导致二次“渲染”:
import { atom, useAtomValue } from 'jotai';
const countAtom = atom(0);
const Page = () => {
const count = useAtomValue(count);
return <div>{count}</div>;
}
我去 jotai
的 GitHub 查了相关的问题,确实有不少人提问,而官方在 issue 1015 和 issue 1444 给了解释:jotai
的实现使用了 useReducer
,二次“渲染”是由 useReducer
引起的。
在 React 18 中,去除了 useReducer
的 跳出机制(eager bailout),来修复 React 中的一些其他问题。跳出机制是指:在更新状态时,如果状态值相同,则不去触发一个新的更新。比如 useState
仍保持着这一行为机制。而对于 useReducer
举个简单例子:
import { useReducer } from 'react';
const initialState = 0;
const reducer = (state, action) => {
switch(action) {
case 'reset':
return initialState;
// other actions...
}
};
const Page = () => {
console.log('rendering...'); // 检测是否被渲染
const [count, dispatch] = useReducer(reducer, initialState);
return <button onClick={() => dispatch('reset')}>Reset</button>;
}
当点击按钮时,在 React 17 中是不会触发 console
打印的,而在 18 中每次点击都会触发。但这并不意味着 Page
在每次点击按钮时都被重复渲染了。React 之所以能自信地把 useReducer
的跳出机制移除,是因为 React 背后还有 Fiber 调度,Fiber 会比对新旧参数来确定是否真的提交渲染工作,即应用到 DOM 上。
通过 Fiber ,React 将渲染工作分为了两个阶段:
- 一阶段:比对组件树(fiber 树),确定所需的更新操作;
- 二阶段:将更新操作应用到 DOM 上。
在 React 18 中,引入了渲染中断机制,即旧的渲染工作可以被新的渲染工作打断。这意味着组件的一次更新可能确实进入过一阶段,但不会进入二阶段。而上面的例子中,console
打印只能表明渲染工作进入了一阶段。而如果要确定是否进入了二阶段,可以用一个不带依赖项的 useEffect
,它代表组件确实被更新了,而不仅仅只是被渲染:
const Page = () => {
console.log('rendering...'); // 检测是否被渲染
const [count, dispatch] = useReducer(reducer, initialState);
useEffect(() => console.log('render committed')); // 检测是否被更新
return <button onClick={() => dispatch('reset')}>Reset</button>;
}