Webpack学习之基本使用

346 阅读18分钟

Webpack 打包

前端模块化开发不仅仅是指 js 的模块化开发,它是 html、JavaScript、css、其它静态资源的模块化,在开发阶段它们是零散分布的,而且会使用到很多的 es6 新的特性,HTML 模板语法,CSS 的预处理器,svg,font 等等资源,这些都需要通过一个工具将它们整合起来,这就是打包工具的由来。

打包工具有很多,我们主要学习一下 webpack,它也是目前主流的打包工具之一。

webpack 的使用

基本配置

webpack 工作需要一个配置文件,当然最新的 webpack 将基础配置内置,提供接口供外部自定义一些配置,我们还是从基础配置学习,下面有一个项目目录:

webpack-test
├─ dist
│  └─ bundle.js
├─ src
│  ├─ createElement.js
│  └─ main.js
├─ index.html
├─ package.json
├─ webpack.config.js
└─ yarn.lock

webpack.config.js 就是我们 webpack 的工作配置文件:

module.exports = {
  mode: "development",
  entry: "./src/main.js",
  output: {
    filename: "bundle.js",
    path: path.join(__dirname, "dist"),
  },
};
  • mode 可以配置我们当前的工作环境,这个可以通过 process.env.NODE_ENV 确认
  • entry 是我们项目运行的入口文件,可以配置多个,它可以是一个对象,也可以是一个数组
  • output 是打包后的资源输出,output.path 必须是绝对路径

以上三个简单的配置就可以把以上项目的 src 打包成 bundle.js。

打包结果的运行原理

webpack 打包过程其实就是将所有类型的文件编译成js文件,并合并到一个文件中(前提是我们未做其它分离配置),并分析它们相互引用依赖的关系,通过 webpack 自己的实现逻辑将它们关联起来,通过这些引用依赖关系使原先的代码能够正常执行。

其实就是递归查找引用,形成引用关系树,再根据这个引用关系树重新组织代码,最终输出的代码必定是能够正常运行的。

webpack 加载资源的方式

  • 遵循 ES Modules 标准的 import 声明

  • 遵循 CommonJS 标准的 require 函数

  • 遵循 AMD 标准的 define 函数和 require 函数

  • 样式代码中的 @import 指令和 url 函数

  • HTML 代码中图片标签的 src 属性

webpack 资源加载器 Loader

webpack 默认会提供 js 文件的 Loader,而其它文件的 Loader 需要借助于其它插件来实现,比如 CSS 文件可以借助于 css-loader、 style-loader:

yarn add css-loader style-loader -D
// webpack.config.js

module.exports = {
  mode: "none",
  entry: "./src/index.css",
  output: {
    filename: "bundle.js",
    path: path.join(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};

module 就是配置资源加载的地方,rules 就是配置不同类型文件的加载方式,use 中的插件是从下向上(从右往左)执行的方式,其中 css-loader 就是解析我们 css 文件的解析器,style-loader 将 css-loader 的解析结果以 style 标签的形式插入到文档中:

<html lang="en">
  <head>
    ...
    <style>
      body {
        padding: 0;
        margin: 0;
        background-color: #fafafa;
        color: blueviolet;
      }
    </style>
  </head>
  <body>
    <script src="./dist/bundle.js"></script>
  </body>
</html>

以上是将 css 文件作为入口文件了,这是不合理的,我们的入口文件只能是 main 或者 index 命名的 js 文件,那么引用 css 文件就可以在 js 中导入:

// main.js

import "./index.css";

import "./title.css";

import { CreaterElement } from "./createElement";

const p = CreaterElement("p", "hello my boy.", ["title", "active"]);

document.body.appendChild(p);
// createElement.js

export const CreaterElement = (tagName, content, classes) => {
  const tag = document.createElement(tagName);
  tag.innerHTML = content;
  tag.classList.add(...classes);
  return tag;
};

此时 css-loader 就会分析代码中的 @importurl() 进行处理,将引用的 css 代码解析出来然后通过 style-loader 插入到 html 模板中,注意 style-loader 建议在 dev 模式下使用,生产环境建议剥离css代码至单独文件,和其它文件并行加载。

在加载资源的时候往往会考虑到浏览器的兼容情况,我们一般会通过配置 browserslistrc 来限制编译目标浏览器版本,此时 postcss-loader 或者 babel-laoder 等插件就会根据这个配置做相应的解析处理。

// .browserslistrc

> %1  // 市场上占有率大于1%的浏览器
last 2 version // 最新的两个版本
not dead // 未被淘汰的浏览器(长期没有新版本发布的浏览器就会被认为被淘汰的)

下面我们了解一些常用的资源加载器:

1. postcss-loader

在 css-loader 之前可以对 css 代码进行一些兼容性处理,它会根据 browserslistrc 的配置将 css 代码处理成目标浏览器版本能够兼容的代码:

yarn add postcss-loader postcss postcss-loader style-loader css-loader -D
// webpack.config.js

{
  test: /.css$/,
  use: ['style-loader', 'css-loader', 'postcss-loader']
}
// postcss.config.js

module.exports = {
  plugins: [
    [
      "postcss-preset-env",
      {
        // Options
      },
    ],
  ],
};
// .browserslistrc

> 0.1%
last 2 version
not dead

postcss-preset-env 包含了很多预设的插件,比如 Autoprefixer:

.title {
  color: #43322278;
  transform: translateX(40px);
  user-select: none;
}

最终编译结果:

<style>
  .title {
    color: rgba(67, 50, 34, 0.47059);
    -webkit-transform: translateX(40px);
    transform: translateX(40px);
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
  }
</style>

postcss-loader 自身没有 css 处理的能力,它需要借助于其它插件来实现一些功能,另外 postcss-loader 在解析 css 时遇见 @import 时并不会处理目标 css 文件,所以需要通过额外的配置来处理此类引用:

use: [
  ...
  {
    loader: 'css-loader',
    options: {
      importLoaders: 1 // 当碰见@import时后退一个Loader执行,这里即回到postcss-laoder解析目标文件(数字即表示回退几个Loader)
    }
  }
  'postcss-loader'
]

2. file-loader

每个资源都会有它自己的加载器,这里以图片为例,对于此类文件可以使用 file-loader 进行加载:

yarn add file-loader -D
// webpack.config.js

{
  test: /.(png|svg|jpe?g|gif)$/,
  use: ['file-loader']
}

// main.js
import shop from './assets/images/shop.svg'
import school from './assets/images/school.jpeg'
import hospital from './assets/images/hospital.png'

const images = [shop, school, hospital]

images.forEach(src => {
  const imageEL = new Image()
  imageEL.src = src
  document.body.appendChild(imageEL)
})

file-loader 会将引用的文件解析成一个路径,在使用时 src 就指向了这个文件的路径,生成的文件的文件名就是文件内容的 MD5 哈希值并会保留所引用资源的原始扩展名:

// build.js

(function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (__webpack_require__.p + \"3f7c21d8d0a29e1d82b84f7a4d61eb40.png\");\n\n//# sourceURL=webpack://webpack5-entry/./src/images/school.png?");
}),

这里需要注意的是 webpack5 在使用 require 导入文件时,由上可以看出文件会被解析为一个 ESModule 的默认导出,此时文件路径就会显示成一个对象,导致 require 无法加载,import ... from '...' 本来就是默认导入,无需处理:

// index.js

const images = ["girl.svg", "school.png", "student.png"];

images.forEach((src) => {
  const imageEL = new Image();
  imageEL.src = require(`./images/${src}`);
  document.body.appendChild(imageEL);
});
<img src="[object Module]" />
<img src="[object Module]" />
<img src="[object Module]" />

所以这里的 src 需要写成 require('./images/${src}').default 才能正常加载文件资源,那么我们不可能每次使用 require 加载文件都加上 default,此时就可以通过配置 file-loader 来进行解析处理:

// webpack.config.js

{
  test: /.(png|svg|jpe?g|gif)$/,
  use: [{
    loader: 'file-loader',
    options: {
      esModule: false
    }
  }]
}

此时图片就不会被解析成 ESModule 了,我们就可以通过 require 正常加载文件:

// build.js

(function(module, __unused_webpack_exports, __webpack_require__) {
eval("module.exports = __webpack_require__.p + \"3f7c21d8d0a29e1d82b84f7a4d61eb40.png\";\n\n//# sourceURL=webpack://webpack5-entry/./src/images/school.png?");
}),

同理,css 文件中通过 url('...') 加载图片,url 会被解析成 require,此时如果图片被解析成 ESModule 的话我们就无法进行加载了(不可能在 url() 后面加上 default 的),所以在 css-loader 的过程中我们要阻止将 require 的文件解析成一个 ESModule:

// webpack.config.js

{
  test: /.css$/,
  use: [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        esModule: false
      }
    },
    'postcss-loader'
  ]
}

