在 React 中如何做组件级别按需加载

3,567 阅读10分钟

在 React 应用程序中,随着组件数量的增加,应用程序的大小也会随之增加。这可能会导致应用程序加载时间过长,并且会给用户带来不好的体验。为了解决这个问题,我们可以使用组件级别的按需加载。按需加载可以提高应用程序的性能,降低页面加载时间,改善用户体验。在本篇博客中,我将详细介绍如何在 React 中实现组件级别按需加载。

什么是按需加载?

按需加载(也称为懒加载)是一种技术,它允许我们延迟加载代码,直到它们真正需要被执行为止。在 React 应用程序中,按需加载允许我们在需要时动态加载组件或其他资源,而不是在初始加载时加载它们。这意味着应用程序可以更快地启动,减少不必要的网络请求和文件大小,提高性能和响应速度。

为什么要使用按需加载?

在 React 应用程序中使用按需加载的主要原因是提高性能和用户体验。当我们使用按需加载时,我们只会在需要时加载代码,而不是在应用程序启动时加载所有代码。这可以减少页面加载时间和网络请求,提高应用程序的响应速度和用户满意度。此外,按需加载还可以降低应用程序的文件大小,因为我们只需要加载必要的代码和资源。

按需加载的实现方式

React 支持多种按需加载组件的方式,包括使用 React.lazy 和 Suspense API、使用高阶组件和使用动态导入。下面我将介绍每种方式的用法

使用 React.lazy 和 Suspense API

React.lazy 是一个函数,它可以让我们动态地加载组件。当组件被需要时,React.lazy 会自动加载代码并渲染组件。使用 React.lazy 需要配合 Suspense 组件一起使用,以处理在组件加载期间的等待状态。

使用 React.lazy() 函数来创建一个懒加载组件:

const MyComponent = React.lazy(() => import('./MyComponent'));

在需要使用这个组件的地方,用 组件包裹起来,并设置 fallback 属性为加载中的占位符(例如 loading 动画):

import React, { Suspense } from 'react';

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

需要注意的是,React.lazy 只支持默认导出组件。如果要动态加载具名导出组件,需要使用动态导入。例如,如果我们要动态加载 MyNamedComponent 组件,可以这样写:

import React, { lazy, Suspense } from 'react';

const MyNamedComponent = lazy(() => import('./MyNamedComponent').then(module => ({ default: module.MyNamedComponent })));

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

export default App;

在上面的代码中,我们使用 then 方法动态获取 MyNamedComponent 组件,然后将其导出为默认组件。这样我们就可以使用 React.lazy 动态加载 MyNamedComponent 组件了。

使用 react-loadable

react-loadable 是一个 React 应用程序中动态加载组件的库,它允许我们在需要时加载组件,而不是在应用程序初始化时加载所有组件。使用 react-loadable,我们可以轻松地实现组件级别的按需加载。

安装和使用

要使用 react-loadable,我们需要先安装它。可以通过 npm 或者 pnpm 安装:

npm install --save react-loadable
或者
pnpm i react-loadable

使用 react-loadable 函数来创建一个懒加载组件:

import Loadable from 'react-loadable';

const MyComponent = Loadable({
  loader: () => import('./MyComponent'),
  loading: () => <div>Loading...</div>,
});

在需要使用这个组件的地方直接使用 MyComponent:

function App() {
  return <MyComponent />;
}

以上两种方式都可以实现组件级别的按需加载,提高应用程序的性能和用户体验。在项目中执行的时候,两种方式都可以实现。但是需要注意的是,在使用 Webpack 等打包工具时,需要确保使用了动态导入(dynamic import)语法,才能实现真正的按需加载。

实战

看完上面的理论和用法,我们已经大致可以在项目中使用了。但是我们在组件库开发的时候,如果期望按需加载的方式是被封装在组件库时,又要如何编写代码,还有什么特别的注意事项需要额外的关注呢?下面我用一个简单的演示 demo 来说明一些可能遇到的问题和对应的解法。

初始化一个多包管理项目

随便找一个多包管理项目,或者如果你掌握了 link 调试技巧也可以新建几个前端项目。当然为了演示的一致性,我建议你直接下载我为你准备的初始工程。

git clone https://github.com/xiaohuoni/monorepo-demo.git
cd monorepo-demo/
pnpm i
pnpm build

新建一个子包,用一个 umi 的空包验证吧

# 或者手动新建文件夹 umi-demo 
mkdir packages/umi-demo 
cd packages/umi-demo
npm init -y

