Webpack

Getting Started

Intro to Webpack

Webpack takes modules with dependencies and generates static assets representing those modules.

webpack-cli 是一个与 webpack 相关的命令行工具,用于在终端中执行指定命令,解析命令行参数,并把这些参数传递给 webpack。

注意,webpack 本身并没有内置命令行工具,如果没有安装 webpack-cli,直接在终端里执行 webpack 命令时会报错。

$ npx webpack --entry ./src/main.js --output-path ./build --mode=development

npx 可理解为使用本地的模块,实际上是运行当前项目中 node_modules/.bin 下的可执行文件。

package.json 文件的 scripts 字段不需要在命令的前面加上 npx。与 webpack.config.js 一样,package.json 中的 scripts 字段可以直接使用本地安装的模块或全局安装的命令。

{
  "scripts": {
    "build": "webpack --entry ./src/main.js --output-path ./build --mode=development"
  }
}

npm run 会在当前目录下新建一个 Shell,并将当前目录下的 node_modules/.bin 加入 PATH 变量,使得在执行脚本命令时可以直接引用该目录下的可执行文件。在执行结束以后,路径变量会恢复原样,以保证环境变量的稳定性。这就是 npm run 能够执行项目本地安装的 CLI 工具的原因。

除上述方法外,项目中更多见的是使用 webpack.config.js 之流的配置文件进行打包的相关设置。

const { resolve } = require("path");
module.exports = { 
  entry: "./src/main.js", // 入口文件可以是相对路径
  output: {
  	filename: "bundle.js",
  	path: resolve(__dirname, "./build") // 输出文件路径必须是绝对路径
  },
  mode: "development"
};
$ npx webpack --config webpack.config.js
"scripts": {
  "build": "webpack --config webpack.config.js"
}

webpack 通常使用 CommonJS 模块化规范来读取配置文件,即配置文件中通过 module.exports 导出一个对象,对象中包含了各种打包配置信息。注意,配置文件须放在项目根目录下。

通过读取项目里的配置文件,解析代码中模块引用关系,可将这些模块打包成一个或多个 JS bundle 文件。这些 bundle 文件包含了应用中的所有代码、样式、图片等资源。

通过将这些资源打包成 bundle,可以减少浏览器加载资源的请求次数,提高页面性能和加载速度:

  • 在 HTTP/1.1 协议下,打包文件可以减少客户端发起请求的次数,降低 TCP 连接的占用
  • 在 HTTP/2 协议下,代码分割可以将应用代码拆分成多个小块,进一步提升页面加载速度

HTTP/1.1 协议规定,在客户端发起请求时会建立一个 TCP 连接,在该连接上进行请求和响应。在此期间,不管请求的文件大小是多少,都会占用该连接,直到响应完成才会释放连接。

HTTP/2 协议支持多路复用,即一个 TCP 连接可以同时发送多个请求和响应。因此,使用 HTTP/2 协议时,每个文件的请求和响应都可以独立完成,不会相互影响。同时,HTTP/2 还支持服务器推送,即在客户端请求一个资源时,服务器可以主动推送与该资源相关的其他资源。

在打包时默认会将所有直接或间接被引入到入口文件中的模块都打包,包括那些被引用的但实际上并未被使用的模块。摇树优化可以通过静态代码分析,识别出哪些模块中的代码是无用的,并将其从打包结果中移除,从而减小打包文件的体积。

总的来说,Webpack 是一种静态打包工具,通过在本地将所有代码和资源打包到一个或多个 bundle 之中,并且可以在构建过程中对代码进行一系列的优化,如代码压缩、摇树优化、代码分割等,最终生成优化后的打包文件。Webpack 缺点是构建速度较慢,尤其是在大型项目中,构建时间的可能会很长。

Vite is built on top of Snowpack, a lightweight alternative to Webpack. Vite leverages the native ES module support in modern browsers, employs an on-demand compilation packaging approach, and dynamically compiles code at runtime without the need to pre-bundle all content. This enables Vite to start up and rebuild applications more quickly.

应用程序启动和重新加载过程中常见的名词解释:

  • 热重载或热更新:程序运行时,无需停止或重启应用即可动态更新代码或资源文件
  • 冷启动:在应用的首次启动时,需要重新加载所有必要的资源和配置文件
  • 热启动:在应用已经启动并运行时,再次启动该应用程序(应用的许多资源已在内存中加载)
  • 温启动:在应用已经启动并运行时,重新加载已经被关闭或过期的组件

Basic Loaders

Loaders 用于对模块的源代码进行转换,将一种形式的源代码转换为另一种形式的源代码,以此满足特定的需求。

# 因缺乏相应 loader 而导致的解析失败
Module parse failed: Unexpected token. You may need an appropriate loader to handle this file type.

