[译]动态导入、代码分割、延迟加载和错误边界

2,401 阅读11分钟

译文:medium.com/better-prog…
作者:Jennifer Fu

本文是有关如何使用动态导入(dynamic import)的详细指南,该功能可实现代码分隔和延迟加载,另外还介绍了如何使用错误边界(error boundaries)来捕获错误。

import()是函数吗?

import()当前处于TC39流程的第4阶段,在JavaScript中以类函数的方式加载模块。

它在很多方面看起来像一个函数:

  • 通过()操作符调用
  • 它为所请求的模块返回该模块对象的Promise,在获取,实例化和计算模块的所有依赖项以及模块本身之后创建的。

但它并不是函数:

  • 这只是一种使用括号的语法形式,类似super()
  • 不是继承自Function.prototype,因此,不能使用apply或call来调用它
  • 不是继承自Object.prototype,因此,不能用作变量,const a = import是非法的。

另一个有趣的地方,import.meta,TC39第四阶段提案,将上下文的具体元数据暴露给JavaScript模块,包含有关模块的信息,例如模块的URL。import.meta是一个原型null的对象,它是可扩展的,并且其属性是可写、可配置和可枚举的。

动态导入,代码拆分,延迟加载和错误边界是有趣的技术。你想知道更多吗?

什么是动态导入(Dynamic Import)?

import("/path/to/import-module.js") // .js can be skipped
  .then((module) => {
    // do something with the module
  });

MDN Web文档中描述了五个动态导入的场景:

  • 当静态导入的模块很明显的降低了代码的加载速度且被使用的可能性很低,或者并不需要马上使用它。
  • 当静态导入的模块很明显的占用了大量系统内存且被使用的可能性很低。
  • 当被导入的模块,在加载时并不存在,需要异步获取
  • 当导入模块的说明符,需要动态构建。(静态导入只能使用静态说明符)
  • 被导入的模块有副作用(这里说的副作用,可以理解为模块中会直接运行的代码),这些副作用只有在触发了某些条件才被需要时。(原则上来说,模块不能有副作用,但是很多时候,你无法控制你所依赖的模块的内容)

静态import的例子

这是一个网页的用户交互界面,所有内容都是静态导入的,不管用户是否要进行选择。

选择菜单有两个选项

  • 微前端(Micro Frontends)
  • React工具(React Utilities)

当选择了微前端(Micro Frontends)后,会显示两个文章链接,当单击任一链接时,将显示相关的介绍:

同样,当选择了React工具(React Utilities)后,会显示另外两个文章链接,单击任一链接时,显示相关的介绍:

创建这个示例,通过Create React App方便快捷的启动React编码环境:

npx create-react-app my-app
cd my-app
npm start

安装两个依赖:

"dependencies": {
  "react-router-dom": "^5.2.0",
  "react-select": "^3.1.0"
}
  • react-router-dom路由管理
  • react-select是一种实现下拉列表的优雅方法。

修改src/App.css文件,加入样式:

.App {
  padding: 50px;
}

.select-category {
  width: 300px;
}

src/index.js文件中,第10行和12行加入BrowserRouter

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";

ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

创建文件src/buildComponent.js

import React from "react";
export const buildComponent = ({title, paragraphs}) => (
  <>
    <h1>{title}</h1>
    {paragraphs.map((item, i) => (
      <p key={i}>{item}</p>
    ))}
  </>
);

src/microFrontendRoutes.js文件中为微前端(Micro Frontends)选项创建路由信息

import { buildComponent } from "./buildComponent";

const fiveStepsArticle = {
  title: "5 Steps to Turn a Random React Application Into a Micro Front-End",
  paragraphs: [
    "What is a micro front-ends approach? The term micro front-ends first came " +
      "up in the ThoughtWorks Technology Radar in November 2016. It extends the " +
      "concepts of microservices to front-end development.",
    "The approach is to split the browser-based code into micro front-ends by " +
      "breaking down application features. By making smaller and feature-centered " +
      "codebases, we achieve the software development goal of decoupling.",
    "Although the codebases are decoupled, the user experiences are coherent. " +
      "In addition, each codebase can be implemented, upgraded, updated, and " +
      "deployed independently.",
  ],
};

const ecoSystemArticle = {
  title: "Build Your Own Micro-Frontend Ecosystem",
  paragraphs: [
    "Micro-frontend architecture is a design approach. It modularizes a " +
      "monolithic application into multiple independent smaller applications, " +
      "which are called micro-frontends. Micro-frontends can also be spelled as " +
      "micro front-ends, micro frontends, micro front ends, or microfrontends.",
    "The goal of the micro-frontend approach is decoupling. It allows each " +
      "micro-frontend to be independently implemented, tested, upgraded, updated, " +
      "and deployed. A thin micro-frontend container launches multiple " +
      "micro-frontends.",
  ],
};

