悄悄告訴你:React18文檔里寫錯的地方
大家好,我卡頌
React18正式版已經(jīng)發(fā)布一段時間了,如果你升級到v18,且仍使用ReactDOM.render創(chuàng)建應用,會收到如下報警:
大意是說:v18使用createRoot而不是render創(chuàng)建應用,如果你仍使用render創(chuàng)建應用,那么應用的行為將同v17一樣。
React團隊之所以有底氣讓大家都升級到v18,使用createRoot,是因為他們作出了承諾:
大意是說:如果你升級到v18,只要不使用「并發(fā)特性」(比如useTransition),React會和之前版本表現(xiàn)一致(更新會同步、不可中斷)。
今天這篇文章想說的是:某些情況下,上述說法是錯誤的。
不說廢話,上示例示
例中有a、b兩個狀態(tài),首次渲染完2秒后會觸發(fā)a、b更新。
其中觸發(fā)b更新的方式比較特殊:模擬點擊,間接觸發(fā)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>
);
}
完整示例地址[1]
現(xiàn)在我們有兩種掛載的方式。
v18之前的方式:
const rootElement = document.getElementById("root");
// v18之前創(chuàng)建應用的方式
ReactDOM.render(<App/>, rootElement);
v18提供的方式:
const root = ReactDOM.createRoot(rootElement);
// v18創(chuàng)建應用的方式
root.render(
<App />
);
為了看清這兩者的區(qū)別,有兩種方式:
調大setA(9000)中的值,使頁面渲染更多項。頁面渲染時卡頓越明顯,渲染順序的差異越明顯。
setTimeout(() => {
setA(9000);
BtnRef.current?.click();
}, 2000);
在react-dom.development.js的commitRootImpl方法中打斷點。
這個方法是React渲染時調用的方法,在這里打斷點可以看出頁面渲染的順序。
對于ReactDOM.render創(chuàng)建的應用,觸發(fā)更新后渲染順序如下:
首先:
其次:
對于ReactDOM.createRoot創(chuàng)建的應用,觸發(fā)更新后渲染順序如下:
首先:
其次:
渲染順序顯然是變了,這和React文檔里的說法是相悖的。
背后的原因是什么呢?
更新的優(yōu)先級,無處不在
先解釋下示例中的b為什么采用「觸發(fā)onClick事件」的方式間接觸發(fā)更新:
BtnRef.current?.click();
這是因為:不同方式觸發(fā)的更新有不同「優(yōu)先級」,onClick回調中觸發(fā)的更新是最高優(yōu)的,即「同步優(yōu)先級」。
那么問題來了,v18不使用并發(fā)特性,所有更新不都該是「同步、不可中斷」么?
這話是沒錯,更新本身是「同步、不可中斷」的。但是更新是需要調度的。
在示例中,如果采用ReactDOM.createRoot創(chuàng)建應用,那么觸發(fā)更新時的優(yōu)先級如下:
setTimeout(() => {
// 觸發(fā)更新,優(yōu)先級為“默認優(yōu)先級”
setA(9000);
// 觸發(fā)更新,優(yōu)先級為“同步優(yōu)先級”
BtnRef.current?.click();
}, 2000);
接下來React的執(zhí)行流程如下:
- a觸發(fā)更新,優(yōu)先級為“默認優(yōu)先級”。
- 調度a的更新,優(yōu)先級為“默認優(yōu)先級”。
- b觸發(fā)更新,優(yōu)先級為“同步優(yōu)先級”。
- 調度b的更新,優(yōu)先級為“同步優(yōu)先級”。
- 此時發(fā)現(xiàn)已經(jīng)有個更新在調度(a的更新),且優(yōu)先級更低(默認優(yōu)先級 < 同步優(yōu)先級)。
- 取消a的更新的調度,轉而開始調度b的更新。
- 調度流程結束,開始同步、不可中斷的執(zhí)行b的更新。
- b對應更新渲染到頁面中。
- 此時發(fā)現(xiàn)還有一個更新(a的更新),調度他。
- 調度流程結束,開始同步、不可中斷的執(zhí)行a的更新。
- a對應更新渲染到頁面中。
可見,只要采用ReactDOM.createRoot創(chuàng)建應用,那么「優(yōu)先級」的影響就會一直存在,與「使用了并發(fā)特性」的區(qū)別是:
- 只有「默認優(yōu)先級」與「同步優(yōu)先級」。
- 優(yōu)先級只會影響調度,不會中斷更新的執(zhí)行。
老版React的歷史包袱
那么采用ReactDOM.render創(chuàng)建的應用執(zhí)行順序又是怎么一回事呢?
記不記得一道經(jīng)典(且毫無意義)的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早期實現(xiàn)批處理時的瑕疵造成的,并不是什么有意為之的特性。
當React使用Fiber架構重構后,完全可以規(guī)避這個瑕疵。但為了與老版本行為保持一致,刻意實現(xiàn)成這樣。
所以,在我們的示例中,這兩個更新不會受到「優(yōu)先級」的影響,但會受到「為了兼容老版本」造成的影響:
setTimeout(() => {
setA(9000);
BtnRef.current?.click();
}, 2000);
React的執(zhí)行流程如下:
- a觸發(fā)更新,因為是在setTimeout中觸發(fā)的,所以會同步執(zhí)行后續(xù)更新流程。
- a對應更新渲染到頁面中。
- b觸發(fā)更新,因為是在setTimeout中觸發(fā)的,所以會同步執(zhí)行后續(xù)更新流程。
- b對應更新渲染到頁面中。
總結
React作為一款維護了快10年的框架,在經(jīng)歷重大版本更新后要保持框架行為前后一致,實屬不易。
更新順序的變化對一般應用影響不大。
但是,如果你的應用依賴更新后「頁面中當前的值」作出后續(xù)判斷,那么需要注意升級到v18后的這些細微變化。
參考資料
[1]完整示例地址:
https://codesandbox.io/s/strange-cartwright-iq1s2m?file=/src/index.tsx。