带你从零开始了解webpack

Study Notes

本博主会持续更新各种前端的技术,如果各位道友喜欢,可以关注、收藏、点赞下本博主的文章。

入口起点(entry points)

在 webpack 配置中有多种方式定义 entry 属性

单个入口(简写)语法

用法:entry: string|Array<string>

webpack.config.js

const config = {
  entry: './src/index.js',
};

module.exports = config;

entry 属性的单个入口语法,是下面的简写:

const config = {
  entry: {
    main: './src/index.js',
  },
};

对象语法

用法:entry: {[entryChunkName: string]: string|Array<string>}

const config = {
  entry: {
    app: './src/app.js',
    vendors: './src/vendors.js',
  },
};

“可扩展的 webpack 配置”是指,可重用并且可以与其他配置组合使用。这是一种流行的技术,用于将关注点(concern)从环境(environment)、构建目标(build target)、运行时(runtime)中分离。然后使用专门的工具(如 webpack-merge)将它们合并。

常见场景

分离 应用程序(app) 和 第三方库(vendor) 入口

const config = {
  entry: {
    app: './src/app.js',
    vendors: './src/vendors.js',
  },
};

多页面应用程序

const config = {
  entry: {
    pageOne: './src/pageOne/index.js',
    pageTwo: './src/pageTwo/index.js',
    pageThree: './src/pageThree/index.js',
  },
};

输出(output)

配置 output 选项可以控制 webpack 如何向硬盘写入编译文件。注意,虽然可以存在多个入口起点,但只指定一个输出配置。

用法(Usage)

在 webpack 中配置 output 属性的最低要求是,将它的值设置为一个对象,包括以下两点:

  • filename 用于输出文件的文件名。 -目标输出目
  • path 的绝对路径。

webpack.config.js

const config = {
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'bundle.js',
  },
};

module.exports = config;

多个入口起点

如果配置创建了多个单独的 "chunk"(例如,使用多个入口起点或使用像 splitChuckPlugin ---> webpack 4.0 后提供 这样的插件),则应该使用占位符(substitutions)来确保每个文件具有唯一的名称。

const config = {
  entry: {
    app: './src/app.js',
    search: './src/search.js',
  },
  output: {
    filename: '[name].js',
    path: __dirname + '/dist',
  },
};

// 写入到硬盘:./dist/app.js, ./dist/search.js

模式(mode)

提供mode配置选项,告知 webpack 使用相应模式的内置优化。

string

用法

只在配置中提供 mode 选项

module.exports = {
  mode: 'production',
};

或者从 CLI 参数中传递:

webpack --mode=production
选项 描述
development 会将 process.env.NODE_ENV 的值设为 development。启用 NamedChunksPlugin 和 NamedModulesPlugin。
production 会将 process.env.NODE_ENV 的值设为 production。启用 FlagDependencyUsagePlugin, FlagIncludedChunksPlugin, ModuleConcatenationPlugin, NoEmitOnErrorsPlugin, OccurrenceOrderPlugin, SideEffectsFlagPlugin 和 UglifyJsPlugin.
none 会将 process.env.NODE_ENV 的值设为 none,不做任何处理

记住,只设置 NODE_ENV,则不会自动设置 mode。

loader

loader 用于对模块的源代码进行转换。loader 可以使你在 import 或"加载"模块时预处理文件。因此,loader 类似于其他构建工具中“任务(task)”,并提供了处理前端构建步骤的强大方法。loader 可以将文件从不同的语言(如 TypeScript)转换为 JavaScript,或将内联图像转换为 data URL。loader 甚至允许你直接在 JavaScript 模块中 import CSS 文件!

使用 loader

在你的应用程序中,有三种使用 loader 的方式:

  • 配置(推荐):在 webpack.config.js 文件中指定 loader。
  • 内联:在每个 import 语句中显式指定 loader。
  • CLI:在 shell 命令中指定它们。

配置[Configuration]

module.rules 允许你在 webpack 配置中指定多个 loader。 这是展示 loader 的一种简明方式,并且有助于使代码变得简洁。同时让你对各个 loader 有个全局概览:

const config = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          { loader: 'style-loader' },
          {
            loader: 'css-loader',
            options: {
              modules: true,
            },
          },
        ],
      },
    ],
  },
};

内联

可以在 import 语句或任何等效于 "import" 的方式中指定 loader。使用 ! 将资源中的 loader 分开。分开的每个部分都相对于当前目录解析。

import Styles from 'style-loader!css-loader?modules!./styles.css';

通过前置所有规则及使用 !,可以对应覆盖到配置中的任意 loader。

选项可以传递查询参数,例如 ?key=value&foo=bar,或者一个 JSON 对象,例如 ?{"key":"value","foo":"bar"}。

尽可能使用 module.rules,因为这样可以减少源码中的代码量,并且可以在出错时,更快地调试和定位 loader 中的问题。

CLI

webpack --module-bind jade-loader --module-bind 'css=style-loader!css-loader'

这会对 .jade 文件使用 jade-loader,对 .css 文件使用 style-loader 和 css-loader。

loader 特性

  • loader 支持链式传递。能够对资源使用流水线(pipeline)。一组链式的 loader 将按照相反的顺序执行。
  • loader 链中的第一个 loader 返回值给下一个 loader。在最后一个 loader,返回 webpack 所预期的 JavaScript。
  • loader 可以是同步的,也可以是异步的。
  • loader 运行在 Node.js 中,并且能够执行任何可能的操作。
  • loader 接收查询参数。用于对 loader 传递配置。
  • loader 也能够使用 options 对象进行配置。
  • 除了使用 package.json 常见的 main 属性,还可以将普通的 npm 模块导出为 loader,做法是在 package.json 里定义一个 loader 字段。
  • 插件(plugin)可以为 loader 带来更多特性。
  • loader 能够产生额外的任意文件。

样式

style-loader

将模块的导出作为样式添加到 DOM 中

安装

npm i style-loader -D

用法

建议将 style-loader 与 css-loader 结合使用

const config = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
      },
    ],
  },
};

css-loader

解析 CSS 文件后,使用 import 加载,并且返回 CSS 代码

安装

npm i css-loader -D

用法

css-loader 解释(interpret) @import 和 url() ,会 import/require() 后再解析(resolve)它们。

const config = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
      },
    ],
  },
};

less-loader

加载和转译 LESS 文件

安装

npm i less-loader less -D

用法

const config = {
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          {
            loader: 'style-loader', // 将 JS 字符串生成为 style 节点
          },
          {
            loader: 'css-loader', // 将 CSS 转化成 CommonJS 模块
          },
          {
            loader: 'less-loader', // 将 Less 编译成 CSS
          },
        ],
      },
    ],
  },
};

sass-loader

加载和转译 SASS/SCSS 文件

安装

npm i sass-loader node-sass webpack -D

用法

const config = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          {
            loader: 'style-loader', // 将 JS 字符串生成为 style 节点
          },
          {
            loader: 'css-loader', // 将 CSS 转化成 CommonJS 模块
          },
          {
            loader: 'sass-loader', // 将 Sass 编译成 CSS
          },
        ],
      },
    ],
  },
};

postcss-loader

使用 PostCSS 加载和转译 CSS/SSS 文件

安装

npm i postcss-loader -D

用法

const config = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'postcss-loader'],
      },
    ],
  },
};

设置 postcss.config.js 后,将 postcss-loader 添加到 webpack.config.js 中。 您可以单独使用它,也可以将其与 css-loader 结合使用(推荐)。 如果使用 css-loader 和 style-loader,但要使用其他预处理程序,例如 sass | less | stylus-loader,请使用它。

当单独使用 postcss-loader 时(不使用 css-loader),请勿在 CSS 中使用@import,因为这可能导致捆绑包非常膨胀

const config = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          { loader: 'css-loader', options: { importLoaders: 1 } },
          'postcss-loader',
        ],
      },
    ],
  },
};

stylus-loader

加载和转译 Stylus 文件

安装

npm i stylus-loader stylus -D

用法

const config = {
  module: {
    rules: [
      {
        test: /\.styl/,
        use: [
          {
            loader: 'style-loader', // 将 JS 字符串生成为 style 节点
          },
          {
            loader: 'css-loader', // 将 CSS 转化成 CommonJS 模块
          },
          {
            loader: 'stylus-loader', // 将 Stylus 编译成 CSS
          },
        ],
      },
    ],
  },
};

文件

raw-loader

加载文件原始内容(utf-8)

安装

npm i raw-loader -D

用法

通过 webpack 配置、命令行或者内联使用 loader

webpack 配置

module.exports = {
  module: {
    rules: [
      {
        test: /\.txt$/,
        use: 'raw-loader',
      },
    ],
  },
};

通过命令行(CLI)

webpack --module-bind 'txt=raw-loader'

内联使用

import txt from 'raw-loader!./assets/index.txt';

val-loader

将代码作为模块执行,并将 exports 转为 JS 代码

安装

npm i val-loader -D

用法

此 loader 所加载的模块必须符合以下接口

加载的模块必须使用以下函数接口,将 default export 导出为一个函数。

module.exports = function () {...};

还支持 Babel 编译的模块

export default function () {...};

示例

answer.js

module.exports = function () {
  return {
    code: 'module.exports = "test val-loader";',
  };
};

webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: require.resolve('./src/answer.js'),
        use: {
          loader: 'val-loader',
        },
      },
    ],
  },
};

url-loader

将文件作为 base64 编码的 URL 加载

安装

npm i url-loader -D

用法

url-loader 功能类似于 file-loader,但是在文件大小(单位 byte)低于指定的限制时,可以返回一个 DataURL。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpge|jpg|git|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 1024 * 4,
          },
        },
      },
    ],
  },
};

file-loader

将文件发送到输出文件夹,并返回(相对)URL

安装

npm i file-loader -D

用法

默认情况下,生成的文件的文件名就是文件内容的 MD5 哈希值并会保留所引用资源的原始扩展名。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpge|jpg|git|svg)$/,
        use: {
          loader: 'file-loader',
        },
      },
    ],
  },
};

转换编译(Transpiling)

vue-loader

处理 Vue 文件

安装

npm i vue-loader vue-template-compiler -D

配置

指定加载src目录下的,忽略node_modules目录

webpack.config.js

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

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.[hash:8].js',
    path: path.join(__dirname, 'dist'),
  },
  mode: 'production',
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        exclude: /node_modules/,
        include: path.join(__dirname, 'src'),
      },
    ],
  },
  plugins: [
    new VueLoaderPlugin(), // vue loader 15 必须添加plugin
  ],
};

babel-loader

将 es6+ 转换为 es5

安装

npm i babel-loader @babel/core @babel/preset-env -D

配置

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.[hash:8].js',
    path: path.join(__dirname, 'dist'),
  },
  mode: 'production',
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
      },
    ],
  },
};

配置 babel 使用插件集合将 es6+ 转换为 es5

babel.config.js

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false,
        targets: {
          browsers: ['> 1%', 'last 2 versions', 'not ie <= 8'],
        },
      },
    ],
  ],
};

buble-loader

es6+代码加载并转换为es5

traceur-loader

es6+代码加载并转换为es5

ts-loader

TypeScript转换为JavaScript

coffee-loader

CoffeeScript转换为JavaScript

Webpack 开发一个 Loader

loader 是导出为一个函数的 node 模块。该函数在 loader 转换资源的时候调用。给定的函数将调用 loader API,并通过 this 上下文访问。

简单用法

当一个 loader 在资源中使用,这个 loader 只能传入一个参数 - 这个参数是一个包含包含资源文件内容的字符串

同步 loader 可以简单的返回一个代表模块转化后的值。在更复杂的情况下,loader 也可以通过使用 this.callback(err, values...) 函数,返回任意数量的值。错误要么传递给这个 this.callback 函数,要么扔进同步 loader 中。

loader 会返回一个或者两个值。第一个值的类型是 JavaScript 代码的字符串或者 buffer。第二个参数值是 SourceMap,它是个 JavaScript 对象。

复杂用法

当链式调用多个 loader 的时候,请记住它们会以相反的顺序执行。取决于数组写法格式,从右向左或者从下向上执行。

  • 最后的 loader 最早调用,将会传入原始资源内容。
  • 第一个 loader 最后调用,期望值是传出 JavaScript 和 source map(可选)。
  • 中间的 loader 执行时,会传入前一个 loader 传出的结果。

案例

这里开发一个解析 md 文件 loader

安装

npm i marked -D

编写 loader

markdown-loader.js

/**
 * @author Wuner
 * @date 2020/7/23 11:28
 * @description
 */
const marked = require('marked');
module.exports = function (source) {
  const content = marked(source);
  // 需要返回包含默认导出文本的 JavaScript 模块
  return `module.exports = ${JSON.stringify(content)}`;
};

用法

import md from './md/README.md';

webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.md$/,
        use: { loader: './src/loader/markdown-loader' },
      },
    ],
  },
};

插件(plugins)

插件是 webpack 的支柱功能。webpack 自身也是构建于,你在 webpack 配置中用到的相同的插件系统之上!

插件目的在于解决 loader 无法实现的其他事。

剖析

webpack 插件是一个具有 apply 属性的 JavaScript 对象。apply 属性会被 webpack compiler 调用,并且 compiler 对象可在整个编译生命周期访问。

用法

由于插件可以携带参数/选项,你必须在 webpack 配置中,向 plugins 属性传入 new 实例。

根据你的 webpack 用法,这里有多种方式使用插件。

配置

const HtmlWebpackPlugin = require('html-webpack-plugin'); //通过 yarn或者npm 安装
const webpack = require('webpack'); //访问内置的插件
const path = require('path');

const config = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: 'babel-loader',
      },
    ],
  },
  plugins: [
    new webpack.optimize.UglifyJsPlugin(),
    new HtmlWebpackPlugin({ template: './src/index.html' }),
  ],
};

module.exports = config;

Node API

即便使用 Node API,用户也应该在配置中传入 plugins 属性。compiler.apply 并不是推荐的使用方式。

some-node-script.js

const webpack = require('webpack'); //访问 webpack 运行时(runtime)
const configuration = require('./webpack.config.js');

let compiler = webpack(configuration);
compiler.apply(new webpack.ProgressPlugin());

compiler.run(function (err, stats) {
  // ...
});

以上看到的示例和webpack 自身运行时(runtime) 极其类似。webpack 源码 中隐藏有大量使用示例,你可以用在自己的配置和脚本中。

CleanWebpackPlugin

一个 webpack 插件,用于删除/清理您的构建文件夹。

注意:支持 Node v8+和 webpack v3+。

安装

npm i clean-webpack-plugin -D

用法

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
  plugins: [new CleanWebpackPlugin()],
};

HtmlWebpackPlugin

HtmlWebpackPlugin 简化了 HTML 文件的创建,以便为你的 webpack 包提供服务。这对于在文件名中包含每次会随着编译而发生变化哈希的 webpack bundle 尤其有用。 你可以让插件为你生成一个 HTML 文件,使用 lodash 模板提供你自己的模板,或使用你自己的 loader。

使用非 lodash 模板

安装

npm i html-webpack-plugin -D

用法

该插件将为你生成一个 HTML5 文件, 其中包括使用 script 标签的 body 中的所有 webpack 包。 只需添加插件到你的 webpack 配置如下:

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

const webpackConfig = {
  entry: 'index.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'bundle.js',
  },
  plugins: [new HtmlWebpackPlugin()],
};

DefinePlugin

DefinePlugin 允许创建一个在编译时可以配置的全局常量。这可能会对开发模式和发布模式的构建允许不同的行为非常有用。如果在开发构建中,而不在发布构建中执行日志记录,则可以使用全局常量来决定是否记录日志。这就是 DefinePlugin 的用处,设置它,就可以忘记开发和发布构建的规则。

new webpack.DefinePlugin({
  // Definitions...
});

用法

每个传进 DefinePlugin 的键值都是一个标志符或者多个用.连接起来的标志符。

  • 如果这个值是一个字符串,它会被当作一个代码片段来使用。
  • 如果这个值不是字符串,它会被转化为字符串(包括函数)。
  • 如果这个值是一个对象,它所有的 key 会被同样的方式定义。
  • 如果在一个 key 前面加了 typeof,它会被定义为 typeof 调用。

这些值会被内联进那些允许传一个代码压缩参数的代码中,从而减少冗余的条件判断。

new webpack.DefinePlugin({
  PRODUCTION: JSON.stringify(true),
  VERSION: JSON.stringify('5fa3b9'),
  BROWSER_SUPPORTS_HTML5: true,
  TWO: '1+1',
  'typeof window': JSON.stringify('object'),
});

注意,因为这个插件直接执行文本替换,给定的值必须包含字符串本身内的实际引号。通常,有两种方式来达到这个效果,使用 '"production"', 或者使用 JSON.stringify('production')。

建议使用'process.env.NODE_ENV': JSON.stringify('production')这种定义。 使用{ env: { NODE_ENV: JSON.stringify('production') } }将覆盖过程对象,这可能会破坏与期望在过程对象上定义其他值的某些模块的兼容性。

MiniCssExtractPlugin(CSS 提取)

该插件将 CSS 提取到单独的文件中。 它为每个包含 CSS 的 JS 文件创建一个 CSS 文件。 它支持 CSS 和 SourceMap 的按需加载。

基于 webpack v4。

extract-text-webpack-plugin比较:

  • 异步加载
  • 没有重复的编译(性能)
  • 更容易使用
  • 特定于 CSS

安装

npm i mini-css-extract-plugin -D

用法

webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  plugins: [new MiniCssExtractPlugin()],
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

在 production 环境 压缩

因为 webpack 的 production 只会压缩 JS 代码,所以我们这边需要自己配置optimize-css-assets-webpack-plugin插件来压缩 CSS

webpack 官方建议我们放在 optimization 里,当 optimization 开启时,才压缩。

因为我们在 optimization 使用数组配置了optimize-css-assets-webpack-plugin 插件,webpack 认为我们需要自定义配置,所以导致 JS 压缩失效,相对的我们需要使用terser-webpack-plugin 插件来压缩 JS 代码

const TerserJSPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [new TerserJSPlugin({}), new OptimizeCSSAssetsPlugin({})],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
      chunkFilename: '[id].css',
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

CopyWebpackPlugin

复制文件

安装

npm i copy-webpack-plugin -D

配置

babel.config.js

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

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.[hash:8].js',
    path: path.join(__dirname, 'dist'),
  },
  mode: 'production',
  plugins: [
    new CopyWebpackPlugin({
      patterns: [
        {
          from: path.resolve(__dirname, 'public'),
          to: 'public',
        },
      ],
    }),
  ],
};

开发一个插件

插件向第三方开发者提供了 webpack 引擎中完整的能力。使用阶段式的构建回调,开发者可以引入它们自己的行为到 webpack 构建流程中。创建插件比创建 loader 更加高级,因为你将需要理解一些 webpack 底层的内部特性来做相应的钩子,所以做好阅读一些源码的准备!

创建插件

webpack 插件由以下组成:

  • 一个 JavaScript 命名函数。
  • 在插件函数的 prototype 上定义一个 apply 方法。
  • 指定一个绑定到 webpack 自身的事件钩子。
  • 处理 webpack 内部实例的特定数据。
  • 功能完成后调用 webpack 提供的回调。
// 一个 JavaScript 命名函数。
function MyExampleWebpackPlugin() {}

// 在插件函数的 prototype 上定义一个 `apply` 方法。
MyExampleWebpackPlugin.prototype.apply = function (compiler) {
  // 指定一个挂载到 webpack 自身的事件钩子。
  compiler.plugin('webpacksEventHook', function (
    compilation /* 处理 webpack 内部实例的特定数据。*/,
    callback,
  ) {
    console.log('This is an example plugin!!!');

    // 功能完成后调用 webpack 提供的回调。
    callback();
  });
};

Compiler 和 Compilation

在插件开发中最重要的两个资源就是 compiler 和 compilation 对象。理解它们的角色是扩展 webpack 引擎重要的第一步。

  • compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。

  • compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

这两个组件是任何 webpack 插件不可或缺的部分(特别是 compilation),因此,开发者在阅读源码,并熟悉它们之后,会感到获益匪浅:

基本插件架构

插件是由「具有 apply 方法的 prototype 对象」所实例化出来的。这个 apply 方法在安装插件时,会被 webpack compiler 调用一次。apply 方法可以接收一个 webpack compiler 对象的引用,从而可以在回调函数中访问到 compiler 对象。一个简单的插件结构如下:

function HelloWorldPlugin(options) {
  // 使用 options 设置插件实例……
}

HelloWorldPlugin.prototype.apply = function (compiler) {
  compiler.hooks.compile.tap('done', function () {
    console.log('Hello World!');
  });
};

module.exports = HelloWorldPlugin;
class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.compile.tap('done', function () {
      console.log('Hello World!');
    });
  }
}

module.exports = HelloWorldPlugin;

访问 compilation 对象

使用 compiler 对象时,你可以绑定提供了编译 compilation 引用的回调函数,然后拿到每次新的 compilation 对象。这些 compilation 对象提供了一些钩子函数,来钩入到构建流程的很多步骤中。

class HelloCompilationPlugin {
  apply(compiler) {
    // 设置回调来访问 compilation 对象:
    compiler.hooks.compilation.tap('HelloCompilationPlugin', function (
      compilation,
    ) {
      // 现在,设置回调来访问 compilation 中的步骤:
      compilation.hooks.optimize.tap('optimize', function () {
        console.log('Assets are being optimized.');
      });
    });
  }
}

关于 compiler, compilation 的可用回调,和其它重要的对象的更多信息,请查看插件文档。

异步编译插件

有一些编译插件中的步骤是异步的,这样就需要额外传入一个 callback 回调函数,并且在插件运行结束时,必须调用这个回调函数。

class HelloAsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync('emit', function (compilation, callback) {
      // 做一些异步处理……
      setTimeout(function () {
        console.log('Done with async work...');
        callback();
      }, 1000);
    });
  }
}

示例:给 js 文件添加注释

class AddJsNote {
  apply(compiler) {
    // emit: 生成资源到 output 目录之前。
    // 我们这里需要在生成js文件后执行,所以使用这个钩子
    compiler.hooks.emit.tap('emit', function (compilation) {
      let note = `/**
 * @author Wuner
 * @date 2020/7/23 11:28
 * @description
 */\n`;
      for (let filename in compilation.assets) {
        if (filename.endsWith('.js')) {
          let content = note + compilation.assets[filename].source();
          compilation.assets[filename] = {
            source: () => content,
            size: () => note.length,
          };
        }
      }
    });
  }
}

开发工具

在每次编译代码时,手动运行 npm run build 会显得很麻烦。

webpack 提供几种可选方式,帮助你在代码发生变化后自动编译代码:

  • webpack watch mode(webpack 观察模式)
  • webpack-dev-server
  • webpack-dev-middleware

大多数场景下我们会使用 webpack-dev-server

使用 watch mode(观察模式)

你可以指示 webpack "watch" 依赖图中所有文件的更改。如果其中一个文件被更新,代码将被重新编译,所以你不必再去手动运行整个构建。

启动 webpack watch mode

webpack --watch

如果能够自动刷新浏览器就更好了,因此可以通过 browser-sync 实现此功能。

缺点: 每次编译都要读写磁盘

使用 webpack-dev-server

webpack-dev-server 为你提供了一个简单的 web server,并且具有 live reloading(实时重新加载) 功能。

安装

npm i webpack-dev-server -D

webpack.config.js

module.exports = {
  devServer: {
    // 当使用内联模式(inline mode)时,控制台(console)将显示消息,可能的值有 none, error, warning 或者 info(默认值)。
    clientLogLevel: 'none',
    //当使用 HTML5 History API 时,任意的 404 响应都可能需要被替代为 index.html
    historyApiFallback: {
      index: `index.html`,
    },
    // 启用 webpack 的模块热替换特性
    hot: true,
    // 告诉服务器从哪里提供内容。只有在你想要提供静态文件时才需要。我们这里直接禁用掉
    contentBase: false,
    // 一切服务都启用gzip 压缩:
    compress: true,
    // 指定使用一个 host。默认是 localhost
    host: 'localhost',
    // 指定要监听请求的端口号
    port: '8000',
    // local服务器自动打开浏览器。
    open: true,
    // 当出现编译器错误或警告时,在浏览器中显示全屏遮罩层。默认情况下禁用。
    overlay: false,
    // 浏览器中访问的相对路径
    publicPath: '',
    // 代理配置
    proxy: {
      '/api/': {
        target: 'https://github.com/',
        changeOrigin: true,
        logLevel: 'debug',
      },
    },
    // 除了初始启动信息之外的任何内容都不会被打印到控制台。这也意味着来自 webpack 的错误或警告在控制台不可见。
    // 我们配置 FriendlyErrorsPlugin 来显示错误信息到控制台
    quiet: true,
    // webpack 使用文件系统(file system)获取文件改动的通知。监视文件 https://webpack.js.org/configuration/dev-server/#devserver-watchoptions-
    watchOptions: {
      poll: false,
    },
    disableHostCheck: true,
  },
};

webpack-dev-server 具有许多可配置的选项。关于其他更多配置,请查看配置文档

模块热替换(hot module replacement)

使用 webpack-dev-middleware

Devtool

此选项控制是否生成,以及如何生成source map

使用 SourceMapDevToolPlugin 进行更细粒度的配置。查看 source-map-loader 来处理已有的 source map。

devtool

string false

选择一种 source map 格式来增强调试过程。不同的值会明显影响到构建(build)和重新构建(rebuild)的速度。

你可以直接使用 SourceMapDevToolPlugin/EvalSourceMapDevToolPlugin 来替代使用 devtool 选项,因为它有更多的选项。切勿同时使用 devtool 选项和 SourceMapDevToolPlugin/EvalSourceMapDevToolPlugin 插件。devtool 选项在内部添加过这些插件,所以你最终将应用两次插件。

git 仓库 里有用于测试 Source Map 示例,便于理解下面的 Source Map 对比表格

devtool 构建速度 重新构建速度 生产环境 质量(quality)
none +++ +++ yes 打包后的代码
eval +++ +++ no 生成后的代码
cheap-eval-source-map + ++ no 转换过的代码(仅限行)
cheap-module-eval-source-map o ++ no 原始源代码(仅限行)
eval-source-map -- + no 原始源代码
cheap-source-map + o no 转换过的代码(仅限行)
cheap-module-source-map o - no 原始源代码(仅限行)
inline-cheap-source-map + o no 转换过的代码(仅限行)
inline-cheap-module-source-map o - no 原始源代码(仅限行)
source-map -- -- yes 原始源代码
inline-source-map -- -- no 原始源代码
hidden-source-map -- -- yes 原始源代码
nosources-source-map -- -- yes 无源代码内容

+++ 非常快速, ++ 快速, + 比较快, o 中等, - 比较慢, -- 慢

其中一些值适用于开发环境,一些适用于生产环境。对于开发环境,通常希望更快速的 source map,需要添加到 bundle 中以增加体积为代价,但是对于生产环境,则希望更精准的 source map,需要从 bundle 中分离并独立存在。

