作者:大柱 政采雲前端團隊
轉發連結:https://mp.weixin.qq.com/s/l_kv6rzUXSF3R9bfIko5BQ
React.lazy 是什麼
隨著前端應用體積的擴大,資源加載的優化是我們必須要面對的問題,動態代碼加載就是其中的一個方案,webpack 提供了符合 ECMAScript 提案(https://github.com/tc39/proposal-dynamic-import) 的 import()語法(https://www.webpackjs.com/api/module-methods#import-) ,讓我們來實現動態的加載模塊(註:require.ensure 與 import() 均為 webpack 提供的代碼動態加載方案,在 webpack 2.x 中,require.ensure 已被 import 取代)。
在 React 16.6 版本中,新增了 React.lazy 函數,它能讓你像渲染常規組件一樣處理動態引入的組件,配合 webpack 的 Code Splitting,只有當組件被加載,對應的資源才會導入 ,從而達到懶加載的效果。
使用 React.lazy
在實際的使用中,首先是引入組件方式的變化:
// 不使用 React.lazy
import OtherComponent from './OtherComponent';
// 使用 React.lazy
const OtherComponent = React.lazy(() => import('./OtherComponent'))
React.lazy 接受一個函數作為參數,這個函數需要調用 import() 。它需要返回一個 Promise,該 Promise 需要 resolve 一個 defalut export 的 React 組件。
// react/packages/shared/ReactLazyComponent.js
export const Pending = 0;
export const Resolved = 1;
export const Rejected = 2;
在控制台列印可以看到,React.lazy 方法返回的是一個 lazy 組件的對象,類型是 react.lazy,並且 lazy 組件具有 _status 屬性,與 Promise 類似它具有 Pending、Resolved、Rejected 三個狀態,分別代表組件的加載中、已加載、和加載失敗三種狀態。
需要注意的一點是,React.lazy 需要配合 Suspense 組件一起使用,在 Suspense 組件中渲染 React.lazy 異步加載的組件。如果單獨使用 React.lazy,React 會給出錯誤提示。
上面的錯誤指出組件渲染掛起時,沒有 fallback UI,需要加上 Suspense 組件一起使用。
其中在 Suspense 組件中,fallback 是一個必需的占位屬性,如果沒有這個屬性的話也是會報錯的。
接下來我們可以看看渲染效果,為了更清晰的展示加載效果,我們將網絡環境設置為 Slow 3G。
組件的加載效果:
可以看到在組件未加載完成前,展示的是我們所設置的 fallback 組件。
在動態加載的組件資源比較小的情況下,會出現 fallback 組件一閃而過的的體驗問題,如果不需要使用可以將 fallback 設置為 null。
當然針對這種場景,React 也提供了對應的解決方案,在 Concurrent Mode(https://react.docschina.org/docs/concurrent-mode-intro.html) 模式下,給 Suspense 組件設置 maxDuration 屬性,當異步獲取數據的時間大於 maxDuration 時間時,則展示 fallback 的內容,否則不展示。
<Suspense
maxDuration={500}
fallback={<div>抱歉,請耐心等待 Loading...</div>}
>
<OtherComponent />
<OtherComponentTwo />
</Suspense>
注:需要注意的一點是 Concurrent Mode 目前仍是試驗階段的特性,不可用於生產環境。
Suspense 可以包裹多個動態加載的組件,這也意味著在加載這兩個組件的時候只會有一個 loading 層,因為 loading 的實現實際是 Suspense 這個父組件去完成的,當所有的子組件對象都 resolve 後,再去替換所有子組件。這樣也就避免了出現多個 loading 的體驗問題。所以 loading 一般不會針對某個子組件,而是針對整體的父組件做 loading 處理。
以上是 React.lazy 的一些使用介紹,下面我們一起來看看整個懶加載過程中一些核心內容是怎麼實現的,首先是資源的動態加載。
Webpack 動態加載
上面使用了 import() 語法,webpack 檢測到這種語法會自動代碼分割。使用這種動態導入語法代替以前的靜態引入,可以讓組件在渲染的時候,再去加載組件對應的資源,這個異步加載流程的實現機制是怎麼樣呢?
話不多說,直接看代碼:
__webpack_require__.e = function requireEnsure(chunkId) {
// installedChunks 是在外層代碼中定義的對象,可以用來緩存了已加載 chunk
var installedChunkData = installedChunks[chunkId]
// 判斷 installedChunkData 是否為 0:表示已加載
if (installedChunkData === 0) {
return new Promise(function(resolve) {
resolve()
})
}
if (installedChunkData) {
return installedChunkData[2]
}
// 如果 chunk 還未加載,則構造對應的 Promsie 並緩存在 installedChunks 對象中
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject]
})
installedChunkData[2] = promise
// 構造 script 標籤
var head = document.getElementsByTagName("head")[0]
var script = document.createElement("script")
script.type = "text/javascript"
script.charset = "utf-8"
script.async = true
script.timeout = 120000
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc)
}
script.src =
__webpack_require__.p +
"static/js/" +
({ "0": "alert" }[chunkId] || chunkId) +
"." +
{ "0": "620d2495" }[chunkId] +
".chunk.js"
var timeout = setTimeout(onScriptComplete, 120000)
script.onerror = script.onload = onScriptComplete
function onScriptComplete() {
script.onerror = script.onload = null
clearTimeout(timeout)
var chunk = installedChunks[chunkId]
// 如果 chunk !== 0 表示加載失敗
if (chunk !== 0) {
// 返回錯誤信息
if (chunk) {
chunk[1](new Error("Loading chunk " + chunkId + " failed."))
}
// 將此 chunk 的加載狀態重置為未加載狀態
installedChunks[chunkId] = undefined
}
}
head.appendChild(script)
// 返回 fullfilled 的 Promise
return promise
}
結合上面的代碼來看,webpack 通過創建 script 標籤來實現動態加載的,找出依賴對應的 chunk 信息,然後生成 script 標籤來動態加載 chunk,每個 chunk 都有對應的狀態:未加載、 加載中、已加載。
我們可以運行 React.lazy 代碼來具體看看 network 的變化,為了方便辨認 chunk。我們可以在 import 裡面加入 webpackChunckName 的注釋,來指定包文件名稱。
const OtherComponent = React.lazy(() => import(/* webpackChunkName: "OtherComponent" */'./OtherComponent'));
const OtherComponentTwo = React.lazy(() => import(/* webpackChunkName: "OtherComponentTwo" */'./OtherComponentTwo'));
webpackChunckName 後面跟的就是打包後組件的名稱。
打包後的文件中多了動態引入的 OtherComponent、OtherComponentTwo 兩個 js 文件。
如果去除動態引入改為一般靜態引入:
可以很直觀的看到二者文件的數量以及大小的區別。
以上是資源的動態加載過程,當資源加載完成之後,進入到組件的渲染階段,下面我們再來看看,Suspense 組件是如何接管 lazy 組件的。
Suspense 組件
同樣的,先看代碼,下面是 Suspense 所依賴的 react-cache 部分簡化源碼:
// react/packages/react-cache/src/ReactCache.js
export function unstable_createResource<I, K: string | number, V>( fetch: I => Thenable<V>,
maybeHashInput?: I => K,): Resource<I, V> {
const hashInput: I => K =
maybeHashInput !== undefined ? maybeHashInput : (identityHashFn: any);
const resource = {
read(input: I): V {
readContext(CacheContext);
const key = hashInput(input);
const result: Result<V> = 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);
}
},
preload(input: I): void {
readContext(CacheContext);
const key = hashInput(input);
accessResult(resource, fetch, input, key);
},
};
return resource;
}
從上面的源碼中看到,Suspense 內部主要通過捕獲組件的狀態去判斷如何加載,上面我們提到 React.lazy 創建的動態加載組件具有 Pending、Resolved、Rejected 三種狀態,當這個組件的狀態為 Pending 時顯示的是 Suspense 中 fallback 的內容,只有狀態變為 resolve 後才顯示組件。
結合該部分源碼,它的流程如下所示:
Error Boundaries 處理資源加載失敗場景
如果遇到網絡問題或是組件內部錯誤,頁面的動態資源可能會加載失敗,為了優雅降級,可以使用 Error Boundaries(https://react.docschina.org/docs/error-boundaries.html) 來解決這個問題。
Error Boundaries 是一種組件,如果你在組件中定義了 static getDerivedStateFromError() 或 componentDidCatch() 生命周期函數,它就會成為一個 Error Boundaries 的組件。
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) { // 更新 state 使下一次渲染能夠顯示降級後的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) { // 你同樣可以將錯誤日誌上報給伺服器
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) { // 你可以自定義降級後的 UI 並渲染
return <h1>對不起,發生異常,請刷新頁面重試</h1>;
}
return this.props.children;
}
}
你可以在 componentDidCatch 或者 getDerivedStateFromError 中列印錯誤日誌並定義顯示錯誤信息的條件,當捕獲到 error 時便可以渲染備用的組件元素,不至於導致頁面資源加載失敗而出現空白。
它的用法也非常的簡單,可以直接當作一個組件去使用,如下:
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
我們可以模擬動態加載資源失敗的場景。首先在本地啟動一個 http-server 伺服器,然後去訪問打包好的 build 文件,手動修改下打包的子組件包名,讓其查找不到子組件包的路徑。然後看看頁面渲染效果。
可以看到當資源加載失敗,頁面已經降級為我們在錯誤邊界組件中定義的展示內容。
流程圖例:
需要注意的是:錯誤邊界僅可以捕獲其子組件的錯誤,它無法捕獲其自身的錯誤。
總結
React.lazy() 和 React.Suspense 的提出為現代 React 應用的性能優化和工程化提供了便捷之路。React.lazy 可以讓我們像渲染常規組件一樣處理動態引入的組件,結合 Suspense 可以更優雅地展現組件懶加載的過渡動畫以及處理加載異常的場景。
「
注意:React.lazy 和 Suspense 尚不可用於伺服器端,如果需要服務端渲染,可遵從官方建議使用 Loadable Components(https://github.com/gregberge/loadable-components)。
推薦React 學習相關文章
《「乾貨滿滿」React Hooks 最佳實踐》
《手把手教你如何實現一個React水印組件「實踐」》
《「實踐」React 中必會的 10 個概念》
《「乾貨」深入淺出React組件邏輯復用的那些事兒》
《手把手教你從Mixin深入到HOC再到Hook【React】》
《深入Facebook 官方React 狀態管理器Recoil講解》
《手把手教你實踐搭建React組件庫「超詳細」》
《在 React 中自動複製文本到剪貼板「實踐」》
《「乾貨滿滿」從零實現 react-redux》
《深入詳解大佬用33行代碼實現了React》
《讓你的 React 組件性能跑得再快一點「實踐」》
《React源碼分析與實現(三):實踐 DOM Diff》
《React源碼分析與實現(一):組件的初始化與渲染「實踐篇」》
《React源碼分析與實現(二):狀態、屬性更新->setState「實踐篇」》
《細說React 核心設計中的閃光點》
《手把手教你10個案例理解React hooks的渲染邏輯「實踐」》
《React-Redux 100行代碼簡易版探究原理》
《手把手深入教你5個技巧編寫更好的React代碼【實踐】》
《React 函數式組件性能優化知識點指南匯總》
《13個精選的React JS框架》
《深入淺出畫圖講解React Diff原理【實踐】》
《【React深入】React事件機制》
《Vue 3.0 Beta 和React 開發者分別槓上了》
《手把手深入Redux react-redux中間件設計及原理(上)【實踐】》
《手把手深入Redux react-redux中間件設計及原理(下)【實踐】》
《前端框架用vue還是react?清晰對比兩者差異》
《為了學好 React Hooks, 我解析了 Vue Composition API》
《【React 高級進階】探索 store 設計、從零實現 react-redux》
《寫React Hooks前必讀》
《深入淺出掌握React 與 React Native這兩個框架》
《可靠React組件設計的7個準則之SRP》
《React Router v6 新特性及遷移指南》
《用React Hooks做一個搜索欄》
《你需要的 React + TypeScript 50 條規範和經驗》
《手把手教你繞開React useEffect的陷阱》
《淺析 React / Vue 跨端渲染原理與實現》
《React 開發必須知道的 34 個技巧【近1W字】》
《三張圖詳細解說React組件的生命周期》
《手把手教你深入淺出實現Vue3 & React Hooks新UI Modal彈窗》
《手把手教你搭建一個React TS 項目模板》
《全平台(Vue/React/微信小程序)任意角度旋圖片裁剪組件》
《40行代碼把Vue3的響應式集成進React做狀態管理》
《手把手教你深入淺出React 迷惑的問題點【完整版】》
作者:大柱 政采雲前端團隊
轉發連結:https://mp.weixin.qq.com/s/l_kv6rzUXSF3R9bfIko5BQ