配置文件中 module.rules 字段对应的值是数组 [Rule]。数组中存放一个或多个 Rule 对象,Rule 对象中具有 testuse 属性。

  • test 字段用于对资源进行匹配,通常设置成正则表达式
  • use 字段值是一个 [UseEntry] 数组,其中 UseEntry 对象又具有以下属性
    • loader 必选属性,对应的值是一个字符串
    • options 可选属性,值是字符串或者对象,通常会传入到 loader 中

基础样式资源的打包通常需要 css-loaderstyle-loader

  • css-loader 处理 @importurl(),将 CSS 文件解析成样式字符串的 CJS 模块加载至 JS
  • style-loader 创建 style 标签并将 JS 里的样式添加到 head 元素(插入 DOM 树)
$ npm i css-loader style-loader less-loader less -D

通常 use 属性的值是一个字符串数组,这是使用了 loader 属性的简写方式。也有将 use 属性省略掉的时候,直接写上 loader 属性,但这只适用于一个 loader 的情况。

module: { // 不同文件必须配置不同 loader 处理
  rules: [
    {
      test: /\.css$/,
      use: [ 'style-loader', 'css-loader' ] // 语法糖
    },
    { 
      test: /\.less$/, 
      use: [ 'style-loader', 'css-loader', 'less-loader' ] // less-loader 将 less 文件编译成 css 
    } 
  ] 
}

在 Concepts 下 Loaders 中除 Configuration 外,还有一种 Inline 内联的方式使用 loaders。多个 loader 的执行顺序是从后往前的。

browserslist 是一款用于配置项目中目标浏览器的工具,可以在不同的工具之间共享信息。

browserslist is used in: Autoprefixer, Babel, postcss-preset-env, eslint-plugin-compat, stylelint-no-unsupported-browser-features, postcss-normalize, obsolete-webpack-plugin...

通过 browserslist 指定一系列的浏览器及其版本,可以通知其他工具应该如何生成代码,以适配指定的目标浏览器(自动添加 CSS 前缀、使用相应的 Polyfill、转换 ES6+ 语法等)。

$ npx browserslist ">1%, last 2 version, not dead"

browserslist 的配置可以写在项目根目录下的 .browserslistrc 中,也可以写在 package.json 文件中的 browserslist 字段,支持的浏览器版本可以使用通配符以及范围来配置。

// package.json
"browserslist": { 
  "development": [ 
    // 兼容最近的浏览器版本
    "last 1 chrome version", 
    "last 1 firefox version",
    "last 1 safari version" 
  ],
  "production": [ 
    ">0.2%",
    "not dead",
    "not op_mini all" 
  ]
}

通常情况下,在使用 Webpack 进行项目开发时,会默认安装 browserslist 这个库,因为很多 Webpack 插件和 loader 都会使用 browserslist 来进行浏览器兼容性检查和自动添加前缀等操作。而 browserslist 内部使用了 caniuse-lite 数据库来进行浏览器的兼容性查询。

PostCSS 是一个用 JavaScript 实现的 CSS 处理器,通过解析 CSS 并以抽象语法树 AST 的形式存储,从而可以在 AST 层面上对 CSS 进行操作,例如添加前缀、处理嵌套、转换 CSS 语法等。如果需要单独在命令行中使用,应该额外再安装一个 postcss-cli 工具。

同时有很多优秀的插件也是基于 PostCSS 诞生的,如 PreCSS、CSSNext、cssnano、Autoprefixer 等。

{
  test: /\.css$/,
  use: [
    "style-loader",
    "css-loader",
    {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          plugins: [
            require("autoprefixer")
          ]
        }
      }
    }
  ]
}

事实上,在配置 postcss-loader 时,更多的会使用 postcss-preset-env。postcss-preset-env 将一些现代 CSS 特性转换为大多数浏览器都能理解的 CSS,并自动添加所需的 polyfill。此外,postcss-preset-env 还自动集成了 Autoprefixer 插件,因此不需要单独配置 Autoprefixer。

{
  test: /\.css$/,
  use: [
    "style-loader",
    "css-loader",
    {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          plugins: [
            require("postcss-preset-env")
          ]
        }
      }
    }
  ]
}

此外,也完全可以将 postcss-loader 的配置移到 postcss.config.js 文件中,代码如下。

// postcss.config.js
module.exports = {
  plugins: [
    require("postcss-preset-env")
  ]
}
use: [ "style-loader", "css-loader", "postcss-loader" ]

如果有在 CSS 文件中通过 @import 导入其他的 CSS 文件,那么这些导入的 CSS 可能不会被 postcss-loader 或者 less-loader 等处理,因为 Webpack 只会对直接引入的 CSS 文件应用 loaders,而不会递归地处理引入的 CSS 文件。

此时可以在使用 css-loader 时配置 importLoaders 选项。importLoaders 选项控制在处理 CSS 文件时,css-loader 应该使用几个额外的 loader 来处理 @import 引入的其他 CSS 文件。例如,在 css-loader 中设置 importLoaders: 1,那么在处理 CSS 文件时,css-loader 将同时使用 postcss-loader 来处理 @import 导入的 CSS 文件。

{
  test: /\.css$/,
  use: [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        importLoaders: 1 // 这里设置为 1
      }
    },
    'postcss-loader'
  ]
}
{
  test: /\.less$/,
  use: [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        importLoaders: 2 // 这里设置为 2
      }
    },
    'postcss-loader',
    'less-loader'
  ]
}

使用图片时,常见的两种方式是 img 元素的 src 属性和 CSS 中的 background-image 属性。

The file-loader resolves import/require() on a file into a url and emits the file into the output dir.

The url-loader works like file-loader, but can return a DataURL if a file is smaller than a byte limit.

file-loader 会根据文件的内容使用 MD4 算法生成文件名。但是在某些情况下,可能需要使用占位符来自定义生成的文件名,以确保每张图片都有一个明确的对应关系。此时应考虑 placeholders

{
  test: /\.(png|jpg|gif)$/i,
  loader: 'file-loader',
  options: {
    name: '[name]-[hash].[ext]',
    outputPath: 'images/'
  }
}

开发中,较大的图片文件往往会被存放在单独的目录,通过异步加载的方式来减少页面的加载时间。较小的图片通常会使用 Base64 数据直接嵌入到 HTML 或 CSS 文件,而不作为单独的文件加载。

url-loader 可以将指定大小以下的图片文件转换成 Base64 数据,直接嵌入到生成的 bundle.js 中,从而减少了额外的网络请求,提高页面的性能和加载速度。

{
  test: /\.(png|jpe?g|gif)$/i,
  use: [
    {
      loader: 'url-loader',
      options: {
        limit: 100 * 1024, // 100KB
        name: 'images/[name]-[hash:6].[ext]'
      }
    }
  ]
}

url-loader 默认使用 ESM,而 html-loader 默认使用 CommonJS。如果在使用 url-loader 时,解析出现了 [object Module] 的问题,可能是由于 url-loader 默认使用了 ESM 模块系统,而配置文件中存在与 ESM 不兼容的语法或模块系统。为解决这个问题尝试关闭 url-loader 的 ESM,改为使用 CJS 模块系统解析文件。

rules: [ 
  {
    test: /\.(jpg|png|gif)$/, // 默认处理不了 html 中 img 图片
    loader: "url-loader", // 仅使用一个 loader 可以不需要 use
    options: {
      limit: 8 * 1024, // 图片大小小于 8kb => 被 base64 处理
      esModule: false, 
      name: "[hash:10].[ext]" // 重命名 => [hash:10] 取图片的 hash 的前 10 位
    }
  },
  {
    test: /\.html$/, // 处理图片除样式引入外的 html 标签引入
    loader: "html-loader" 
  } 
]

在 webpack 5 中,可以使用 Asset Modules 替代之前的 url-loader、file-loader 等加载资源的方式。

Asset Modules 可通过 type 属性来指定资源的类型,webpack 会自动将其转换成合适的模块类型。

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.(png|jpe?g|gif)$/i,
        type: "asset",
        generator: {
          filename: "images/[name]-[hash][ext]"
        },
        parser: {
          dataUrlCondition: {
            maxSize: 100 * 1024 // 100kb
          }
        }
      }
    ]
  }
}

在处理特殊字体的资源时,可以使用 file-loader 或者 Asset Modules。

在 Asset Modules 中的 [ext] 占位符会自动包含文件名里的扩展名。故在文件名模板中不需要再显式地添加点号,即可以省略 [ext] 前面的点号,如:[name]-[hash]。但对于其他情况,如使用 file-loader 或 url-loader 时,文件名模板需要显式地添加点号。比如:[name].[ext]

rules: [
  {
    test: /\.(eot|ttf|woff2?)$/i,
    type: "asset/resource",
    generator: {
      filename: "font/[name]_[hash:6][ext]" // 此处为 filename 中写目录
    }
  }
]

General Plugins

While loaders are used to transform certain types of modules, plugins can be leveraged to perform a wider range of tasks like bundle optimization, asset management and injection of env variables.

在不清空构建目录的情况下,虽然打包所生成的文件会直接覆盖构建目录中的同名文件,但是仍然会有残余的文件被保留下来。可以使用 clean-webpack-plugin 来自动删除构建目录。

const { CleanWebpackPlugin } = require("clean-webpack-plugin"); // 引入类

module.exports = {
  ...
  plugins: [ new CleanWebpackPlugin() ]
}

html-webpack-plugin 可以根据打包后的结果自动生成 HTML,并自动引入打包后的 JS 和 CSS 文件。

const HtmlWebpackPlugin = require('html-webpack-plugin');

plugins: [
  ...
  new HtmlWebpackPlugin({
    title: "webpack title",
    template: './src/index.html'
  })
]

html-webpack-plugin 默认使用 ejs 模板引擎来生成 HTML 文件。在自定义模板数据填充时,可以使用 DefinePlugin 在编译时创建全局常量。DefinePlugin 是 Webpack 内置的一个插件。