质量(quality)说明

  • 打包后的代码

    将所有生成的代码视为一大块代码。你看不到相互分离的模块。

  • 生成后的代码

    每个模块相互分离,并用模块名称进行注释。可以看到 webpack 生成的代码。示例:你会看到类似 var moduleWEBPACK_IMPORTED_MODULE_1 = webpack_require(42); moduleWEBPACK_IMPORTED_MODULE_1.a();,而不是 import {test} from "module"; test();。

  • 转换过的代码

    每个模块相互分离,并用模块名称进行注释。可以看到 webpack 转换前、loader 转译后的代码。示例:你会看到类似 import {test} from "module"; var A = function(_test) { ... }(test);,而不是 import {test} from "module"; class A extends test {}。

  • 原始源代码

    每个模块相互分离,并用模块名称进行注释。你会看到转译之前的代码,正如编写它时。这取决于 loader 支持。

  • 无源代码内容

    source map 中不包含源代码内容。浏览器通常会尝试从 web 服务器或文件系统加载源代码。你必须确保正确设置 output.devtoolModuleFilenameTemplate,以匹配源代码的 url。

  • (仅限行)

    source map 被简化为每行一个映射。这通常意味着每个语句只有一个映射(假设你使用这种方式)。这会妨碍你在语句级别上调试执行,也会妨碍你在每行的一些列上设置断点。与压缩后的代码组合后,映射关系是不可能实现的,因为压缩工具通常只会输出一行。

对于开发环境

以下选项非常适合开发环境:

  • eval

    每个模块都使用 eval() 执行,并且都有 //@ sourceURL。此选项会非常快地构建。主要缺点是,由于会映射到转换后的代码,而不是映射到原始代码(没有从 loader 中获取 source map),所以不能正确的显示行数。

  • eval-source-map

    每个模块使用 eval() 执行,并且 source map 转换为 DataUrl 后添加到 eval() 中。初始化 source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 source map。

  • cheap-eval-source-map

    类似 eval-source-map,每个模块使用 eval() 执行。这是 "cheap(阉割版)" 的 source map,因为它没有生成列映射(column mapping),只是映射行数。它会忽略源自 loader 的 source map,并且仅显示转译后的代码,就像 eval devtool。

  • cheap-module-eval-source-map

    类似 cheap-eval-source-map,并且,在这种情况下,源自 loader 的 source map 会得到更好的处理结果。然而,loader source map 会被简化为每行一个映射(mapping)。

特定场景

以下选项对于开发环境和生产环境并不理想。他们是一些特定场景下需要的,例如,针对一些第三方工具。

  • inline-source-map

    source map 转换为 DataUrl 后添加到 bundle 中。

  • cheap-source-map

    没有列映射(column mapping)的 source map,忽略 loader source map。

  • inline-cheap-source-map

    类似 cheap-source-map,但是 source map 转换为 DataUrl 后添加到 bundle 中。

  • cheap-module-source-map

    没有列映射(column mapping)的 source map,将 loader source map 简化为每行一个映射(mapping)。

  • inline-cheap-module-source-map

    类似 cheap-module-source-map,但是 source map 转换为 DataUrl 添加到 bundle 中。

对于生产环境

这些选项通常用于生产环境中:

  • none(省略 devtool 选项)

    不生成 source map。这是一个不错的选择。

  • source-map

    整个 source map 作为一个单独的文件生成。它为 bundle 添加了一个引用注释,以便开发工具知道在哪里可以找到它。

你应该将你的服务器配置为,不允许普通用户访问 source map 文件!

  • hidden-source-map

    与 source-map 相同,但不会为 bundle 添加引用注释。如果你只想 source map 映射那些源自错误报告的错误堆栈跟踪信息,但不想为浏览器开发工具暴露你的 source map,这个选项会很有用。

你不应将 source map 文件部署到 web 服务器。而是只将其用于错误报告工具。

  • nosources-source-map

    创建的 source map 不包含 sourcesContent(源代码内容)。它可以用来映射客户端上的堆栈跟踪,而无须暴露所有的源代码。你可以将 source map 文件部署到 web 服务器。

这仍然会暴露反编译后的文件名和结构,但它不会暴露原始代码。

在使用 uglifyjs-webpack-plugin 时,你必须提供 sourceMap:true 选项来启用 source map 支持。

模块热替换(hot module replacement)

模块热替换(HMR - Hot Module Replacement)功能会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面时丢失的应用程序状态。
  • 只更新变更内容,以节省宝贵的开发时间。
  • 调整样式更加快速 - 几乎相当于在浏览器调试器中更改样式。

在应用程序中

通过以下步骤,可以做到在应用程序中置换(swap in and out)模块:

  • 应用程序代码要求 HMR runtime 检查更新。
  • HMR runtime(异步)下载更新,然后通知应用程序代码。
  • 应用程序代码要求 HMR runtime 应用更新。
  • HMR runtime(同步)应用更新。

你可以设置 HMR,以使此进程自动触发更新,或者你可以选择要求在用户交互时进行更新。

在编译器中

除了普通资源,编译器(compiler)需要发出 "update",以允许更新之前的版本到新的版本。"update" 由两部分组成:

  • 更新后的 manifest(JSON)
  • 一个或多个更新后的 chunk (JavaScript)

manifest 包括新的编译 hash 和所有的待更新 chunk 目录。每个更新 chunk 都含有对应于此 chunk 的全部更新模块(或一个 flag 用于表明此模块要被移除)的代码。

编译器确保模块 ID 和 chunk ID 在这些构建之间保持一致。通常将这些 ID 存储在内存中(例如,使用 webpack-dev-server 时),但是也可能将它们存储在一个 JSON 文件中。

在模块中

HMR 是可选功能,只会影响包含 HMR 代码的模块。举个例子,通过 style-loader 为 style 样式追加补丁。为了运行追加补丁,style-loader 实现了 HMR 接口;当它通过 HMR 接收到更新,它会使用新的样式替换旧的样式。

类似的,当在一个模块中实现了 HMR 接口,你可以描述出当模块被更新后发生了什么。然而在多数情况下,不需要强制在每个模块中写入 HMR 代码。如果一个模块没有 HMR 处理函数,更新就会冒泡(bubble up)。这意味着一个简单的处理函数能够对整个模块树(complete module tree)进行更新。如果在这个模块树中,一个单独的模块被更新,那么整组依赖模块都会被重新加载。

有关 module.hot 接口的详细信息,请查看 HMR API 页面。

在 HMR Runtime 中

这些事情比较有技术性……如果你对其内部不感兴趣,可以随时跳到 HMR API 页面或 HMR 用例

对于模块系统的 runtime,附加的代码被发送到 parents 和 children 跟踪模块。在管理方面,runtime 支持两个方法 check 和 apply。

check 发送 HTTP 请求来更新 manifest。如果请求失败,说明没有可用更新。如果请求成功,待更新 chunk 会和当前加载过的 chunk 进行比较。对每个加载过的 chunk,会下载相对应的待更新 chunk。当所有待更新 chunk 完成下载,就会准备切换到 ready 状态。

