likes
comments
collection
share

Webpack 杂记

作者站长头像
站长
· 阅读数 12

前言

webpack 作为最热门的构建工具之一,相信你肯定有接触过。无论是自己从零开始搭建手脚还是使用类似 create-react-app 工具来创建脚手架,你都在使用 webpack。

webpack 搭建脚手架的大体流程如下:

  • 基础配置:资源的输入和输出等。
  • JavaScript 处理:babel 以及 react、vue 等相关的插件。
  • 样式处理:SCSS、CSS 在不同环境下的处理。
  • 静态资源处理:字体、图片的加载。
  • 优化:开发环境和生产环境的配置调整。

整个搭建过程中有很多细节内容,这些知识点相对都比较零散,本文将汇总介绍一些常见的知识点。

Webpack 知识点

1. CommonJS 和 ES6 Module 区别

  • CommonJS 与 ES6 Module 最本质的区别在于前者对模块依赖的解决是“动态的”,而后者是“静态的”。在这里“动态”的含义是,模块依赖关系的建立发生在代码运行阶段;而“静态”则表示模块依赖关系的建立发生在代码编译阶段。
  • 在导入一个模块时,对于 CommonJS 来说获取的是一份导出值的副本;而在 ES6 Module 中则是值的动态映射。
// calculator.js
var count = 0;
module.exports = {
    count: count,
    add: function(a, b) {
        count += 1;
        return a + b;
    }
};
   
// index.js
var count = require('./calculator.js').count;
var add = require('./calculator.js').add;
console.log(count); // 0(这里的count是calculator.js中count值的副本)
add(2, 3);
console.log(count); // 0(calculator.js中变量值的改变不会对这里的副本造成影响)
count += 1;
console.log(count); // 1(副本的值可以更改)
// calculator.js
let count = 0;
const add = function(a, b) {
    count += 1;
    return a + b;
};
export { count, add };
   
// index.js
import { count, add } from './calculator.js';
console.log(count); // 0(对 calculator.js 中 count 值的映射)
add(2, 3);
console.log(count); // 1(实时反映calculator.js 中 count值的变化)
   
// count += 1; // 不可更改,会抛出SyntaxError: "count" is read-only

2. UMD

// calculator.js
(function (global, main) {
    // 根据当前环境采取不同的导出方式
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(...);
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = ...;
    } else {
        // 非模块化环境
        global.add = ...;
    }
}(this, function () {
    // 定义模块主体
    return {...}
}));

需要注意的是,UMD 模块一般都最先判断 AMD 环境,也就是全局下是否有 define 函数,而通过 AMD 定义的模块是无法使用 CommonJS 或 ES6 Module 的形式正确引入的。在 Webpack 中,由于它同时支持 AMD 及 CommonJS,也许工程中的所有模块都是 CommonJS,而 UMD 标准却发现当前有 AMD 环境,并使用了 AMD 方式导出,这会使得模块导入时出错。当需要这样做时,我们可以更改 UMD 模块中判断的顺序,使其以 CommonJS 的形式导出。

3. Webpack 打包原理

需要打包的代码:

// index.js
const calculator = require('./calculator.js');
const sum = calculator.add(2, 3);
console.log('sum', sum);
   
// calculator.js
module.exports = {
    add: function(a, b) {
        return a + b;
    }
};

上面的代码经过 Webpack 打包后将会成为如下形式(为了易读性,这里只展示代码的大体结构):

// 立即执行匿名函数
(function(modules) {
  //模块缓存
  var installedModules = {};
  // 实现require
  function __webpack_require__(moduleId) {
      ...
  }
  // 执行入口模块的加载
  return __webpack_require__(__webpack_require__.s = 0);
})({
  // modules:以key-value的形式存储所有被打包的模块
  0: function(module, exports, __webpack_require__) {
      // 打包入口
      module.exports = __webpack_require__("3qiv");
  },
  "3qiv": function(module, exports, __webpack_require__) {
      // index.js内容
  },
  jkzz: function(module, exports) {
      // calculator.js 内容
  }
});

这是一个最简单的 Webpack 打包结果(bundle),分为以下几个部分:

  • installedModules 对象。每个模块只在第一次被加载的时候执行,之后其导出值就被存储到这个对象里面,当再次被加载的时候 webpack 会直接从这里取值,而不会重新执行该模块。
  • __webpack_require__函数。对模块加载的实现,在浏览器中可以通过调用__webpack_require__(module_id)来完成模块导入。
  • modules 对象。工程中所有产生了依赖关系的模块都会以 key-value 的形式放在这里。key 可以理解为一个模块的 id;value 则是由一个匿名函数包裹的模块实体,匿名函数的参数赋予了每个模块导出和导入的能力。

接下来让我们看看bundle是如何在浏览器中执行的:

  1. 在最外层匿名函数中初始化浏览器执行环境,包括定义 installedModules 对象、__webpack_require__ 函数等,为模块的加载和执行做一些准备工作。
  2. 加载入口模块。每个 bundle 都有且只有一个入口模块,在上面的示例中,index.js 是入口模块,在浏览器中会从它开始执行。
  3. 执行模块代码。如果执行到了 module.exports 则记录下模块的导出值;如果中间遇到 require 函数(准确地说是__webpack_require__),则会暂时交出执行权,进入 __webpack_require__ 函数体内进行加载其他模块的逻辑。
  4. 在 __webpack_require__ 中判断即将加载的模块是否存在于 installedModules 中。如果存在则直接取值,否则回到第 3 步,执行该模块的代码来获取导出值。
  5. 所有依赖的模块都已执行完毕,最后执行权又回到入口模块。当入口模块的代码执行完毕,也就意味着整个 bundle运行结束。

不难看出,第 3 步和第 4 步是一个递归的过程。Webpack 为每个模块创造了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序与模块加载的顺序是完全一致的,这也是 Webpack 模块打包的奥秘。

4. entry

传入一个数组的作用是将多个资源预先合并,这样 Webpack 在打包时会将数组中的最后一个元素作为实际的入口路径。如:

module.exports = {
    entry: ['babel-polyfill', './src/index.js'] ,
};

等同于

// webpack.config.js
module.exports = {
    entry: './src/index.js',
};
   
// index.js
import 'babel-polyfill';

5. PublicPath

publicPath 是一个非常重要的配置项,并且容易与 path 混淆。从功能上来说,path 用来指定资源的输出位置,publicPath 则用来指定资源的请求位置。

请求位置:由 JS 或 CSS 所请求的间接资源路径。页面中的资源分为两种,一种是由 HTML 页面直接请求的,比如通过 script 标签加载的 JS;另一种是由 JS 或 CSS 来发起请求的间接资源,如图片、字体等(也包括异步加载的JS)。publicPath 的作用就是指定这部分间接资源的请求位置。

// 假设当前 HTML 地址为 https://example.com/app/index.html
// 异步加载的资源名为 0.chunk.js
publicPath: "" // 实际路径 https://example.com/app/0.chunk.js
publicPath: "./js" // 实际路径 https://example.com/app/js/0.chunk.js
publicPath: "../assets/" // 实际路径 https://example.com/aseets/0.chunk.js
// 假设当前 HTML 地址为https://example.com/app/index.html
// 异步加载的资源名为0.chunk.js
publicPath: "/" // 实际路径 https://example.com/0.chunk.js
publicPath: "/js/" // 实际路径 https://example.com/js/0.chunk.js
publicPath: "/dist/" // 实际路径 https://example.com/dist/0.chunk.js
// 假设当前页面路径为 https://example.com/app/index.html
// 异步加载的资源名为 0.chunk.js
publicPath: "http://cdn.com/" // 实际路径 http://cdn.com/0.chunk.js
publicPath: "https://cdn.com/" // 实际路径 https://cdn.com/0.chunk.js
publicPath: "//cdn.com/assets/" // 实际路径 //cdn.com/assets/0.chunk.js

6. css-loader style-loader

css-loader 的作用仅仅是处理 CSS 的各种加载语法(@import 和 url() 函数等),如果要使样式起作用还需要 style-loader 来把样式插入页面。css-loader 与 style-loader 通常是配合在一起使用的。

一般来说,在生产环境下,我们希望样式存在于 CSS 文件中而不是 style 标签中,因为文件更有利于客户端进行缓存,我们可以使用 mini-css-extract-plugin 提取样式到 CSS 文件。

// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = () => {
  return {
    // ...
    module: {
      rules: [
        // ...
        {
          test: /\.less$/i,
          use: [
            isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
            'css-loader',
          ],
        },
      ],
    },
    plugins: [
      // ...
      isProduction ? new MiniCssExtractPlugin({
        filename: '[name].css',
        chunkFilename: '[name].chunk.css',
      }) : null
    ].filter(Boolean)
  };
};

7. file-loader, asset/resource, url-loader, asset/inline

file-loader 用于打包文件类型的资源。

module: {
    rules: [
        {
            test: /\.(png|jpg|gif)$/,
            use: 'file-loader',
        }
    ],
},

注意,Webpack 5 也提供了另一种方式(asset/resource)来处理文件类型资源,可以用来替代 file-loader。并且这种方式是内置的,使用起来更加便捷:

module: {
    rules: [
        {
            test: /.(png|jpg|gif)$/,
            type: 'asset/resource'
        }
    ]
}

url-loader 的作用与 file-loader 类似,唯一的不同在于,url-loader 允许用户设置一个文件大小的阈值,当大于该阈值时它会与 file-loader 一样返回 publicPath,而小于该阈值时则返回 base64 形式的编码。

Webpack 5也提供了内置的解决方案,可以替代url-loader来处理inline类型的资源。

module: {
    rules: [
        {
            test: /.svg$/,
            type: 'asset/inline'
        }
    ]
}

8. CSS Modules

CSS 模块化方案,特点有:

  • 每个 CSS 文件中的样式都拥有单独的作用域,不会和外界发生命名冲突。
  • 对 CSS 进行依赖管理,可以通过相对路径引入 CSS 文件。
  • 使用 CSS Modules 时不需要额外安装模块,只要开启 css-loader 中的 modules 配置项即可。
module: {
    rules: [
        {
            test: /.css/,
            use: [
                'style-loader',
                {
                    loader: 'css-loader',
                    options: {
                        modules: true,
                        localIdentName: '[name]__[local]__[hash:base64:5]',
                    },
                }
            ],
        }
    ],
},

9. CommonsChunkPlugin

CommonsChunkPlugin 是 Webpack 4之前内部自带的插件(Webpack 4之后替换为SplitChunks)。它可以将多个Chunk中公共的部分提取出来。

一般用于提取多入口之间的公共模块和提取第三方类库及业务中不常更新的模块。

提取多入口之间的公共模块:

const webpack = require('webpack');
module.exports = {
    entry: {
        foo: './foo.js',
        bar: './bar.js',
    },
    output: {
        filename: '[name].js',
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'commons', // name:用于指定公共chunk的名字。
            filename: 'commons.js', // filename:提取后的资源文件名。
        })
    ],
};

提取第三方类库及业务中不常更新的模块:

// webpack.config.js
const webpack = require('webpack');
module.exports = {
    entry: {
        app: './app.js',
        vendor: ['react'],
    },
    output: {
        filename: '[name].js',
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            filename: 'vendor.js',
        })
    ],
};

不足之处:

  • 一个 CommonsChunkPlugin 只能提取一个 vendor,假如我们想提取多个 vendor 则需要配置多个插件,这会增加很多重复的配置代码。
  • 由于内部设计上的一些缺陷,CommonsChunkPlugin 在提取公共模块的时候会破坏掉原有 Chunk 中模块的依赖关系,导致难以进行更多的优化。比如在异步 Chunk 的场景下 CommonsChunkPlugin 并不会按照我们的预期正常工作。

10. optimization.SplitChunks

optimization.SplitChunks(简称SplitChunks)是 Webpack 4 为了改进 CommonsChunkPlugin 而重新设计和实现的代码分片特性。它不仅比CommonsChunkPlugin 功能更加强大,还更简单易用。

使用细节可以阅读这篇文章 webpack5 SplitChunksPlugin 实用指南

11. 资源异步加载

在 webpack 中有两种异步加载的方式—— import 函数和 require.ensure。require.ensure 是 Webpack 1 支持的异步加载方式,import 函数从 Webpack 2 开始引入,并得到官方推荐,因此我们这里只介绍import函数。

// webpack.config.js
module.exports = {
    entry: {
        foo: './foo.js',
    },
    output: {
        publicPath: '/dist/',
        filename: '[name].js',
        chunkFilename: '[name].js',
    },
    mode: 'development',
};
   
// foo.js
import(/* webpackChunkName: "bar" */ './bar.js').then(({ add }) => {
    console.log(add(2, 3));
});

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

12. 环境变量

通常我们需要为生产环境和本地环境添加不同的环境变量,在 Webpack 中可以使用 DefinePlugin 进行设置。请看下面的例子:

// webpack.config.js
const webpack = require('webpack');
module.exports = {
    entry: './app.js',
    output: {
        filename: 'bundle.js',
    },
    mode: 'production',
    plugins: [
        new webpack.DefinePlugin({
          ENV: JSON.stringify('production'),
          IS_PRODUCTION: true,
          ENV_ID: 130912098,
          CONSTANTS: JSON.stringify({
              TYPES: ['foo', 'bar']
          })
      })
    ],
};
   
// app.js
document.write(ENV);

如果启用了 mode: production,则Webapck已经设置好了 process.env.NODE_ENV,不需要再人为添加了。

13. source-map

source-map 指的是将编译、打包、压缩后的代码映射回源代码的过程。经过 Webpack 打包压缩后的代码基本上已经不具备可读性,此时若代码抛出了一个错误,要想回溯它的调用栈是非常困难的。而有了 source-map,再加上浏览器调试工具,要做到这一点就非常容易。source-map 对于线上问题的追查也有一定帮助。

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

当我们打开浏览器的开发者工具时,map 文件会同时被加载,这时浏览器会使用它来对打包后的 bundle 文件进行解析,分析出源代码的目录结构和内容。map 文件有时会很大,但是不用担心,只要不打开开发者工具,浏览器是不会加载这些文件的,因此对于普通用户来说并没有影响。