安装 umi 和 antd

pnpm i umi antd

新建 packages/umi-demo/pages/index.tsx 做如下修改:

import { Button } from 'antd';

export default function HomePage() {
  return (
    <div>
      <Button>test</Button>
    </div>
  );
}

修改 package.json

{
  "dev": "umi dev",
  "build": "umi build"
},

安装完成之后执行 pnpm dev,此时如果顺利的话,我们应该能在类似 http://localhost:8000 端口上看到页面上的 test 按钮。

项目中按需加载 antd 包

这里说的“按需加载”的概念和我们一直说的“按需加载antd“有点类似但又有一点点不一样,我们之前说的 “按需加载antd“ 是指“按需加载antd的组件“比如只用到一个 按钮,我们期望产物中只包含按钮的代码。通常我们使用 babel-plugin-import 来处理。最终产物可能都在一个 js 文件中,我们这里要做的按需,则是将 antd 的包独立在一个js文件中。(就是之前的按需加载加默认拆包的行为)

执行构建

pnpm build

我们先执行一次构建,看看当前的项目产物如何

info  - File sizes after gzip:

  108.79 kB  dist/umi.js
  32.89 kB   dist/882.async.js
  201 B      dist/p__index.async.js

React.lazy 加载 antd

用 React.lazy 函数,修改上述的代码。

import React, { Suspense }  from 'React';

const Button = React.lazy(async () => {
  const antd = await import/* webpackChunkName: "antd" */('antd');
  return { default: antd.Button };
});

export default function HomePage() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <Button>test</Button>
      </Suspense>
    </div>
  );
}

执行编译

pnpm build

... 

info  - File sizes after gzip:

  415.64 kB  dist/antd.async.js
  108.77 kB  dist/umi.js
  3.34 kB    dist/p__index.async.js

不难发现,产物中生成了一个名为 “antd.async” 的 js 文件。虽然它的大小很大,因为它包含了所有的 antd 组件。文件的名称是由 webpack 的 magic 代码决定的,也就是如我们看到的注释代码 /* webpackChunkName: "antd" */

当然我们也可以只导入我们需要的 Button 组件,只需要对上面的代码进行简单的修改即可。

import React, { Suspense }  from 'React';

const Button = React.lazy(async () => {
+  const antd = await import/* webpackChunkName: "antd" */('antd/lib/button');
+  return antd;
});

export default function HomePage() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <Button>test</Button>
      </Suspense>
    </div>
  );
}

这要求对应组件库的产物要包含独立的文件。如 antd/lib 。当前产物文件是 cjs 和 esm 的都能正常使用。

执行编译

pnpm build

...

info  - File sizes after gzip:

  109.6 kB           dist/umi.js
  62.91 kB           dist/antd.async.js
  3.35 kB            dist/p__index.async.js

以上两种方式都可以,这要看我们的需求场景需不需要全量的产物了。

效果演示
cd dist
npx serve

...
   ┌──────────────────────────────────────────────────┐
   │                                                  │
   │   Serving!                                       │
   │                                                  │
   │   - Local:            http://localhost:3000      │
   │   - On Your Network:  http://10.128.4.158:3000   │
   │                                                  │
   │   Copied local address to clipboard!             │
   │                                                  │
   └──────────────────────────────────────────────────┘

打开浏览器开发者工具,将网络调成低速 3G 我们将可以在 js 文件加载完成之前看到 Loading...

由于演示的时候 antd 已经是 5.x 版本了,如果我们用的是更低版本的 antd 在真实场景中,还要处理样式丢失等问题。

使用 react-loadable

安装 react-loadable

pnpm i react-loadable

用 React.lazy 函数,修改上述的代码。

import Loadable from 'react-loadable';

const Button = Loadable({
  loader: async () => {
    const antd = await import(/* webpackChunkName: "antd" */ 'antd/lib/button');
    return antd;
  },
  loading: () => <div>Loading...</div>,
});

export default function HomePage() {
  return (
    <div>
      <Button>test</Button>
    </div>
  );
}

执行编译

pnpm build

...

info  - File sizes after gzip:

  109.49 kB       dist/umi.js
  62.91 kB        dist/antd.async.js
  2.26 kB  dist/p__index.async.js

可以看出效果这两者是差不多的,值得注意的是 React.lazy(loader)react-loadable({loader}) 这两个 loader 是一致的,差别就在加载完成之前的 fallback 写在哪。

所以依旧是需要根据使用场景来选择不用的包,感觉 react-loadable 用法更灵活一些。而 React.lazy 则方便些,不用再装一个包。以上就是在项目中的使用方式。