const FiveStepsComponent = () => buildComponent(fiveStepsArticle);
const EcoSystemComponent = () => buildComponent(ecoSystemArticle);

export const currentRoutes = [
  { path: "/5steps", name: fiveStepsArticle.title, component: FiveStepsComponent },
  { path: "/3steps", name: ecoSystemArticle.title, component: EcoSystemComponent },
];

src/reactUtilitiesRoutes.js文件中创建React工具 React Utilities选项的路由纤细:

import { buildComponent } from "./buildComponent";

const useAsyncArticle = {
  title: "The Power and Convenience of useAsync",
  paragraphs: [
    "How do you make async calls in React? Do you use axios, fetch, or even " +
      "GraphQL?",
    "In that case, you should be familiar with getting data for a successful " +
      "call, and receiving an error for a failed call. Likely, you also need to " +
      "track the loading status to show pending state.",
    "Have you considered wrapping them with a custom Hook?",
    "All of these have been accomplished by react-async, a utility belt for " +
      "declarative promise resolution and data fetching. We are going to show you " +
      "how easy it is to use this powerful react-async.",
  ],
};

const reactTableArticle = {
  title: "An Introduction to React-Table",
  paragraphs: [
    "A table, also called a data grid, is an arrangement of data in rows and " +
      "columns, or possibly in a more complex structure. It is an essential " +
      "building block of a user interface. I’ve built tables using Java Swing, " +
      "ExtJs, Angular, and React. I’ve also used a number of third party tables. " +
      "As a UI developer, there’s no escape from table components.",
    "Build vs. buy? It is always a choice between cost and control. When there " +
      "is an open-source with a proven track record, the choice becomes a " +
      "no-brainer.",
    "I would recommend using React Table, which provides a utility belt for " +
      "lightweight, fast, and extendable data grids. This project started in " +
      "October, 2016, with hundreds of contributors and tens of thousands of " +
      "stars. It presents a custom hook, useTable, which implements row sorting, " +
      "filtering, searching, pagination, row selection, infinity scrolling, and " +
      "many more features.",
  ],
};

const UseAsyncComponent = () => buildComponent(useAsyncArticle);
const ReactTableComponent = () => buildComponent(reactTableArticle);

export const currentRoutes = [
  {
    path: "/useAsync",
    name: useAsyncArticle.title,
    component: UseAsyncComponent,
  },
  {
    path: "/reactTable",
    name: reactTableArticle.title,
    component: ReactTableComponent,
  },
];

接下来看一下src/App.js文件中的变化:

import React, { useState, useMemo, useCallback } from "react";
import { NavLink, Route, Switch, useHistory } from "react-router-dom";
import Select from "react-select";
import { currentRoutes as mfaRoutes } from "./microFrontendRoutes";
import { currentRoutes as utilRoutes } from "./reactUtilitiesRoutes";
import "./App.css";

const App = () => {
  const [topic, setTopic] = useState("");
  const [routes, setRoutes] = useState([]);

  const selectOptions = useMemo(
    () => [
      {
        value: "mfa",
        label: "Micro Frontends",
      },
      {
        value: "util",
        label: "React Utilities",
      },
    ],
    []
  );

  const routeMapping = useMemo(
    () => ({
      mfa: mfaRoutes,
      util: utilRoutes,
    }),
    []
  );

  const history = useHistory();

  const handleTopicChange = useCallback(
    (selected) => {
      setTopic(selected);
      setRoutes(routeMapping[selected.value]);
      history.push(`/`);
    },
    [history, routeMapping]
  );

  return (
    <div className="App">
      <h1>This is an example of static import</h1>
      <p>Jennifer's articles are initially loaded to be used.</p>
      <Select
        className="select-category"
        value={topic}
        options={selectOptions}
        onChange={handleTopicChange}
      />
      <ul>
        {routes.map(({ path, name }) => (
          <li key={path}>
            <NavLink to={path}>{name}</NavLink>
          </li>
        ))}
      </ul>

      <Switch>
        {routes.map(({ path, component }) => (
          <Route key={path} path={path} component={component} />
        ))}
      </Switch>
    </div>
  );
};

export default App;

在第4行和第5行,mfaRoutesutilRoutes被静态导入,在第26-32行使用到。

第9行定义 topic state,该状态由Select组件设置,选项由第12-24行定义。Select组件由第49-54行定义。当选择一个主题时,第53行的onChange将调用handleTopicChange(第36-43行)。

第10行定义了routesstate。 调用handleTopicChange时,它将在第38行设置选定的主题,并在第39行设置选定的routesroutes更改将导致在55-61行重新呈现链接,而在63-67行发生路由更改。

此示例中的所有内容都是静态导入。


