源码级解析,搞懂 React 动态加载(上)

1,999 阅读9分钟

本系列作为 SPA 单页应用相关技术栈的探索与解析,先从 React 动态加载角度入手,探索市面当前流行的方案的实现原理。

笔者毕业后接触的第一个项目是一个重交互且复杂的单页应用(SPA),其特点是仅有一份 HTML ,多个页面承载多种业务逻辑,通过路由跳转的方式来协调。随着产品需求的不断迭代,项目复杂度越来越高,代码体积也迅速膨胀。特别是笔者负责的企业微信文档融合项目,更是直接将代码量翻了个倍。

作为千万pv的用户产品,数十万行的代码量的前端应用,如果没有一个良好的体积控制与模块加载方案,性能与用户体验将是灾难性的。基于 SPA 的特性,自然可以想到通过按需加载的方式,等到用户需要进入某些页面/组件时再加载对应的模块代码。

说说代码分割(code-splitting)

幸运的是,webpack等打包工具已经支持各种 code-splitting 策略进行优化代码体积。code-splitting,顾名思义,就是将一个完整的 bundle 拆分成多个,实现按需加载或被浏览器缓存,进而实现前端应用代码量加载体积减少。

import()

ES 标准中的 import() 函数提供了原生动态加载支持,例如:

import Module from './bar.js';
console.log(Module);

可以改写成:

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

我们使用 babel 对其编译结果如下:

image

理解起来并不复杂:import 将模块内容转换为 ESM 标准的数据结构后,通过 promise 形式返回,加载完成后获取 Module 并在 then 中注册回调函数。

此外,当 webpack 检测到 import() 存在时,将会自动进行 code-splitting,将动态 import 的模块打到一个新 bundle 中:

image

如果我们希望给动态加载的 bundle 指定命名,也可以在 import 中插入注释:

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

image

可以看到,这里动态加载的 bundle 就变成了我们指定的 bar ,配上后缀的 .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 架构。