const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new webpack.DefinePlugin({
      APP_TITLE: JSON.stringify('My App'),
      APP_VERSION: JSON.stringify('1.0.0'),
    }),
    new HtmlWebpackPlugin({
      template: 'src/index.ejs',
      filename: 'index.html',
      inject: true,
    }),
  ],
};
<!DOCTYPE html>
<html>
  <head>
    <title><%= APP_TITLE %> - <%= APP_VERSION %></title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

The copy-webpack-plugin copies individual files or entire dirs, which already exist, to the build dir.

copy-webpack-plugin 会根据 output.path 配置项指定的输出目录自动计算出正确的构建目录,因此在 patterns 中的 to 配置项里可以写相对路径,例如 ./ 表示输出目录的根目录。若不设置 to 配置项 copy-webpack-plugin 会默认将文件复制到输出目录的根目录下。

const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new CopyWebpackPlugin({
      patterns: [
        {
          from: 'src/assets',
          to: './',
          globOptions: {
            ignore: [
              '**/index.html', // 忽略 src/assets 目录及其子目录下的所有 index.html 文件
              'src/assets/images', // 忽略 src/assets/images 目录及其子目录
            ],
          },
        },
      ],
    }),
  ],
};

Modular Support

webpack 的模块化本质上就是为每个模块创建了一个独立的函数作用域。

在加载模块时,会先根据模块的路径生成一个 moduleId,并将其传递给 __webpack_require__ 函数。__webpack_require__ 函数会使用这个 moduleId 来获取对应的模块工厂函数,然后调用这个函数来获取模块的导出值。

默认 webpack 会为每个模块生成一个独立的模块工厂函数,并将其存储在 __webpack_modules__ 对象(可理解为模块映射)中,键名是模块的路径,值是包装了模块代码的函数。

// 模块工厂函数加载执行模块
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)

Source Map

webpack 中可以通过配置 devtool 选项来生成 source-map。source-map 是一种映射关系,可以将编译后的代码映射回原始源码,使得在浏览器控制台中准确地显示出错误和警告的位置,方便定位和调试问题。

webpack 在 mode: "development" 模式下默认设置 devtool: "eval"。这会将每个模块的源代码转换成字符串,并使用 eval 函数对其执行。利用 eval 包裹代码的末尾注释,可以标记每个模块的位置信息、依赖信息以及代码映射关系等,方便在开发者工具中进行调试。

这些注释被称为 sourceURL 和 sourceMappingURL 注释。其中,sourceURL 注释用于标记 eval 包裹的代码对应的源文件路径和行号信息,而 sourceMappingURL 注释用于标记生成的 source map 文件路径。

source-map 会生成独立的 source-map,并在打包文件中生成指向 source-map 文件的注释,这个注释通常以 //# sourceMappingURL= 开头,后面跟着 source-map 文件的 URL。这个注释会被浏览器解析,然后自动下载对应的 source-map 文件。

eval-source-map 生成的 source-map 是以 DataUrl 的形式添加到 eval 函数后面,而不是作为单独的文件存在。

inline-source-map 会将生成的 source-map 以 DataUrl 的形式添加到打包文件的尾部。

cheap-source-map 中的 cheap 表示低开销,其生成的 source-map 不包含列映射信息,只包含行映射信息。开发中一般使用行映射信息即可定位异常。

cheap-module-source-map 会比 cheap-source-map 包含更多的信息,特别是对于使用了 loader 处理的代码,可以提供更加完整的源代码映射。

常规来说 devtool 的组合规则可以按照如下格式,其中,[inline-|hidden-|eval-] 表示 source map 的嵌入方式,选项 [nosource-] 表示不包含源代码信息,选项 [cheap-[module-]] 表示在仅包含行映射的条件下,是否使用对 loader 转换后的源代码进行更完整信息的显示,最后 source-map 表示生成独立的 source map 文件。

[inline-|hidden-|eval-][nosource-][cheap-[module-]]source-map

开发和测试阶段推荐选择 source-mapcheap-module-source-map

为提高代码运行效率和减小文件体积,打包阶段通常不包含源代码映射,推荐缺省或 false

Babel

巴别塔 Babel 是一个工具链,可以将 ECMAScript 2015+ 代码转换为支持在当前和旧版本浏览器环境中运行的 JavaScript 版本(向后兼容)。

在开发环境依赖中安装 Babel 核心模块 @babel/core 和命令行工具 @babel/cli。

  • @babel/core 提供了 Babel 的编译功能,包括解析源码、转换和生成目标代码等
  • @babel/cli 提供了在命令行中使用 Babel 的能力,可以通过命令行参数指定相关的操作
$ npm install --save-dev @babel/core @babel/cli

使用 Babel 来转换箭头函数和块级作用域时,需要额外安装两个插件 @babel/plugin-transform-arrow-functions 和 @babel/plugin-transform-block-scoping。

