這就是你日思夜想的 React 原生動態加載「值得收藏」

echa攻城獅 發佈 2020-05-25T22:54:30+00:00

隨著前端應用體積的擴大,資源加載的優化是我們必須要面對的問題,動態代碼加載就是其中的一個方案,webpack 提供了符合 ECMAScript 提案 的 import語法 ,讓我們來實現動態的加載模塊。


作者:大柱 政采雲前端團隊

轉發連結: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

關鍵字: