Skip to content

webpack的生产环境配置

引言

在前面的我们已经了解了足够多的 Webpack 使用方法,但到了生产环境(或者称为线上环境)中,资源打包将会遇到许多新的问题。在生产环境中我们关注的是如何让用户更快地加载资源,涉及如何压缩资源、如何添加环境变量优化打包、如何最大限度地利用缓存等。

环境配置的封装

生产环境的配置与开发环境有所不同,比如要设置 mode、环境变量,为文件名添加 chunk hash 作为版本号等。如何让 Webpack 可以按照不同环境采用不同的配置呢?一般来说有以下两种方式。

使用相同的配置文件。

比如令 Webpack 不管在什么环境下打包都使用 webpack.config.js,只是在构建开始前将当前所属环境作为一个变量传进去,然后在 webpack.config.js 中通过各种判断条件来决定具体使用哪个配置。

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

上面的例子中,我们通过 npm 脚本命令中传入了一个 ENV 环境变量,webpack.config.js 则根据它的值来确定具体采用什么配置。

为不同环境创建各自的配置文件。

比如,我们可以单独创建一个 webpack.production.config.js,开发环境的则可以叫 webpack.development.config.js。然后修改 package.json。

json
{
  ...
  "scripts": {
    "dev": " webpack-dev-server --config=webpack.development.config.js",
    "build": " webpack --config=webpack.production.config.js"
  },
}

上面我们通过--config 指定打包时使用的配置文件。但这种方法存在一个问题,即 webpack.development.config.js 和 webpack.production.config.js 肯定会有重复的部分,一改都要改,不利于维护。“在这种情况下,可以将公共的配置提取出来,比如我们单独创建一个 webpack.common.config.js。

js
module.exports = {
  entry: "./src/index.js",
  // development 和 production共有配置
};

然后让另外两个 JS 分别引用该文件,并添加上自身环境的配置即可。

开启 production 模式

在早期的 Webpack 版本中,开发者有时会抱怨,不同环境所使用的配置项太多,管理起来复杂。以至于 Webpack 4 中直接加了一个 mode 配置项,让开发者可以通过它来直接切换打包模式。如:

js
// webpack.config.js
module.exports = {
  mode: "production",
};

这意味着当前处于生产环境模式,Webpack 会自动添加许多适用于生产环境的配置项,减少了人为手动的工作。 Webpack 这样做其实是希望隐藏许多具体配置的细节,而将其转化为更具有语义性、更简洁的配置提供出来。

环境变量

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

js
// 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"),
    }),
  ],
};

// app.js
document.write(ENV);

上面的配置通过 DefinePlugin 设置了 ENV 环境变量,最终页面上输出的将会是字符串 production。 除了字符串类型的值以外,我们也可以设置其他类型的环境变量。

js
new webpack.DefinePlugin({
  ENV: JSON.stringify("production"),
  IS_PRODUCTION: true,
  ENV_ID: 130912098,
  CONSTANTS: JSON.stringify({
    TYPES: ["foo", "bar"],
  }),
});

注意: 我们在一些值的外面加上了 JSON.stringify,这是因为 DefinePlugin 在替换环境变量时对于字符串类型的值进行的是完全替换。假如不添加 JSON.stringify 的话,在替换后就会成为变量名,而非字符串值。因此对于字符串环境变量及包含字符串的对象都要加上 JSON.stringify 才行。

source map

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

原理

Webpack 对于工程源代码的每一步处理都有可能会改变代码的位置、结构,甚至是所处文件,因此每一步都需要生成对应的 source map。若我们启用了 devtool 配置项,source map 就会跟随源代码一步步被传递,直到生成最后的 map 文件。这个文件默认就是打包后的文件名加上.map,如 bundle.js.map。

在生成 mapping 文件的同时,bundle 文件中会追加上一句注释来标识 map 文件的位置。如:

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

当我们打开了浏览器的开发者工具时,map 文件会同时被加载,这时浏览器会使用它来对打包后的 bundle 文件进行解析,分析出源代码的目录结构和内容。