$ npm install --save-dev @babel/plugin-transform-arrow-functions @babel/plugin-transform-block-scoping
$ npx babel src --out-dir dist --plugins=@babel/plugin-transform-arrow-functions, @babel/plugin-transform-block-scoping

@babel/preset-env 是一个预设插件集合,提供了根据当前的环境自动确定需要使用哪些插件来进行语法转换的能力。@babel/preset-env 预设插件集合在使用时,可以通过设置 targets 选项来指定转换的目标执行环境。这个选项可以是一个对象,也可以是一个字符串。

$ npm install --save-dev @babel/preset-env 
$ npx babel src --out-dir dist --presets=@babel/preset-env --targets '{"chrome": "58", "ie": "11"}'

targets 以外,@babel/preset-env 还提供了一些其他的选项,用于控制预设插件集合的行为。其中比较常用的有:根据目标环境自动导入所需的 polyfill 的 useBuiltIns 和在控制台输出详细的调试信息,包括每个插件的名称、版本和选项的 debug.babelrcbabel.config.js 中添如下。

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage", // 自动根据使用的特性来决定是否导入 polyfill
        "corejs": 3,
        "debug": true
      }
    ]
  ]
}

@babel/polyfill 是一个 JavaScript lib,提供对 ECMAScript 新特性的 polyfill 支持,以使这些特性在旧版浏览器中也可以生效。其包含两个主要的部分:core-js 和 regenerator-runtime。

core-js 是一个模块化的标准库,为 ECMAScript 的各种特性提供 polyfills,以解决不同浏览器之间的兼容性问题。包括 Promise、Map、Set、Reflect、Proxy、Symbol 等。

npm install core-js@3 --save
# or
npm install core-js@2 --save

regenerator-runtime 是一个运行时库,提供了对于 ECMAScript 6 generators 和 async/await 语法的支持。通常用来处理生成器和异步函数。

提示:@babel/polyfill 在 Babel 7.4.0 中已经被弃用了,建议使用 @babel/preset-env 中的 useBuiltIns 选项来引入需要的 polyfill。只需指定 corejs 版本,不需要额外指定 regenerator-runtime。

插件 @babel/plugin-transform-runtime 可以将代码中使用到的一些辅助函数进行替换,以实现在不污染全局命名空间的情况下使用(如 Object.assign、Promise、Symbol 等)。这可以解决 polyfill 机制中将新增的静态方法和实例方法直接添加到全局变量或全局变量原型上的问题(与第三方库产生冲突)。

yarn add @babel/plugin-transform-runtime -D
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage", // 如果 @babel/plugin-transform-runtime 配置了 corejs:3 => preset-env 的 useBuiltIns 就不会生效
        "debug": true,
        "targets": {
          "ie": 10
        },
        "comments": false // 不产生注释
      }
    ]
  ],
  "plugins": [
    [
      // 转换为引用 babel-runtime/regenerator 和 babel-runtime/core-js 模块中的方法
      "@babel/plugin-transform-runtime",
      {
        "corejs": 3 // 指定 runtime-corejs 的版本 => 目前有 2、3
      }
    ]
  ]
}

Babel 的执行过程其实和很多编译器的工作原理是类似的。

  • 解析:使用解析器 Parser 将源码解析成 AST 抽象语法树
  • 转换:使用转换器 Transformer 对 AST 进行修改,并应用插件 Plugins 对特定的语法进行转化
  • 生成:使用生成器 Generator 将修改后的 AST 转换为目标代码,可以被 V8 引擎解释执行

解析一般包括词法分析和语法分析两个阶段。词法分析会将源代码转换成记号流,而语法分析会分析这些 tokens 流并将其转换成一颗抽象语法树 AST。

当需要将 TypeScript 转换为 JavaScript 时,可以结合使用 babel-loader 与 tsc,以弥补这两者各自的不足。babel-loader 在编译时不会对类型错误进行检测,仅使用 tsc 会缺少解决兼容性问题的 polyfill。

具体可以在 package.json 中添加脚本 "type-check-watch": "tsc --noEmit --watch" 来开启类型检查,在 webpack 中使用 babel-loader 进行编译转换和解决兼容性的问题。

Advanced Support

Transitions for Vue SFC

在将 Vue 单文件组件转换为 JavaScript 模块时,需要借助 vue-loader 与 vue-template-compiler。前者可以将一个 .vue 文件转换为 JavaScript 对象。后者会将 .vue 文件中的 <template> 标签编译为渲染函数,最终生成实际的 DOM 结构并进行渲染,以便在浏览器中可以显示相关的组件。

$ npm install vue-loader vue-template-compiler -D

配置 vue-loader 时需要在 webpack 的配置文件中使用 VueLoaderPlugin 插件。具体来说,需要在配置文件中的 plugins 数组中创建一个 VueLoaderPlugin 的实例,并将其作为插件添加进去。

const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
  // ...其他配置
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      // ...其他规则
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
};

DevServer and HMR

