一.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 各的互不衝突。
暫無評論,快來發表你的看法吧