apply 方法将所有被更新模块标记为无效。对于每个无效模块,都需要在模块中有一个更新处理函数(update handler),或者在它的父级模块们中有更新处理函数。否则,无效标记冒泡,并也使父级无效。每个冒泡继续,直到到达应用程序入口起点,或者到达带有更新处理函数的模块(以最先到达为准,冒泡停止)。如果它从入口起点开始冒泡,则此过程失败。

之后,所有无效模块都被(通过 dispose 处理函数)处理和解除加载。然后更新当前 hash,并且调用所有 "accept" 处理函数。runtime 切换回闲置状态(idle state),一切照常继续。

模块热替换 API(HMR API)

如果已经通过 HotModuleReplacementPlugin 启用了 HMR,则它的接口将被暴露在 module.hot 属性下面。通常,用户先要检查这个接口是否可访问,然后再开始使用它。举个例子,你可以这样 accept 一个更新的模块:

if (module.hot) {
  module.hot.accept('./library.js', function () {
    // 使用更新过的 library 模块执行某些操作...
  });
}

accept

接受(accept)给定依赖模块的更新,并触发一个回调函数来对这些更新做出响应。

module.hot.accept(
  dependencies, // 可以是一个字符串或字符串数组
  callback, // 用于在模块更新后触发的函数
);

使用 ESM import 时,将从依赖项中导入的所有符号自动更新。 注意:依赖项字符串必须与 import 中的 from 字符串完全匹配。 在某些情况下,甚至可以省略 callback。 在回调中使用 require()没有意义。

使用 CommonJS 时,您需要通过在 callback 中使用 require()来手动更新依赖项。 省略 callback 在这里没有意义。

decline

拒绝给定依赖模块的更新,使用decline方法强制更新失败。

module.hot.decline(
  dependencies, // 可以是一个字符串或字符串数组
);

dispose(或 addDisposeHandler)

添加一个处理函数,在当前模块代码被替换时执行。此函数应该用于移除你声明或创建的任何持久资源。如果要将状态传入到更新过的模块,请添加给定 data 参数。更新后,此对象在更新之后可通过 module.hot.data 调用。

module.hot.dispose((data) => {
  // 清理并将 data 传递到更新后的模块……
});

removeDisposeHandler

删除由 dispose 或 addDisposeHandler 添加的回调函数。

module.hot.removeDisposeHandler(callback);

status

取得模块热替换进程的当前状态。

module.hot.status(); // 返回以下字符串之一……
Status 描述
idle 该进程正在等待调用 check(见下文)
check 该进程正在检查以更新
prepare 该进程正在准备更新(例如,下载已更新的模块)
ready 此更新已准备并可用
dispose 该进程正在调用将被替换模块的 dispose 处理函数
apply 该进程正在调用 accept 处理函数,并重新执行自我接受(self-accepted)的模块
abort 更新已中止,但系统仍处于之前的状态
fail 更新已抛出异常,系统状态已被破坏

check

测试所有加载的模块以进行更新,如果有更新,则应用它们。

module.hot
  .check(autoApply)
  .then((outdatedModules) => {
    // 超时的模块……
  })
  .catch((error) => {
    // 捕获错误
  });

autoApply 参数可以是布尔值,也可以是 options,当被调用时可以传递给 apply 方法

可选的 options 对象可以包含以下属性:

options 描述
ignoreUnaccepted (boolean) 忽略对未接受的模块所做的更改。
ignoreDeclined (boolean) 忽略对拒绝的模块所做的更改。
ignoreErrored (boolean) 忽略接受处理函数,错误处理函数以及重新评估模块时抛出的错误。
onDeclined (function(info)) 拒绝模块的通知者
onUnaccepted (function(info)) 未接受模块的通知程序
onAccepted (function(info)) 接受模块的通知者
onDisposed (function(info)) 废弃模块的通知者
onErrored (function(info)) 异常通知者

info 参数可能存在以下对象:

{
  type: "self-declined" | "declined" |
        "unaccepted" | "accepted" |
        "disposed" | "accept-errored" |
        "self-accept-errored" | "self-accept-error-handler-errored",
  moduleId: 4, // The module in question.
  dependencyId: 3, // For errors: the module id owning the accept handler.
  chain: [1, 2, 3, 4], // For declined/accepted/unaccepted: the chain from where the update was propagated.
  parentId: 5, // For declined: the module id of the declining parent
  outdatedModules: [1, 2, 3, 4], // For accepted: the modules that are outdated and will be disposed
  outdatedDependencies: { // For accepted: The location of accept handlers that will handle the update
    5: [4]
  },
  error: new Error(...), // For errors: the thrown error
  originalError: new Error(...) // For self-accept-error-handler-errored:
                                // the error thrown by the module before the error handler tried to handle it.
}

addStatusHandler

注册一个函数来监听 status 的变化。

module.hot.addStatusHandler((status) => {
  // 响应当前状态……
});

removeStatusHandler

移除一个注册的状态处理函数。

module.hot.removeStatusHandler(callback);

HMR 用例

模块热替换(Hot Module Replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新各种模块,而无需进行完全刷新。

HMR 不适用于生产环境,这意味着它应当只在开发环境使用

启用 HMR

配置 webpack-dev-server ,并使用 webpack 的内置 HMR 插件

HMR 修改样式

借助于 style-loader 的帮助,CSS 的模块热替换实际上是相当简单的。当更新 CSS 依赖模块时,此 loader 在后台使用 module.hot.accept 来修补(patch)<style>标签。

所以,可以使用以下命令安装两个 loader :

npm i style-loader css-loader -D

接下来我们来更新 webpack 的配置,让这两个 loader 生效。

webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const webpack = require('webpack');

module.exports = {
  mode: 'development',
  entry: {
    main: './src/main.js',
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js',
  },
  devtool: 'eval-source-map',
  devServer: {
    contentBase: false,
    hot: true,
    open: true,
  },
  plugins: [new CleanWebpackPlugin(), new HtmlWebpackPlugin()],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: 'style-loader',
          },
          {
            loader: 'css-loader',
          },
        ],
      },
    ],
  },
};

src/textarea.js

module.exports = function () {
  const textarea = document.createElement('textarea');
  textarea.id = 'text';
  return textarea;
};

src/assets/main.css

#text {
  color: red;
}

src/main.js

import './assets/main.css';
import textarea from './textarea';

const textareaEl = textarea();
const body = document.body;
body.appendChild(textareaEl);

将 id 为 text 上的样式修改为 color: black,你可以立即看到页面的字体颜色随之更改,但并未刷新页面。

src/assets/main.css

#text {
  color: black;
}

HMR 修改 js

这里我们使用HMR API里的accept来修补(patch)

webpack.config.js 配置

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const webpack = require('webpack');

