ソース コード レベルの分析、React の動的読み込みを理解する (パート 1)

このシリーズでは、 React の動的読み込みの観点から始めて、現在市場で人気のあるソリューションの実装原理を探る、SPA シングルページ アプリケーション関連のテクノロジ スタックの探索と分析を行います。

著者が卒業後に最初に触れたプロジェクトは、高度にインタラクティブで複雑なシングルページ アプリケーション (SPA) で、HTML は 1 つしかなく、複数のページにさまざまなビジネス ロジックが含まれ、ルーティング ジャンプによって調整されることが特徴です。製品要件の継続的な反復により、プロジェクトの複雑さはますます高くなり、コードサイズも急速に拡大しています。特に、著者が担当しているエンタープライズ WeChat ドキュメント フュージョン プロジェクトでは、コード量が 2 倍になりました。

数千万の pv を持つユーザー製品と、数十万行のコードを持つフロントエンド アプリケーションとして、適切なボリューム コントロールとモジュールのロード スキームがなければ、パフォーマンスとユーザー エクスペリエンスは悲惨なものになります。SPA の特性に基づいて、ユーザーがオンデマンド ロードによっていくつかのページ/コンポーネントを入力する必要がある場合、対応するモジュール コードをロードすることを考えるのが自然です。

コード分​​割について話す

幸いなことに、webpack などのパッケージング ツールは、コード サイズを最適化するためのさまざまなコード分割戦略を既にサポートしています。コード分​​割とは、その名のとおり、完全なバンドルを複数に分割し、オンデマンドの読み込みまたはブラウザによるキャッシュを実現して、フロントエンド アプリケーション コードの読み込み量を減らすことです。

輸入()

ES 標準の import() 関数は、ネイティブの動的読み込みサポートを提供します。次に例を示します。

import Module from './bar.js';
console.log(Module);
复制代码

次のように書き換えることができます。

import('./bar.js').then(Module => {
        console.log(Module);
});
复制代码

次のように、babel を使用して結果をコンパイルします。

image

import はモジュールの内容を ESM 標準のデータ構造に変換し、それを promise の形で返します. ロード後、モジュールを取得し、次にコールバック関数を登録します.

さらに、webpack が import() の存在を検出すると、自動的にコード分割を実行し、動的にインポートされたモジュールを新しいバンドルに入力します。

image

動的にロードされるバンドルの名前を指定したい場合は、インポートにコメントを挿入することもできます:

