悄悄告訴你:React18文件裡寫錯的地方

卡頌發表於2022-04-27

大家好,我卡頌

React18正式版已經發布一段時間了,如果你升級到v18,且仍使用ReactDOM.render建立應用,會收到如下報警:

大意是說:v18使用createRoot而不是render建立應用,如果你仍使用render建立應用,那麼應用的行為將同v17一樣。

React團隊之所以有底氣讓大家都升級到v18,使用createRoot,是因為他們作出了承諾:

大意是說:如果你升級到v18,只要不使用併發特性(比如useTransition),React會和之前版本表現一致(更新會同步、不可中斷)

今天這篇文章想說的是:某些情況下,上述說法是錯誤的。

歡迎加入人類高質量前端框架群,帶飛

不說廢話,上示例

示例中有ab兩個狀態,首次渲染完2秒後會觸發ab更新。

其中觸發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 />
);

為了看清這兩者的區別,有兩種方式:

  1. 調大setA(9000)中的值,使頁面渲染更多項。頁面渲染時卡頓越明顯,渲染順序的差異越明顯
setTimeout(() => {
  setA(9000);
  BtnRef.current?.click();
}, 2000);
  1. react-dom.development.jscommitRootImpl方法中打斷點

這個方法是React渲染時呼叫的方法,在這裡打斷點可以看出頁面渲染的順序。

對於ReactDOM.render建立的應用,觸發更新後渲染順序如下:

首先:

其次:

對於ReactDOM.createRoot建立的應用,觸發更新後渲染順序如下:

首先:

其次:

渲染順序顯然是變了,這和React文件裡的說法是相悖的。

背後的原因是什麼呢?

更新的優先順序,無處不在

先解釋下示例中的b為什麼採用觸發onClick事件的方式間接觸發更新:

BtnRef.current?.click();

這是因為:不同方式觸發的更新有不同優先順序onClick回撥中觸發的更新是最高優的,即同步優先順序

那麼問題來了,v18不使用併發特性,所有更新不都該是同步、不可中斷麼?

這話是沒錯,更新本身是同步、不可中斷的。但是更新是需要排程的。

在示例中,如果採用ReactDOM.createRoot建立應用,那麼觸發更新時的優先順序如下:

setTimeout(() => {
  // 觸發更新,優先順序為“預設優先順序”
  setA(9000);
  // 觸發更新,優先順序為“同步優先順序”
  BtnRef.current?.click();
}, 2000);

接下來React的執行流程如下:

  1. a觸發更新,優先順序為“預設優先順序”
  2. 排程a的更新,優先順序為“預設優先順序”
  3. b觸發更新,優先順序為“同步優先順序”
  4. 排程b的更新,優先順序為“同步優先順序”
  5. 此時發現已經有個更新在排程(a的更新),且優先順序更低(預設優先順序 < 同步優先順序)
  6. 取消a的更新的排程,轉而開始排程b的更新
  7. 排程流程結束,開始同步、不可中斷的執行b的更新
  8. b對應更新渲染到頁面中
  9. 此時發現還有一個更新(a的更新),排程他
  10. 排程流程結束,開始同步、不可中斷的執行a的更新
  11. 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的執行流程如下:

  1. a觸發更新,因為是在setTimeout中觸發的,所以會同步執行後續更新流程
  2. a對應更新渲染到頁面中
  3. b觸發更新,因為是在setTimeout中觸發的,所以會同步執行後續更新流程
  4. b對應更新渲染到頁面中

總結

React作為一款維護了快10年的框架,在經歷重大版本更新後要保持框架行為前後一致,實屬不易。

更新順序的變化對一般應用影響不大。

但是,如果你的應用依賴更新後頁面中當前的值作出後續判斷,那麼需要注意升級到v18後的這些細微變化。

相關文章