通常在开发阶段需要频繁地修改和测试代码,如果每次都通过手动编译和刷新浏览器,那么开发效率就会受到影响,此时可以使用 devServer 在内存中编译打包,并提供一个自动刷新浏览器的开发环境。

虽然使用 watch 参数或者 live-server 插件也可以实现代码修改后的页面更新,但效率不如 devServer 高。因为 watch 参数或者 live-server 插件都是直接刷新整个页面,而 devServer 是通过 HMR 热模块替换技术实现了页面的局部更新,从而避免了刷新整个页面的开销。

...
module.exports = {
  mode: 'development',
  devServer: {
    contentBase: resolve(__dirname, 'build'), // 项目构建后路径
    compress: true, // 启动 gzip 压缩
    port: 3000, // 端口号 
    open: true // 自动打开浏览器 
  } 
};

需要注意的是 webpack-cli 的版本,webpack-cli 4 已经将 devServer 的实现方式进行了重构,需要使用 webpack serve 来启动开发服务器,而 webpack-cli 4 以前的版本,使用 webpack-dev-server

webpack-dev-middleware 是一个 Express 中间件,可以将 webpack 打包的文件传递给服务器,并且可以将打包结果缓存到内存中,以便在文件发生变化时自动重新构建。

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');

const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);

app.use(webpackDevMiddleware(compiler, {
  publicPath: config.output.publicPath
}));

app.listen(3000, function () {
  console.log('App listening on port 3000!\n');
});

如果没有在入口文件中通过 module.hot.accept 函数来指定需要开启 HMR 的模块,那么默认情况下只有在根模块发生变化时,整个应用程序才会被热更新,而其他模块则会触发完整刷新。点此查看

if (module.hot) {
  module.hot.accept('./your-module', function() {
    // 当 './your-module' 模块更新后执行的逻辑
  })
}

在使用 Vue 或 React 框架开发时,社区已经提供了比较成熟的 HMR 解决方案,点此查看。注意 React Hot Loader 现已被官方弃用,改为 react-refresh 方案。

HMR 热更新的实现原理是 webpack-dev-server 或 webpack-dev-middleware 将打包好的文件传递给服务器 Express,同时建立一个长连接的 socket 服务,监听文件的变化事件。当某个模块的代码发生变化时,webpack 会重新打包这个模块的代码,然后通过 socket 服务推送给客户端的浏览器。

Code Splitting

代码分割作为一种常规的优化手段,可以将打包后的代码拆分成多个小块,然后可以按需加载或并行加载这些文件。

在使用入口起点的方式进行代码分割时,每个入口文件都会被单独的打包。同时,还可以使用的占位符来指明打包后的 bundle 文件名。

module.exports = {
  entry: {
    app: './src/app.js',
    vendor: './src/vendor.js'
  },
  output: {
    filename: '[name].[contenthash:8].js',
    path: __dirname + '/dist'
  }
};

当多个入口文件使用了相同的第三方包时,如果不进行处理,这些第三方包可能会被重复打包,导致打包后的文件体积过大。为避免这种情况,可使用 Entry dependenciesSplitChunksPlugin

optimization.splitChunks.chunks 的默认值是 async,这意味着在 webpack 中,异步或动态导入的文件会被打包成一个独立的 chunk。

注意,该 chunk 的名称是自动生成的 chunk id,具体生成规则受到 optimization.chunkIds 配置的影响。可以通过 webpackChunkName 这种 magic comments 来自定义生成的 chunk 名称。

import(
  /* webpackChunkName: "utils" */
  "./utils"
).then({default: utils}) => { utils(); });

Tree Shaking

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup. More.

optimization.usedExports 设置为 true 时,Webpack 会在编译过程中生成一些帮助 Terser 进行代码优化的注释。例如,当模块中有某个函数被导出,但是并没有在项目里被实际的使用到,那么会产生 unused harmony export ... 注释。

然而,要使 Tree Shaking 真正的生效,还需要配置 optimization.minimize: true,否则,虽然注释已经生成,但 optimization.minimizer 中的插件没有被启用,Terser 就不会进行代码优化。

在进行上述配置后,仍然可能存在一些残余代码,例如在打包文件中存在无意义的导入。此时,可以通过设置 package.json 中的 "sideEffects" 属性来标记哪些模块具有副作用,进一步优化摇树效果。

CSS Tree Shaking 可以选择 PurgeCSS,早期的 PurifyCSS 方案现已不再维护。

UglifyJS VS Terser

Terser 和 UglifyJS 都是压缩和混淆 JavaScript 代码的工具,用于在构建过程中减小代码体积。Terser 在 UglifyJS 的基础上进行了改进和优化,并且支持 ES6+ 语法,可以处理箭头函数、模板字符串、解构赋值等新特性。此外,精确 Tree Shaking 以及并发压缩也让 Terser 更好地支持现代项目。

HTTP & HTML COMP