map 文件有时会很大,但是不用担心,只要不打开开发者工具,浏览器是不会加载这些文件的,因此对于普通用户来说并没有影响。但是使用 source map 会有一定的安全隐患,即任何人都可以通过 dev tools 看到工程源码。

source map 配置

JavaScript 的 source map 的配置很简单,只要在 webpack.config.js 中添加 devtool 即可。

js
module.exports = {
  // ...
  devtool: "source-map",
};

对于 CSS、SCSS、Less 来说,则需要添加额外的 source map 配置项。

开启 source map 之后,打开 Chrome 的开发者工具,在“Sources”选项卡下面的“webpack://”目录中可以找到解析后的工程源码。

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

在生产环境中由于我们会对代码进行压缩,而最常见的压缩插件 UglifyjsWebpack-Plugin 目前只支持完全的 source-map,因此没有那么多选择,我们只能使用 source-map、hidden-source-map、nosources-source-map 这 3 者之一。

安全

source map 不仅可以帮助开发者调试源码,当线上有问题产生时也有助于查看调用栈信息,是线上查错十分重要的线索。同时,有了 source map 也就意味着任何人通过浏览器的开发者工具都可以看到工程源码,对于安全性来说也是极大的隐患。那么如何才能在保持其功能的同时,防止暴露源码给用户呢?Webpack 提供了 hidden-source-map 及 nosources-source-map 两种策略来提升 source map 的安全性。

hidden-source-map 意味着 Webpack 仍然会产出完整的 map 文件,只不过不会在 bundle 文件中添加对于 map 文件的引用。这样一来,当打开浏览器的开发者工具时,我们是看不到 map 文件的,浏览器自然也无法对 bundle 进行解析。如果我们想要追溯源码,则要利用一些第三方服务,将 map 文件上传到那上面。目前最流行的解决方案是 Sentry。

Sentry 是一个错误跟踪平台,开发者接入后可以进行错误的收集和聚类,以便于更好地发现和解决线上问题。Sentry 支持 JavaScript 的 source map,我们可以通过它所提供的命令行工具或者 Webpack 插件来自动上传 map 文件。同时我们还要在工程代码中添加 Sentry 对应的工具包,每当 JavaScript 执行出错时就会上报给 Sentry。Sentry 在接收到错误后,就会去找对应的 map 文件进行源码解析,并给出源码中的错误栈。

另一种配置是 nosources-source-map,它对于安全性的保护则没那么强,但是使用方式相对简单。打包部署之后,我们可以在浏览器开发者工具的 Sources 选项卡中看到源码的目录结构,但是文件的具体内容会被隐藏起来。对于错误来说,我们仍然可以在 Console 控制台中查看源代码的错误栈,或者 console 日志的准确行数。它对于追溯错误来说基本足够,并且其安全性相对于可以看到整个源码的 source-map 配置来说要略高一些。

在所有这些配置之外还有一种选择,就是我们可以正常打包出 source map,然后通过服务器的 nginx 设置(或其他类似工具)将.map 文件只对固定的白名单(比如公司内网)开放,这样我们仍然能看到源码,而在一般用户的浏览器中就无法获取到它们了。

资源压缩

在将资源发布到线上环境前,我们通常都会进行代码压缩,或者叫 uglify,意思是移除多余的空格、换行及执行不到的代码,缩短变量名,在执行结果不变的前提下将代码替换为更短的形式。一般正常的代码在 uglify 之后整体体积都将会显著缩小。同时,uglify 之后的代码将基本上不可读,在一定程度上提升了代码的安全性。

缓存

缓存是指重复利用浏览器已经获取过的资源。合理地使用缓存是提升客户端性能的一个关键因素。具体的缓存策略(如指定缓存时间等)由服务器来决定,浏览器会在资源过期前一直使用本地缓存进行响应。

这同时也带来一个问题,假如开发者想要对代码进行了一个 bug fix,并希望立即更新到所有用户的浏览器上,而不要让他们使用旧的缓存资源应该怎么做?此时最好的办法是更改资源的 URL,这样可迫使所有客户端都去下载最新的资源。

资源 hash