转换为动态导入

这是与上面相同的用户交互界面。Select组件下面的内容将被动态导入。

本示例在静态导入的代码上对src/App.js进行了一些更改。

import React, { useState, useMemo, useCallback } from "react";
import { NavLink, Route, Switch, useHistory } from "react-router-dom";
import Select from "react-select";
import "./App.css";

const App = () => {
  const [topic, setTopic] = useState("");
  const [routes, setRoutes] = useState([]);

  const selectOptions = useMemo(
    () => [
      {
        value: "mfa",
        label: "Micro Frontends",
      },
      {
        value: "util",
        label: "React Utilities",
      },
    ],
    []
  );

  const routeMapping = useMemo(
    () => ({
      mfa: "microFrontendRoutes",
      util: "reactUtilitiesRoutes",
    }),
    []
  );

  const history = useHistory();

  const handleTopicChange = useCallback(
    (selected) => {
      setTopic(selected);
      import("./" + routeMapping[selected.value]).then((module) => {
        setRoutes(module.currentRoutes);
      });
      history.push(`/`);
    },
    [history, routeMapping]
  );

  return (
    <div className="App">
      <h1>This is an example of dynamic loading</h1>
      <p>Jennifer's articles are not loaded until the category is selected.</p>
      <Select
        className="select-category"
        value={topic}
        options={selectOptions}
        onChange={handleTopicChange}
      />
      <ul>
        {routes.map(({ path, name }) => (
          <li key={path}>
            <NavLink to={path}>{name}</NavLink>
          </li>
        ))}
      </ul>

      <Switch>
        {routes.map(({ path, component }) => (
          <Route key={path} path={path} component={component} />
        ))}
      </Switch>
    </div>
  );
};

export default App;

删除了静态导入的mfaRoutesutilRoute,第24行至第30行的routeMapping指向文件名,而不是静态导入,省略了文件扩展名。

关键区别在于handleTopicChange(第34-43行),动态导入相关模块来设置currentRoutes

这就是所有的变化。看起来简单明了。

代码分割

如果你仔细查看上面的src/App.js文件,您可能想知道,我们为什么在第37行构造文件路径,而不是在routeMapping中构建完整的路径。

试运行一下。

将会出现这样的错误:Cannot find module with dynamic import.,这个错误来自Create React App中使用的Webpack。Webpack在构建时执行静态分析,可以很好地处理静态路径,但是很难从变量中推断出哪些文件需要放在单独的chunks中。

