All articles

Webpack打包优化以及开发环境优化

Sep 10, 2022

经过一周,终于有时间来把剩下的打包优化以及开发环境优化进行总结,书上其实写的很好,不过个人觉得有部分内容的描述不清晰,给读者来一些麻烦,因此阅读此书时推荐搭配着 Webpack 官方文档进行学习。

推荐直接看 Webpack5 的文档。

代码分片

代码分片(code splitting)的目的很清晰,就是为了每次只加载必要的资源,优先级不高的资源可以通过延迟加载的机制提高页面的加载速度。当然,这样的话需要关注对哪些模块进行分片、分片后的资源如何管理等。

Webpack 中的每一个入口都讲生成一个对应的资源文件,通过入口的配置可以进行一些简单的代码拆分。对于 Web 应用来说,许多库和工具模块都不经常变动,可以把它们放在单独的入口中,由于此入口的资源不会经常更新,因此可以有效利用客户端缓存,不必每次都去加载。

entry: {
  app: './app.js',
  lib: ['lib-a', 'lib-b', 'lib-c']
}

这种通过手动去配置和提取公共模块的方法很是繁琐,因此 Webpack 提供了专门的插件用来解决手动配置的问题,在 Webpack4 之前内部有自带插件 CommonsChunkPlugin,在 Webpack4 之后正对前者的不足,重新设计之后替换成了 optimization.SplitChunks。例如,如下代码,在不采用代码分片时,输出的文件都包含 react

// webpack.config.js
module.exports = {
  entry: {
    foo: './foo.js',
    bar: './bar.js',
  },
  output: {
    filename: '[name].js',
  },
}

// foo.js
import React from 'react'
document.write('foo.js', React.version)

// bar.js
import React from 'react'
document.write('bar.js', React.version)

CommonsChunkPlugin

主要用于将多个入口之间的公共模块提取出来之后,也可以提取单个入口文件,有利于:

  • 开发过程中减少了重复模块的打包,提高开发速度。
  • 减少整体资源体积。
  • 合理分片有利于客户端的缓存。

其简单用法如下所示,可以将公共依赖提取出来。

// webpack.config.js
module.exports = {
  entry: {
    foo: './foo.js',
    bar: './bar.js'
  },
  output: {
    filename: '[name].js'
  },
  plugin: {
    new webpack.optimize.CommonsChunkPlugin({
    	name: 'commons', // name -> commons chunk name
    	filename: 'commons.js' // asset
    })
  }
};

注意:页面中添加一个新的 script 标签引入 commons.js,并且其一定要在其他 JS 之前引入。

当配置 vendor 时,可以将单个入口中的模块提取出来,同时采用上面 new webpack.optimize.CommonsChunkPlugin 方法制定 chunk nameasset。在多个入口文件中,可以制定从那些入口文件进行提取公共模块,如下代码:

// webpack.config.js
module.exports = {
  entry: {
    foo: './foo.js',
    bar: './bar.js',
    baz: './baz.js'
  },
  output: {
    filename: '[name].js'
  },
  plugin: {
    new webpack.optimize.CommonsChunkPlugin({
    	name: 'commons', // name -> commons chunk name
    	filename: 'commons.js', // asset
    	chunks: ['foo', 'baz'] // set the chunkname to be extracted
    })
  }
};

除此之外,其可以配置相关的提取规则,其默认的规则是只要一个模块被两个入口 chunk 所使用就会被提取出来。通过其 minChunks 属性可以配置提取规则,当设置为 n 时,只有该模块被 n 个入口同时引入才会被提取出来。

最后使用 CommonsChunkPlugin 绕不开的一个问题就是 hash 与长效缓存。提取的资源内部不仅仅是模块的代码,还包含 Webpack 的运行时(runtime:创建模块缓存对象、声明模块加载函数等)。这样会导致模块的 id 改变,使得 runtime 内部代码发生变化,从而影响 chunk hash 的生成,进而导致版本号变化,用户频繁的更新资源。为了解决这个问题,只需要将 runtime 的代码单独提取出来。

plugin: {
  new webpack.optimize.CommonsChunkPlugin({
    name: 'commons', // name -> commons chunk name
    filename: 'commons.js', // asset
    chunks: ['foo', 'baz'] // set the chunkname to be extracted
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'manifest'
  }),
}

