跳到主要內容
黯羽輕揚每天積累一點點

React Suspense 與 try...catch

免費2019-12-07#JS#React Suspense Boundary#React Suspense原理#React Suspense机制#React Suspense教程#UI层try catch

5 行代碼理解 React Suspense

一.UI 層的 try...catch

先拋出結論,Suspense 就像是 try...catch,決定 UI 是否安全

try {
  // 一旦有沒 ready 的東西
} catch {
  // 立即進入 catch 塊,走 fallback
}

那麼,如何定義安全?

試想,如果一個組件的代碼還沒加載完,就去渲染它,顯然是不安全的。所以,姑且狹義地認為組件代碼已就緒的組件就是安全的,包括同步組件和已加載完的異步組件(React.lazy),例如:

// 同步組件,安全
import OtherComponent from './OtherComponent';
// 異步組件,不安全
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));
// ...等到 AnotherComponent 代碼加載完成之後
// 已加載完的異步組件,安全
AnotherComponent

Error Boundary

有個類似的東西是 Error Boundary,也是 UI 層 try...catch 的一種,其安全的定義是組件代碼執行沒有 JavaScript Error:

Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed.

我們發現這兩種定義並不衝突,事實上,Suspense 與 Error Boundary 也確實能夠共存,比如通過 Error Boundary 來捕獲異步組件加載錯誤:

If the other module fails to load (for example, due to network failure), it will trigger an error. You can handle these errors to show a nice user experience and manage recovery with Error Boundaries.

例如:

import MyErrorBoundary from './MyErrorBoundary';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

const MyComponent = () => (
  <div>
    <MyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </MyErrorBoundary>
  </div>
);

二.手搓一個 Suspense

開篇的 5 行代碼可能有點意思,但還不夠清楚,繼續填充:

function Suspense(props) {
  const { children, fallback } = props;
  try {
    // 一旦有沒 ready 的東西
    React.Children.forEach(children, function() {
      assertReady(this);
    });
  } catch {
    // 立即進入 catch 塊,走 fallback
    return fallback;
  }

  return children;
}

assertReady 是個斷言,對於不安全的組件會拋出 Error:

import { isLazy } from "react-is";

function assertReady(element) {
  // 尚未加載完成的 Lazy 組件不安全
  if (isLazy(element) && element.type._status !== 1) {
    throw new Error('Not ready yet.');
  }
}

P.S. react-is 用來區分 Lazy 組件,而 _status 表示 Lazy 組件的加載狀態,具體見 React Suspense | 具體實現

試玩一下:

function App() {
  return (<>
    <Suspense fallback={<div>loading...</div>}>
      <p>Hello, there.</p>
    </Suspense>
    <Suspense fallback={<div>loading...</div>}>
      <LazyComponent />
    </Suspense>
    <Suspense fallback={<div>loading...</div>}>
      <ReadyLazyComponent />
    </Suspense>
    <Suspense fallback={<div>loading...</div>}>
      <p>Hello, there.</p>
      <LazyComponent />
      <ReadyLazyComponent />
    </Suspense>
  </>);
}

對應界面內容為:

Hello, there.
loading...
ready lazy component.
loading...

首次渲染結果符合預期,至於之後的更新過程(組件加載完成後把 loading 替換回實際內容),更多地屬於 Lazy 組件渲染機制的範疇,與 Suspense 關係不大,這裡不展開,感興趣可參考 React Suspense | 具體實現

P.S. 其中,ReadyLazyComponent 的構造有點小技巧:

const ReadyLazyComponent = React.lazy(() =>
  // 模擬 import('path/to/SomeOtherComponent.js')
  Promise.resolve({
    default: () => {
      return <p>ready lazy component.</p>;
    }
  })
);

// 把 Lazy Component 渲染一次,觸發其加載,使其 ready
const rootElement = document.getElementById("root");
// 僅用來預加載 lazy 組件,忽略缺少外層 Suspense 引發的 Warning
ReactDOM.createRoot(rootElement).render(<ReadyLazyComponent />);

setTimeout(() => {
  // 等上面渲染完後,ReadyLazyComponent 就真正 ready 了
});

因為Lazy Component 只在真正需要 render 時才加載(所謂 lazy),所以先渲染一次,之後再次使用時就 ready 了。

三.類比 try...catch

如上所述,Suspense 與 try...catch 的對應關係為:

  • Suspense:對應 try

  • fallback:對應 catch

  • 尚未加載完成的 Lazy Component:對應 Error

由於原理上的相似性,Suspense 的許多特點都可以通過類比 try...catch 來輕鬆理解,例如:

  • 就近 fallback:Error 拋出後向上找最近的 try 所對應的 catch

  • 存在未 ready 組件就 fallback:一大塊 try 中,只要有一個 Error 就立即進入 catch

所以,對於一組被 Suspense 包起來的組件,要麼全都展示出來(包括可能含有的 fallback 內容),要麼全都不展示(轉而展示該 Suspense 的 fallback),理解到這一點對於掌握 Suspense 尤為重要。

性能影響

如前面示例中的:

<Suspense fallback={<div>loading...</div>}>
  <p>Hello, there.</p>
  <LazyComponent />
  <ReadyLazyComponent />
</Suspense>

渲染結果為 loading...,因為處理到 LazyComponent 時觸發了 Suspense fallback,無論是已經處理完的 Hello, there.,還是尚未處理到的 ReadyLazyComponent 都無法展示。那麼,存在 3 個問題:

  • 傷及池魚:一個尚未加載完成的 Lazy Component 就能讓它前面許多本能立即顯示的組件無法顯示

  • 阻塞渲染:尚未加載完成的 Lazy Component 會阻斷渲染流程,阻塞最近 Suspense 祖先下其後所有組件的渲染,造成串行等待

所以,像使用 try...catch 一樣,濫用 Suspense 也會造成(UI 層的)性能影響,雖然技術上把整個應用都包到頂層 Suspense 裡確實能為所有 Lazy Component 提供 fallback:

<Suspense fallback={<div>global loading...</div>}>
  <App />
</Suspense>

但必須清楚地意識到這樣做的後果。

結構特點

Suspense 與 try...catch 一樣,通過提供一種固定結構來消除條件判斷:

try {
  // 如果出現 Error
} catch {
  // 則進入 catch
}

將分支邏輯固化到了語法結構中,Suspense 也類似:

<Suspense fallback={ /* 則進入 fallback */ }>
  { /* 如果出現未 ready 的 Lazy 組件 */ }
</Suspense>

這樣做的好處是代碼中不必出現條件判斷,因而不依賴局部狀態,我們能夠輕鬆調整其作用範圍���

<Suspense fallback={<div>loading...</div>}>
  <p>Hello, there.</p>
  <LazyComponent />
  <ReadyLazyComponent />
</Suspense>

改成:

<p>Hello, there.</p>
<Suspense fallback={<div>loading...</div>}>
  <LazyComponent />
</Suspense>
<ReadyLazyComponent />

前後幾乎沒有改動成本,甚至比調整 try...catch 邊界還要容易(因為不用考慮變量作用域),這對於無傷調整 loading 的粒度、順序很有意義

Suspense lets us change the granularity of our loading states and orchestrate their sequencing without invasive changes to our code.

四.在線 Demo

文中涉及的所以重要示例,都在 Demo 項目中(含詳盡註釋):

五.總結

Suspense 就像是 UI 層的 try...catch,但其捕獲的不是異常,而是尚未加載完成的組件。

當然,Error Boundary 也是,二者各 catch 各的互不衝突。

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論