Skip to content

打包体积和性能优化的手段

打包性能分析

使用 speed-measure-webpack-plugin 可评估每个 loader/plugin 的执行耗时

更快的 js loader:swc

loader 进行编译时的 AST 操作均为 CPU 密集型任务,使用 Javascript 性能低下,此时可采用高性能语言 rust 编写的 swc

js
module: {
  rules: [
    {
      test: /\.m?js$/,
      exclude: /(node_modules)/,
      use: {
        loader: 'swc-loader'
      }
    }
  ]
}
module: {
  rules: [
    {
      test: /\.m?js$/,
      exclude: /(node_modules)/,
      use: {
        loader: 'swc-loader'
      }
    }
  ]
}

开启持久化缓存

webpack5 内置了关于缓存的插件,可通过 cache 字段配置开启。

它将 Module、ChunkModuleChunk 等信息序列化到磁盘中,二次构建避免重复编译计算,编译速度得到很大提升。

js
{
  cache: {
    type: 'filesystem'
  }
}
{
  cache: {
    type: 'filesystem'
  }
}

如对一个 JS 文件配置了 eslinttypescriptbabelloader,他将有可能执行五次编译,被五次解析为 AST

  1. acorn: 用以依赖分析,解析为 acorn 的 AST

  2. eslint-parser: 用以 lint,解析为 espree 的 AST

  3. typescript: 用以 ts,解析为 typescript的 AST

  4. babel: 用以转化为低版本,解析为 @babel/parser 的 AST

  5. terser: 用以压缩混淆,解析为 acorn 的 AST

而当开启了持久化缓存功能,最耗时的 AST 解析将能够从磁盘的缓存中获取,再次编译时无需再次进行解析 AST

得益于持久化缓存,二次编译甚至可得到与 Unbundle 的 vite 等相近的开发体验

::: 在 webpack4 中使用的是 cache-loader :::

开启多进程

thread-loader 为官方推荐的开启多进程的 loader,可对 babel 解析 AST 时开启多线程处理,提升编译的性能。

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              workers: 8
            }
          },
          'babel-loader'
        ]
      }
    ]
  }
}
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              workers: 8
            }
          },
          'babel-loader'
        ]
      }
    ]
  }
}

::: 在 webpack4 中使用的是 happypack 插件 :::

打包体积分析

可以使用webpack-bundle-analyzer分析打包后各模块的体积。

在查看页面中,有三个体积选项:

  1. stat: 每个模块的原始体积

  2. parsed: 每个模块经 webpack 打包处理之后的体积,比如 terser 等做了压缩,便会体现在上边

  3. gzip: 经 gzip 压缩后的体积

JS 代码压缩

目前通常使用swcterser来进行压缩,通过 AST 分析来生成一棵体积更小的 AST,他们拥有相同的 API。

常见方案如下

  1. 去除多余的字符、空格、换行、注释

  2. 压缩变量名、函数名、属性名

  3. 解析程序逻辑,合并声明及布尔简化

  4. 解析程序逻辑,编译预计算

Tree Shaking

基于 esm 进行静态分析,通过 AST 将没有用到的函数进行移除,减少打包体积。

垫片体积控制

垫片的作用

由于垫片的存在,打包后体积便会增加,所需支持的浏览器版本越高,垫片越少,体积就会越小。

babel@babel/preset-env 中使用 core-js 作为垫片

postcss 使用 autoprefixer 作为垫片

core-js已经集成到了babel/swc之中

通过配置,babel编译代码后将会自动包含所需的polyfill

关于前端打包体积与垫片关系,我们有以下几点共识:

  1. 由于低浏览器版本的存在,垫片是必不可少的

  2. 垫片越少,则打包体积越小

  3. 浏览器版本越新,则垫片越少

垫片体积优化

那在前端工程化实践中,当我们确认了浏览器版本号,那么它的垫片体积就会确认。

假设项目只需要支持最新的两个谷歌浏览器。那么关于 browserslist 的查询,可以写作 last 2 Chrome versions

browserslist依赖caniuse-lite的数据库,因此需要自己经常更新

shell
npx browserslist@latest --update-db
npx browserslist@latest --update-db

该命令将会对caniuse-lite进行升级,可体现在lock文件中

常用的查询语法

用户份额

> 5%: 在全球用户份额大于 5% 的浏览器 > 5% in CN: 在中国用户份额大于 5% 的浏览器

根据最新浏览器版本

last 2 versions: 所有浏览器的最新两个版本 last 2 Chrome versions: Chrome: 浏览器的最新两个版本

不再维护的浏览器

dead: 官方不在维护已过两年,比如 IE10

浏览器版本号

Chrome > 90: Chrome: 大于 90 版本号的浏览器

分包

为什么需要进行分包,而不是使用一个大的bundle.js