如果采用类似import("./microFrontendRoutes.js"这种形式进行硬编码,该文件将处理成单独的chunk。

如果是形如import("./" + routeMapping[selected.value]),会将./目录下的每个文件处理成单独的chunk。

如果是形如import(someVariable),Webpack将会抛出一个错误。

什么是代码分割?
它的功能是将代码分成多个bundles(chunks),然后可以按需或并行加载这些代码块。它可以用来实现更小的chunks并且控制资源加载的优先级,如果使用得当,则可以减少加载时间。

Webpack提供了三种实现代码分割的方法:

  • Entry points:使用使用entry配置手动分割代码
  • 防止重复: 使用SplitChunksPlugin消除重复
  • 动态导入:通过import()分割代码

对于静态导入的示例,npm run build显示以下结果:

File sizes after gzip:
71.85 KB (+32.46 KB)  build/static/js/2.489c17a1.chunk.js
2.24 KB (+1.6 KB)     build/static/js/main.7c91b243.chunk.js
776 B                 build/static/js/runtime-main.8894ea17.js
312 B (-235 B)        build/static/css/main.83b9e03d.chunk.css
  • main.[hash].chunk.js:应用程序代码,包括App.js等。
  • [number].[hash].chunk.js:第三方vendor代码或者是分割块
  • runtime-main.[hash].js:Webpack运行时逻辑代码块,用于加载和运行应用程序。
  • main.[hash].chunk.css:css文件

使用硬编码import("./ microFrontendRoutes.js"),可以看到生成了一个额外的chunk:

File sizes after gzip:
71.85 KB (+70.95 KB)  build/static/js/2.489c17a1.chunk.js
  1.17 KB (-74 B)       build/static/js/runtime-main.c08b891b.js
  902 B (-912 B)        build/static/js/main.a5e0768c.chunk.js
  885 B (-290 B)        build/static/js/3.b3637929.chunk.js
  312 B                 build/static/css/main.83b9e03d.chunk.css

运行代码import("./" + routeMapping[selected.value]),可以看到./目录下的每一个文件产生了一个chunk。

File sizes after gzip:
71.86 KB           build/static/js/9.e1addb36.chunk.js
50.68 KB           build/static/js/1.ee2fd9a6.chunk.js
49.51 KB           build/static/js/0.ee6ff7e2.chunk.js
1.77 KB (-477 B)   build/static/js/main.21dcc146.chunk.js
1.24 KB (+496 B)   build/static/js/runtime-main.834cfecb.js
1.15 KB            build/static/js/3.5ca48094.chunk.js
919 B (-70.95 KB)  build/static/js/2.4da83169.chunk.js
312 B              build/static/css/main.83b9e03d.chunk.css
283 B              build/static/js/5.0753ed26.chunk.js
283 B              build/static/js/4.7720f7a0.chunk.js
177 B              build/static/js/10.880ba423.chunk.js
160 B              build/static/js/6.f3a8c74d.chunk.js

延迟加载

延迟加载是代码分割的后续,由于代码已在逻辑断点处拆分,因此我们在需要时加载它们,这样,我们可以大大提高性能。虽然加载时间的总量可能是相同的,但初始加载时间得到了改善。通过这种方式,也可以避免用户完全无法访问的情况。

动态导入会延迟加载任何JavaScript模块,React.lazy可以使得动态导入像常规组件一样正常加载。

以下是一个类似用户交互界面,Other Articles链接下方的内容被延迟加载。单击Other Articles链接后将加载其余内容。

创建一个要延迟加载的组件src/OtherRouteComp.js,目前,这个React组件必须导出为默认值,已命名导出不支持延迟加载。

import React from "react";

const articles = [
  "10 Useful Plugins for Visual Studio Code",
  "How to Use VS Code to Debug Unit Test Cases",
  "How to Use Chrome DevTools to Debug Unit Test Cases",
  "Natural Language Processing With Node.js",
];
const OtherRouteComp = () => (
  <>
    <h1>Other Articles</h1>
    <ul>
      {articles.map((item, i) => (
        <li key={i}>{item}</li>
      ))}
    </ul>
  </>
);

export default OtherRouteComp;

src/App.js中延迟加载该组件

import React, { Suspense } from "react";
import { NavLink, Route, Switch } from "react-router-dom";
import "./App.css";

const App = () => {
  const OtherRouteComp = React.lazy(() => import("./OtherRouteComp"));

  return (
    <div className="App">
      <Suspense fallback={<div>Loading...</div>}>
        <h1>This is an example of React Lazy Loading</h1>
        <p>Jennifer's other articles are not loaded until the link is clicked.</p>
        <ul>
          <NavLink to="/others">Other Articles</NavLink>
        </ul>

        <Switch>
          <Route key="/others" path="/others" component={OtherRouteComp} />
        </Switch>
      </Suspense>
    </div>
  );
};

export default App;

第6行延迟加载组件OtherRouteComp,返回一个可解析为src/OtherRouteComp.js导出的组件的promise。该组件包装在<Suspense>中,在第10行有一个兜底处理,用来在动态加载期间过渡。

类似地,由于延迟加载,会生成额外的chunk。

File sizes after gzip:
47.91 KB (+480 B)  build/static/js/2.27608de7.chunk.js
1.17 KB (-1 B)     build/static/js/runtime-main.fce20771.js
911 B (+232 B)     build/static/js/main.6680ea99.chunk.js
372 B (-10 B)      build/static/js/3.df091b29.chunk.js
312 B              build/static/css/main.83b9e03d.chunk.css

Network面板中,可以看到点击链接后一个新的chunk被加载。这种延迟加载行为适用于所有动态导入情况。

错误处理

延迟加载返回一个promise,如果这个转换失败了怎么办?

设计良好的用户体验可以很好地处理这种情况。Error boundary用于此目的,它是一个组件,可以在子组件树的任何位置捕获JavaScript错误,它可以记录这些错误并显示一个兜底的用户界面,而不是在组件树中直接崩溃。

src/ pp.js在第11行和第25行设置了MyErrorBoundary

import React, { Suspense } from "react";
import { NavLink, Route, Switch } from "react-router-dom";
import { MyErrorBoundary } from "./MyErrorBoundary";
import "./App.css";

const App = () => {
  const OtherRouteComp = React.lazy(() => import("./OtherRouteComp"));

  return (
    <div className="App">
      <MyErrorBoundary>
        <Suspense fallback={<div>Loading...</div>}>
          <h1>This is an example of React Lazy Loading</h1>
          <p>
            Jennifer's other articles are not loaded until the link is clicked.
          </p>
          <ul>
            <NavLink to="/others">Other Articles</NavLink>
          </ul>

          <Switch>
            <Route key="/others" path="/others" component={OtherRouteComp} />
          </Switch>
        </Suspense>
      </MyErrorBoundary>
    </div>
  );
};

export default App;

src/MyErrorBoundary.js组件

import React from "react";

export class MyErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error(error);
    console.error(errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong...</h1>;
    }

    return this.props.children;
  }
}