manifest 必须出现在最后,否则 Webpack 无法正常提取模块。

其不足也显而易见:

  • 一个 CommonsChunkPlugin 只能提取一个 vendor,想要提取多个 vendor 需要配置多个插件。
  • manifest 实际会使得浏览器多加载一个资源,对页面渲染速度不友好。
  • 内部的设计缺陷,CommonsChunkPlugin 在提取公共模块会破坏原来 Chunk 中模块的依赖关系,难以进行更多的优化。例如异步加载。

optimization.SplitChunks

optimization.SplitChunks 简称为 SplitChunks,是 Webpack4 为了改进 CommonsChunkPlugin 重新设计和实现的代码分片特性。前面所提到异步加载的问题换成 SplitChunks 之后可以自动提取出 react,如下代码:

// webpack.config.js
module.exports = {
  entry: {
    foo: './foo.js',
  },
  output: {
    filename: 'foo.js',
    publicPath: '/dist/',
  },
  mode: 'development',
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
}

// foo.js
import React from 'react'
import('./bar.js') // Asynchronous import of bar.js
document.write('foo.js', React.version)

// bar.js
import React from 'react'
document.write('bar.js', React.version)

指定 SplitChunkschunksall,表示会对所有的 chunks 生效。modeWebpack4 新增的配置项,正对当前不同的开发环境自动添加对应的一些 Webpack 配置。

打包的结果应该是 foo.js 以及 0.foo.js(异步加载 bar.js 的结果),由于 SplitChunks 的存在,有生成一个 vendors~main.foo.js,并且把 react 提取到里面。

默认情况下的提取条件如下:

  • 提取后的 chunk 可被共享或者来自 nodes_modules 目录。这样更倾向于通用模块适合被提取。
  • 提取后的 JS chunk 体积大于 30kBCSS chunk 体积大于 50kB。提取资源过于小带来的优化很一般。
  • 按需加载过程中,并行请求的资源最大值小于等于 5。不希望同时加载过多的资源,每一个请求都要花费建立链接和释放链接的成本。
  • 首次加载,并行请求的资源最大值小于等于 3。对首次要求更高。

有了以上的提取规则,对上面的代码进行验证,满足所有条件才会提取出 react

  • react 属于 nodes_modules 目录下模块。
  • react 体积大于 30kB
  • 按需加载时并行请求数量为 1,为 0.foo.js
  • 首次加载时并行请求数量为 2,为 foo.jsvendors~main.foo.js。这里 vendors~main.foo.js 算在第四条的原因是在页面初始化时就需要进行加载。

为更好的了解 SplitChunks 的工作原理,其默认配置如下:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // 匹配模式: async、initial、all
      chunks: 'async',
      // 匹配条件
      minSize: 20000,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      // Webpack5 新增通过确保拆分后剩余的最小 chunk 体积超过限制来避免大小为零的模块。
      // 'development' 模式 中默认为 0。
      minRemainingSize: 0,
      // 强制执行拆分的体积阈值和其他限制(minRemainingSize,maxAsyncRequests,maxInitialRequests)将被忽略。
      enforceSizeThreshold: 50000,
      // 分离 chunks 时的规则。
      cacheGroups: {
        // 提取所有 node_modules 中符合条件的模块
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        // 作用于被多次引用的模块
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
}

对于资源的异步加载来说,主要是采用 import() 函数来加载模块,返回一个 Promise 对象。首屏加载的 JS 资源通过页面的 script 标签进行引入,间接资源(异步加载的 JS)的位置需要 output.publicPath 来制定。

import 函数还有一个重要的特性,在 ES6 Module 中要求 import 必须出现在代码的顶层作用域,而 Webpackimport 函数则可以在任何需要的时候调用。

异步加载打包后的资源名称都是数字 id0.foo.js1.foo.js),没有可读性,可以通过相关 Webpack 配置添加 name。如下配置代码:

// webpack.config.js
module.exports = {
  entry: {
    foo: './foo.js',
  },
  output: {
    filename: '[name].js',
    publicPath: '/dist/',
    chunkFilename: '[name].js',
  },
  mode: 'development',
}

// foo.js
import(/* webpackChunkName: bar */ './bar.js').then(({ add }) => {
  console.log(add(2, 3))
})

