悄悄告诉你:React18文档里写错的地方
大家好,我卡颂
React18
正式版已经发布一段时间了,如果你升级到v18
,且仍使用ReactDOM.render
创建应用,会收到如下报警:
大意是说:v18
使用createRoot
而不是render
创建应用,如果你仍使用render
创建应用,那么应用的行为将同v17
一样。
React
团队之所以有底气让大家都升级到v18
,使用createRoot
,是因为他们作出了承诺:
大意是说:如果你升级到v18
,只要不使用「并发特性」(比如useTransition
),React
会和之前版本表现一致(更新会同步、不可中断)
今天这篇文章想说的是:某些情况下,上述说法是错误的。
不说废话,上示例
示例中有a
、b
两个状态,首次渲染完2秒后会触发a
、b
更新。
其中触发b
更新的方式比较特殊:模拟点击,间接触发b
更新:
function App() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const BtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
setTimeout(() => {
setA(9000);
BtnRef.current?.click();
}, 2000);
}, []);
return (
<div>
<button
ref={BtnRef}
onClick={() => setB(1)}>
b: {b}
</button>
{Array(a).fill(0).map((_, i) => {
return <div key={i}>{a}</div>;
})}
</div>
);
}
现在我们有两种挂载<App/>
的方式。
v18
之前的方式:
const rootElement = document.getElementById("root");
// v18之前创建应用的方式
ReactDOM.render(<App/>, rootElement);
v18
提供的方式:
const root = ReactDOM.createRoot(rootElement);
// v18创建应用的方式
root.render(
<App />
);
为了看清这两者的区别,有两种方式:
-
调大 setA(9000)
中的值,使页面渲染更多项。页面渲染时卡顿越明显,渲染顺序的差异越明显
setTimeout(() => {
setA(9000);
BtnRef.current?.click();
}, 2000);
-
在 react-dom.development.js
的commitRootImpl
方法中打断点
这个方法是React
渲染时调用的方法,在这里打断点可以看出页面渲染的顺序。
对于ReactDOM.render
创建的应用,触发更新后渲染顺序如下:
首先:
其次:
对于ReactDOM.createRoot
创建的应用,触发更新后渲染顺序如下:
首先:
其次:
渲染顺序显然是变了,这和React
文档里的说法是相悖的。
背后的原因是什么呢?
更新的优先级,无处不在
先解释下示例中的b
为什么采用「触发onClick事件」的方式间接触发更新:
BtnRef.current?.click();
这是因为:不同方式触发的更新有不同「优先级」,onClick回调
中触发的更新是最高优的,即「同步优先级」。
那么问题来了,v18
不使用并发特性,所有更新不都该是「同步、不可中断」么?
这话是没错,更新本身是「同步、不可中断」的。但是更新是需要调度的。
在示例中,如果采用ReactDOM.createRoot
创建应用,那么触发更新时的优先级如下:
setTimeout(() => {
// 触发更新,优先级为“默认优先级”
setA(9000);
// 触发更新,优先级为“同步优先级”
BtnRef.current?.click();
}, 2000);
接下来React
的执行流程如下:
-
a
触发更新,优先级为“默认优先级” -
调度
a
的更新,优先级为“默认优先级” -
b
触发更新,优先级为“同步优先级” -
调度
b
的更新,优先级为“同步优先级” -
此时发现已经有个更新在调度(
a
的更新),且优先级更低(默认优先级 < 同步优先级) -
取消
a
的更新的调度,转而开始调度b
的更新 -
调度流程结束,开始同步、不可中断的执行
b
的更新 -
b
对应更新渲染到页面中 -
此时发现还有一个更新(
a
的更新),调度他 -
调度流程结束,开始同步、不可中断的执行
a
的更新 -
a
对应更新渲染到页面中
可见,只要采用ReactDOM.createRoot
创建应用,那么「优先级」的影响就会一直存在,与「使用了并发特性」的区别是:
-
只有「默认优先级」与「同步优先级」
-
优先级只会影响调度,不会中断更新的执行
老版React的历史包袱
那么采用ReactDOM.render
创建的应用执行顺序又是怎么一回事呢?
记不记得一道经典(且毫无意义)的React
面试题:React
的更新是同步还是异步的?
下面两种情况,a
打印的结果是1
么?
// 情况1
onClick() {
this.setState({a: 1});
console.log(a);
}
// 情况2
onClick() {
setTimeout(() => {
this.setState({a: 1});
console.log(a);
})
}
其中,情况2中a
打印结果是1
。
之所以会有这种情况,是React
早期实现批处理
时的瑕疵造成的,并不是什么有意为之的特性。
当React
使用Fiber
架构重构后,完全可以规避这个瑕疵。但为了与老版本行为保持一致,刻意实现成这样。
所以,在我们的示例中,这两个更新不会受到「优先级」的影响,但会受到「为了兼容老版本」造成的影响:
setTimeout(() => {
setA(9000);
BtnRef.current?.click();
}, 2000);
React
的执行流程如下:
-
a
触发更新,因为是在setTimeout
中触发的,所以会同步执行后续更新流程 -
a
对应更新渲染到页面中 -
b
触发更新,因为是在setTimeout
中触发的,所以会同步执行后续更新流程 -
b
对应更新渲染到页面中
总结
React
作为一款维护了快10年的框架,在经历重大版本更新后要保持框架行为前后一致,实属不易。
更新顺序的变化对一般应用影响不大。
但是,如果你的应用依赖更新后「页面中当前的值」作出后续判断,那么需要注意升级到v18
后的这些细微变化。