在解析文件时默认会根据文件内容生成一串 hash 字符串作为文件名称,此时我们可以通过配置来设置文件名称,保持原有的名称和扩展名,同时添加 hash 避免缓存导致文件更新失效,并添加路径将同一类型文件打包至同一目录:

{
  test: /\.(png|gif|jpe?g|svg)$/,
  use: [{
    loader: 'file-loader',
    options: {
      esModule: false,
      name: 'image/[name].[hash:5].[ext]'
    }
  }]
}

3. url-loader

Data URLs 另一种加载文件的方式,即前缀为 data: 协议的 URL,其允许内容创建者向文档中嵌入小文件 data: [<mediatype>][;base64], <data>

  • data 是表示协议
  • [<mediatype>] 是个 MIME 类型的字符串,例如 "image/jpeg" 表示 JPEG 图像文件。如果被省略,则默认值为 text/plain;charset=US-ASCII
  • [;base64] 如果非文本则为可选的 base64 标记
  • <data> 文件内容

例如:data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D

这种方式只适合加载较小的文件,因为大文件转码后的长度会超过限制,我们可以通过配置来规定那些小体积的文件使用 url-loader 加载:

yarn add url-loader -D
// webpack.config.js

{
  test: /.png|.svg|.jpeg$/,
  use: {
    loader: 'url-loader',
    options: {
      limit: 10 * 1024 // 10kb
    }
  }
}

如上所示,低于 10kb 的文件使用 url-loader 加载,在这里我们不能卸载 file-loader,因为大于 10kb 的文件还是需要使用它加载的。

4. asset 处理图片

资源模块(asset module)是一种模块类型,它允许使用资源文件(字体,图标等)而无需配置额外 loader。

在 webpack5 之前我们习惯使用 file-loader(拷贝一份文件)、url-loader(文件解析为 base64)来解析我们的引用文件,webpack5 专门提供一个 asset 来专门处理文件,我们不必要再安装专门的 Loader 来处理文件了。

资源模块类型(asset module type),通过添加 4 种新的模块类型,来替换所有这些 loader:

  • asset/resource 发送一个单独的文件并导出 URL。之前通过使用 file-loader 实现。
  • asset/inline 导出一个资源的 data URI。之前通过使用 url-loader 实现。
  • asset/source 导出资源的源代码。之前通过使用 raw-loader 实现。
  • asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现。

我们可以通过配置 asset/resourceasset/inlineasset/source 来以不同的方式处理引用的文件,当然也可以通过配置 asset 来自动选择以何种方式解析文件:

// webpack.config.js

{
  test: /\.(png|gif|jpe?g|svg)$/,
  type: 'asset',
  generator: {
    filename: 'images/[name].[hash:4].[ext]'
  },
  parser: {
    dataUrlCondition: {
      maxSize: 100 * 1024 // 100kb 这里需要的是字节
    }
  }
}

如上所示:当文件大于 100kb 时就以 resource 的方式处理,否则就以 inline 的方式处理,raw 一般很少用。

asset 模块不仅可以处理图片类文件,字体类文件同样可以使用 asset/resource 进行处理:

// webpack.config.js

{
  test: /\.(ttf|woff2?)$/,
  type: 'asset/resource',
  generator: {
    filename: 'fonts/[name].[hash:3].[ext]'
  }
}

5. 常用加载器分类

  • 编译转换类,将资源编译转换为 js 代码
  • 文件加载类,将文件转换为一个引用路径或者是一个 data url
  • 代码检测类,不对代码做任何修改,只输出检测结果

6. webpack 和 ES2015之babel-loader

webpack 只是打包工具,自身不存在编译转换的功能,它需要借助于 Loader 来实现编译转换,ES2015 需要使用 babel-loader 来编译转换,babel-loader 需要两个核心模块 @babel/core 和 @babel/preset-env:

yarn add babel-loader @babel/core @babel/preset-env -D
{
  test: /.js$/,
  use: {
    loader: 'babel-loader',
    options: {
      presets: ['@babel/preset-env']
    }
  }
},

@babel/core 是 babel 的核心库,包含 babel 各种转换的包;@babel/preset-env 是转换的预设,可以转换 ES6+ 的大部分语法。

babel-loader 会把大部分的 ES6+ 编写的代码转换为 ES5,在实际项目开发中我们往往通过配置文件 .babelrc 来设置 babel-loader 的选项:

// .babelrc

{
  preset: ["@babel/preset-env"];
}

7. polyfill 的使用

babel-loader 的 preset 只能帮我们处理一些简单的语法糖,对于一些现代的对象引用是无法处理的,比如 Promise、Symbol、Generator 等等,所以对于这些新的对象就需要我们自己引用其它的处理库来解决兼容问题,而 polyfill 就是做这件事情的。

webpack4 在打包过程中默认会帮我们使用 polyfill 处理 js 的一些兼容问题,但是这也无疑加大了打包后的代码量,所以 webpack5 帮我们去除了 polyfill,我们需要自己去配置需要的 polyfill。

使用 webpack,有许多种方式可以包含 polyfills,当与 babel-preset-env 一起使用时:

  • 如果在 .babelrc 中设置 useBuiltIns: 'usage',则不要在 webpack.config.js entry 数组或 source 中包含 babel-polyfill。注意,仍然需要安装 babel-polyfill

  • 如果在 .babelrc 中设置 useBuiltIns: 'entry',那么就需要使用 requireimport 在应用程序入口文件的顶部引入 babel-polyfill

  • 如果在 .babelrc 中设置 seBuiltIns: 'false'babel-polyfill添加到 wbpack.config.js 入口数组中。

// .babelrc

{
  preset: [
    [
      "@babel/preset-env",
      {
        useBuiltIns: "usage",
      },
    ],
  ];
}
  • useage 是查找我们代码中是否存在预设无法转译的代码,然后进行按需加载处理模块,填充代码,最终的打包体积比较小

  • entry 会根据目标浏览器的兼容性进行解析,需要我们在入口文件的顶端导入 babel-polyfill,会填充所有那些不被目标浏览器兼容的代码,打包体积较大

一般 false 不建议使用,可以看出 useage 是最佳的选择,但是在使用过程中我们需要排除掉 node_modules 目录,避免处理多次。

在 babel 7.4.0 时 @babel/polyfill 将被废弃,我们可以安装 core-js/stable (填充 ECMAScript 功能) 和regenerator-runtime/runtime (需要使用转译的生成器函数) 来处理 polyfill。

8. 开发一个 Loader

Loader 其实就是一个转换输入内容之后再次输出的工具,输出的必须是一段标准的 JavaScript 代码,比如解析 Markdown 文件:

// markdown-loader.js

/**
 * markdown-loader
 * @param {加载进来的文件数据,一段字符串} source
 */

const marked = require("marked");

module.exports = (source) => {
  // 解析Markdown为HTML文本
  const res = marked(source);

  return res;
};

过程很简单,就是把 Markdown 解析 HTML 文本,然后返回,这段返回的 HTML 文本还可以经过 html-loader 的解析,最终能够编译为 JavaScript 代码:

// webpack.config.js
{
  test: /.md$/,
  use:  [
    'html-loader',
    './src/loaders/markdown-loader.js'
  ]
}

更多 loaders 请前往webpack.docschina.org/loaders/查看。

webpack 插件机制 plugins

Loader 是帮助我们编译转换文件的,那么 plugins 就是帮助我们完成一系列的打包工作,webpack 正是由 Loader + Plugins 相互配合工作才完成一个项目的构建打包。

下面是一些常用插件的介绍:

1. clean-webpack-plugin

clean-webpack-plugin 是清除打包目录的一个插件,它会在每次打包任务执行之前清除上一次打包输出的目录文件:

yarn add clean-webpack-plugin -D
// webpack.config.js

const { CleanWebpackPlugin } = require('clean-webpack-plugin')

...

plugins: [
  new CleanWebpackPlugin()
]

...

它会根据 output.path 来检测输出目录,存在输出的文件夹就先删除,然后再打包输出。

plugins 是一个数组对象,可以配置多个插件,插件导出的是一个类,需要经过实例化来使用。

2. html-webpack-plugin

html-webpack-plugin 是生成 HTML 模板的一个插件:

yarn add html-webpack-plugin -D
// webpack.config.js

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

...

new HtmlWebpackPlugin({
  template: './public/index.html'
})

...

可以指定一个模板,模板中做一些初始化的处理,不指定模板会默认生成一个 index.html 模板。每一个插件实例可以生成一个 HTML 模板,多页面应用可以配置多个 html-webpack-plugin 插件,然后根据 chunks 配置对应的 chunk。

3. copy-webpack-plugin

copy-webpack-plugin 是一个文件拷贝的插件,它将静态文件拷贝至 dist 目录中:

yarn add copy-webpack-plugin -D
// webpack.config.js

...

new CopyWebpackPlugin({
  patterns: [{
    from: './public/assets',
    to: './public/assets'
  }]
})`

...

在拷贝文件时可以忽略某些文件:

// webpack.config.js

new CopyPlugin({
  patterns: [
    {
      from: "public/**/*",
      globOptions: {
        dot: true,
        gitignore: true,
        ignore: ["**/file.*", "**/ignored-directory/**"],
      },
    },
  ],
}),

4. 开发一个插件

webpack 执行过程中每个任务都会提供一个 hook 用来挂载任务,可以利用这个钩子来开发插件,插件必须是一个具有 apply 方法的对象:

class MyPlugin {
  // 类必须提供一个apply的方法,方法接收一个compiler的参数
  apply(compiler) {
    // compiler 的hooks上挂载有emit钩子,处理文件输出,我们可以在这个钩子上注册自己的插件函数,对输出进行处理
    compiler.hooks.emit.tap("MyPlugin", (compilation) => {
      // compilation是当前执行的上下文,它包含了当前处理的文件
      // assets就是待输出的资源
      for (let name in compilation.assets) {
        if (name.endsWith(".js")) {
          const contents = compilation.assets[name].source();
          let withoutComments;
          try {
            withoutComments = contents.replace(/\s/g, "");
          } catch (error) {
            withoutComments = contents.toString().replace(/\s/g, "");
          }
          compilation.assets[name] = {
            source: () => withoutComments,
            size: () => withoutComments.length,
          };
        }
      }
    });
  }
}

更多 plugins 请前往webpack.docschina.org/plugins/查看。

webpack-dev-serve

启动一个开发服务的工具,它能够自动编译,将编译结果存储在内存中,浏览器能够实时的从内存中读取最新的编译结果。

1. Dev serve 静态资源访问

devServer: {
  contentBase: './public'
},

contentBase 指定了开发环境时静态文件的访问路径,在webpack5中改为 devServer.static.publicPath

// webpack.config.js

const path = require('path');

module.exports = {
  //...
  devServer: {
    static: [
      {
        directory: path.join(__dirname, 'assets'),
        publicPath: '/serve-public-path-url',
      },
      {
        directory: path.join(__dirname, 'css'),
        publicPath: '/other-serve-public-path-url',
      },
    ],
  },
};

2. Dev serve api 代理

devServer: {
    proxy: {
      '/api': {
        target: 'http://www.baidu.com/',
        pathRewrite: {'^/api' : ''},
        changeOrigin: true,     // target是域名的话,需要这个参数,
        secure: false,          // 设置支持https协议的代理
      },
      '/api2': {
          .....
      }
    }
  }
  • \api 表示的是路由,代理会匹配到该路由,然后根据该路由的具体配置进行请求转发。
  • target 表示目标地址,就是请求转发到的目标地址
  • pathRewrite 重写路由,将匹配到的路由重写
  • changeOrigin 域名请求需要主机地址,将 request host 修改成和 target 同一域名和同一端口,解决跨域请求问题
  • secure 支持 https 协议

webpack-dev-middleware

webpack-dev-middleware 是一个封装器(wrapper),它可以把 webpack 处理过的文件发送到一个 server。webpack-dev-server 在内部使用了它,然而它也可以作为一个单独的 package 来使用,以便根据需求进行更多自定义设置。下面是一个 webpack-dev-middleware 配合 express server 的示例。

  1. 首先我们需要安装 express 和 webpack-dev-middleware : yarn add express webpack-dev-middleware -D;

  2. 其次我们需要在 webpack 配置中设置 output.publicPath"/",这是因为我们将会在 server 脚本使用 publicPath,以确保文件资源能够正确地 serve 在 http://localhost:3000 下;

  3. 我们需要新建一个 serve 文件来开启一个 express 服务

// serve.js

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

// 建立一个服务
const app = express();

// 获取webpack的配置,并根据这个配置调用webpack函数生成一个compiler实例
// 这个compiler实例就是一个打包过程的所有控制对象
const config = require("./webpack.config.js");
const compiler = webpack(config);

// 告知 express 使用 webpack-dev-middleware,
// 以及将 webpack.config.js 配置文件作为基础配置。
app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath,
  })
);

// 将文件 serve 到 port 3000。
app.listen(3000, function () {
  console.log("Example app listening on port 3000!\n");
});

在这里我们就是将 webpack 的打包实例 compiler 交由中间件 webpack-dev-middlewrae 处理,app 服务使用这个中间件并启动一个服务,此时浏览器就能接收到 webpack 的打包结果。

Source Map

我们编写的源代码经过打包工具编译构建后会变得不可阅读,在调试查找 bug 的时候就不怎么友好了,那么 Source Map 就建立了一套 源代码编译构建后的代码 之间的对应关系,它简称为源代码的地图,可以反向编译出源代码,供我们在生产环境中进行调试查错,一些前端代码监控系统也依赖于 Source Map 文件来定位生产代码的故障。

1. Webpack SourceMap

webpack 可以通过配置 devtool: "source-map" 来开启 sourceMap,开启后会在编译后的代码末行添加上 #sourceMappingURL=bundle.js.map 来指示 sourceMap 文件地址。

sourceMap 的工作模式有十几种之多,每种模式的工作效率都是不一样的,sourceMap 越详细生成的速度就越慢,但其效率会更高,反之亦然。

下面是各种模式的对比(webpack4):

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

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

  • eval 均是通过 eval 方式处理的,不生成 .map 文件,浏览器通过解析 eval 中的字符串执行源码
  • cheap 但是不带 module 的展示的都是转换过的代码,比如 ES6 -> ES5,带有 module 的是源码,但是两者都仅限行
  • eval-source-mapsource-map 之间,没有 cheapmodule 的是能映射到每一个变量名的,debugger 也能单行内多次调试
  • hidden-source-mapnosources-source-map 区分生产环境和开发环境,生产环境正常,开发环境或不能显示,或只能显示目录结构
  • inline 是将 map 以 base64 的方式嵌入到源代码中

webpack5 多了很多种配置,模式命名也更为严格:webpack.js.org/configurati…

2. 选择 sourcemap

开发环境建议选择 cheap-module-eval-source-map,它能保留我们的原始代码,虽然第一次编译较慢,重新构建的速度非常快,很适合开发环境使用。

生产环境不建议使用 sourcemap,因为会暴露源代码,如果一定需要使用的话,建议使用 nosources-source-map,它既能定位错误位置,也不会暴露源代码。

Webpack HMR

webpack-dev-serve 监听到代码变更会刷新浏览器来呈现最新的效果,这种刷新方法会丢失页面状态,比如已经输入的一些内容,需要修改文字样式,修改完毕页面刷新后输入的内容就会丢失,这种体验是非常不友好的,HMR 就是帮助我们解决这类问题的。

HMR 是一个热更新工具,它能够在不影响当前页面状态的前提下呈现最新的代码效果,例如 js 脚本、css 样式更新。

1. 开启 HMR

webpack 集成了 HMR 模块,devServer 中开启 hot,并使用 webpack 提供的插件:HotModuleReplacemtPlugin

// webpack.config.js

devServer: {
  hot: true;
}

plugins: [new webpack.HotModuleReplacementPlugin()];

请注意,要完全启用 HMR ,需要 webpack.HotModuleReplacementPlugin。如果使用 --hot 选项启动 webpack 或 webpack-dev-server,该插件将自动添加,因此你可能不需要将其添加到 webpack.config.js 中。

webpackd-dev-serve 已经自动帮我们加载了这个 HotModuleReplacementPlugin 插件,只要配置 devserver: { hot: true } 即可,下面就是 webpack-dev-server updateCompiler 模块的源码,会根据配置判断是否加载插件:

'use strict';

/* eslint-disable
  no-shadow,
  no-undefined
*/
const webpack = require('webpack');
const addEntries = require('./addEntries');
const getSocketClientPath = require('./getSocketClientPath');

function updateCompiler(compiler, options) {
  if (options.inline !== false) {
    const findHMRPlugin = (config) => {
      if (!config.plugins) {
        return undefined;
      }

      return config.plugins.find(
        (plugin) => plugin.constructor === webpack.HotModuleReplacementPlugin
      );
    };

    const compilers = [];
    const compilersWithoutHMR = [];

    ......

    // do not apply the plugin unless it didn't exist before.
    if (options.hot || options.hotOnly) {
      compilersWithoutHMR.forEach((compiler) => {
        // addDevServerEntrypoints above should have added the plugin
        // to the compiler options
        const plugin = findHMRPlugin(compiler.options);
        if (plugin) {
          plugin.apply(compiler);
        }
      });
    }
  }
}

module.exports = updateCompiler;

webpack5 好像不再提供这个 HotModuleReplacementPlugin 插件给使用者,实例化这个插件时会抛出错误:webpack.HotModuleReplacementPlugin is not a constructor,所以我们已经不需要再手动实例化这个插件了,直接配置 hot 即可。

2. HMR 的问题以及解决

当我们修改 js 代码时会发现页面还是刷新了,这是因为我们需要手动在 js 代码中处理热更新的响应,css 代码因为有 css-loader 和 style-loader,这两个 Loader 已经实现了热更新的 api:

var isNamedExport = !_node_modules_css_loader_dist_cjs_js_index_css__WEBPACK_IMPORTED_MODULE_6__.default.locals;
var oldLocals = isNamedExport ? _node_modules_css_loader_dist_cjs_js_index_css__WEBPACK_IMPORTED_MODULE_6__ : _node_modules_css_loader_dist_cjs_js_index_css__WEBPACK_IMPORTED_MODULE_6__.default.locals;
// 处理热更新
module.hot.accept(
  8,
  ......
)

如果已经通过 HotModuleReplacementPlugin 启用了 Hot Module Replacement, 则它的接口将被暴露在 module.hot 以及 import.meta.webpackHot 属性下。请注意,只有 import.meta.webpackHot 可以在 strict ESM 中使用。 通常,用户先要检查这个接口是否可访问, 再使用它,你可以这样使用 accept 操作一个更新的模块:

if (module.hot) {
  module.hot.accept("./library.js", function () {
    // 对更新过的 library 模块做些事情...
  });
}

// or
if (import.meta.webpackHot) {
  import.meta.webpackHot.accept("./library.js", function () {
    // Do something with the updated library modue…
  });
}

accept:

接受(accept)给定 依赖模块(dependencies) 的更新,并触发一个 回调函数 来响应更新,除此之外,你可以附加一个可选的 error 处理程序:

module.hot.accept(
  dependencies, // 可以是一个字符串或字符串数组,表示当前更新的依赖
  callback // 用于在模块更新后触发的函数
  errorHandler // (err, {moduleId, dependencyId}) => {}
);

由上可知,当热更新触发时,我们必须在模块中调用 HMR 提供的 api 来实现热更新的响应:

const oldInput = null;
module.hot.accept(
  ["./createElement.js"], // 可以是一个字符串或字符串数组,表示依赖模块路径
  () => {
    const oldValue = input.value;
    document.body.removeChild(input);
    const newInput = CreaterElement("input", "", []);
    document.body.appendChild(newInput);
    newInput.value = oldValue;
    oldInput = newInput;
  }, // 用于在模块更新后触发的函数
  (err, { moduleId, dependencyId }) => {
    console.log(err);
  } // 响应失败时的处理逻辑
);

可以看出,webpack 无法帮助我们实现 JS 的热更新,这是因为我们的模块代码纷繁复杂,依赖模块的变动,引用时的更新代码都不会相同,实现逻辑不可能完全一致,需要我们手动去响应更新。

3. 图片模块热替换

图片的热更新也需要我们手动响应,当我们修改了图片,就是重新设置一下图片路径:

module.hot.accept(
  ["./background.png"], // 可以是一个字符串或字符串数组
  () => {
    bg.src = background;
  }, // 用于在模块更新后触发的函数
  (err, { moduleId, dependencyId }) => {
    console.log(err);
  } // 响应失败时的处理逻辑
);

4.HMR 注意事项

  • 如果启用的是 hot,当构建失败时会回退到页面刷新,hotOnly 在启用热模块替换,而无需页面刷新作为构建失败时的回退。
  • accept 只有配置 hottrue 时才会暴露出来,如果配置为 false 这个 api 就不存在,所以以上代码需要添加容错机制:
if (module.hot) {
  module.hot.accept(
    ["./background.png"], // 可以是一个字符串或字符串数组
    () => {
      bg.src = background;
    }, // 用于在模块更新后触发的函数
    (err, { moduleId, dependencyId }) => {
      console.log(err);
    } // 响应失败时的处理逻辑
  );
}

当 hot 设置为 false 时里面的代码在构建时就不会携带了,最终编译结果 if (false) {}

output 中的 publicPath 和 devserver 中的 publicPath

output.path

output.path 是我们打包时的输出目录,一般对应process.cwd(),也就是项目打包运行时的环境,一般为项目的根目录,它是一个绝对路径 path: path.resolve(__dirname, 'dist'),当我们运行 webpack 进行 build 时,就会在配置的目录下生成一个 dist 目录作为文件输出的目标位置。

webpack 默认就会在根目录创建一个 dist 目录用来存放输出 this.set("output.path", path.join(process.cwd(), "dist"));

output.publicPath

publicPath 配置选项在各种场景中都非常有用,你可以通过它来指定应用程序中所有资源的基础路径。

从 webpack 合并默认 options 的源码看来,output.publicPath 是没有默认选项的,那么我们设置一些值看看打包后的效果:

1). 当 output.publicPath 为一个空字符串时或者不设置该属性时,html 模板中对于打包 js 文件的引用 src 是这样的 src="js/build.d2f9.js",实际请求是这样的(打包后文件在服务器环境下,即使用 http-serve 启动) http://127.0.0.1:5500/relase/js/build.d2f9.js,图片等资源文件的引用是这样的 http://127.0.0.1:5500/relase/images/dd.jpg,此时我们访问 html 的目录为 relase,当 src 的开头不为 / 时,我们访问的 js 和 image 资源就可以认为是相对于当前 html 模板的位置,也就是 relase 目录,它下面存在 js 和 image 目录,里面分别存在访问的资源,此时对于 js 文件的 request url 就可以认为是 当前启动的服务URI + '/' + 目标html模板的目录 + '/' + js输出filename, 所以我们的访问是正常的。那么一旦我们在 HtmlWebpackPlugin 中将 HTML 模板的输出修改一下 filename: 'html/index.html',此时 html 就会被打包至 relase 目录下的 html 目录中,此时相对位置就会变为 html 目录,此时再看 request url http://127.0.0.1:5500/relase/html/js/build.382a.js,我们的 js 资源访问就会导致 404 了。所以结论就是:output.publicPath 不设置或者设置为空字符串时,所有资源的访问路径都是相对于当前能够访问到的目标资源,对于我们正常的项目来言,就是我们访问的 html 所存在的目录,路径就是 当前启动的服务URI + '/' + 命中目标资源所在的目录 + '/' + 引用资源输出filename

output: {
  path: path.resolve(__dirname, 'dist'),
  publicPath: '',
  filename: 'js/build.[hash:4].js'
},

我们再来看一下它对于 dev server 的影响:

首先我们需要知道的一点是,webpack-dev-serve 启动一个本地服务时默认将资源放在这个本地服务的根目录下(内存中,而不是在磁盘中,前提是我们没有配置 devserver.publicPath)。此时访问启动的本地服务时 http://localhost:8081/ 就会找根目录下的 index.html,此时是能够找到的,访问正常,我们再来看一下对于 build.js 的引用 src="js/build.29e6.js",实际 request http://localhost:8081/js/build.29e6.js,此时所有文件路径都是绝对路径。此时我们修改一下 html 模板的输出来依次看一下对于 build.js 文件的引用:html/index.html -> http://localhost:8081/js/build.29e6.jshtml/user/index.html -> http://localhost:8081/html/js/build.29e6.js; html/user/user1/index.html -> http://localhost:8081/html/user/js/build.29e6.js。所以结论就是:当我们的目标资源存在于根目录下,对于引用路径 js/build.29e6.js 会直接在根目录下查找,request 路径就是 本地server URI + '/' + 引用资源配置的filename;当我们的目标资源存在于多级目录下时,对于引用路径 js/build.29e6.js 会在当前命中的目标资源目录下返回一级目录进行查找,request 路径就是 本地server URI + '/' + ../命中目标资源所在的目录 + '/' + 引用资源配置的filename

2). 当 output.publicPath/.../ 时,打包后 html 模板对于 js 资源的引用是这样的 src="/js/build.3382.js",实际 request url 是这样的 http://127.0.0.1:5500/js/build.3382.js,引用路径前面加上 / 表示绝对路径访问,此时无论你的 html 模板在哪个目录下,你命中的资源在引用其它资源时都是按照绝对路径来查找的,规则就是 当前启动的服务URL + output.publicPath + '/' + 引用资源filename,而此时我们链接对应的根目录下是不存在对应的引用资源的,这就会导致资源无法访问的问题,所以不建议将 output.publicPath 设置为 / ,因为我们一般都是将资源打包至一个包中放置在服务器的 web 服务目录中,即使是 CDN 资源也是如此,过于分散的资源是不利于管理和维护的。

我们再来看一下它对于 dev server 的影响:

因为 dev serve 启动时,webpack 会将资源打包至一个虚拟目录中,资源缓存在内存,此时的 output.publicPath 就会起一定的作用,我们的资源会被统一打包至配置的绝对路径下,所有的资源引用都会从配置的绝对路径开始,规则就是 本地server URL + output.publicPath + '/' + 引用资源filename,无论你的访问目标在几级目录下,它的引用资源路径都会被解析成从配置路径出发的绝对路径。

比如 publicPath: '/dist/myApp', 是配置的 output.publicPathfilename: 'html/user/index.html' 是配置的 html 模板的 filename ,http://localhost:8081/dist/myApp/html/user 访问命中 index.html,它对于 build.js 引用路径是 src="/dist/myApp/js/build.4374.js",而实际 request url 是 http://localhost:8081/dist/myApp/js/build.4374.js,访问都是正常渲染的。

从上面可以看出 output.publicPath 配置为一个绝对路径时对于生产环境是有影响的,因为 output.path 起着文件输出的作用,对于开发环境没有什么影响。

3). 当 output.publicPath./ 时,其实就表明了是相对位置关系,此时的 html 模板对于文件的引用路径就被解析为 ./js/build.1677.js,这就很好理解了,直接是相对于当前访问命中目标资源的路径,和没有配置或者配置为空字符串没有区别,路径规则也是 当前启动的服务URI + '/' + 命中目标资源所在的目录 + '/' + 引用资源输出filename

我们再来看一下它对于 dev server 的影响:

我们知道 dev server 启动时,webpack 将打包资源输出自一个目录中,没有配置 output.publicPath 时默认是根目录,配置了绝对路径的话那么就是服务 URL 加上配置的绝对路径,一旦配置为相对路径时就会导致问题了,我们无法在本地服务的同级目录下创建文件,所以当 outputh.publicPath 配置为 ./ 时开发环境就无法使用了。

需要注意的是,只要配置 output.publicPath,就必须以 / 结尾

devServer.publicPath

devServer.publicPath 配置开发环境下的资源访问路径,这里需要注意的是只要配置了就必须以 / 开头和结尾,只要配置了该项,dev serve 启动时,webpack 就会将所有资源打包至该配置对应的目录下。该配置对生产环境没有什么影响,生产环境还是 output.publicPath 优先。

为了保障生产和开发环境都能访问到引用资源,建议 devServer.publicPath 与 output.publicPath 相同,因为在解析资源路径时还是以 output.publicPath 优先 。

在 webpack-dev-server 4 版本中已经修改了该项配置,改由 devServer.devMiddleware.publicPathdevServer.static.publicPath 配置用来访问 public 目录下的文件。

output: {
  path: path.resolve(__dirname, 'relase'),
  publicPath: '/dist/',
  filename: 'js/build.[hash:4].js'
},
devServer: {
  devMiddleware: {
    publicPath: '/builder/'
  }
},

此时我们访问 HTML 模板的 request 是这样的 http://localhost:8081/builder/,我们看一下打包后的 HTML 模板对于 build.js 的引用 <script defer src="/dist/js/build.5af3.js"></script>,实际 request http://localhost:8081/dist/js/build.5af3.js

从上面的配置可以看出,我们启动 DevServer 的时候,webpack 会将打包资源输出至 开发服务URL + devServer.devMiddleware.publicPath 下,但是在解析资源访问路径时还是会依据 output.publicPath 来处理路径拼接,所有建议这两个 publicPath 尽量一致。

虽然官方文档建议两个配置保持一致,但是路径只有绝对和相对两种情况,如果全部配置为绝对路径,开发环境是能够正常访问,但是生产环境就无法正常访问了;如果全部配置为相对路径又会导致开发环境无法正常输出,所以最佳方法就是 devServer.devMiddleware.publicPath 配置为绝对路径 /output.publicPath 配置为相对路径 ./,这样开发环境时文件能够正常输出至根目录下,访问时又会基于 html 模板进行相对路径查找,生产环境时所有文件都会输出至 output.path 中,访问时相对路径不会出现任何问题。

webpack 生产环境优化

生成环境需要提供出纯净的代码,很多开发时的辅助工具都不需要了,而且注释,代码压缩分割等都需要考虑到,这就需要区别于开发环境的配置选项。

1. 配置文件中根据命令行传入的参数导出不同的配置

module.exports = (env, argv) => {
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html'
    }),
  ]

  const config = {
    ......
    devtool: 'source-map',
    devServer: {
      hotOnly: true,
      contentBase: './public'
    },
    module: {
      ......
    },
    plugins
  }

  if (env === 'production') {
    config.devtool = 'none'
    config.devServer.hot = false
    config.plugins = [
      ...config.plugins,
      new CleanWebpackPlugin(),
      new CopyWebpackPlugin({
        patterns: [{
          from: './public/assets',
          to: './public/assets'
        }]
      }),
    ]
  }

  return config
}

2. 根据环境变量使用不同的配置文件

我们可以创建公共的配置文件、开发环境配置文件和生产环境的配置文件,通过环境变量来判断使用具体哪个配置:

// webpack.production.js

const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");

const { merge } = require("webpack-merge");

const common = require("./webpack.common.js");

module.exports = merge(common, {
  devtool: "none",
  devServer: {
    hot: false,
  },
  plugins: [
    new CleanWebpackPlugin(),
    new CopyWebpackPlugin({
      patterns: [
        {
          from: "./public/assets",
          to: "./public/assets",
        },
      ],
    }),
  ],
});
// package.json

"scripts": {
  "dev": "webpack serve",
  "build:prod": "cross-env NODE_ENV=production webpack --config build/webpack.production.js"
},

3. DefinePlugin

DefinePlugin 是 webpack 内置的一个插件,它可以帮我们注入一个全局对象,在所有模块中都可以访问得到:

new webpack.DefinePlugin({
  // 这里的值不能是一个字符串,必须是一段标准的JavaScript代码,也就是字符串字面量,
  // 或者经过序列化的字符串:JSON.stringify('http://www.exampleapi.com')
  BASE_API_URL: '"http://www.exampleapi.com"'
}),

这样我们就可以在模块中访问到 BASE_API_URL 这个全局变量了。

4. Tree Shaking

根据摇树效应衍生出的一个 webpack 生产环代码优化功能,它不是指一个具体的配置或者插件,它是一些功能的组合:

optimization: {
  usedExports,
  minimize
},

optimization 主要配置一些代码优化的功能,其中 usedExports 被使用的导出,它会找到那些不被使用的代码或者模块,然后通过 minimizeminimizer 来清除掉。

mode 设置为 production 时会默认开启 Tree-Shaking。

Tree Shaking 依赖于 ESModule,最新的 babel-loader 默认会关闭 ESModule 的转换,以便支持 Tree-shaking,如果我们强制使用 CommonJS 模式的话,tree-shaking 也就会失效:

presets: [["@babel/preset-env", { modules: "commonjs" }]];

5. concatenateModules

告知 webpack 去寻找模块图形中的片段,哪些是可以安全地被合并到单一模块中,设置为 true 时,所有的代码都会打包至一个函数中,作用域提升了,提高了代码的执行效率,同时也减小了代码体积,称之为 Scope-Hoisting。

6. sideEffects

告知 webpack 去辨识 package.json 中的 副作用 标记或规则,以跳过那些当导出不被使用且被标记不包含副作用的模块,也就是模块执行时除了导出成员之外所做的其它事情。

sideEffects 一般用于开发 npm 包,npm 包中的 package.json 中通过 sideEffects: false 字段标识包中不存在副作用代码、模块,我们项目的 webpack 配置中通过配置 sideEffects: true 来开启这个功能。

如果模块中没有导出任何成员,只是对原生对象做一些原型扩展,或者类似于此类的操作,这些代码都是有效地,但是也会被标记为副作用,还有 css 代码,都会被标记为副作用,当我们的 webpack 开启这个功能时,这些代码都会被移除,此时我们就需要备注这些模块是不存在副作用的,sideEffects 可以设置为一个数组,里面添加文件路径,用来标识那些文件是不具备副作用条件的。

7. Code Splitting

对于大型应用,代码分割是必要的,这是因为当我们把所有代码都打包至一个文件中时,浏览器加载该文件就需要耗费大量的时间,导致页面长时间的空白,体验非常的差,这就需要我们根据一定的规则将代码分割成一个个较为小的文件进行加载,这样主入口文件的体积就会减少很多,大大提高加载速度。

代码分割包括 多入口打包动态导入

1)多入口打包

多入口打包适用于多页面应用,它会根据不同的入口文件输出相应的页面和引用脚本:

// webpack.config.js

entry: {
  index: './src/index.js',
  user: './uaer/index.js'
},

...

plugins: [
  new HtmlWebpackPlugin({
    template: './public/index.html',
    chunks: ['index']
  }),
  new HtmlWebpackPlugin({
    template: './public/user.html',
    chunks: ['user']
  }),
]

输出 html 文件时通过 chunks 字段标识引用的打包脚本。

2)公共文件的提取

多入口打包时每个入口文件都会有相同的引用模块,这些模块作为公共资源需要单独提取出来,optimization/splitChunks 提供了代码分割的相关配置:

optimization: {
  usedExports,
  minimize,
  splitChunks: {
    chunks: 'all'
  }
},
optimization:{
    splitChunks: {
      // 表示选择哪些 chunks 进行分割,可选值有:async,initial和all
      chunks: "async",
      // 表示新分离出的chunk必须大于等于minSize,默认为30000,约30kb。
      minSize: 30000
      // 表示一个模块至少应被minChunks个chunk所包含才能分割。默认为1。
      minChunks: 1,
      // 表示按需加载文件时,并行请求的最大数目。默认为30。
      maxAsyncRequests: 30,
      // 表示加载入口文件时,并行请求的最大数目。默认为30。
      maxInitialRequests: 30,
      // 表示拆分出的chunk的名称连接符。默认为~。如chunk~vendors.js
      automaticNameDelimiter: '~',
      // boolean = false  string function (module, chunks, cacheGroupKey) => string,也可用于每个cacheGroup: splitChunks.cacheGroups.{cacheGroup}.name。
      // 拆分块的名称。提供false将保持块的相同名称,因此不会不必要地更改名称。这是生产构建的建议值。
      // 提供字符串或函数使您可以使用自定义名称。指定字符串或始终返回相同字符串的函数会将所有通用模块和供应商合并为一个块。这可能会导致更大的初始下载量并减慢页面加载速度。
      name: false,
      // cacheGroups 下可以可以配置多个组,每个组根据test设置条件,符合test条件的模块,就分配到该组。
      // 模块可以被多个组引用,但最终会根据priority来决定打包到哪个组中。
      // 默认将所有来自 node_modules目录的模块打包至vendors组,将两个以上的chunk所共享的模块打包至default组。
      cacheGroups: {
          vendors: {
              test: /[\\/]node_modules[\\/]/,
              priority: -10
          },
         default: {
              minChunks: 2,
              priority: -20,
              reuseExistingChunk: true
         }
      }
    }
  },

关于 splitChunks 更多

8. 动态导入

使用 ESModule 的动态导入函数 import() 可以实现模块的动态导入,只有在使用到时才导入相应的模块,webpack 会自动将其打包成一个独立文件,进行分包和按需加载,react 或者 vue 的路由系统 template 的按需加载就是如此。

我们可以通过 webpack 提供的魔法注释来命名动态导入文件的打包名称:import(/* webpackChunkName: 'user' */, './use.js'),这样打包后的文件名称就是 user.bundle.js,名字相同的会被打包至同一个文件中。

module.exports = {
  //...
  output: {
    //...
    chunkFilename: "[name].js",
  },
};

打包时,这里的 name 就会被替换为动态导入时的 webpackChunkName,如果没有提供 chunkName ,在 webpack runtime 输出 bundle 值时,将 chunk id 的值对应映射到占位符(如 [name] 和 [chunkhash])。

9. prefetch 和 preload

prefetch 预获取的意思,表示将来某些导航下可能需要的资源,比如我们有一个按钮,点击这个按钮时动态获取某个资源:

const btn = document.getElementById("btn");

btn.addEventListener("click", () => {
  import(/* webpackPrefetch: true */ "./util/index").then((util) => {
    console.log(util.sum(5, 8));

    console.log(util.sql(4));
  });
});

这个 utils/index.js 虽然是动态加载的,但是我们给它标注了预获取,此时 HTML 模板中就会插入这个预获取的 script 标签 <link rel="prefetch" as="script" href="./js/src_util_index_js.f4e84.js">,指示浏览器在空闲时间加载该资源,只要父 chunk 完成加载,webpack 就会添加 prefetch hint(预取提示)。

与 prefetch 指令相比,preload 指令有许多不同之处:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。

  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。

  • preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻。

  • 浏览器支持程度不同。

preftech 所表示的该资源在将来可能会被使用到,所以先获取下来,以备后用,注意这里只是先获取下来,并不会解析执行,只有等到后续使用到的时候才会解析执行提前获取的代码,也就是说在 import 函数外部我们是无法获取到任何依赖资源的相关信息。

preloader 所表示的是预加载,它获取的资源是当前必然会使用到的,即它会和父 chunk 并行加载执行,比如 html 模板依赖的样式文件、图片、字体文件等,这些资源都可以和 html 模板并行加载,而不是在使用到的时候再去加载,减少加载资源的时间。但是不正确地使用预加载会损耗性能,这里要注意使用,如果提前加载的依赖又依赖于其它模块,而这些其它模块暂时没有什么作用,这个预加载就没有什么意义了。

10. runtimeChunk

动态导入只有在代码运行到那里时才会进行代码的加载,这种异步的导入就是运行时代码,例如 Vue 中的路由懒加载,只有访问路由匹配的模块时才会加载该模块和模块对应的相关依赖,那么对于这些运行时代码我们可以将其从主代码中剥离出来,当引用代码发生改变时,主代码无需重新打包。

// utils/index.js

const sum = (m, n) => m + n;

const sql = (m) => m * m;

export { sum, sql };
// index.js

import "./style/index.css";

import { getUser } from "./api/user";

console.log(getUser());

import("./util/index").then((util) => {
  console.log(util.sum(5, 8));

  console.log(util.sql(4));
});
// webpack.config.js

output: {
  path: path.resolve(__dirname, 'relase'),
  publicPath: './',
  filename: 'js/[name].[contenthash:4].js'
},
optimization: {
  runtimeChunk: true
},

这里注意的是以文件内容生成 hash,看一下打包后的结果:

js
├─ main.42900.js
├─ runtime~main.b7335.js
└─ src_util_index_js.c32ef.js

main.42900.js 是依据 src/index 生成的主入口模块,将 src/index.js 中所有的依赖全部作为 main 模块添加至 webpackChunkwebpack5_entry 中:

(self["webpackChunkwebpack5_entry"] =
  self["webpackChunkwebpack5_entry"] || []).push([
  ["main"],
  {
    "./src/api/user.js": function (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) {
      "use strict";
      eval(...);
    },

    "./src/index.js": function (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) {
      "use strict";
      eval(...);
    },

    "./node_modules/css-loader/dist/runtime/api.js": function (module) {
      "use strict";
      eval(
       ...
      );
    },

    ...

    "./src/images/student.png": function (module) {
      "use strict";
      eval(
        'module.exports = "data:image/png;base64,...";\n\n//# sourceURL=webpack://webpack5-entry/./src/images/student.png?'
      );
    },
  },
  function (__webpack_require__) {
    // webpackRuntimeModules
    "use strict";

    var __webpack_exec__ = function (moduleId) {
      return __webpack_require__((__webpack_require__.s = moduleId));
    };
    var __webpack_exports__ = __webpack_exec__("./src/index.js");
  },
]);

src_util_index_js.c32ef.js 就是动态导入的 utils/index.js 文件的模块添加至 webpackChunkwebpack5_entry

// src_util_index_js.c32ef.js

(self["webpackChunkwebpack5_entry"] =
  self["webpackChunkwebpack5_entry"] || []).push([
  ["src_util_index_js"],
  {
    "./src/util/index.js": function (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) {
      "use strict";
      eval(
        ...
      );
    },
  },
]);

runtime~main.b7335.js 就是处理所有模块加载的,我们省略具体的核心代码:

(function () {
  // webpackBootstrap

  ...

  /* webpack/runtime/jsonp chunk loading */
  !(function () {
      ...
    };

    var chunkLoadingGlobal = (self["webpackChunkwebpack5_entry"] =
      self["webpackChunkwebpack5_entry"] || []);
    chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
    chunkLoadingGlobal.push = webpackJsonpCallback.bind(
      null,
      chunkLoadingGlobal.push.bind(chunkLoadingGlobal)
    );
  })();
})();

我们修改一下依赖的 utils:

// utils/index.js

const sum = (m, n) => m + n * 2;

const sql = (m) => m * m;

export { sum, sql };

此时再来看一下打包后的文件:

js 
├─ main.42900.js 
├─ runtime~main.fff2f.js 
└─ src_util_index_js.f4e84.js

由此可以看出,我们的主入口文件和动态加载的文件被作为一个模块添加到待加载数组中,而 runtime~main.b7335.js 仅仅是用来加载这些模块的处理方法,所以当 utils 中的方法有所改变时,主入口文件它不会发生任何改变,仅仅只有该依赖文件 src_util_index_js.c32ef.js 会因为内容改变而重新打包,runtime~main.b7335.js 也会因为依赖文件变化需要重新打包,这两个文件都是及其小的,加载速度会很快,这就避免了某些细小的依赖文件发生改变而导致主文件也重新打包,最终导致缓存失效而加载整个较大的主文件。

对于每个 runtime chunk,导入的模块会被分别初始化,因此如果你在同一个页面中引用多个入口起点,请注意此行为。你或许应该将其设置为 single,或者使用其他只有一个 runtime 实例的配置。

11. MiniCssExtractPlugin

MiniCssExtractPlugin 能够帮我们把 css 代码提取到单独文件中,以 link 的方式嵌入到 HTML 中,它适合大体量的 css 代码剥离,对于小的体量 css 代码不建议提取,因为提取出的文件会增加一次额外的请求。

yarn add mini-css-extract-plugin -D
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

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

这里需要注意的是,webpack 默认只会对 js 文件进行压缩,我们抽离出来的 css 文件不会被压缩,需要我们自己去配置压缩插件,这里借助于 css-minimizer-webpack-plugin 插件进行 css 文件的压缩,这里关于文件压缩的配置最好配置在 optimizationminimizer 属性中,这样就可以根据是否开启了 minimiz 来确定是否是要压缩代码,而 production 模式下默认开启了 minimiz:

optimization: {
  minimizer: [
    // 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`),将下一行取消注释
    // 就是webpack5开箱即用,它内部包含了TerserWebpackPlugin压缩代码,我们使用 ... 即扩展webpack5自带的代码压缩工具
    // `...`,
    new CssMinimizerPlugin(),
  ],
},