这里配置 output.chunkFilename 指定异步 chunk 的文件名,并且设置 /* webpackChunkName: bar */ 特定的注释,获得异步 chunk 名字。

/* webpackChunkName: bar */ 这个注释叫做魔法注释,在 vue-router 中,也这样使用,配置输出的静态资源名称。

生产环境配置

生产环境即线上环境,更加关注更快地加载资源、涉及如何压缩资源、优化打包、利用缓存等。生产环境的配置和开发环境的配置有所不同,需要设置 mode、环境变量、chunk hash 作为版本号。Webpack 可以按照不同的环境采用不同的配置。

// package.json
{
  "scripts": {
    "dev": "NODE_ENV=development webpack-dev-server",
    "build": "NODE_ENV=production webpack"
  }
}
// webpack.config.js
const ENV = process.env.NODE_ENV
const isProd = ENV === 'production'
module.exports = {
  output: {
    filename: isProd ? 'bundle@[chunkhash].js' : 'bundle.js',
  },
  mode: ENV,
}

通过 npm 脚本命令传入了一个 ENV 环境变量,webpack.config.js 则根据他的值来确定采用什么环境。

DefinePlugin

大部分只设置 mode 是不够的,还需要其他的自定义配置。可以使用 DefinePlugin 设置 ENV 以及其他相关的环境配置。

plugin: [
  new webpack.DefinePlugin({
    ENV: JSON.stringify('production'),
    IS_PRODUCTION: true,
  }),
]

SourceMap

主要就是在打包后,产生的压缩资源运行发现错误时,可以在 devtool 中定位到错误代码产生的位置。会有一个对应的 map 文件,如 bundle.js.map。在生成 mapping 文件的同时,bundle 文件中会追加一句注释来标识 map 文件的位置。

// bundle.js
;(function () {
  // ...
})()
//# sourceMappingURL=bundle.js.map

虽然可以通过文件映射找到错误代码,但是这样伴随着一个问题,任何人都可以通过 sourceMap 文件查看工程源码。

对于 soureMap 的配置很简单,只需要在 webpack.config.js 中添加 devtool 即可。

module.exports = {
  devtool: 'source-map',
}

对于 CSSSCSSLess 来说,则需要添加额外的配置,添加响应的 options 选项。

module.exports = {
  devtool: 'source-map',
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              // css-loader config
              sourceMap: true,
            },
          },
          {
            loader: 'sass-loader',
            options: {
              // sass-loader config
              sourceMap: true,
            },
          },
        ],
      },
    ],
  },
}

回到刚刚所提到的,任何人都可以通过 sourceMap 查看源代码,因此需要对其安全性进行严格的管理。如:hidden-source-map 以及 nosources-source-map 两种策略提高安全性,这两者具体配置可以参考官方教程。笔者认为最好的方式是通过服务器的 Nginx 配置,将 .map 文件只对公司内网开放,这样开发者仍然可以看到源码,一般用户的浏览器无法获取。

资源压缩

资源发布到线上之前,会进行代码压缩,叫做 uglify,意思是移除多余的空格、换行以及执行不到的代码,缩短变量名,同时进行这些操作之后代码基本上不可读,一定程度上有利于提高安全性。

对于不同资源有不同的压缩插件,对于 JavaScript 而言,在 Webpack3 集成了 UglifyJS,从 Webpack4 开始采用 terser 进行代码压缩,支持 ES6+,默认使用了 terser 的插件 terser-webpack-plugin。在 Webpack4 之后,这项配置移动到了 optimization.minimize。如下实例代码,如果开启了 --mode=production 则不需要进行配置:

module.exports = {
  entry: './app.js',
  output: {
    filename: 'bundle.js',
  },
  optimization: {
    minimize: true,
  },
}

也可以使用 terser-webpack-plugin,这里不进行演示。

对于 CSS 文件而言,需要使用 mini-css-extract-plugin 将样式提取出来之后,使用 optimiza-css-assets-webpack-plugin 进行压缩,本质是使用压缩器 cssnano