import((/* webpackChunkName: "bar" */ './bar.js').then(Module => {
        console.log(Module);
});
复制代码

image

ご覧のとおり、ここで動的にロードされるバンドルは、サフィックス .chunk.js が付いた、指定したバーになります。

一个简易版的动态加载方案

有了 code-splitting 和 import 的理论基础,我们可以实现一个简易版的组件动态加载方案:

import Loading from './components/loading';

const MyComponent: React.FC<{}> = () => {
  const [Bar, setBar] = useState(null);
  // 首次 render 前加载 Bar 组件,加载完成后设置 this.state.Bar
  // 起到 componentWillMount 的效果
  const firstStateRef = useRef({});
  if (firstStateRef.current) {
    firstStateRef.current = undefined;
    import(/* webpackChunkName: "bar" */ './components/Bar').then(Module => {
      setBar(Module.default);
    });
  }
  
  if (!Bar) return <Loading />;
  return <Bar />;
}
复制代码

image

上述代码在组件渲染之前,先加载 Bar 组件,加载完成前先渲染 Loading,完成后再重新渲染 Bar 组件。然而,对于每个动态加载组件都需要补充生命周期,兼容加载失败与未完成等场景,是十分复杂且不优雅的。为了更好地封装与复用,同时处理各种附加场景,本系列我们研究几种 React 相关的动态加载方案,结合源码来给大家讲解其实现原理。

React-loadable 是什么

image

引用官方文档的描述: Loadable is a higher-order component (a function that creates a component) which lets you dynamically load any module before rendering it into your app.

简单来说,react-loadable 提供了一个动态加载任意模块(主要是UI组件)的函数,返回一个封装了动态加载模块(组件)的高阶组件。通过传入诸如模块加载函数、Loading 状态组件、超时等配置参数,统一封装并处理动态加载成功与异常等场景。

React-loadable 改造开头的例子

回到文章开头的例子中,我们可以利用 react-loadable 将其改造成 Loadable Component:

import Loadable from 'react-loadable';
import Loading from './components/loading';

const MyComponent = Loadable({
  loader: () => import('./components/Bar'),
  loading: () => <Loading />,
});
复制代码

此外,react-loadable 还支持多资源动态加载,借用官方文档的例子:

Loadable.Map({
  loader: {
    Bar: () => import('./Bar'),
    i18n: () => fetch('./i18n/bar.json').then(res => res.json()),
  },
  render(loaded, props) {
    // loaded 此时是一个对象,key 对应 loader 传入的 key
    // 访问 loaded[key] 即可获取加载完成后的对应模块
    let Bar = loaded.Bar.default;
    let i18n = loaded.i18n;
    return <Bar {...props} i18n={i18n}/>;
  },
});
复制代码

下面结合源码来分析下 react-loadable 动态加载的原理。

Loadable Component 是如何支持动态加载的?

Loadable 的核心是 createLoadableComponent 函数,采用策略模式,根据不同他场景(单资源 or 多资源 Map)传入对应的 load/loadMap 方法:

image

Loadable.Map 必须传入 render 方法,而 Loadable 则不需要,原因再分析到 createLoadableComponent 时自然就有答案了,这里我们先跳过,来看看上面的 load 和 loadMap 参数分别是什么:

function load(loader) {
  let promise = loader();

  let state = {
    loading: true,
    loaded: null,
    error: null
  };

  state.promise = promise
    .then(loaded => {
      state.loading = false;
      state.loaded = loaded;
      return loaded;
    })
    .catch(err => {
      state.loading = false;
      state.error = err;
      throw err;
    });

  return state;
}
复制代码
function loadMap(obj) {
  let state = {
    loading: false,
    loaded: {},
    error: null
  };

  let promises = [];

  try {
    Object.keys(obj).forEach(key => {
      let result = load(obj[key]);

      if (!result.loading) {
        state.loaded[key] = result.loaded;
        state.error = result.error;
      } else {
        state.loading = true;
      }

      promises.push(result.promise);

      result.promise
        .then(res => {
          state.loaded[key] = res;
        })
        .catch(err => {
          state.error = err;
        });
    });
  } catch (err) {
    state.error = err;
  }

  state.promise = Promise.all(promises)
    .then(res => {
      state.loading = false;
      return res;
    })
    .catch(err => {
      state.loading = false;
      throw err;
    });

  return state;
}
复制代码

load 方法其实就是对传入的 loader 加载器进行调用并封装其加载状态与结果。loadMap 接收一个 Object ,key-value 分别为 key 以及对应的 loader,分别调用 load 方法,加载传入 Object 中所有 loader。

策略模式种的两种加载器已经分析完了,接下来就让我们看看核心的工厂方法 createLoadableComponent:

function createLoadableComponent(loadFn, options) {
  // 必须传入 loading 组件
  if (!options.loading) {
    throw new Error("react-loadable requires a `loading` component");
  }
  // 初始化 options 参数
  let opts = Object.assign({
      loader: null,   // loader 加载器函数
      loading: null,  // 是否处于 loading 状态
      delay: 200,     // loading 过程中,且超过了这个时间,会展示 loading 中的 pastDelay 组件
      timeout: null,  // 超时时间,超时后展示 Loading 中的 timedOut 组件
      render: render, // render 方法,默认 React.createElement 渲染
      webpack: null,  // webpack加载模块函数(loader自动添加)
      modules: null   // 动态加载本地资源的模块地址(loader自动添加)
    }, options);

  let res = null;

  // 加载函数:调用 loadFn (上面分析的load或者loadMap)并返回模块的 promise
  function init() {
    if (!res) {
      res = loadFn(opts.loader);
    }
    return res.promise;
  }
  // 将所有加载函数注册进 ALL_INITIALIZERS 数组里(SSR 用)
  ALL_INITIALIZERS.push(init);

  // 对于动态加载的本地资源模块注册进 READY_INITIALIZERS 数组里(SSR 用)
  if (typeof opts.webpack === "function") {
    READY_INITIALIZERS.push(() => {
      if (isWebpackReady(opts.webpack)) {
        return init();
      }
    });
  }
  // 返回高阶组件,下面继续分析
  return LoadableComponent;
}
复制代码

在调用 Loadable({ xxx }) 后,会经历1)初始化参数,2)加载函数存入ALL_INITIALIZERS、READY_INITIALIZERS 以便 SSR 场景下实现同步渲染,3)返回 LoadableComponent 高阶组件。其实 LoadableComponent 高阶组件的实现逻辑很简单,和文章开头 简易版的动态加载方案 中的实现很相似,不过增加了一些场景的封装,总结核心逻辑如下:

image

上图中省略了部分逻辑,而折叠的 _loadModule 方法主要负责给动态加载的组件 promise 注册完成与失败时更新组件 state 的逻辑,大致如下:

image

此外,我们注意到 LoadableComponent 高阶组件在动态加载完成时,会调用 opts.render 方法,默认使用 React.createElement() :

function resolve(obj) {
  return obj && obj.__esModule ? obj.default : obj;
}

function render(loaded, props) {
  return React.createElement(resolve(loaded), props);
}
复制代码

当我们使用 Loadable.Map 时,由于传入的 loader 是一个对象,调用上面的默认 render 自然会失败,这也是为什么使用 Loadable.Map 时需要显式传入 render 函数的原因。loaded 对象上附带了 loader 加载后生成的 Object,直接通过 loaded[key] 获取对应的动态组件即可。

服务端渲染中的 Loadable Component

