Skip to content

JS优化

以下都是基于 webpack5 的使用

一、Terser 代码压缩

通过牺牲可读性、语义、优雅度而力求用最少字符数的方式生成代码。

Terser

Terser 是当下 最为流行 的 ES6 代码压缩工具之一,支持 Dead-Code Eliminate、删除注释、删除空格、代码合并、变量名简化等等一系列代码压缩功能。Terser 的前身是大名鼎鼎的 UglifyJS,它在 UglifyJS 基础上增加了 ES6 语法支持,并重构代码解析、压缩算法,使得执行效率与压缩率都有较大提升。

Webpack 中使用 Terser

Webpack5.0 后默认使用 Terser 作为 JavaScript 代码压缩器,简单用法只需通过 optimization.minimize 配置项开启压缩功能即可:

js
module.exports = {
  //...
  optimization: {
    minimize: true
  }
};

也可以通过 optimization.minimizer 进行详细的配置,前提是 optimization.minimize 要设置为 true

js
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  // ...
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        // ...
      }),
    ],
  },
};

TIP

在生产模式下,即使不配置也会默认开启使用

二、Tree-Shaking 去除无用代码

通过AST树主干,也就是入口文件。去查找所有的枝干,把所有没用到的枝干去除掉。在编程中也就是在代码中把 Dead Code给去除掉

Webpack 中使用 Tree-Shanking

在 Webpack 中,启动 Tree Shaking 功能必须同时满足三个条件:

  • 使用 ESM 规范编写模块代码
  • 配置 optimization.usedExports 为 true,启动标记功能
  • 启动代码优化功能,例如:
    • 配置 mode = production
    • 配置 optimization.minimize = true
    • 提供 optimization.minimizer 数组

为什么要开启代码压缩(optimization.minimize = true)

这是因为 Webpack 中,Tree-shaking 的实现,一是需要先 「标记」 出模块导出值中哪些没有被用过,然后在通过代码压缩插件压缩时删掉。

TIP

在生产模式下,即使不配置也会默认开启使用

三、JS分割

Webpack 默认会将尽可能多的模块代码打包在一起,优点是能减少最终页面的 HTTP 请求数,但缺点也很明显:

  1. 页面初始代码包过大,影响首屏渲染性能
  2. 无法有效应用浏览器缓存,特别对于 NPM 包这类变动较少的代码,业务代码哪怕改了一行都会导致 NPM 包缓存失效。

Webpack 如何处理 Chunk

Chunk 类型

在此之前我们的先了解 Webpack 有几种 chunk 类型

  • Initial Chunk:entry 模块及相应子模块打包成 Initial Chunk
  • Async Chunk:通过 import('./xx') 等语句导入的异步模块及相应子模块组成的 Async Chunk
  • Runtime Chunk:运行时代码抽离成 Runtime Chunk,可通过 entry.runtime 配置项实现

处理过程

我们都知道 Webpack 是根据 extry 配置的入口文件,找到所有的模块,并且处理成 chunk。如下图这样:
webpack
如果只有一个入口文件和没有异步模块情况下,那么 webpack 就只会生成一个 chunk。但是要是遇到异步模块情况下,Webpack 就会为它生成一个新的 chunk,并且该异步模块下的依赖模块也会并入同一个 chunk。
webpack

TIP

Runtime Chunk 是 Webpack 自己添加的代码,作为运行时处理。这里就不额说明

Webpack 默认处理 Chunk 的缺点

模块会重复打包

如果有多个 chunk 依赖同一个模块,那么这个模块会被重复打包进各自的 chunk
webpack

chunk 过大

由于 Webpack 默认只能拆分两种类型的 chunk ,那么会很容易导致 chunk 过大。从而导致页面加载慢,不利于缓存等缺点。

SplitChunksPlugin

针对上面两个问题,Webapck 提供了 SplitChunksPlugin 插件。能让我们实现更灵活、更自主的按照项目的需求进行分包策略。

默认配置