HTTP compression 可以减小 Server 与 Client 之间的数据量。Client 请求资源时携带 Accept-Encoding 请求头,表明支持哪些压缩算法。如果服务端支持其中一种压缩算法并且有配置为压缩响应,就可以在响应中包含一个 Content-Encoding 响应头,以指示响应已被压缩。

webpack 中,可以通过 compression-webpack-plugin 插件来启用 HTTP 压缩。compression-webpack-plugin 会在编译时自动检测所有的资源文件,将其压缩后输出,并在响应中添加 Content-Encoding。

对 HTML 文件进行压缩可以使用 HtmlWebpackPlugin 插件中的 minify 属性。如果需要将 chunk 出来的模块(如运行时代码)内联到 HTML,可以使用 react-dev-utils 中的 InlineChunkHtmlPlugin。

const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');

module.exports = {
  // 其他配置项
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      // 压缩 HTML 代码
      minify: {
        removeComments: true, // 移除注释
        collapseWhitespace: true, // 折叠空白字符
        removeRedundantAttributes: true, // 移除冗余的属性
        useShortDoctype: true, // 使用短的 <!DOCTYPE html> 声明
        removeEmptyAttributes: true, // 移除 HTML 元素中的空属性
        removeStyleLinkTypeAttributes: true, // 移除 type="text/css" 属性
        keepClosingSlash: true, // 保留自闭合标签的末尾斜杠
        minifyJS: true, // 压缩内联 JS
        minifyCSS: true, // 压缩内联 CSS
        minifyURLs: true, // 压缩 URL
      },
    }),
    // 将 chunk 内联到 HTML 中
    new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime.*\.js/]),
  ],
};

Tips and Hints

Operators && and &

&& 运算符会将两个命令连接起来,并当第一个命令执行成功后再执行第二个命令。

& 运算符是将两个命令同时执行,不需要等待第一个命令的执行完毕,常用于同时执行多个任务。

publicPath in output and devServer

配置项 output.publicPathdevServer.publicPath 都是相对于打包输出目录的 URL,这个选项会成为引用静态资源时的前缀。两者的区别是客户端访问静态资源和访问开发服务器上的静态资源。

Entry and Context

The context is the base directory, an absolute path, for resolving entry points and loaders from the configuration.

entry 通常使用相对路径,并且相对于配置中的 context 属性。context 属性是一个用于解析相对路径的绝对路径,这个绝对路径可以是任何存在的目录,但通常会选择项目的根目录作为上下文。

Filename and ChunkFilename

output.filename 用于指定入口起点生成的 bundle 文件名,而 output.chunkFilename 用于指定非入口起点生成的文件名,例如懒加载这种动态导入的代码块的文件名格式。注意,webpackChunkName 优先级高于 output.chunkFilename 高于默认的 chunk id。

Hash & ChunkHash & ContentHash

hash 是通过 webpack 编译过程中的一些信息(如打包时的模块内容、打包时间等)计算得出的。

chunkhash 是通过 chunk 内容计算的,当 chunk 内容发生变化时,该哈希值才会发生变化。

contenthash 是通过文件内容计算的。当文件内容发生变化时,该哈希值才会发生变化。

fullhash 是通过整个项目的内容计算得到的,只有在项目内容发生变化时才会生成新的文件名。

注意,chunkhash 会随着引入的模块内容变化而发生改变,contenthash 常应用于静态资源的文件名,以避免不必要的缓存失效。因需要计算所有资源的哈希值,fullhash 的生成速度要比 hash 慢很多(fullhash 会计算静态资源的哈希值,但是 hash 不会)。

webpack-dev-server 兼容问题

运行webpack-dev-server出现如下报错

> webpack-dev-server

internal/modules/cjs/loader.js:883
  throw err;
  ^

Error: Cannot find module 'webpack-cli/bin/config-yargs'

报错内容为找不到 webpack-cli 中对应模块。报错时项目相应 webpack 配置如下:

"webpack": "^5.24.3",
"webpack-cli": "^4.5.0",
"webpack-dev-server": "^3.11.2"

解决方案:

  • 方法一:重装 webpack-cliwebpack-dev-server 兼容的版本
  • 方法二:添加 "dev":"webpack serve --open Chrome"package.json 中的 "script" 选项

Gzip Compression

设置 devServer.compress: true 可以启用开发服务器的 Gzip 压缩功能,但这并不会影响打包后的资源文件。为了在生产环境中也能使用 Gzip 压缩,可以借助 compression-webpack-plugin 插件。

npm install compression-webpack-plugin --save-dev
// vue.config.js
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = defineConfig({
  configureWebpack: {
    plugins: [
      new CompressionPlugin({
        test: /\.(js|css|html|svg|json|ico|woff|ttf)$/, // 需要压缩的文件类型
        threshold: 10240, // 10KB 以上的文件才会被压缩
      }),
    ],
  },
});

