深入理解React:懶載入(lazy)實現原理

forcheng發表於2020-06-15

目錄

  • 程式碼分割
  • React的懶載入
    • import() 原理
    • React.lazy 原理
    • Suspense 原理
  • 參考

1.程式碼分割

(1)為什麼要進行程式碼分割?

現在前端專案基本都採用打包技術,比如 Webpack,JS邏輯程式碼打包後會產生一個 bundle.js 檔案,而隨著我們引用的第三方庫越來越多或業務邏輯程式碼越來越複雜,相應打包好的 bundle.js 檔案體積就會越來越大,因為需要先請求載入資源之後,才會渲染頁面,這就會嚴重影響到頁面的首屏載入。

而為了解決這樣的問題,避免大體積的程式碼包,我們則可以通過技術手段對程式碼包進行分割,能夠建立多個包並在執行時動態地載入。現在像 Webpack、 Browserify等打包器都支援程式碼分割技術。


(2)什麼時候應該考慮進行程式碼分割?

這裡舉一個平時開發中可能會遇到的場景,比如某個體積相對比較大的第三方庫或外掛(比如JS版的PDF預覽庫)只在單頁應用(SPA)的某一個不是首頁的頁面使用了,這種情況就可以考慮程式碼分割,增加首屏的載入速度。


2.React的懶載入

示例程式碼:

import React, { Suspense } from 'react';

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

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

如上程式碼中,通過 import() React.lazySuspense 共同一起實現了 React 的懶載入,也就是我們常說了執行時動態載入,即 OtherComponent 元件檔案被拆分打包為一個新的包(bundle)檔案,並且只會在 OtherComponent 元件渲染時,才會被下載到本地。

那麼上述中的程式碼拆分以及動態載入究竟是如何實現的呢?讓我們來一起探究其原理是怎樣的。


import() 原理

import() 函式是由TS39提出的一種動態載入模組的規範實現,其返回是一個 promise。在瀏覽器宿主環境中一個import()的參考實現如下:

function import(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    const tempGlobal = "__tempModuleLoadingVariable" + Math.random().toString(32).substring(2);
    script.type = "module";
    script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;

    script.onload = () => {
      resolve(window[tempGlobal]);
      delete window[tempGlobal];
      script.remove();
    };

    script.onerror = () => {
      reject(new Error("Failed to load module script with URL " + url));
      delete window[tempGlobal];
      script.remove();
    };

    document.documentElement.appendChild(script);
  });
}

當 Webpack 解析到該import()語法時,會自動進行程式碼分割。


React.lazy 原理

以下 React 原始碼基於 16.8.0 版本


React.lazy 的原始碼實現如下:

export function lazy<T, R>(ctor: () => Thenable<T, R>): LazyComponent<T> {
  let lazyType = {
    $$typeof: REACT_LAZY_TYPE,
    _ctor: ctor,
    // React uses these fields to store the result.
    _status: -1,
    _result: null,
  };

  return lazyType;
}

可以看到其返回了一個 LazyComponent 物件。


而對於 LazyComponent 物件的解析:

...
case LazyComponent: {
  const elementType = workInProgress.elementType;
  return mountLazyComponent(
    current,
    workInProgress,
    elementType,
    updateExpirationTime,
    renderExpirationTime,
  );
}
...
function mountLazyComponent(
  _current,
  workInProgress,
  elementType,
  updateExpirationTime,
  renderExpirationTime,
) { 
  ...
  let Component = readLazyComponentType(elementType);
  ...
}
// Pending = 0, Resolved = 1, Rejected = 2
export function readLazyComponentType<T>(lazyComponent: LazyComponent<T>): T {
  const status = lazyComponent._status;
  const result = lazyComponent._result;
  switch (status) {
    case Resolved: {
      const Component: T = result;
      return Component;
    }
    case Rejected: {
      const error: mixed = result;
      throw error;
    }
    case Pending: {
      const thenable: Thenable<T, mixed> = result;
      throw thenable;
    }
    default: { // lazyComponent 首次被渲染
      lazyComponent._status = Pending;
      const ctor = lazyComponent._ctor;
      const thenable = ctor();
      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;
    }
  }
}

注:如果 readLazyComponentType 函式多次處理同一個 lazyComponent,則可能進入Pending、Rejected等 case 中。

從上述程式碼中可以看出,對於最初 React.lazy() 所返回的 LazyComponent 物件,其 _status 預設是 -1,所以首次渲染時,會進入 readLazyComponentType 函式中的 default 的邏輯,這裡才會真正非同步執行 import(url)操作,由於並未等待,隨後會檢查模組是否 Resolved,如果已經Resolved了(已經載入完畢)則直接返回moduleObject.default(動態載入的模組的預設匯出),否則將通過 throw 將 thenable 丟擲到上層。

為什麼要 throw 它?這就要涉及到 Suspense 的工作原理,我們接著往下分析。


Suspense 原理

由於 React 捕獲異常並處理的程式碼邏輯比較多,這裡就不貼原始碼,感興趣可以去看 throwException 中的邏輯,其中就包含了如何處理捕獲的異常。簡單描述一下處理過程,React 捕獲到異常之後,會判斷異常是不是一個 thenable,如果是則會找到 SuspenseComponent ,如果 thenable 處於 pending 狀態,則會將其 children 都渲染成 fallback 的值,一旦 thenable 被 resolve 則 SuspenseComponent 的子元件會重新渲染一次。


為了便於理解,我們也可以用 componentDidCatch 實現一個自己的 Suspense 元件,如下:

class Suspense extends React.Component {
  state = {
    promise: null
  }

  componentDidCatch(err) {
    // 判斷 err 是否是 thenable
    if (err !== null && typeof err === 'object' && typeof err.then === 'function') {
      this.setState({ promise: err }, () => {
        err.then(() => {
          this.setState({
            promise: null
          })
        })
      })
    }
  }

  render() {
    const { fallback, children } = this.props
    const { promise } = this.state
    return <>{ promise ? fallback : children }</>
  }
}

小結

至此,我們分析完了 React 的懶載入原理。簡單來說,React利用 React.lazyimport()實現了渲染時的動態載入 ,並利用Suspense來處理非同步載入資源時頁面應該如何顯示的問題。


3.參考

程式碼分割– React

動態import - MDN - Mozilla

proposal-dynamic-import

React Lazy 的實現原理

相關文章