minimizer 会指定我们的压缩代码方式,需要集成其它的代码压缩插件,例如 js 的 terser-webpack-plugin

关于 MiniCssExtractPlugin 更多

12. 三方库的 CDN 加载

我们知道 node_modules 中的所有资源一般都会被打包至 vendor.js 中,该文件会非常大,加载速度较慢,首屏加载效果受到严重影响,所以我们就可以通过一些配置来限制这些三方包的打包,从而减小 vendor.js 的体积。

比如 jQuery,我们就可以使用它的 CDN 资源:

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

其中 integrity 是 web 安全的内容安全策略,值是根据资源内容生成的 sha256 的 hash 编码,这是因为获取的第三方资源有可能存在被篡改的风险,为了避免我们加载不安全的三方库,我们就提供官方给出的文件 hash 编码,当我们获取的文件 hash 和官方给出的 hash 编码不一致时就说明文件是被篡改了的,此时会发出警告,阻止解析。crossorigin 表示匿名的跨域请求,不发送用户凭据,例如用户 cookie 等。

此时我们还需要在 webpack 中设置 jQuery 的打包:

// webpack.config.js

module.exports = {
  //...
  externals: {
    jquery: "jQuery",
  },
};

这里的键必须是依赖的第三方库模块名,值必须是该模块导出的可用对象名,配置好后在打包时 jQuery 就不会被打包至 vendor.js 中,会直接从 CDN 中加载对应的资源。这里要特别注意的是,CDN 有可能存在不稳定的时候,一旦资源加载失败就会导致应用无法使用,所以使用 CDN 存在一定的风险,我们可以将资源拷贝至我们自己的服务其中,由我们自己的服务器提供资源加载,提高稳定保障,也可以通过 script 的 onError 事件加载我们本地存在的依赖资源。