const ExtractTextPlugin = require('extract-text-webpack-plugin')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: 'css-loader',
        }),
      },
    ],
  },
  plugins: [new ExtractTextPlugin('styles.css')],
  optimization: {
    minimizer: [
      new OptimizeCSSAssetsPlugin({
        // 生效范围,只压缩匹配到的资源
        assetNameRegExp: /\.optimize\.css$/g,
        // 压缩处理器,默认为 cssnano
        cssProcessor: require('cssnano'),
        // 压缩处理器配置
        cssProcessorOptions: { discardComments: { removeAll: true } },
        // 是否展示 log
        canPrint: true,
      }),
    ],
  },
}

缓存

合理使用缓存是提升客户端性能的一个关键因素。缓存时间由服务器决定,浏览器会在资源过期前一直使用本地缓存进行响应。这样也带来了一个问题,加入开发者想对代码进行 bug fix,并且希望立刻更新到用户浏览器,此时最好的方法是更改资源的 URL,强制所有客户端去下载资源。

module.exports = {
  entry: './app.js',
  output: {
    filename: 'bundle@[chunkhash].js',
  },
  mode: 'production',
}

每当代码发生变化时,其对应的 hash 也会发生变化。这样伴随着一个问题,在 HTML 中引用的路径也会发生改变,手动去维护很不方便,可以使用 html-webpack-plugin 将最新的资源名同步过去,并且可以设置自己的 HTML 模版,可以放入许多个性化的内容。

注意:在使用 Webpack3 及以前的版本,使用 CommonsChunkPlugin 要注意 vendor.jschunk hash 变动的问题,Webpack 为每个模块指定的 id 是按照数字递增的,当插入进来新的模块时会导致其他模块的 id 也发生变化,进而影响 vendor.js 中的内容。

打包优化

通过优化 Webpack 配置的方法,使得打包速度更快,输出的资源更小

注意:不要过早进行优化,在初期不要看到任何优化的地方就加到项目中,这样不但增加复杂度,而且优化效果不理想。

HappyPack

通过多线程来提高 Webpack 打包速度的工具。在打包过程中,使用 loader 将各种资源进行转译非常耗时,转译的工作原理流程如下:

  • 获取打包入口。

  • 匹配 loader,对入口模版进行转译。

  • 转译后模块进行依赖查找(如 a.js 中加载了 b.jsc.js)。

  • 对新找到的模块重复上面两个步骤,知道没有新模块。

Webpack 是一个单线程,假如一个模块依赖于几个模块,Webpack 必须对这些模块逐个进行转译。而 HappyPack 就是通过多线程同时加载多个模块,从而达到不错的效果。

const HappyPack = require('happypack')

module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loaders: 'happypack/loader?id=js',
      },
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        loaders: 'happypack/loader?id=ts',
      },
    ],
  },
  plugins: [
    new HappyPack({
      id: 'js',
      loaders: [
        {
          loader: 'babel-loader',
          options: {}, // babel options
        },
      ],
    }),
    new HappyPack({
      id: 'ts',
      loaders: [
        {
          loader: 'ts-loader',
          options: {}, // ts options
        },
      ],
    }),
  ],
}

采用 HappyPack 使用 happypack/loader 替换了原来的 babel-loader,并在 plugin 中添加了 HappyPack 插件,将原来 babel-loader 的配置插入进去。并且在多个 loader 同时,意味着要插入多个 HappyPack 的插件,每个插件通过标识 id 来进行区分。

缩小打包作用域

  • 增加资源:通过更多的 CPU 或内存,缩短执行时间。
  • 缩小范围:针对任务本身,去掉冗余的流程。

前面提到的 HappyPack 采用的就是增加资源,下面说一下如何缩小范围

  • 采用前面提到的 exclude 和 include

  • noParse 忽略掉文件名中包含的模块,虽然会打包进资源文件,但是不会被任何解析。

    module.exports = {
      module: {
        noParse: function (fullPath) {
          // fullPath是绝对路径,如 /Users/me/app/webpack-no-parse/lib/lodash.js
          return /lib/.test(fullPath)
        },
      },
    }
    

    上面配置会忽略掉所有 lib 目录下的资源解析。

  • IgnorePlugin 可以完全排除一些模块,被排除的模块即使被引用了也不会被打包进资源文件中。如,Moment.js 中做了许多其他地区的语言包,会占许多体积,这时就可以用 IgnorePlugin 进行去掉。

    plugins: [
      new webpack.IgnorePlugin({
        // 匹配资源文件
        resourceRegExp: /^\.\/locale$/,
        // 匹配检索目录
        contextRegExp: /moment$/,
      }),
    ]
    
  • 有些 loader 还有 cache 配置项,编译代码后,同时保存一份缓存,下一次编译之前,检查源文件是否发生变化,如果没有就直接采用 cache

