跳过正文
  1. 前端工程体验优化实战/

超简单的代码模块懒加载:让JS加载体积减少13%

hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录
代码优化 - 这篇文章属于一个选集。
§ 1: 本文

懒加载(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图表,可选值有truefalse

分析了项目当前的模块构成,我们再学习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.js
  • effector.mjs
  • react-hook-form/dist/index.esm.mjs:6.83 KB
  • axios
  • history
  • react.production.min.js
  • mardown-to-jsx/dist/index.modern.js: 5.39 KB

这其中大多数都是项目初始化的核心依赖模块,例如react-dom, axios,没有这些模块,前端页面无法完成渲染,所以不建议进行懒加载优化处理。

但是react-hook-form/dist/index.esm.mjsmardown-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. 改动1:直接导入改为动态导入
  2. 改动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'
));
  1. 改动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. 改动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>
  );
};
  1. 改动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}.jshome.${hash}.js等等,但是我们专门处理过的markdown-to-jsx模块却没有被拆分为独立产物文件,仍然留在了vendors.${hash}.js之中,这显然不符合预期。

这正是懒加载的常见问题之一,下一节我们将专门探讨3类懒加载实施的常见问题,帮助大家轻松驾驭懒加载。

代码优化 - 这篇文章属于一个选集。
§ 1: 本文

相关文章