13. 打包 dll 库(动态链接库)

动态链接库 dll 的概念:

所谓动态链接,就是把一些经常会共享的代码制作成 DLL 档,当可执行文件调用到 DLL 档内的函数时,Windows 操作系统才会把 DLL 档加载存储器内,DLL 档本身的结构就是可执行档,当程序有需求时函数才进行链接。透过动态链接方式,存储器浪费的情形将可大幅降低。

在 webpack 世界里就是将基本上短期内不会变更的第三方库打包至一个目录中(也就是打包缓存),然后生成一个 dll 说明文件对应这些三方库 build 后的 moduleId 映射,在 webpack 打包的主配置中就会引用这个说明文件。可知,当第一次打包时我们的三方库还是需要经过一次 build 的,之后在说明文件中就会存储 build module 信息,也就是保留了一份缓存,当第二次打包或者后续的打包就会根据这份说明文件跳过已打包的模块,减少了打包的时间,提高了 build 效率,狭义上讲就是以空间换取了时间。

DllPlugin 和 DllReferencePlugin 用某种方法实现了拆分 bundles,同时还大幅度提升了构建的速度。

  1. DllPlugin

此插件用于在单独的 webpack 配置中创建一个 dll-only-bundle。 此插件会生成一个名为 manifest.json 的文件,这个文件是用于让 DllReferencePlugin 能够映射到相应的依赖上。