tree shaking

之前提到过 ES6 Module 依赖关系是在编译期间就建立的,基于这一点 Webpack 提供了 tree shaking 的功能,可以在打包过程检测工程中没有被引用过的模块,也就是“死代码”,Webpack 会对这部分代码进行标记,从而在资源压缩的时候将它们从最终的 bundle 中去掉。

// index.js
import { foo } from './util'
foo()
// util.js
export function foo() {
  console.log('foo')
}
export function bar() {
  // 没有引用,属于“死代码”
  console.log('bar')
}

Webpack 打包时候会标记 bar,在正常的开发模式下仍然存在,在生产环境中会直接移除掉。tree shaking 只能对 ES6 Module 生效,对于一些 npm 包同时提供了 ES6 ModuleCommonJS的形式,尽可能的选择 ES6 Module

开发环境优化

这部分内容,书中提到了一些 Webpack 开发效率的插件,这些插件因人而异,不一定每个人都喜欢这样的插件,例如:

  • webpack-dashboard 构建出控制台

  • webpack-merge 进行不同环境的配置

  • speed-measure-webpack-plugin分析出 Webpack 打包过程中在各个 loaderplugin 上的耗时

  • size-plugin 监控资源的体积。

这部分我觉得最值得关注的是模块热替换

模块热替换

模块热替换(hot module replacementHMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新。

plugin: [
  new webpack.HotModuleReplacementPlugin()
],
devServer: {
  hot: true
}

上面配置产生的结果是 Webpack 会为每个模块绑定一个 module.hot 对象这个对象包含了 HMRAPI。调用 HMR API 可以通过手动方式添加代码,借助现成工具,如 react-hot-loadervue-loader等。不过还是推荐使用现成工具,可以避免很多预想不到的问题。开启 HMR 之后资源体积会比原来更大,这是因为 Webpack 为了实现 HMR 注入了许多相关代码。

在本地开发环境下,浏览器是客户端,webpack-dev-serverWDS)相当于是服务端。HMR 核心就是客户端从服务端拉去更新后的资源(不是整个资源,而是 chunk diff)。

  • 实际上 WDS 与浏览器之间维护了一个 websocket,在本地资源发生变化的时,WDS 回想浏览器推送更新事件,并且带上这次构建的 hash,让客户端遇上一次进行对比。这样也解释了为什么开启多个本地页面时,代码一改所有的页面都会更新。live reload 也是依赖于 websocket
  • 监听到需要拉取资源,就需要知道拉取什么资源,但是这部分信息没有包含在 websocket 当中。通常会发起一个名为 [hash].hot-update.json 的请求,返回结果 {"h":"e388ea0f0e0054e37cee","c":{"main":true}}。告诉客户端需要更新的 chunkmain,版本为 e388ea0f0e0054e37cee
  • 客户端就可以借助这些信息继续想 WDS 发起请求获取该 chunk 的增量更新。

总结

最近花了一段时间阅读了**《Webpack 入门、进阶与调优》**,对 Webpack 有了一个新的认识,从以前自己的项目走过来,没有做过太多的 Webpack 相关的内容,这次算是系统的学习了 Webpack,目的主要是为了提升自己,以及为了在产品上能够进行维护甚至优化打包过程。阅读下来整体感受还算可以,书中从不同方面对 Webpack 进行了全面的讲解,这两篇博客主要是为了记录一下重要的内容,方便之后回顾,当然只是阅读还是远远不够的,需要有相关的经验,之后会对阮一峰 Webpack Demo进行初步的练习,再进行其他复杂的 Webpack 操作。

前端技术更新迭代速度非常的快,打包工具也层出不穷,例如还有追求更快打包速度的 Parcel、专注于 JavaScript 打包的 Rollup。以及由尤雨溪开发的一种新型前端构建工具 Vite,都值得学习研究。

目前对打包工具进行系统学习后,可以继续研究探讨一下目前的前端包管理工具优劣,其内部的原理也值得我进行学习。

antcao.me © 2022-PRESENT

: 0x9aB9C...7ee7d