发送请求时,Accept-Encoding 字段会由浏览器自动设置。当服务端返回压缩的资源时,也通常会在响应头中设置 Content-Encoding: gzip,告知浏览器该资源已被压缩。在 Express 中启用 Gzip 压缩可以使用 compression 中间件。Nginx 可以在配置文件中通过 location 来指定哪些请求需要启用 Gzip 压缩。

gzip on;
gzip_min_length 1000;
gzip_types text/plain application/xml application/javascript;

然而,并不是所有的项目都需要启用压缩。当项目非常小,并且没有大量的静态资源需要传输,那么启用压缩可能并不会带来显著的性能提升。相反,甚至会增加服务器的 CPU 负担。

Black Box Analysis

Deprecated Configuration

Dynamic Linking Library

DDL 动态链接库通过 DllPlugin 插件来实现。该插件会将指定的模块打包成一个动态链接库,并生成一个 manifest 文件,用于描述动态链接库的内容和对应的模块名称。

在项目根目录下,创建一个名为 webpack.dll.config.js 的配置文件,并在其中配置 DllPlugin 插件,指定需要打包为动态链接库的模块。

const path = require('path');
const { DllPlugin } = require('webpack');

module.exports = {
  mode: 'production',
  entry: {
    vendor: ['react', 'react-dom', 'lodash'], // 需要打包为动态链接库的模块
  },
  output: {
    filename: '[name].dll.js',
    path: path.resolve(__dirname, 'dll'),
    library: '[name]',
  },
  plugins: [
    new DllPlugin({
      name: '[name]',
      path: path.resolve(__dirname, 'dll/[name].manifest.json'),
    }),
  ],
};

然后,在 package.json 中添加一个脚本命令,用于执行构建 DDL 动态链接库的任务。

运行 npm run build:dll 会执行构建动态链接库的操作,根据配置文件 webpack.dll.config.js,将指定的模块打包成一个名为 vendor.dll.js 的动态链接库,并生成一个 vendor.manifest.json 的 manifest 文件。

{
  "scripts": {
    "build:dll": "webpack --config webpack.dll.config.js"
  }
}

在项目的主配置文件中,使用 DllReferencePlugin 插件引用刚刚生成的动态链接库。

const path = require('path');
const { DllReferencePlugin } = require('webpack');

module.exports = {
  mode: 'development',
  entry: {
    main: './src/index.js', // 项目的入口文件
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  plugins: [
    new DllReferencePlugin({
      context: __dirname,
      manifest: require('./dll/vendor.manifest.json'),
    }),
  ],
};

最后,在项目的入口文件 index.js 中,可以正常引用 DDL 动态链接库中的模块。

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<App />, document.getElementById('root'));

除开 Webpack 提供的 DllPlugin 和 DllReferencePlugin 插件,配置选项 Externals 也可以避免将某些外部依赖库打包进业务代码,而通过 script 标签或者其他方式在运行时从外部引入这些依赖。

假设有一个大型的项目,项目依赖了很多第三方库,例如 react、react-dom、lodash、axios 等。这些第三方库的版本比较稳定,不经常变动。在这种情况下,可以考虑使用 DllPlugin 和 DllReferencePlugin 将这些库预先打包成 DLL 文件。这样在每次构建项目时,Webpack 不需要重新解析和打包这些库,从而大大缩短构建时间。而对于简单的小型项目,可能只需要引入一个或两个第三方库,或者直接使用 CDN 的方式引入一些库,这时可以选择使用 Externals 将这些库排除在构建过程之外,减小输出文件的体积。

HappyPack

HappyPack 是一个可以将 Webpack 的任务分解成多个子进程并行处理的插件,更高效地利用多核 CPU。然而,随着 Webpack 的不断发展和优化,HappyPack 已经不再被推荐使用了。

如果希望进一步优化构建性能,可以通过 thread-loader 来替代 HappyPack。thread-loader 将耗时较长的 Loader(如 Babel 或 TypeScript)放入单独的 worker 池中,并在 worker 池中使用多线程并行处理这些任务。

通过 NPM 安装 thread-loader,并将 thread-loader 添加到耗时较长的 Loader 配置之前。

npm install thread-loader --save-dev
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        // 将 thread-loader 添加到 babel-loader 之前
        use: ['thread-loader', 'babel-loader'],
        exclude: /node_modules/,
      },
      // ...其他规则
    ],
  },
  // ...其他配置
};

有需要可以在 thread-loader 中配置 worker 池的大小,即并行处理任务的线程数,默认为 os.cpus().length

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'thread-loader',
            options: {
              // 指定 worker 池的大小
              workers: os.cpus().length - 1, // 默认是 os.cpus().length
            },
          },
          'babel-loader',
        ],
        exclude: /node_modules/,
      },
      // ...其他规则
    ],
  },
  // ...其他配置
};

thread-loader 可以提高构建速度,但也可能会增加构建过程中的内存开销。在实际开发中,应该根据项目的具体情况和硬件资源,合理配置 worker 池的大小,避免过度占用系统资源。

结束

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!