webpack 上已经挂载了这个插件,它的 options 参数选项如下:

  • context(可选): manifest 文件中请求的 context (默认值为 webpack 的 context)

  • format (boolean = false):如果为 true,则 manifest json 文件 (输出文件) 将被格式化。

  • name:暴露出的 DLL 的函数名(TemplatePaths:[fullhash] & [name] )

  • path:manifest.json 文件的 绝对路径(输出文件)

  • entryOnly (boolean = true):如果为 true,则仅暴露入口

  • type:dll bundle 的类型

在给定的 path 路径下创建一个 manifest.json 文件。这个文件包含了从 require 和 import 中 request 到模块 id 的映射。 DllReferencePlugin 也会用到这个文件。此插件与 output.library 的选项相结合可以暴露出(也称为放入全局作用域)dll 函数。

// webpack.dll.js

const path = require("path");
const webpack = require("webpack");
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  mode: "production",
  entry: {
    // 这里可以配置多个入口,多个入口对应多个输出
    // vue: ["vue"],
    // lodash: ["lodash"],
    // element: ["element-ui"]
    dll: ["vue", "lodash", "element-ui"],
  },
  output: {
    path: path.resolve(__dirname, "dll"),
    filename: "_dll_[name].[contenthash:4].js",
    library: "_dll_[name]",
  },
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        extractComments: false, // 不启用注释剥离的功能(启用后会将注释剥离至 [模块名].LICENSE.txt 文件中)
      }),
    ],
  },
  plugins: [
    new webpack.DllPlugin({
      name: "_dll_[name]", // 这里必须和output.library一致
      path: path.resolve(__dirname, "dll/[name].manifest.json"),
      entryOnly: true,
    }),
  ],
};