module.exports = {
  mode: 'development',
  entry: {
    main: './src/main.js',
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js',
  },
  devtool: 'eval-source-map',
  devServer: {
    contentBase: false,
    hot: true,
    open: true,
  },
  optimization: {
    NamedModulesPlugin,
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin(),
    new webpack.HotModuleReplacementPlugin(),
  ],
};

我们还添加了 NamedModulesPlugin,以便更容易查看要修补(patch)的依赖.

src/textarea.js

module.exports = function () {
  const textarea = document.createElement('textarea');
  textarea.id = 'text';
  return textarea;
};

src/main.js

import textarea from './textarea';

let textareaEl = textarea();
const body = document.body;
body.appendChild(textareaEl);

// 判断是否开启模块热替换
if (module.hot) {
  module.hot.accept('./textarea.js', () => {
    console.log('Accepting the updated textarea module!');
    // 获取页面重新渲染前的值
    const val = textareaEl.value;
    // 移除Element
    body.removeChild(textareaEl);
    // 这里拿到的是最新的Element
    textareaEl = textarea();
    // 将value值写入
    textareaEl.value = val;
    // 添加Element
    body.appendChild(textareaEl);
  });
}

将 id 修改为 text1,你可以立即看到页面的 textarea 标签 id 也随之更改,但并未刷新页面。

src/textarea.js

module.exports = function () {
  const textarea = document.createElement('textarea');
  textarea.id = 'text1';
  return textarea;
};

Webpack 多环境多配置文件

开发环境(development)和生产环境(production)的构建目标差异很大。在开发环境中,我们需要具有强大的、具有实时重新加载(live reloading)或热模块替换(hot module replacement)能力的 source map 和 localhost server。而在生产环境中,我们的目标则转向于关注更小的 bundle,更轻量的 source map,以及更优化的资源,以改善加载时间。建议为每个环境编写彼此独立的 webpack 配置。

因为生产环境和开发环境的配置只有略微区别,所以将共用部分的配置作为一个通用配置。使用webpack-merge工具,将这些配置合并在一起。通过通用配置,我们不必在不同环境的配置中重复代码。

安装webpack-merge

npm i webpack-merge -D

示例

build/webpack.base.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  entry: {
    main: './src/main.js',
  },
  output: {
    path: path.join(__dirname, '../dist'),
    filename: 'main.js',
  },
  plugins: [new CleanWebpackPlugin(), new HtmlWebpackPlugin()],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: 'style-loader',
          },
          {
            loader: 'css-loader',
          },
        ],
      },
      {
        test: /\.txt$/,
        use: {
          loader: 'raw-loader',
        },
      },
      {
        test: require.resolve('../src/answer.js'),
        use: {
          loader: 'val-loader',
        },
      },
      {
        test: /\.(png|jpge|jpg|git|svg)$/,
        use: {
          loader: 'file-loader',
          options: {},
        },
      },
    ],
  },
};

webpack.dev.config.js

const webpack = require('webpack');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');

module.exports = merge(baseConfig, {
  mode: 'development',
  devtool: 'eval-source-map',
  devServer: {
    contentBase: false,
    hot: true,
    open: true,
  },
  plugins: [
    new webpack.NamedModulesPlugin(),
    new webpack.HotModuleReplacementPlugin(),
  ],
});

webpack.pro.config.js

const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.config');

module.exports = merge(baseConfig, {
  mode: 'production',
  devtool: false,
  plugins: [new UglifyJSPlugin()],
});

Tree Shaking

Tree Shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块系统中的静态结构特性,例如 import 和 export。这个术语和概念实际上是兴起于 ES2015 模块打包工具 rollup。

你可以将未引用代码(dead-code)理解为枯树叶,使用 Tree Shaking 「摇掉」枯树叶

webpack 的 mode 为 production 时,会自动开启 Tree Shaking

optimization.usedExports

boolean = true

负责标记「枯树叶」

optimization.minimizer

负责「摇掉」它们

示例

不使用 Tree Shaking

webpack.config.js

const path = require('path');
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
};

src/common.js

export const Button = () => {
  return document.createElement('button');

  console.log('dead-code');
};

export const Link = () => {
  return document.createElement('a');
};

export const Heading = (level) => {
  return document.createElement('h' + level);
};

src/main.js

import { Button } from './common';

document.body.append(Button());

从上图可以看出,webpack 会将那些未引用代码导出

使用 Tree Shaking

这里我们只配置 usedExports

webpack.config.js

const path = require('path');
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
  optimization: {
    usedExports: true,
  },
};

从上图可以看出,webpack 会将那些未引用代码标记以及不导出,这里我们就可以使用 minimizer,开启 webpack 压缩优化,将其「摇掉」

webpack.config.js

const path = require('path');
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
  optimization: {
    usedExports: true,
  },
};

从上图可以看出,webpack 会将那些未引用代码移除。

Webpack Tree Shaking 与 Babel

在老版本的 Babel,使用preset-env,会将 ESModule 转换为 CommonJs,这将导致 Tree Shaking 失效;不过在新版本的 Babel 将 ESModule 转换为 CommonJs 功能关闭了。

optimization.concatenateModules (合并模块)

webpack.config.js

const path = require('path');
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
  optimization: {
    usedExports: true,
    concatenateModules: true,
  },
};

src/main.js

import { Button } from './common';
import { Img } from './other';

document.body.append(Button());
document.body.append(Img());

从上图可以看出,webpack 会将多个模块合并到一个函数里

更多构建优化可以查看optimization

sideEffects

sideEffects 将文件标记为有无副作用

通常我们用于 NPM 包的开发

案例

src/common/button.js

export const Button = () => {
  return document.createElement('button');

  console.log('dead-code');
};

src/common/head.js

export const Heading = (level) => {
  return document.createElement('h' + level);
};

src/common/link.js

export const Link = () => {
  return document.createElement('a');
};

src/common/other.js

export const Img = () => {
  return document.createElement('img');
};

src/common/index.js

export { Button } from './button';
export { Img } from './other';
export { Link } from './link';
export { Heading } from './head';

src/main.js

import { Button, Img } from './common';

document.body.append(Button());
document.body.append(Img());

不开启sideEffects

webpack.config.js

const path = require('path');
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
  optimization: {},
};

从上图可以看出,webpack 会将那些所有的模块都导出

开启sideEffects

webpack.config.js

const path = require('path');
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
  optimization: {
    sideEffects: true,
  },
};

将项目代码标记为无副作用

package.json

{
  "name": "tree-shaking",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "webpack": "^4.44.0",
    "webpack-cli": "^3.3.12"
  },
  "sideEffects": false
}

从上图可以看出,webpack 会将那些未使用代码移除

sideEffects 注意点

任何导入的文件和原型链都会受到 tree shaking 的影响。这意味着,如果在项目中使用类似 css-loader 并导入 CSS 文件,则需要将其添加到 side effect 列表中,以免在生产模式中无意中将它删除

案例

src/extend.js