主要从 2 方面考虑

  1. 一行代码的改动将使整个 bundle.js 的缓存失效

  2. 每次页面仅需要 bundle.js 中的部分代码,因此没有必要都加载进来

如何更好地分包

可以从以下几块内容去做拆分

  1. 打包工具运行时

webpack 运行时代码不容易变更,可以单独抽离出来,比如webpack.runtime.js。甚至可以注入到index.html中,减少 http 请求数。

  1. 前端框架运行时

例如 Vue、React 的运行时代码,可以单独抽离出来framework.runtime.js。但是需要把框架和它的依赖共同抽离,否则它的依赖也会打到其它页面分包造成不必要的性能损耗。

最终结果如下

webpack.runtime.js 5KB ✅

framework.runtime.js 40KB ✅ (+10KB)

page-a.chunk.js 50KB ✅

  1. 高频库

1 个模块被 2 个以上的 chunk 使用,可以认为是公共模块,可以抽离出来形成 vendor.js

问题 1:假如一个模块体积很大(超过 1MB),例如 echarts,不是每个页面都依赖它,该如何解决?

可以在需要使用它的页面,通过import()引入,通过异步加载单独分包。

问题 2:如果公共模块的数量很多,导致 vendor.js的体积很大(超过 1MB),怎么处理?**

思路一:可以对vender.js改变策略,按照被引入的频次进一步拆包

思路二:根据vender.js的体积进行分包,把大于100KB的包拆分成几个小包

webpack 的分包实现

可以使用SplitChunksPlugin进行分包

示例如下:

ts
// webpack.config.js
{
  "optimization": {
    "splitChunks": {
      chunks: (chunk) => {
        return !/^(polyfills|main|pages\/_app)$/.test(chunk.name) &&
        !MIDDLEWARE_ROUTE.test(chunk.name), // 对页面进行分包
      }
      cacheGroup: {
        framework: {
          chunks: (chunk: webpack.compilation.Chunk) => !chunk.name?.match(MIDDLEWARE_ROUTE),
          name: "framework"
        },
        commons: {
          name: 'commons',
          minChunks: totalPages,
          priority: 20,
        }
        lib: {
          test(module: {
            size: Function
            nameForCondition: Function
          }): boolean {
            return (
              module.size() > 160000 &&
              /node_modules[/\\]/.test(module.nameForCondition() || '')
            )
          },
          name(module: {
            type: string
            libIdent?: Function
            updateHash: (hash: crypto.Hash) => void
          }): string {
            const hash = crypto.createHash('sha1')
            if (isModuleCSS(module)) {
              module.updateHash(hash)
            } else {
              if (!module.libIdent) {
                throw new Error(
                  `Encountered unknown module type: ${module.type}. Please open an issue.`
                )
              }

              hash.update(module.libIdent({ context: dir }))
            }

            return hash.digest('hex').substring(0, 8)
          },
        },
        middleware: {
          chunks: (chunk: webpack.compilation.Chunk) =>
            chunk.name?.match(MIDDLEWARE_ROUTE),
          filename: 'server/middleware-chunks/[name].js',
          minChunks: 2,
          enforce: true,
        }
      }
    }
  }
}
// webpack.config.js
{
  "optimization": {
    "splitChunks": {
      chunks: (chunk) => {
        return !/^(polyfills|main|pages\/_app)$/.test(chunk.name) &&
        !MIDDLEWARE_ROUTE.test(chunk.name), // 对页面进行分包
      }
      cacheGroup: {
        framework: {
          chunks: (chunk: webpack.compilation.Chunk) => !chunk.name?.match(MIDDLEWARE_ROUTE),
          name: "framework"
        },
        commons: {
          name: 'commons',
          minChunks: totalPages,
          priority: 20,
        }
        lib: {
          test(module: {
            size: Function
            nameForCondition: Function
          }): boolean {
            return (
              module.size() > 160000 &&
              /node_modules[/\\]/.test(module.nameForCondition() || '')
            )
          },
          name(module: {
            type: string
            libIdent?: Function
            updateHash: (hash: crypto.Hash) => void
          }): string {
            const hash = crypto.createHash('sha1')
            if (isModuleCSS(module)) {
              module.updateHash(hash)
            } else {
              if (!module.libIdent) {
                throw new Error(
                  `Encountered unknown module type: ${module.type}. Please open an issue.`
                )
              }

              hash.update(module.libIdent({ context: dir }))
            }

            return hash.digest('hex').substring(0, 8)
          },
        },
        middleware: {
          chunks: (chunk: webpack.compilation.Chunk) =>
            chunk.name?.match(MIDDLEWARE_ROUTE),
          filename: 'server/middleware-chunks/[name].js',
          minChunks: 2,
          enforce: true,
        }
      }
    }
  }
}