我们使用这个配置进行 build "dll": "webpack --config ./webpack.dll.js",,此时运行 yarn dll 时就会进行打包,生成 dll 信息:

dll 
├─ _dll_dll.0333.js 
└─ dll.manifest.json

_dll_dll.0333.js 就是入口 dll 中各个模块代码,dll.manifest.json 就是模块映射:

{
  "name": "_dll_dll",
  "content": {
    "./node_modules/vue/dist/vue.runtime.esm.js": {
      "id": 144,
      "buildMeta": {
        "exportsType": "namespace"
      },
      "exports": ["default"]
    },
    "./node_modules/element-ui/lib/element-ui.common.js": {
      "id": 4720,
      "buildMeta": {}
    },
    "./node_modules/lodash/lodash.js": {
      "id": 6486,
      "buildMeta": {}
    }
  }
}

需要注意的是:建议 DllPlugin 只在 entryOnly: true 时使用,否则 DLL 中的 tree shaking 将无法工作,因为所有 exports 均可使用。

entryOnly (boolean = true):如果为 true,则仅暴露入口 从这句话可以看出,entryOnly 限制我们 mainfest 中的映射是否只包含入口,默认设置为 true,这样打包后的 mainfest 文件就如上面,如果设置为 false,mainfest 将包含所有模块依赖之间的 require 和 import 获取到的 request moduleID,这样最终打包时就觉得所有的 exports module 都是可用的,无法通过 tree shaking 进行优化。

2)DllReferencePlugin

此插件配置在 webpack 的主配置文件中,此插件会把 dll-only-bundles 引用到需要的预编译的依赖中。

  • context:(绝对路径) manifest (或者是内容属性)中请求的上下文

  • extensions:用于解析 dll bundle 中模块的扩展名 (仅在使用 'scope' 时使用)

  • manifest :包含 content 和 name 的对象,或者是一个字符串 —— 编译时用于加载 JSON manifest 的绝对路径

  • content (可选): 请求到模块 id 的映射(默认值为 manifest.content)

  • name (可选):dll 暴露地方的名称(默认值为 manifest.name)(可参考 externals)

  • scope (可选):dll 中内容的前缀

  • sourceType (可选):dll 是如何暴露的 (libraryTarget)

通过引用 dll 的 manifest 文件来把依赖的名称映射到模块的 id 上,之后再在需要的时候通过内置的 webpack_require 函数来 require 对应的模块

这里需要注意的是 name 必须和 output.library 一致。

// webapck.config.js

...

