本文介紹與 Suspense
在三種情景下使用方法,並結合原始碼進行相應解析。歡迎關注個人部落格。
Code Spliting
在 16.6 版本之前,code-spliting
通常是由第三方庫來完成的,比如 react-loadble(核心思路為: 高階元件 + webpack dynamic import), 在 16.6 版本中提供了 Suspense
和 lazy
這兩個鉤子, 因此在之後的版本中便可以使用其來實現 Code Spliting
。
目前階段, 服務端渲染中的
code-spliting
還是得使用react-loadable
, 可查閱 React.lazy, 暫時先不探討原因。
Code Spliting
在 React
中的使用方法是在 Suspense
元件中使用 <LazyComponent>
元件:
import { Suspense, lazy } from 'react'
const DemoA = lazy(() => import('./demo/a'))
const DemoB = lazy(() => import('./demo/b'))
<Suspense>
<NavLink to="/demoA">DemoA</NavLink>
<NavLink to="/demoB">DemoB</NavLink>
<Router>
<DemoA path="/demoA" />
<DemoB path="/demoB" />
</Router>
</Suspense>
複製程式碼
原始碼中 lazy
將傳入的引數封裝成一個 LazyComponent
function lazy(ctor) {
return {
$$typeof: REACT_LAZY_TYPE, // 相關型別
_ctor: ctor,
_status: -1, // dynamic import 的狀態
_result: null, // 存放載入檔案的資源
};
}
複製程式碼
觀察 readLazyComponentType 後可以發現 dynamic import
本身類似 Promise
的執行機制, 也具有 Pending
、Resolved
、Rejected
三種狀態, 這就比較好理解為什麼 LazyComponent
元件需要放在 Suspense
中執行了(Suspense
中提供了相關的捕獲機制, 下文會進行模擬實現`), 相關原始碼如下:
function readLazyComponentType(lazyComponent) {
const status = lazyComponent._status;
const result = lazyComponent._result;
switch (status) {
case Resolved: { // Resolve 時,呈現相應資源
const Component = result;
return Component;
}
case Rejected: { // Rejected 時,throw 相應 error
const error = result;
throw error;
}
case Pending: { // Pending 時, throw 相應 thenable
const thenable = result;
throw thenable;
}
default: { // 第一次執行走這裡
lazyComponent._status = Pending;
const ctor = lazyComponent._ctor;
const thenable = ctor(); // 可以看到和 Promise 類似的機制
thenable.then(
moduleObject => {
if (lazyComponent._status === Pending) {
const defaultExport = moduleObject.default;
lazyComponent._status = Resolved;
lazyComponent._result = defaultExport;
}
},
error => {
if (lazyComponent._status === Pending) {
lazyComponent._status = Rejected;
lazyComponent._result = error;
}
},
);
// Handle synchronous thenables.
switch (lazyComponent._status) {
case Resolved:
return lazyComponent._result;
case Rejected:
throw lazyComponent._result;
}
lazyComponent._result = thenable;
throw thenable;
}
}
}
複製程式碼
Async Data Fetching
為了解決獲取的資料在不同時刻進行展現的問題(在 suspenseDemo 中有相應演示), Suspense
給出瞭解決方案。
下面放兩段程式碼,可以從中直觀地感受在 Suspense
中使用 Async Data Fetching
帶來的便利。
- 一般進行資料獲取的程式碼如下:
export default class Demo extends Component {
state = {
data: null,
};
componentDidMount() {
fetchAPI(`/api/demo/${this.props.id}`).then((data) => {
this.setState({ data });
});
}
render() {
const { data } = this.state;
if (data == null) {
return <Spinner />;
}
const { name } = data;
return (
<div>{name}</div>
);
}
}
複製程式碼
- 在
Suspense
中進行資料獲取的程式碼如下:
const resource = unstable_createResource((id) => {
return fetchAPI(`/api/demo`)
})
function Demo {
const data = resource.read(this.props.id)
const { name } = data;
return (
<div>{name}</div>
);
}
複製程式碼
可以看到在 Suspense
中進行資料獲取的程式碼量相比正常的進行資料獲取的程式碼少了將近一半!少了哪些地方呢?
- 減少了
loading
狀態的維護(在最外層的 Suspense 中統一維護子元件的 loading) - 減少了不必要的生命週期的書寫
總結: 如何在 Suspense 中使用 Data Fetching
當前 Suspense
的使用分為三個部分:
第一步: 用 Suspens
元件包裹子元件
import { Suspense } from 'react'
<Suspense fallback={<Loading />}>
<ChildComponent>
</Suspense>
複製程式碼
第二步: 在子元件中使用 unstable_createResource
:
import { unstable_createResource } from 'react-cache'
const resource = unstable_createResource((id) => {
return fetch(`/demo/${id}`)
})
複製程式碼
第三步: 在 Component
中使用第一步建立的 resource
:
const data = resource.read('demo')
複製程式碼
相關思路解讀
來看下原始碼中 unstable_createResource
的部分會比較清晰:
export function unstable_createResource(fetch, maybeHashInput) {
const resource = {
read(input) {
...
const result = accessResult(resource, fetch, input, key);
switch (result.status) {
case Pending: {
const suspender = result.value;
throw suspender;
}
case Resolved: {
const value = result.value;
return value;
}
case Rejected: {
const error = result.value;
throw error;
}
default:
// Should be unreachable
return (undefined: any);
}
},
};
return resource;
}
複製程式碼
結合該部分原始碼, 進行如下推測:
- 第一次請求沒有快取, 子元件
throw
一個thenable
物件,Suspense
元件內的componentDidCatch
捕獲之, 此時展示Loading
元件; - 當
Promise
態的物件變為完成態後, 頁面重新整理此時resource.read()
獲取到相應完成態的值; - 之後如果相同引數的請求, 則走
LRU
快取演算法, 跳過Loading
元件返回結果(快取演算法見後記);
官方作者是說法如下:
所以說法大致相同, 下面實現一個簡單版的 Suspense
:
class Suspense extends React.Component {
state = {
promise: null
}
componentDidCatch(e) {
if (e instanceof Promise) {
this.setState({
promise: e
}, () => {
e.then(() => {
this.setState({
promise: null
})
})
})
}
}
render() {
const { fallback, children } = this.props
const { promise } = this.state
return <>
{ promise ? fallback : children }
</>
}
}
複製程式碼
進行如下呼叫
<Suspense fallback={<div>loading...</div>}>
<PromiseThrower />
</Suspense>
let cache = "";
let returnData = cache;
const fetch = () =>
new Promise(resolve => {
setTimeout(() => {
resolve("資料載入完畢");
}, 2000);
});
class PromiseThrower extends React.Component {
getData = () => {
const getData = fetch();
getData.then(data => {
returnData = data;
});
if (returnData === cache) {
throw getData;
}
return returnData;
};
render() {
return <>{this.getData()}</>;
}
}
複製程式碼
效果除錯可以點選這裡, 在 16.6
版本之後, componentDidCatch
只能捕獲 commit phase
的異常。所以在 16.6
版本之後實現的 <PromiseThrower>
又有一些差異(即將 throw thenable
移到 componentDidMount
中進行)。
ConcurrentMode + Suspense
當網速足夠快, 資料立馬就獲取到了,此時頁面存在的 Loading
按鈕就顯得有些多餘了。(在 suspenseDemo 中有相應演示), Suspense
在 Concurrent Mode
下給出了相應的解決方案, 其提供了 maxDuration
引數。用法如下:
<Suspense maxDuration={500} fallback={<Loading />}>
...
</Suspense>
複製程式碼
該 Demo 的效果為當獲取資料的時間大於(是否包含等於還沒確認) 500 毫秒, 顯示自定義的 <Loading />
元件, 當獲取資料的時間小於 500 毫秒, 略過 <Loading>
元件直接展示使用者的資料。相關原始碼。
需要注意的是 maxDuration
屬性只有在 Concurrent Mode
下才生效, 可參考原始碼中的註釋。在 Sync 模式下, maxDuration
始終為 0。
後記: 快取演算法
LRU
演算法:Least Recently Used
最近最少使用演算法(根據時間);LFU
演算法:Least Frequently Used
最近最少使用演算法(根據次數);
若資料的長度限定是 3, 訪問順序為 set(2,2),set(1,1),get(2),get(1),get(2),set(3,3),set(4,4)
, 則根據 LRU
演算法刪除的是 (1, 1)
, 根據 LFU
演算法刪除的是 (3, 3)
。
react-cache
採用的是 LRU
演算法。
相關資料
- suspenseDemo: 文字相關案例都整合在該 demo 中
- Releasing Suspense:
Suspense
開發進度 - the suspense is killing redux