js
// webpack.config.js
module.exports = {
    //...
    optimization: {
        splitChunks: {
            chunks: 'async',
            minSize: 20000,
            minRemainingSize: 0,
            minChunks: 1,
            maxAsyncRequests: 30,
            maxInitialRequests: 30,
            enforceSizeThreshold: 50000,
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10,
                    reuseExistingChunk: true,
                },
                default: {
                    minChunks: 2,
                    priority: -20,
                    reuseExistingChunk: true,
                },
            },
        },
    },
};

配置讲解

  1. chunks 配置分割模式,其值为 all, async 和 initial,分别意思是 全部,异步和初始化代码块。默认为 async
  2. minSize 配置最小分割大小要求,超过限制才会被分包,单位为字节
  3. maxSize 配置最大的限度,超过限制则会被额外拆包,单位为字节(一般情况下,使用minSize就足够了)
  4. maxAsyncSize 与 maxSize 功能类似,但只对异步引入的模块生效
  5. maxInitialSize 与 maxSize 类似,但只对 entry 配置的入口模块生效
  6. enforceSizeThreshold 超过这个尺寸的 Chunk 会被强制分包,忽略上述其它 Size 限制。
  7. minChunks 设定被拆分模块被 Chunk 最小引用次数,如果为 1 那么只要被引用一次就会拆分。

TIP

注意,这里“被 Chunk 引用次数”并不直接等价于被 import 的次数,而是取决于上游调用者是否被视作 Initial Chunk 或 Async Chunk 处理

  1. cacheGroups 缓存组是将符合 SplitChunksPlugin 要求的模块代码,按需求分开装在不同的 JS。
  2. maxInitialRequests 用于设置 Initial Chunk 最大并行请求数(指加载一个 chunk 所需要加载的所有分包数,包括自身)
  3. maxAsyncRequests 用于设置 Async Chunk 最大并行请求数(指加载一个 chunk 所需要加载的所有分包数,包括自身)

SplitChunksPlugin 的主体流程如下:

  1. SplitChunksPlugin 尝试将命中 minChunks 规则的 Module 统一抽到一个额外的 Chunk 对象
  2. 判断该 Chunk 是否满足 maxInitialRequests 阈值,若满足则进行下一步
  3. 判断该 Chunk 资源的体积是否大于上述配置项 minSize 声明的下限阈值
    • 如果体积小于 minSize 则取消这次分包,对应的 Module 依然会被合并入原来的 Chunk
    • 如果 Chunk 体积大于 minSize 则判断是否超过 maxSize、maxAsyncSize、maxInitialSize 声明的上限阈值,如果超过则尝试将该 Chunk 继续分割成更小的部分

cacheGroups

上述 minChunks、maxInitialRequest、minSize 都属于分包条件,决定是否对什么情况下对那些 Module 做分包处理。此外, SplitChunksPlugin 还提供了 cacheGroups 配置项用于为不同文件组设置不同的规则,例如:

js
module.exports = {
    //...
    optimization: {
        splitChunks: {
            cacheGroups: {
                vendors: {
                    test: /[\\/]node_modules[\\/]/,
                    minChunks: 1,
                    minSize: 0
                }
            },
        },
    },

示例通过 cacheGroups 属性设置 vendors 缓存组,所有命中 vendors.test 规则的模块都会被归类 vendors 分组,优先应用该组下的 minChunks、minSize 等分包配置。

cacheGroups 支持上述 minSice/minChunks/maxInitialRequest 等条件配置,此外还支持一些与分组逻辑强相关的属性,包括:

  • test:接受正则表达式、函数及字符串,所有符合 test 判断的 Module 或 Chunk 都会被分到该组;
  • type:接受正则表达式、函数及字符串,与 test 类似均用于筛选分组命中的模块,区别是它判断的依据是文件类型而不是文件名,例如 type = 'json' 会命中所有 JSON 文件
  • idHint:字符串型,用于设置 Chunk ID,它还会被追加到最终产物文件名中,例如 idHint = 'vendors' 时,输出产物文件名形如 vendors-xxx-xxx.js
  • priority:数字型,用于设置该分组的优先级,若模块命中多个缓存组,则优先被分到 priority 更大的组

四、动态加载

Webpack 默认会将同一个 Entry 下的所有模块全部打包成一个产物文件 —— 包括那些与页面关键渲染路径无关的代码,这会导致页面初始化时需要花费多余时间去下载这部分暂时用不上的代码,影响首屏渲染性能,例如:

js
import someBigMethod from "./someBigMethod";

document.getElementById("someButton").addEventListener("click", () => {
    someBigMethod();
});

逻辑上,直到点击页面的 someButton 按钮时才会调用 someBigMethod 方法,因此这部分代码没必要出现在首屏资源列表中,此时我们可以使用 Webpack 的动态加载功能将该模块更改为异步导入,修改上述代码:

js
document.getElementById("someButton").addEventListener("click", async () => {
    // 使用 `import("module")` 动态加载模块
    const someBigMethod = await import("./someBigMethod");
    someBigMethod();
});

此时,重新构建将产生额外的产物文件 src_someBigMethod_js.js,这个文件直到执行 import 语句时 —— 也就是上例 someButton 被点击时才被加载到浏览器,也就不会影响到关键渲染路径了。

动态加载是 Webpack 内置能力之一,我们不需要做任何额外配置就可以通过动态导入语句(import、require.ensure)轻易实现。但请 注意,这一特性有时候反而会带来一些新的性能问题:

  • 是过度使用会使产物变得过度细碎,产物文件过多,运行时 HTTP 通讯次数也会变多,在 HTTP 1.x 环境下这可能反而会降低网络性能,得不偿失;
  • 使用时 Webpack 需要在客户端注入一大段用于支持动态加载特性的 Runtime:这段代码即使经过压缩也高达 2.5KB 左右,如果动态导入的代码量少于这段 Runtime 代码的体积,那就完全是一笔赔本买卖了。

因此,请务必慎重,多数情况下我们没必要为小模块使用动态加载能力!目前社区比较常见的用法是配合 SPA 的前端路由能力实现页面级别的动态加载,例如在 Vue 中:

js
import { createRouter, createWebHashHistory } from "vue-router";

const Home = () => import("./Home.vue");
const Foo = () => import(/* webpackChunkName: "sub-pages" */ "./Foo.vue");
const Bar = () => import(/* webpackChunkName: "sub-pages" */ "./Bar.vue");

// 基础页面
const routes = [
    { path: "/bar", name: "Bar", component: Bar },
    { path: "/foo", name: "Foo", component: Foo },
    { path: "/", name: "Home", component: Home },
];

const router = createRouter({
    history: createWebHashHistory(),
    routes,
});

export default router;

五、预获取/预加载(prefetch/preload)

预获取 prefetch

预获取本质就是利用浏览器空闲时间来下载或预取用户在不久的将来可能访问的资源。

webpack中添加 webpackPrefetch: true 字段即可实现预获取,例如:

js
//...
import(/* webpackPrefetch: true */ './path/to/LoginModal.js');

这会生成 < link rel="prefetch" href="login-modal-chunk.js" > 并追加到页面头部,指示着浏览器在闲置时间预取 login-modal-chunk.js 文件。

TIP

  1. 没有合法https证书的站点无法使用prefetch,预提取的资源不会被缓存
  2. 请求的资源能被缓存,prefetch 才会生效

预加载 preload

让浏览器提前加载指定资源(在浏览器的主渲染机制介入前就进行预加载),并在需要执行的时候再执行。这在希望加快某个资源的加载速度时很有用。在 preload 下载完资源后,资源只是被缓存起来,浏览器不会对其执行任何操作。

webpack中添加 webpackPreload: true 字段即可实现预加载,例如:

js
import(/* webpackPreload: true */ 'ChartingLibrary');

其实本质上也是利用 <link rel="preload"> 来完成。

TIP

  1. preload的字体资源必须设置crossorigin属性,否则会导致重复加载。原因是如果不指定crossorigin属性(即使同源),浏览器会采用匿名模式的CORS去preload,导致两次请求无法共用缓存。
  2. prefetch 资源能被缓存

两者的区别:

  1. preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  2. preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。

TIP

preload 和 prefetch 混用的话,并不会复用资源,而是会重复加载,两者并不会阻塞页面的 onload