plugins: [
  ...

  new webpack.DllReferencePlugin({
    name: '_dll_dll',
    context: path.resolve(__dirname, './'),
    manifest: path.resolve(__dirname, './dll/dll.manifest.json')
  }),
  new AddAssetHtmlPlugin({
    outputPath: 'js',
    filepath: path.resolve(__dirname, './dll/_dll_dll.0333.js')
  })

  ...
]

...

DllReferencePlugin 的 context 就是 manifest 中路径映射的上下文,比如 ./node_modules/vue/dist/vue.runtime.esm.js 就是当前项目的根目录,它会找到打包的 js 文件 _dll_dll.0333.js,然后通过 AddAssetHtmlPlugin 拷贝至 relase 中,具体目录由 outputPath 限制,然后再通过 script 标签在 html 模板中添加引用:

<!-- 三方库打包js -->
<script defer src="./js/_dll_dll.0333.js"></script>
<!-- 模块加载 -->
<script defer src="./js/runtime~main.34a69.js"></script>
<!-- 包含index文件的依赖模块信息 -->
<script defer src="./js/main.fc387.js"></script>

现在打包都已经完成了,通过引用 dll,打包的速度非常快,大概只需要 677ms,而不使用 dll 打包需要 1732ms。

现在我们看一下我们的入口文件:

// src/index.js

import _ from "lodash";

import Vue from "vue";

import { Message } from "element-ui";

console.log(_.add(2, 3));

new Vue({
  el: "#root",
  data: {
    message: "Hello world",
  },
});

Message.success({ message: "Hello World" });

我们引用了三个库,此时打包后生成的 main.fc387.js 文件如下:

(self["webpackChunkwebpack5_entry"] =
  self["webpackChunkwebpack5_entry"] || []).push([
  ["main"],
  {
    "./src/index.js": function (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) {
      "use strict";
      eval(
        ...
      );
    },

    "./node_modules/vue/dist/vue.runtime.esm.js": function (
      module,
      __unused_webpack_exports,
      __webpack_require__
    ) {
      eval(
        'module.exports = (__webpack_require__(/*! dll-reference _dll_dll */ "dll-reference _dll_dll"))(144);\n\n//# sourceURL=webpack://webpack5-entry/delegated_./node_modules/vue/dist/vue.runtime.esm.js_from_dll-reference__dll_dll?'
      );
    },

    "./node_modules/element-ui/lib/element-ui.common.js": function (
      module,
      __unused_webpack_exports,
      __webpack_require__
    ) {
      ...
    },

    "./node_modules/lodash/lodash.js": function (
      module,
      __unused_webpack_exports,
      __webpack_require__
    ) {
     ...
    },

    "dll-reference _dll_dll": function (module) {
      "use strict";
      module.exports = _dll_dll;
    },
  },
  function (__webpack_require__) {
    // webpackRuntimeModules
    "use strict";

    var __webpack_exec__ = function (moduleId) {
      return __webpack_require__((__webpack_require__.s = moduleId));
    };
    var __webpack_exports__ = __webpack_exec__("./src/index.js");
  },
]);

所有的模块依赖都指向了 dll。但是我们配置 dll 打包看起来非常麻烦,一旦配置错误就会导致加载失败,AutoDllPlugin 就会帮我们自动完成以上的两个步骤:

// webpack.config.js

new AutoDllPlugin({
  inject: true, // 设为 true 就把 DLL bundles 插到 index.html 里
  filename: "[name].dll.js",
  context: path.resolve(__dirname, "../"), // AutoDllPlugin 的 context 必须和 package.json 的同级目录,要不然会链接失败
  entry: {
    dll: ["lodash", "vue", "element-ui"],
  },
});

14. HardSourceWebpackPlugin

dll 是一种不错的webpack打包优化实施方案,但是我们发现 vue-cli 和 create-react-app 都取消了dll,因为他们认为webpack4的打包效率已经够可以的了,实际上也的确如此,随着webpack的版本不断更新,webpack团队对于打包效率的提升做出了相当大的改善。

HardSourceWebpackPlugin 也是一种提高打包效率的工具。

HardSourceWebpackPlugin 是 webpack 的一个插件,用于为模块提供中间缓存步骤。 为了查看结果,您需要使用此插件运行 webpack 两次:第一次构建将花费正常的时间。 第二个构建将显着更快。

字面上看其实也是利用前一次的打包缓存减少后续打包的时间消耗,这个插件在webpack5之前适用,webpack5支持缓存开箱即用,这里不做过多介绍。

15. scope hoisting

过去 webpack 打包时的一个取舍是将 bundle 中各个模块单独打包成闭包。这些打包函数使你的 JavaScript 在浏览器中处理的更慢。相比之下,一些工具像 Closure Compiler 和 RollupJS 可以提升(hoist)或者预编译所有模块到一个闭包中,提升你的代码在浏览器中的执行速度。

webpack 作用域提升,比如我们上面 src/index.js 里面对于 src/util/index.js 里面 sum 和 sql 函数的引用,在打包时 src/index.jssrc/util/index.js 会被打包成两个module,此时这两个方法的查找就需要到另一个模块中去,效率肯定是低的,那么 scope hoisting 就可以将引用到的 sum 和 sql 方法打包至index module中,此时就无需从其它模块中查找了,效率会有所提升。

// webpack.config.js

new webpack.optimize.ModuleConcatenationPlugin()

16. Css-TreeShaking

sideEffects 一般用于js文件的优化,而我们的css文件中也存在一些不被使用到的样式代码,对于这些不被使用的样式代码我们可以使用 PurgecssPlugin 进行优化:

// webpack.config.js

new MiniCssExtractPlugin({
  filename: "[name].css",
}),
new PurgecssPlugin({
  paths: glob.sync(`${path.resolve(__dirname, './src')}/**/*`,  { nodir: true }),
}),

其中glob是需要自己安装的,它会查找匹配目录下的所有css代码进行优化。

17. 资源压缩

我们最终放置在服务器上的资源如果没有经过压缩处理的话,在应用访问时,获取的资源如果特别大就需要消耗大量的时间,体验很差。我们可以对打包后的资源进行压缩处理,生成压缩后的资源,然后放置在服务器上,再次请求资源时,小体积的资源会节省大量的加载时间,提高使用体验。

插件 CompressionWebpackPlugin 就能实现资源压缩:

// webpack.config.js

const CompressionPlugin = require("compression-webpack-plugin");

module.exports = {
  plugins: [new CompressionPlugin(
    test: /.(css|js)$/, // 设置哪些资源需要被压缩
    include: "", // 包含哪些资源
    exclude: "", // 排除哪些资源
    algorithm: "gzip", // 采用哪种算法,一般默认就是gzip,兼容性比较好,当然也可以设置为br,br对于老的浏览器兼容性不怎么好
    threshold: 0, // 只有大于这个配置的文件才被压缩
    minRatio: 0.8, // 压缩比例,一般设置为0.8,压缩算法有自己的压缩极限
    deleteOriginalAssets: false, // 压缩后是否删除源资源 Boolean | 'keep-source-map' ,可以设置为布尔值或者 'keep-source-map' 保留source-map文件
  )],
};

这里需要注意的是,如果使用nginx做了web代理,需要开启gzip我们的压缩资源才能正常使用:

# 开启gzip
gzip on;

# 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
gzip_min_length 1k;

# gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间,后面会有详细说明
gzip_comp_level 1;

# 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png application/vnd.ms-fontobject font/ttf font/opentype font/x-woff image/svg+xml;

# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;

# 禁用IE 6 gzip
gzip_disable "MSIE [1-6]\.";

# 设置压缩所需要的缓冲区大小     
gzip_buffers 32 4k;

# 设置gzip压缩针对的HTTP协议版本,没做负载的可以不用
# gzip_http_version 1.0;

webpack 文件 hash 命名

服务器会对静态资源设置缓存,并设定有效期,当缓存有效期较长时,我们发布了新的版本,缓存就会导致页面无法刷新,新功能无法及时呈现给用户,导致生产故障,此时我们可以通过 hash 来生成文件名,每次新的修改都会生成不同的文件名,避免缓存导致的新功能不能展现的问题。

  • hash 命名 [name].[hash].js 项目级别的命名,会根据项目生成相同的 hash 值参与命名,这种方法不建议使用,它会使缓存变得没有意义,每发布一次都会全量更新资源,所有文件都会变更,使用应用时会全部请求最新的资源
  • chunkhash 同一个流的文件使用同一个 hash 参与命名,比如从 js 文件剥离出去的 css 文件,它会和源 js 文件使用同一个 hash
  • contenthash 基于文件级别的 hash 命名,它会根据单个文件的变化,使用不同的 hash 来参与命名,每当文件变化,打包时都会生成不同的 hash 值参与命名, contenthash 可以设置具体的 hash 位数 contenthash:8 ,它只会生成 8 位字符的 hash。这是也是建议使用的命名方法。