借助 React.lazy() 和 import() 实现代码分割

使用 React.lazy() 动态加载组件

React 16.6 引入了一个新的 API:React.lazy(),可以帮助我们动态加载组件。

如以下代码:

import React from 'react';
import OtherComponent from './OtherComponent';

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}

现在可以写成:

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

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}

更常见的写法是搭配 React.Suspense

import React, { Suspense } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));

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

OtherComponent 加载完之前先渲染一个加载动画或者占位符,否则会出现如下错误:

Uncaught Error: A React component suspended while rendering, but no fallback UI
was specified.

Add a <Suspense fallback=...> component higher in the tree to provide a loading
indicator or placeholder to display.

如果希望我们的应用有更好的健壮性,还应该考虑加上 Error Boundaries,处理组件加载失败的情况。

import React from 'react';

class MyErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      error: null,
      errorInfo: null
    };
  }

  componentDidCatch(error, errorInfo) {
    this.setState({
      error: error,
      errorInfo: errorInfo
    })
  }

  render() {
    if (this.state.errorInfo) {
      return (
        <div>
          <h2>Something went wrong.</h2>
          <details>
            {this.state.error && this.state.error.toString()}
            <br />
            {this.state.errorInfo.componentStack}
          </details>
        </div>
      );
    }

    return this.props.children;
  }
}

export default MyErrorBoundary;

这样最终的代码就变为:

import React, { Suspense } from 'react';
import MyErrorBoundary from './MyErrorBoundary';
const OtherComponent = React.lazy(() => import('./OtherComponent'));

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

export default MyComponent;

React.lazy() 接收一个函数作为参数,该函数需要返回一个 Promise 对象,reslove 后返回一个模块,模块的默认导出对象作为渲染的 React 组件。

如:

import React from 'react';

function OtherComponent() {
  return (
    <h1>Hello World</h1>
  );
}

export default OtherComponent;

如何支持有名导出的模块?

使用 React.lazy() 加载的模块,如果其中的 React 组件不是默认导出话,可能会报以下错误:

Warning: React.createElement: type is invalid -- expected a string (for built-in 
components) or a class/function (for composite components) but got: undefined. 
You likely forgot to export your component from the file it's defined in, or 
you might have mixed up default and named imports.

这是因为 React.lazy() 目前只支持默认导出(Default Export),不支持有名导出(Named Exports)。假如在 AnotherComponents 中导出了多个组件,在不修改 AnotherComponents 的前提下,可以这样写:

const SomeComponent = React.lazy(() => {
  return new Promise((resolve, reject) => {
    import('./AnotherComponents')
      .then((module) => {
        resolve({
          default: module.SomeComponent
        });
      }).catch(err => {
        reject(err);
      });
  });
});

只使用其中的 AnotherComponents 模块中的 SomeComponent 组件。

搭配 webpack 实现代码分割

借助 webpack 的 Code Splitting 功能,使用动态 import() 引入的模块会被自动拆分为异步加载的 chunk。

如果希望自定义 chunk 的文件名,可以在 import() 中加入 webpack 特定的注释:

const OtherComponent = React.lazy(
  () => import(/* webpackChunkName: "other-component" */ './OtherComponent')
);

动态加载失败后重试

有时因为网络问题可能会出现“Error: Loading chunk failed”,可以尝试加载失败后自动重试。

const retry = (fn, retriesLeft = 5, interval = 1000) => {
  return new Promise((resolve, reject) => {
    fn()
      .then(resolve)
      .catch((error) => {
        setTimeout(() => {
          if (retriesLeft === 1) {
            reject(error);
          } else {
            retry(fn, retriesLeft - 1, interval).then(resolve, reject);
          }
        }, interval);
      });
  });
};

/*
 * React.lazy(() => import('./some-component'))
 * =>
 * lazy(() => import('./some-component'))
 */
const lazy = (fn) => React.lazy(() => retry(fn));

如果重试依然加载失败,可以检查文件是否丢失、被拦截、文件体积超过服务器设定上限。

如果使用了 webpack,并且依赖的代码中包含 __webpack_public_path__ 字段,刷新页面时也会产生该错误。

动态引入 URL 形式的组件

虽然部分浏览器(如 Chrome 和 Safari)已经实现动态 import(),允许你写出这样的代码:

// 引入的模块必须使用 ES Module
import('https://unpkg.com/lodash-es@4.17.11/lodash.js').then(lodash => {
  console.log(lodash.reverse([1, 2, 3]));
});

但目前版本的 webpack 在处理 import() 时只支持使用相对文件路径,不支持传入 URL。

所以只能借助其他加载器实现,例如借助 RequireJS 实现一种混搭写法:

const requirejs = window.requirejs;

const OtherComponent = React.lazy(() => {
  return new Promise((resolve, reject) => {
    requirejs(['https://example.com/js/other-component.min.js'], (module) => {
        resolve(module);
      }, (err) => {
        reject(err);
      });
  });
});

当然这里的 URL 对应的组件必须支持以 AMD 模块的形式引入。

SSR

最后,React.lazySuspense 现在还不支持 SSR,可以使用 react-loadable 代替。

参考链接