소스 코드 수준 분석, React 동적 로딩 이해(1부)

이 시리즈는 React 동적 로딩의 관점에서 시작하여 현재 시장에서 널리 사용되는 솔루션의 구현 원칙을 탐색하는 SPA 단일 페이지 애플리케이션 관련 기술 스택에 대한 탐색 및 분석입니다.

저자가 졸업 후 처음 접한 프로젝트는 인터랙티브하고 복잡한 단일 페이지 애플리케이션(SPA)으로, 그 특징은 하나의 HTML만 있고 여러 페이지가 다양한 비즈니스 로직을 전달하며 라우팅 점프를 통해 조정된다는 것입니다. 제품 요구 사항의 지속적인 반복으로 프로젝트의 복잡성이 점점 더 높아지고 코드 크기도 빠르게 확장되고 있습니다. 특히 저자가 맡은 기업용 위챗 문서 융합 프로젝트는 직접적으로 코드 양을 2배로 늘렸다.

수천만 개의 pv를 가진 사용자 제품과 수십만 줄의 코드를 가진 프런트 엔드 응용 프로그램으로서 좋은 볼륨 제어 및 모듈 로딩 체계가 없으면 성능과 사용자 경험은 비참할 것입니다. SPA의 특성상 On-Demand 로딩을 통해 사용자가 일부 페이지/컴포넌트를 입력해야 하는 경우 해당 모듈 코드를 로딩하는 것을 생각하는 것은 당연합니다.

코드 분할에 대해 이야기하기

다행스럽게도 webpack과 같은 패키징 도구는 이미 코드 크기를 최적화하기 위한 다양한 코드 분할 전략을 지원합니다. 이름에서 알 수 있듯이 코드 분할은 전체 번들을 여러 번들로 분할하고 온디맨드 로딩을 ​​실현하거나 브라우저에 의해 캐시된 다음 프런트 엔드 애플리케이션 코드의 로딩 볼륨을 줄이는 것입니다.

수입()

ES 표준의 import() 함수는 기본 동적 로딩 지원을 제공합니다. 예를 들면 다음과 같습니다.

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

다음과 같이 다시 작성할 수 있습니다.

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

babel을 사용하여 다음과 같이 결과를 컴파일합니다.

image

이해하기 복잡하지 않습니다: 가져오기는 모듈 내용을 ESM 표준 데이터 구조로 변환하고 약속 형식으로 반환합니다.로딩 후 모듈을 가져와 콜백 함수를 등록합니다.

또한 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 架构。

Supongo que te gusta

Origin juejin.im/post/7192971009849294906
Recomendado
Clasificación