懒加载(Lazy Load)是指将部分资源延迟加载,从而优化页面渲染时用户体验的优化手段。 懒加载的优化目标可以是浏览器中一切需要HTTP请求响应的资源,这一节我们将专注于代码模块(Module)的懒加载实现。
在Vue.js官方文档页面中,就对渲染搜索框组件的JS代码模块进行了懒加载优化,这部分组件的代码被分割成了一个独立的 VPAlgoliaSearchBox.d8791224.js文件,并延迟到页面渲染后、用户点击搜索框时才加载,从而为页面初始化节省了 28 KB(占总体积近10%)的JS加载体积及解析执行开销,低投入高产出地改善了用户体验。

但是要如何为前端项目实施懒加载,往往需要细致入微的分析。
1. 模块懒加载核心原理#
首先让我们从模块懒加载的原理讲起。
代码模块懒加载的实现核心基于2项原理:
- 代码模块化
- 动态导入语法
import()
1. 代码模块化#
现代前端工程普遍基于Webpack、Rollup等工具的打包能力,实现源代码模块化,把源代码分割为多个独立的模块(Module),对应独立的JS、CSS文件,从而方便地隔离命名空间、封装代码、复用模块、管理依赖。
前端领域主要的模块化规范有2类,分别是:
- ES Module:ECMAScript 标准定义的模块化规范,主要使用
export&&import语法,实现模块的导入和导出。 - Common JS Module:是被Node.js采用的模块化规范,使用
require()函数和module.exports对象实现模块的导入导出功能。
正是因为基于模块化的开发模式,我们才能实现对指定模块,例如某个JS文件、某个CSS文件,进行懒加载。
2. Webpack 模块动态导入特性#
Webpack等现代前端构建工具的**动态导入(Dynamic Import)** 特性是JS懒加载得以实现的基础。
动态导入在ES模块化规范的基础上新增了**import()** API,使用该语法:
- 导入语句会返回一个
Promise实例,模块加载成功后将转变为fullfilled状态,加载失败则为failed状态。 - 在构建时,对应模块会被拆分为独立的区块(
chunk),生成独立的产物文件。 - 在运行时,会在模块需要加载执行时,通过动态添加
script标签,触发下载并运行对应的产物文件。
使用示例:
| 普通写法 | 懒加载写法 | |
|---|---|---|
| 代码示例 | import LoginPage from ‘../pages/login’ | const LoginPage = import(’../pages/login') |
import()还可以通过魔法注释(magic comment) 配置动态导入的方式,文件名称等细节,详细配置如下:
| 介绍 | 使用示例 | 效果 | |
|---|---|---|---|
| webpackChunkName | 指定产出文件的名称。如果为多个不同的动态导入的模块,备注同一个webpackChunkName,那么这些模块就会被合并为一个区块,最终合并到同一个打包产物文件中。这个技巧,也可以用于把多个模块合并为一个产物文件。 | const LoginPage = import( /* webpackChunkName: "login" */ '../pages/login' ) | 在Webpack打包构建时,@/pages/login对应模块会被组成名为login的区块,如果是JS类型,则最终产物文件名就是login.js。 如果没有声明 webpackChunkName,通常会以区块(chunk)的数字ID作为产物文件名。 |
webpackMode | 指定加载模块的方式,可选值包括:lazy(默认值):懒加载目标模块,打包构建时模块文件会分割出1个独立的区块chunk,对应1个产物文件。只在模块导入语句 import()执行时才发起HTTP请求加载其产物文件。lazy-once:专用于动态导入路径,会把所有动态路径匹配的模块文件合并为1个chunk,打包成1个产物文件。运行时,执行 import()加载产物文件时会复用同一个资源。eager:目标模块打包构建不会产生额外的chunk,也就不会有产物文件,而是会把对应模块的代码合并进已有的chunk中。 | // 1. lazy-once async function getIcon(name) { return import( /* webpackMode: "lazy-once" */ `../image/${name}.svg` ) } const HomeIcon = getIcon('home') const BlogIcon = await getIcon('blog') // 2. eager const LoginPage = import( /* webpackMode: "eager" */ '../pages/login' ) | lazy-once设置为lazy-once的多个.svg文件模块,会被打包成1个chunk,产生1个独立的产物文件。多次调用getIcon(name)也不会重复发送网络请求,加载资源,而是会复用已经加载的资源文件。eager没有额外的打包产物文件。但是import()仍然返回一个Promise实例。 |
在2023年,动态导入语法已经成为ECMA Script 标准的一部分,在全球浏览器平台有96%以上的支持率,浏览器兼容性详情:https://caniuse.com/?search=dynamic%20import
2. 示例:为前端项目实施懒加载优化#
我们仍然以这本书配套的前端优化示例项目 fe-optimization-demo 为例,演示实施懒加载优化的核心流程。
1. 分析懒加载目标模块#
首先,我们要具体问题具体分析,根据项目的模块构成、业务逻辑,分析可以实施懒加载的模块,必备的工具是webpack-bundle-analyzer-plugin。
打包构建分析神器:webpack-bundle-analyzer-plugin#
工欲善其事必先利其器,webpack-bundle-analyzer-plugin是我们处理分析打包构建问题的必备神器。
它作为一个Webpack插件,可以在Webpack运行时,收集各个产物文件的体积大小、模块包含关系等数据,并以HTML文件的形式生成一份可视化图表供我们分析。
示例项目中已经配置好了这一插件,配置源码:https://github.com/JuniorTour/fe-optimization-demo/blob/main/webpack/common.config.js#L16
用 fe-optimization-demo 项目的main分支,运行npm run build就能生成一份,如下图所示的图表:

这张图表中,左侧是页面控制功能,主要有:
体积类型(Treemap sizes):
- 输入的源代码体积(Stat)
- 输出的打包构建后产物体积(Parsed)
- 经过Gzip压缩的打包构建后产物体积(Gzipped)
显示合并模块内容(Show content of concatenated modules (inaccurate)):勾选后可以粗略的显示被Webpack
ModuleConcatenationPlugin合并优化过的模块内容。搜索(Search):支持输入正则表达式,根据模块文件名,过滤只显示部分模块。
右侧是每个产物文件所包含模块的可视化图表,图表中每个方块就代表了一个模块(Module),方块的大小代表了其体积的大小,方块之间的包含关系代表模块的包含关系。
例如上图中最大的的蓝色方块就是打包产物文件vendors.102eae...js文件所包含的各个模块,其中包括:
- 面积最大(也就是代码模块体积最大)的
@hot-loader/react-dom模块 effector/effector.mjs- ……
使用webpack-bundle-analyzer也非常简单,只需要2步。
首先,安装依赖:npm install --save-dev webpack-bundle-analyzer
其次,添加到 Webpack 配置中,示例代码:
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
// ...
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
defaultSizes: 'gzip',
openAnalyzer: false,
}),
]
}webpack-bundle-analyzer有一些常用的配置项:
analyzerMode:生成报告的模式,可选值有:server:在本地启动一个静态资源服务器,访问服务器URL(默认是http://localhost:8888/)就可以看到报告。json:生成一个JSON文件包含模块的体积、包含内容数据。static(推荐选项):生成一个静态HTML文件,包含可视化图表的代码和模块数据。disabled:不生成任何报告。
可以用于通过环境变量动态改变
disabled值,控制是否运行插件、生成报告。对不需要报告的开发环境,关闭
webpack-bundle-analyzer,可以减少打包编译耗时。例如:
// common.config.js new BundleAnalyzerPlugin({ // ... disabled: !!process.env.enableBundleAnalyzer, }), // package.json "scripts": { "build": "cross-env NODE_ENV=production webpack", "build-with-bundle-analyzer": "cross-env NODE_ENV=production enableBundleAnalyzer=true webpack", }
defaultSizes:默认的体积类型,可选值对应生成的可视化图表HTML页面左侧的体积类型(Treemap sizes)选项,有:'stat':源码体积。'parsed':输出的打包构建后产物体积。'gzip':经过Gzip压缩的打包构建后产物体积。推荐选择,因为更接近生产环境的体积
openAnalyzer:是否在构建完成后自动打开生成的HTML图表,可选值有true、false。
分析了项目当前的模块构成,我们再学习2种确定懒加载目标模块的思路。
1. 按路由#
第一种思路是按照前端路由分析懒加载的目标。
常见的单页应用(SPA)前端项目会基于前端路由,形成许多路由路径对应的页面组件,例如登录页/login对应的模块,源码主要写在login.tsx文件中。
用户访问时,一般只需要渲染1个路由,其余的路由暂时不需要加载运行,我们就可以对这些暂不运行的页面组件进行懒加载优化。
以我们的示例项目为例,路由路径和对应页面组件有:
/``login:'@/pages/login'/registration:'@/pages/registration'/home:'@/pages/home'/article:'@/pages/article'- ……
这些页面组件模块都可以进行懒加载优化,延迟加载其代码,推迟到组件需要渲染时再加载,从而减轻页面初始化时的性能负担。

2. 按模块体积#
第一种思路是按照模块体积大小分析懒加载的目标。
我们使用webpack-bundle-analyzer-plugin生成的可视化图表,观察各个模块的体积,把体积较大的模块作为懒加载优化的目标。

在我们的示例项目中,按体积大小排序,前几大模块排名如下:
react-dom.production.min.jseffector.mjsreact-hook-form/dist/index.esm.mjs:6.83 KBaxioshistoryreact.production.min.jsmardown-to-jsx/dist/index.modern.js: 5.39 KB
这其中大多数都是项目初始化的核心依赖模块,例如react-dom, axios,没有这些模块,前端页面无法完成渲染,所以不建议进行懒加载优化处理。
但是react-hook-form/dist/index.esm.mjs、mardown-to-jsx/dist/index.modern.js这2个模块,则并非页面初始化渲染所必须的模块,可以进行懒加载优化。
并且因为这2个模块的体积较大,预计懒加载后也会有较好的优化效果。
2. 修改导入代码#
确定了优化的目标模块,就可以开始修改代码,实现懒加载了。
实现懒加载的工作量,主要在于修改导入,也就是把原来基于import * from 'moduleName'语法直接导入的代码,改成import()动态导入语法。
要注意的细节是:一定要找到目标模块的所有导入,统一修改为动态导入。
如果遗漏了部分直接导入没有改成动态导入,那么目标模块并不会被拆分为独立区块chunk、不会产生独立的产物文件。因为如果遗漏部分import * from直接导入语法,目标模块会被Webpack判断为无法动态导入。
所以我们要全局搜索目标模块的所有导入,统一改为动态导入,同时还可以通过魔法注释设置统一的模块名称,加载方式,确保所有导入可以被拆分为一个独立文件。
请看代码示例:
1. 示例路由组件模块改动#
对于路由组件模块,懒加载优化前,我们使用的是直接导入路由组件:
// app.tsx
import ArticlePage from '@/pages/article';
import EditorPage from '@/pages/editor';
import HomePage from '@/pages/home';
import LoginPage from '@/pages/login';
import NoMatchPage from '@/pages/no-match';
import ProfilePage from '@/pages/profile';
import RegistrationPage from '@/pages/registration';
import SettingsPage from '@/pages/settings';要为这些组件实现懒加载,主要需要3步:
- 改动1:直接导入改为动态导入
- 改动2:对于
React组件类模块,还需要使用框架提供的lazy()包裹动态导入语法,从而将导入的模块转变为React组件(FiberNode)。
// app.tsx
import { lazy, Suspense, useState, useCallback } from 'react';
const LoginPage = lazy(() => import(
/* webpackChunkName: "login" */
'@/pages/login'
));
const RegistrationPage = lazy(() => import(
/* webpackChunkName: "registration" */
'@/pages/registration'
));
const HomePage = lazy(() => import(
/* webpackChunkName: "home" */
'@/pages/home'
));
const EditorPage = lazy(() => import(
/* webpackChunkName: "editor" */
'@/pages/editor'
));
const SettingsPage = lazy(() => import(
/* webpackChunkName: "settings" */
'@/pages/settings'
));
const ProfilePage = lazy(() => import(
/* webpackChunkName: "profile" */
'@/pages/profile'
));
const ArticlePage = lazy(() => import(
/* webpackChunkName: "article" */
'@/pages/article'
));
const NoMatchPage = lazy(() => import(
/* webpackChunkName: "no-match" */
'@/pages/no-match'
));- 改动3:最后别忘了配合
<Suspense fallback={<Loading ``/``>} >组件,在懒加载的组件模块代码加载过程中,为前端应用的UI提供替代UI(fallback)。
<Suspense fallback={<Spinner />}>
// <Route
<Suspense />2. 示例:NPM模块 mardown-to-jsx/dist/index.modern.js#
对于NPM模块,不使用懒加载、直接导入NPM包的代码是这样的:
import Markdown from 'markdown-to-jsx';开启懒加载后的改动,主要有2步:
- 改动1:直接导入改为动态导入
// import Markdown from 'markdown-to-jsx';
import { Row } from '@/shared/ui';
import { selectors } from '../model';
import { Tags } from './tags';
let Markdown = null;
let startLoad = false;
function lazyLoadMarkdownToJSX() {
if (startLoad) {
return;
}
startLoad = true;
import(
/* webpackChunkName: "markdown-to-jsx" */
'markdown-to-jsx'
)
.then((module) => {
Markdown = module.default;
})
.catch((err) => {
console.error(err);
});
}
export const Content = () => {
lazyLoadMarkdownToJSX();
if (!Markdown) {
return null;
}
const { body } = selectors.useArticle();
return (
<Row className="article-content">
<div className="col-xs-12">
<Markdown>{body}</Markdown>
<Tags />
</div>
</Row>
);
};- 改动2:为模块加载过程中的调用,添加保护的兜底逻辑。确保模块未加载时,组件不会直接调用模块,进而导致前端应用报错崩溃。
export const Content = () => {
lazyLoadMarkdownToJSX();
if (!Markdown) {
return null;
}
}另外,Vue.js 框架同样支持懒加载,请看示例代码:
// 1. vue-router 路由组件懒加载
// 文档:https://router.vuejs.org/guide/advanced/lazy-loading.html
const UserDetails = () => import('./views/UserDetails.vue')
const router = createRouter({
// ...
routes: [{ path: '/users/:id', component: UserDetails }],
})
// 2. Vue.js 组件懒加载
// 文档:https://vuejs.org/guide/components/async.html
<script setup>
import { defineAsyncComponent } from 'vue'
const AdminPage = defineAsyncComponent(() =>
import('./components/AdminPageComponent.vue')
)
</script>
<template>
<AdminPage />
</template>3. 验证懒加载效果#
优化目标模块都改成了动态导入,懒加载代码改造就算初步完成了,我们就可以继续用webpack-bundle-analyzer-plugin开始验证懒加载的效果。
运行npm run build-with-bundle-analyzer后,就能得到懒加载后的模块分布可视化图:

仔细观察这张图,我们会发现路由组件确实已经被拆分为独立产物文件:login.${hash}.js。home.${hash}.js等等,但是我们专门处理过的markdown-to-jsx模块却没有被拆分为独立产物文件,仍然留在了vendors.${hash}.js之中,这显然不符合预期。
这正是懒加载的常见问题之一,下一节我们将专门探讨3类懒加载实施的常见问题,帮助大家轻松驾驭懒加载。