组件库中按需加载 antd 包

我们有这样一个场景,写了一个组件库给项目使用,期望项目中对这个组件的用法和常规用法一致,但是效果需要按需加载 js。 简单的说就是写如下代码:

import { Button } from 'demo';

export default function HomePage() {
  return (
    <div>
      <Button>test</Button>
    </div>
  );
}

效果等同于

import Loadable from 'react-loadable';

const Button = Loadable({
  loader: async () => {
    const button = await import(/* webpackChunkName: "demo" */ 'demo/lib/button');
    return button;
  },
  loading: () => <div>Loading...</div>,
});

export default function HomePage() {
  return (
    <div>
      <Button>test</Button>
    </div>
  );
}

虽然说需求多少是有点“离奇”,但是也是存在不少真实的使用场景的。如果我们也遇到类似的需求,不妨试一试。

安装下 demo 组件库

修改 packages/umi-demo/package.json 的 dependencies

  "dependencies": {
    "antd": "^5.3.1",
    "react-loadable": "^5.5.0",
+   "demo":"workspace:*",
    "umi": "^4.0.61"
  },

由于 demo 这个包是我们同仓库的子包,所以我们这里的版本号使用了 pnpm 支持的 workspace:*,我们也可以写和子包中一致的版本号,如此时的 0.0.1

然后按上面提到的,使用下 demo 的 button 组件。

执行编译

pnpm build

...

info  - File sizes after gzip:

  111 kB              dist/umi.js
  2.26 kB             dist/p__index.async.js
  246 B               dist/demo.async.js

验证有效,然后要实现组件库中的按需加载就将我们项目中的逻辑搬运到组件库中。

修改 packages/demo/src/index.tsx 组件库的导出文件,

- export { default as Button } from './button';
+ import React from 'react';
+ import Loadable from 'react-loadable';

+ const Button = Loadable({
+   loader: async () => {
+     const button = await import(/* webpackChunkName: "demo" */ './button');
+     return button;
+   },
+   loading: () => <div>Loading...</div>,
+ });

+ export { Button };

会在第6行的 import 处看到一个错误:Dynamic imports are only supported when the '--module' flag is set to 'es2020', 'es2022', 'esnext', 'commonjs', 'amd', 'system', 'umd', 'node16', or 'nodenext'. 在 tsconfig.json 的 compilerOptions 中增加 "module": "ES2020", 即可修复

修改项目中的用法, packages/umi-demo/pages/index.tsx

import { Button } from 'demo';

export default function HomePage() {
  return (
    <div>
      <Button>test</Button>
    </div>
  );
}

执行编译

pnpm build

...

info  - File sizes after gzip:

  110.98 kB (-21 B)  dist/umi.js
  4.95 kB (+328 B)   dist/p__index.async.js

会发现,我们的功能无效了。这时候要去看组件库 package.json 中的 main 配置。

packages/demo/package.json 现在的 "main": "lib/index.js", 可以看到,main 指定的是 cjs 文件,这些文件已经经过一次 babel 编译了。里面的 magic 注释也会被去除。 好在这时候我们只是按需加载js失效,但项目已经可以正确运行,可以通过 cd dist && npx serve 查看。

"main": "lib/index.js", 修改成 esm 目录,即 "main": "es/index.js",

执行编译

pnpm build

...

info  - File sizes after gzip:

  111 kB   dist/umi.js
  4.62 kB  dist/p__index.async.js
  171 B    dist/demo.async.js

注意:修改过 main 之后,应该确认对应的 files 有没有在发包的文件中,即 package.json"files": ["lib"], 需要增加对应的目录

小结:可以在组件库中内置这个功能,但要求组件库的产物为 esm 。

如果我们想比对最终的结果,可以同仓库切 demo-for-loadable 分支。

总结

按需加载组件是一种优化 React 应用程序性能的重要技术。本文介绍了两种实现按需加载组件的方法:使用 React.lazy 和 Suspense API 和使用 react-loadable 组件。

React.lazy 和 Suspense API 是 React 官方提供的一种简单和直接的方法,可以轻松实现按需加载组件。

react-loadable 是一个高阶组件(Higher-Order Component),它可以将组件的加载过程封装起来,并且提供了许多配置选项,例如延迟加载时间、错误处理等等。

无论我们选择哪种方法,按需加载组件都是优化 React 应用程序性能的重要技术,开发人员应该在应用程序的需求和优势之间做出明智的选择。