// 副作用代码
// 为 Number 的原型添加一个扩展方法
Number.prototype.pad = function (size) {
  // 将数字转为字符串 => '8'
  let result = this + '';
  // 在数字前补指定个数的 0 => '008'
  while (result.length < size) {
    result = '0' + result;
  }
  return result;
};

副作用代码

src/style.css

body {
  padding: 0;
}

src/main.js

import { Button, Img } from './common';
import './extend';
import './style.css';

document.body.append(Button());
document.body.append(Img());
console.log((1).pad(2));

从上图可以看出,webpack 将 style.css 和 extend.js 也移除了

解决

package.json

{
  "name": "tree-shaking",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "css-loader": "^3.6.0",
    "webpack": "^4.44.0",
    "webpack-cli": "^3.3.12"
  },
  "sideEffects": ["./src/extent.js", "*.css"]
}

从上图可以看出,webpack 不会将 style.css 和 extend.js 移除了

代码分离

代码分离是 webpack 中最引人注目的特性之一。此特性能够把代码分离到不同的 bundle 中,然后可以按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。

有三种常用的代码分离方法:

  • 入口起点:使用 entry 配置手动地分离代码。
  • 防止重复:使用 SplitChunksPlugin 去重和分离 chunk。
  • 动态导入:通过模块的内联函数调用来分离代码。

入口起点(entry points)

这是迄今为止最简单、最直观的分离代码的方式。不过,这种方式手动配置较多,并有一些陷阱,我们将会解决这些问题。先来看看如何从 main bundle 中分离另一个模块:

src/index.js

import _ from 'lodash';
import to from './to';

const aEl = document.createElement('a');
aEl.href = _.last(to);
aEl.text = _.last(to);
document.body.append(aEl);

src/other.js

import _ from 'lodash';
import to from './to';

const aEl = document.createElement('a');
aEl.href = _.first(to);
aEl.text = _.first(to);
document.body.append(aEl);

src/to.js

module.exports = ['index.html', 'other.html'];

webpack.config.js

const path = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'none',
  entry: {
    index: './src/index.js',
    other: './src/other.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HTMLWebpackPlugin({
      filename: 'index.html',
      chunks: ['index'],
    }),
    new HTMLWebpackPlugin({
      filename: 'other.html',
      chunks: ['other'],
    }),
  ],
};

构建生成文件

正如前面提到的,这种方法存在一些问题:

  • 如果入口 chunks 之间包含重复的模块,那些重复模块都会被引入到各个 bundle 中。
  • 这种方法不够灵活,并且不能将核心应用程序逻辑进行动态拆分代码。

以上两点中,第一点对我们的示例来说无疑是个问题,因为我们在 ./src/index.js./src/other.js 中都引入过lodashto.js,这样就在两个 bundle 中造成重复引用。接着,我们通过使用 SplitChunksPlugin 来移除重复的模块。

防止重复(Prevent Duplication)

SplitChunksPlugin 可以将公共的依赖模块提取到已有的入口 chunk 中,或者提取到一个新生成的 chunk。让我们使用这个插件,将之前的示例中重复的lodashto.js模块去除:

webpack.config.js

const path = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'none',
  entry: {
    index: './src/index.js',
    other: './src/other.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    splitChunks: { chunks: 'all' },
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HTMLWebpackPlugin({
      filename: 'index.html',
      chunks: ['index'],
    }),
    new HTMLWebpackPlugin({
      filename: 'other.html',
      chunks: ['other'],
    }),
  ],
};

构建生成文件

从上图可以看出,webpack 将公共部分抽取到一个单独的 js 文件里

动态导入(Dynamic Imports)

当涉及到动态代码拆分时,webpack 提供了两个类似的技术。对于动态导入,第一种,也是优先选择的方式是,使用符合 ECMAScript 提案 的 import() 语法。第二种,则是使用 webpack 特定的 require.ensure。

import() 调用会在内部用到 promises。如果在旧有版本浏览器中使用 import(),记得使用 一个 polyfill 库(例如 es6-promise 或 promise-polyfill),来 shim Promise。

现在,我们不再使用静态导入lodashto.js,而是通过使用动态导入来分离一个 chunk:

src/index.js

const render = () => {
  import(/* webpackChunkName: "lodash" */ 'lodash').then((_) => {
    import(/* webpackChunkName: "to" */ './to.js').then(({ default: to }) => {
      const hash = window.location.hash || _.first(to);
      const aEl = document.createElement('a');
      if (hash === _.first(to)) {
        aEl.href = _.last(to);
        aEl.text = _.last(to);
      } else {
        aEl.href = _.first(to);
        aEl.text = _.first(to);
      }
      document.body.innerHTML = '';
      document.body.append(aEl);
    });
  });
};
render();
window.addEventListener('hashchange', render);

在注释中使用了 webpackChunkName。这样做会导致我们的 bundle 被命名为 to.bundle.js ,而不是 [id].bundle.js 。使用同名会将其打包到同一个 js 文件下

src/to.js

module.exports = ['#index', '#other'];

webpack.config.js

const path = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'none',
  entry: {
    index: './src/index.js',
  },
  output: {
    filename: '[name].bundle.js',
    chunkFilename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  plugins: [new CleanWebpackPlugin(), new HTMLWebpackPlugin()],
};

构建生成文件

从上图可以看出,webpack 将动态导入文件抽取到一个单独的 js 文件里

Webpack 构建缓存机制-hash

hash

这边我们将长度指定为 8 位

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: '[name].[hash:8].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

hash 是跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的 hash 值都会更改,并且所有文件都共用相同的 hash 值

chunkhash

根据不同的入口文件(Entry)进行依赖文件解析、构建对应的 chunk,生成对应的哈希值。

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: '[name].[chunkhash:8].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

将样式作为模块 import 到 JavaScript 文件中的,生成的 chunkhash 是一致的

如果我们修改了 js 的内容,css 的打包名称也会改变

contenthash

ontenthash 是针对文件内容级别的,只有自己的文件内容变了,hash 值才会改变

构建目标(Target)

target
string | function(compiler)

告知 webpack 为目标(target)指定一个环境。

外部扩展(Externals)

externals 配置选项提供了「从输出的 bundle 中排除依赖」的方法。相反,所创建的 bundle 依赖于那些存在于用户环境(consumer's environment)中的依赖。此功能通常对 library 开发人员来说是最有用的,然而也会有各种各样的应用程序用到它。

用户(consumer),在这里是指,引用了「使用 webpack 打包的 library」的所有终端用户的应用程序(end user application)。

externals

string array object function regex

防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖(external dependencies)。

例如,从 CDN 引入 jQuery,而不是把它打包:

index.html

<script
  src="https://code.jquery.com/jquery-3.1.0.js"
  integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
  crossorigin="anonymous"
></script>

webpack.config.js

externals: {
  jquery: 'jQuery'
}

这样就剥离了那些不需要改动的依赖模块,换句话,下面展示的代码还可以正常运行:

import $ from 'jquery';

$('.my-element').animate(...);
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349