Webpack 支持多种 source-map 的形式。除了配置为 devtool: 'source-map' 以外,还可以根据不同的需求选择 cheap-source-map、eval-source-map 等。通常它们都是source-map的一些简略版本,因为生成完整的 source-map 会延长整体构建时间,如果对打包速度要求比较高,建议选择一个简化版的 source-map。比如,在开发环境中,cheap-module-eval-source-map 通常是一个不错的选择,属于打包速度和源码信息还原程度的一个良好折中。

14. 资源压缩

常用的压缩 JavaScript 文件的工具有两个,一个是 UglifyJS(Webpack 3 已集成),另一个是 terser(Webpack 4 已集成)。后者由于支持 ES6+ 代码的压缩,更加面向于未来,因此官方在 Webpack 4 中默认使用了 terser 的插件 terser-webpack-plugin。

const webpack = require('webpack');
module.exports = {
    entry: './app.js',
    output: {
        filename: 'bundle.js',
    },
    plugins: [new webpack.optimize.UglifyJsPlugin()],
};


module.exports = {
    entry: './app.js',
    output: {
        filename: 'bundle.js',
    },
    optimization: {
        minimize: true, // 如果开启了mode: production,则不需要人为设置
    },
};

15. bundle 体积监控和分析

VS Code 中有一个插件 Import Cost 可以帮助我们对引入模块的大小进行实时监测。每当我们在代码中引入一个新的模块(主要是 node_modules 中的模块)时,它都会为我们计算该模块压缩后及 gzip 过后将占多大体积。

Webpack 杂记

Webpack 杂记   另外一个很有用的工具是 webpack-bundle-analyzer,它能够帮助我们分析一个 bundle的构成。使用方法也很简单,只要将其添加进 plugins 配置即可。

Webpack 杂记

16. HappyPack

HappyPack 是一个通过多线程来提升 Webpack 打包速度的工具。

const HappyPack = require('happypack');
module.exports = {
  //...
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        loader: 'happypack/loader?id=js',
      },
      {
        test: /.ts$/,
        exclude: /node_modules/,
        loader: '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
      }],
    })
  ]
};

17. 缩小打包作用域

对于有些库,我们希望 Webpack 完全不要去进行解析,即不希望应用任何 loader 规则,库的内部也不会有对其他模块的依赖,那么这时可以使用 noParse 实现。请看下面的例子:

module.exports = {
  //...
  module: {
    noParse: /lodash/,
  }
};

IgnorePlugin 对于排除一些库相关文件非常有用。对于一些由库产生的额外资源,我们其实并不会用到但又无法去掉,因为引用的语句处于库文件的内部。比如,Moment.js 是一个日期时间处理相关的库,为了做本地化它会加载很多语言包,占很大的体积,但我们一般用不到其他地区的语言包,这时就可以用 gnorePlugin 来去掉。

plugins: [
  new webpack.IgnorePlugin({
    resourceRegExp: /^./locale$/, // 匹配资源文件
    contextRegExp: /moment$/, // 匹配检索目录
  })
],

18. 去除死代码

去除死代码只能对 ES6 Module 生效。有时我们会发现虽然只引用了某个库中的一个接口,却把整个库加载进来了,而 bundle 的体积并没有因为去除死代码而减小。这可能是由于该库是使用 CommonJS 的形式导出的,为了获得更好的兼容性,目前大部分的 npm 包还在使用 CommonJS 的形式。

也有一些 npm 包同时提供了 ES6 Module 和 CommonJS 两种形式,我们应该尽可能使用 ES6 Module 形式的模块,这样去除死代码的效率更高。

如果我们在工程中使用了 babel-loader,那么可以通过配置来禁用它的模块依赖解析。因为如果由 babel-loader 来做依赖解析,Webpack 接收到的就都是转化过的 CommonJS 形式的模块,无法对死代码进行去除。禁用 babel-loader 模块依赖解析的配置示例如下:

odule.exports = {
  // ...
  module: {
    rules: [{
      test: /.js$/,
      exclude: /node_modules/,
      use: [{
        loader: 'babel-loader',
        options: {
          presets: [
            // 加上 modules: false
            [@babel/preset-env, { modules: false }]
          ],
        },
      }],
    }],
  },
};

Webpack 提供的去除死代码的功能本身只是为死代码添加标记,真正去除死代码是通过压缩工具来进行的,使用我们前面介绍的 terser-webpack-plugin 即可。在 Webpack 4 之后的版本中,将 mode 设置为 production 也可以达到相同的效果。

小结

webpack 的知识点很多,我们平时可能直接使用社区脚手架进行开发,但是了解如何基于 webpack 搭建一个脚手架是每个前端开发的基本功。

本文内容参考 《Webpack实战:入门、进阶与调优 第二版》,有兴趣的可以花点时间去阅读。