对于 SPA 来说,服务端渲染(SSR)+ 同构的场景是十分常见的,需要服务端生成对应的 DOM string 与 HTML 并返回给客户端,客户端渲染 DOM,加载 JS 后再复用同一份 javascript 代码注水。

而动态加载的组件,在渲染前是无法得知自身的真实 DOM 结构的,也就意味着 SSR 场景下的 HTML 将无法获得准确的组件 DOM string。(总不能直接挂个 XXXLazy 在那吧)所以我们很自然能想到一种简单的解决方案:把异步的动态加载,变成同步的就好了

在 React 18版本之前,React.lazy + Suspense 的原生方案不支持 SSR 。而 react-loadable 基于将异步转化为同步的方案,提供一套简单直观的 API 支持 SSR:

  • Loadable.Capture 高阶组件:用于服务端,获取子组件中的 Loadable Component 并暴露函数上报需要动态加载的本地模块。(用于拼接 HTML script 中的 js 地址,例如 src='dist/a.js')

  • Loadable.preloadAll:用于服务端,等待所有模块都动态加载完毕后,生成确定的 DOM 结构。

  • Loadable.preloadReady:用于客户端,等待所有指定的动态加载的本地模块加载完毕后,调用 hydrate 复用 DOM 结构。

用一个源码中简单的 demo 来表示 SSR 场景下的使用:

客户端渲染代码

// client.js
window.main = () => {
  // 等待所有本地 module 加载完后进行 hydrate,完成前仍展示 SSR 的 DOM
  Loadable.preloadReady().then(() => {
    ReactDOM.hydrate(<App/>, document.getElementById('app'));
  });
};
复制代码

服务端渲染代码

// server.js
app.get('/', (req, res) => {
  let modules = [];
  // Capture: 用于获取本地 module ,生成 bundles 进而拼接 HTML
  let html = ReactDOMServer.renderToString(
    <Loadable.Capture report={moduleName => modules.push(moduleName)}>
      <App/>
    </Loadable.Capture>
  );

  let bundles = getBundles(stats, modules);
  let scripts = bundles.filter(bundle => bundle.file.endsWith('.js'));

  res.send(`
    <!doctype html>
    <html lang="en">
      <body>
        <div id="app">${html}</div>
        <script src="/dist/main.js"></script>
        ${scripts.map(script => {
          return `<script src="/dist/${script.file}"></script>`
        }).join('\n')}
        <script>window.main();</script>
      </body>
    </html>
  `);
});

app.use('/dist', express.static(path.join(__dirname, 'dist')));

// 等待所有 Loadable Component 加载完毕后,开启 server
Loadable.preloadAll().then(() => {
  app.listen(3000, () => {
    console.log('Running on http://localhost:3000/');
  });
}).catch(err => {
  console.log(err);
});
复制代码

大致的流程可以用下面的流程图来表示:

image

理解了上述流程,对应 API 的是实现原理相比也不难猜出:

  • Capture 仅需要在上层暴露一个 report 方法的 context 属性,Loadable Component 子组件在 _loadModule 中调用:
const CaptureContext = createContext(undefined)
CaptureContext.displayName = 'Capture'

function Capture({report, children}) {
  return <CaptureContext.Provider value={report}>
    {children}
  </CaptureContext.Provider>
}
复制代码
  • preloadAll 和 preloadReady 通过 promise.all 处理所有 loader,同时在 then 方法中继续递归,以防止第一层的 Loadable Component 加载完成后,其 loader 加载模块内部仍然有 Loadable Component 的场景:
function flushInitializers(initializers) {
  let promises = [];
  while (initializers.length) {
    let init = initializers.pop();
    promises.push(init());
  }
  // 处理循环 Loadable Component 场景
  // 例如 Loadable 的 loader 中还有 Loadable Component
  return Promise.all(promises).then(() => {
    if (initializers.length) {
      return flushInitializers(initializers);
    }
  });
}

Loadable.preloadAll = () => {
  return new Promise((resolve, reject) => {
    flushInitializers(ALL_INITIALIZERS).then(resolve, reject);
  });
};

Loadable.preloadReady = () => {
  return new Promise((resolve, reject) => {
    flushInitializers(READY_INITIALIZERS).then(resolve, resolve);
  });
};
复制代码

局限性

React loadable 的原始仓库自从 2020 年开始就不再更新维护了,适用的 webpack 与 babel 版本也相对局限,更重要的是,其使用的 React 版本为 16.5.2,对于现代基于 Hooks 等新 React 项目并不兼容,往往需要重复打包 React 才能正常工作。

对此 @react-loadable/revised 包在保留原有功能的基础上进行了 Hooks + ts 重构,弥补了现有的局限性。

React-loadable 的实现原理和思路相对简洁直观,下一篇文章,我们介绍 React.lazy + Suspense 的原生解决方案。作为 React Fiber + reconciler 架构的一环,理解了 ReactLazy 不仅能够对动态加载的实现思路有深度理解,也能帮助理解 Fiber 架构。

おすすめ

転載: juejin.im/post/7192971009849294906