一个常用的方法是在每次打包的过程中对资源的内容计算一次 hash,并作为版本号存放在文件名中,如bundle@2e0a691e769edb228e2.js。bundle 是文件本身的名字,@后面跟的则是文件内容 hash 值,每当代码发生变化时相应的 hash 也会变化。

输出动态 HTML

接下来我们面临的问题是,资源名的改变也就意味着 HTML 中的引用路径的改变。每次更改后都要手动地去维护它是很困难的,理想的情况是在打包结束后自动把最新的资源名同步过去。使用 html-webpack-plugin 可以帮我们做到这一点。

js
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
  // ...
  plugins: [new HtmlWebpackPlugin()],
};

打包结果中多出了一个 index.html, 我们来看一下 index.html 的内容:

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Webpack App</title>
  </head>
  <body>
    <script
      type="text/javascript"
      src="bundle@2e0a691e769edbd228e2.js"
    ></script>
  </body>
</html>

html-webpack-plugin 会自动地将我们打包出来的资源名放入生成的 index.html 中,这样我们就不必手动地更新资源 URL 了。

现在我们看到的是 html-webpack-plugin 凭空创建了一个 index.html,但现实情况中我们一般需要在 HTML 中放入很多个性化的内容,这时我们可以传入一个已有的 HTML 模板。请看下面的例子:

js
<!DOCTYPE html>
<!-- template.html -->
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>Custom Title</title>
  </head>
  <body>
    <div id="app">app</div>
    <p>text content</p>
  </body>
</html>

// webpack.config.js
new HtmlWebpackPlugin({ template: './template.html', })

通过以上配置我们打包出来的 index.html 结果如下:

html
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>Custom Title</title>
  </head>
  <body>
    <div id="app">app</div>
    <p>text content</p>
    <script
      type="text/javascript"
      src="bundle@2e0a691e769edbd228e2.js"
    ></script>
  </body>
</html>

使 chunk id 更稳定

理想状态下,对于缓存的应用是尽量让用户在启动时只更新代码变化的部分,而对没有变化的部分使用缓存。 我们之前介绍过使用 CommonsChunkPlugin 和 SplitChunksPlugin 来划分代码。通过它们来尽可能地将一些不常变动的代码单独提取出来,与经常迭代的业务代码区别开,这些资源就可以在客户端一直使用缓存。

bundle 体积监控和分析

为了保证良好的用户体验,我们可以对打包输出的 bundle 体积进行持续的监控,以防止不必要的冗余模块被添加进来。

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

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

js
const Analyzer = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
module.exports = {
  // ...
  plugins: [new Analyzer()],
};

它可以帮我们生成一张 bundle 的模块组成结构图,每个模块所占的体积一目了然。

最后我们还需要自动化地对资源体积进行监控,bundlesize 这个工具包可以帮助做到这一点。安装之后只需要在 package.json 进行一下配置即可。

json
{
  "name": "my-app",
  "version": "1.0.0",
  "bundlesize": [
    {
      "path": "./bundle.js",
      "maxSize": "50 kB"
    }
  ],
  "scripts": {
    "test:size": "bundlesize"
  }
}

通过 npm 脚本可以执行 bundlesize 命令,它会根据我们配置的资源路径和最大体积验证最终的 bundle 是否超限。我们也可以将其作为自动化测试的一部分,来保证输出的资源如果超限了不会在不知情的情况下就被发布出去。

小结

开发环境中我们可能关注的是打包速度,而在生产环境中我们关注的则是输出的资源体积以及如何优化客户端缓存来缩短页面渲染时间。我们介绍了设置生产环境变量、压缩代码、监控资源体积等方法。缓存的控制主要依赖于从chunk内容生成hash作为版本号,并添加到资源文件名中,使资源更新后可以立即被客户端获取到。

source map对于追溯线上问题十分重要,但也存在安全性隐患。通过一些特殊的source map配置以及第三方服务,我们可以兼顾两者。

Webpack 4提供了“mode:'production'”配置项,通过它可以节省很多生产环境下的特定代码,让配置文件更加简洁。

备案号:闽ICP备2024028309号-1