突破桎梏(八):前端模块化开发 - Webpack

大家好,我是醒途,自己一直处在迷茫中,却还有那么点自知自制的能力,希望能和诸君从当下开始,在迷途中觉醒,一起突破桎梏。

这是迄今为止我在【突破桎梏】系列中耗时最长的文章,内容繁杂和加班等各种原因吧,鸽了快一个月,中间还跨了年写下了 我的 2021 年度规划 … 还望各位老铁多多支持~

先放下【突破桎梏】系列的传送门,诸君有兴趣收个藏关个注:

突破桎梏(七):前端模块化开发 - 开篇

突破桎梏(六):JavaScript 类型系统方案:Flow、TypeScript

突破桎梏(五):一文详解 ECMAScript - 已赞 7 评论 4 已收藏 12

突破桎梏(四):JavaScript 异步编程之 Promise - 已赞 7 评论 3 已收藏 2

突破桎梏(三):单线程的 JavaScript - 已赞 5 评论 2 已收藏 3

突破桎梏(二):函数式编程 - 已赞 16 评论 12 已收藏 22

突破桎梏(一):开锋

以下正文:

上次我们聊过了 “模块化开发的演进过程以及 ES Modules模块化方案”,今天我们要聊的就是风靡前端界的模块打包工具 - Webpack。

全文一万八千字,有点长,建议阅读时间:… emm,看在我努力整理的份儿上多看几眼吧…

好了好了… 我们仍然从大纲开始,先了解一下今天的主题,希望读者朋友们能够坚持看下去(摸着良心说)。

目录

  1. 前端模块打包工具诞生的必要性
  2. Webpack 简述、模块打包工具“必备”功能
  3. Webpack 快速上手
  4. Webpack 配置文件
  5. Webpack 打包结果运行原理(的调试过程)
  6. Webpack 加载器
  7. Webpack 导入资源模块
  8. Webpack 文件资源加载器(file-loader)
  9. Webpack URL 加载器(url-loader)
  10. Webpack 常用加载器分类
  11. Webpack 与 ES 2015
  12. Webpack 加载资源的方式
  13. Webpack 核心工作原理
  14. Webpack Loader 的工作原理(实现一个 markdown-loader)
  15. Webpack 插件机制基本作用
  16. Webpack 常用插件
  17. Webpack 插件实例开发、插件总结
  18. Webpack 开发体验问题
  19. Webpack 自动编译
  20. Webpack 自动刷新浏览器
  21. Webpack Dev Server

中间休息加同步,我这么良心!!

  1. Webpack 静态资源访问
  2. Webpack Dev Server 代理 API 以解决跨域
  3. Webpack Source Map 简述
  4. Wepback 配置 Source Map
  5. Webpack eval 模式的 Source Map
  6. Webpack 自动刷新问题
  7. Webpack HMR(Hot Module Replacement 模块热更新)
  8. Webpack HMR API
  9. Webpack 不同环境下的配置与优化
  10. Webpack 内置插件/内置功能
  11. Webpack Tree Shaking 、Babel
  12. Webpack 合并模块
  13. Webpack sideEffects
  14. Webpack 代码分割
  15. Webpack 多入口 打包
  16. Webpack 提取公共模块
  17. Webpack 动态导入
  18. Webpack 魔法注释
  19. Webpack MiniCssExtractPlugin
  20. Webpack OptimizeCssAssetsWebpackPlugin
  21. Webpack 输出文件名 Hash
  22. Webpack 指定 Hash 长度

1. 前端模块打包工具诞生的必要性

模块化很好地解决了我们在复杂应用开发过程当中的代码组织问题,但由于我们引入了模块化,在代码中出现了新的问题:

  • ES Modules 存在环境兼容问题(我们并不能统一用户的浏览器使用习惯)
  • 零散的模块文件过多,导致网络请求频繁
  • 随着应用的日益复杂,我们的HTML、CSS等资源文件也都会面临同样的问题(需要模块化)。而它们实际上也能够被看作是一个模块,只是模块的类型不同。这也不能说是问题,但是毋庸置疑的,资源的模块化同样地是必要的。

因为以上问题所在,我们希望在模块化方案的基础上拓展出能够解决上述问题的更优方案。

整理一下针对这些问题期望拓展出的功能:

  1. 将存在兼容问题的代码编译至能够在任一浏览器正常运行的代码,这也是模块化最基础的功能,比如:ES6 -> ES5。
  2. 打包出的模块化代码过于零散,我们就期望可以把零散的模块以更优的组合方式打包在一起,比如:各模块JS -> Bundle.js,这也就解决了由于模块过多导致的请求频繁问题。有的同学会有问题了:我们使用模块化方案不就是希望使代码更清晰更易维护吗?为什么还要打包在一起?这是不是背离初衷呢?答案那必然是不是的,我们在开发阶段将代码以模块的形式解耦更好的组织我们的代码就够了,但是对于运行环境实际是没有这个必要的,所以在生产阶段我们就可以把它们再打包在一起。
  3. 我们期望模块打包工具能够支持不同种类的前端资源类型,比如:CSS、图片、字体等等资源文件,这样我们就能够在开发时把这些资源也当作模块来使用,这样在我们前端应用来讲就会有一个统一的模块化方案了。(之前的模块化方案仅针对 JS,满足这点话模块化方案就针对的是整个前端应用了,是应用级的)

综上所述,可以简要概括如下:支持新特性代码编译、能够模块化 JavaScript 打包、支持不同类型的资源模块。

针对代码编译和 JS 打包这两个需求,都知道通过 Babel 等编译工具和构建系统配合就能够解决,但支持不同类型的资源模块这个需求,使用这些已知的方案还是束手无策。那么接下来,就该让我们了解一下【前端模块化开发】的主题之一:前端模块打包工具

相信通过前言你已经明白为什么是前端而不是 JS 打包工具了

2. Webpack 简述、模块打包工具“必备”功能

前端领域目前已经出现了能够解决上面问题的工具,其中最为主流的就是:Webpack、Parcel、Rollup,它们需要满足一些“必备”的功能:Code Splitting、Hashing、Importing Modules、Non-JavaScript Resources、Output Module Formats、Transformations。

我们在本篇文章将首先介绍 Webpack 相关内容,不过我们先就两个“必备”功能简单了解下这些模块打包工具所解决的部分问题:
代码拆分功能(Code Splitting)。这个功能能够帮助我们将代码按照我们希望的组合方式打包在一起,这样就能够解决无脑的把所有代码打包在一起造成文件过大的问题(打包到一起那么大的 JS 文件那得要请求到什么时候哟!)。这个功能的应用实际场景:我们能够把用户首次访问页面必须加载的模块打包在一起,其它模块按页面功能分开打包,这样用户进入页面后浏览器就只会去请求首屏页面所需的 JS 文件啦,当用户跳转到其它页面时,浏览器就才会异步加载当前页面所需的模块 JS。悄咪咪地告诉你,这样渐进式的加载方式就叫做增量加载(or 渐进式加载)。这就完美解决了我们所担心的文件太碎或者是文件太大的极端问题了。
非 JavaScript 类型资源导入的支持能力(Non-JavaScript Resources)。这个功能让我们能够在 webpack 当中就可以通过 JavaScript 直接 Import 一个 CSS 文件,这些 CSS 文件实际上会使用 Style 标签的方式去工作。其它类型的静态资源也会通过类似的方式去实现。

你可以访问 Tooling.Report 了解更多内容

Webpack 作为主流打包工具之一,它必然具备上面几个功能。Webpack 本身为模块打包器(Module bundler),就已经能解决打包零散代码为一个 JS 文件的问题了,而对于将存在环境兼容问题的代码我们就可以在它打包的过程中通过模块加载器(Loader)对其进行编译转换。其它功能也都具有相应的配置项或 Loader 来解决。

在这里我们需要再次声明一下:打包工具解决的是前端整体的模块化,并不单单指 JavaScript 模块化。

3. Webpack 快速上手

Webpack 作为目前业内最主流的 前端模块打包器 它提供了一整套前端项目模块化方案,这样我们通过 Webpack 就可以很轻松的对前端项目涉及到的所有的资源进行模块化。我们下面通过一个案例了解一下它的基本使用,当然,我们首先要安装它:

// 首先初始化一下 package.json
npm init -f

// 安装 webpack 和 webpack-cli
npm install webpack webpack-cli -D

// 再全局安装一下 browser-sync 以通过服务的形式启动我们的示例,具体原因可查看上一篇文章
npm install browser-sync -g

示例代码:
index.html


<html lang="en">
<body>
    <script type="module" src="src/index.js">script>
body>
html>

src/index.js

import { alertHello } from './heading.js'

alertHello()

src/heading.js

function alertHello() {
    alert('hello')
}
export { alertHello }

在示例根目录使用命令启动服务

browser-sync .

我们使用 webpack 命令尝试打包一下 src 目录的 JS 代码:

npx webpack 

输出结果会自动创建 dist 目录并将打包结果存放于此
突破桎梏(八):前端模块化开发 - Webpack_第1张图片

我们可以尝试使用 dist/main.js 查看结果与原始代码相比是否一致。这里值得一提的是,我们此时可以将 type=“module” 去掉了,因为代码不再是 import/export ESM 方式了。


<html lang="en">
<body>
    <script src="dist/main.js">script>
body>
html>

我们的示例仍然可以正常运行。

4. Webpack 配置文件

Webpack 默认入口是 src/index.js,且默认打包输出出口是 dist/main.js。

因为我们这里只讲述 Webpack,并没有配合 Vue/React 去使用,所以我们就单单介绍下 webpack 独立使用时的配置文件的使用,和框架相关的内容我们后续再讲。

Webpack 会默认读取项目根目录下 webpack.config.js 文件,该文件是运行在 node 环境的 js 文件,那么我们就可以使用 CommonJS 的方式书写代码。

const path = require('path')
const Timestamp = new Date().getTime()

module.exports = {
    // 1. 自定义入口路径
    entry: './src/index.js',
    // 2. @filename - 自定义输出文件名、输出路径
    //    @path - 输出路径必须是绝对路径
    output: {
        filename: `[name].${Timestamp}.js`,
        path: path.join(__dirname, 'dist')
    },
    // 3. mode
    mode: 'development'
}

其次,Webpack 在打包时会有多种模式,比如开发模式还是测试模式还是上线模式(打比方)。我们可以通过在命令上加上 mode 参数指定模式,我们也可以像上面配置那样使用 mode 参数:

// 默认 mode 值为 production,可选值有:development、none
// production 的方式会自动优化打包结果:压缩之类的
// development 的方式会自动优化打包速度,并增加一些开发时的辅助到我们的代码当中
npx webpack --mode development

5. Webpack 打包结果运行原理(的调试过程)

emmmmmm… 运行原理最主要的就是从 webpack 的运行过程中一步步的调试查看,要是详细说的话字数可能就收不住了,而且文字化的话会异常的复杂晦涩(容易导致“太长不看”),再而且亲历亲为的去调试更能加深自己的理解,所以我就单写下调试过程咯,分为以下几步:

  1. 上一小节中的 mode 改为 “none”
  2. 执行 “npx webpack” 进行打包
  3. 把 index.html 里引入的 js 改为打包后的 js,如:
<script src="./dist/main.1609169832891.js"></script>
  1. 使用 "browser-sync . " 运行起来 index.html
  2. F12 打开调试工具,找到 sources 再找到 main.1609169832891.js (以我自己的为例)
  3. 打开后,在下图标识出的行代码“单击行号”打上断点

突破桎梏(八):前端模块化开发 - Webpack_第2张图片

  1. 刷新页面,在上图第四个框那里点击,一步步进行调试,然后除了观察啊每一步的跳转外,还需观察右侧 Local 里的变量值随着每一步的变化

上面几步就是我们查看 webpack 运行原理的基本步骤,到这里每一步 webpack 做了什么我就不多加赘述了,这个非常考验我们的代码调试能力,就看诸位对细节的把控程度了…。

以我这个文科出身的思维逻辑来看,webpack 打包后的代码实际上并不复杂,它只是把模块都组合放在了同一个文件当中,并会在这一文件里保持模块之间的依赖关系

6. Webpack 加载器

针对其它资源模块的加载规则的配置,每个规则对象都要设置两个属性
css loader 的作用就是将 css 文件转换为一个 js 模块
style loader 的作用就是把 css 文件转换的结果 通过 style 标签增加到页面上
多个 loader 是从后往前执行的
通过 loader 可以实现加载任何类型的资源

7. Webpack 导入资源模块

打包入口某种程度上来说就是应用的运行入口
目前而言,前端应用的业务是由 JavaScript 驱动的,所以总是把 js 文件作为打包的入口

  module: {
    rules: [
      {
        test: /.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },

JavaScript 驱动整个前端应用

  • 逻辑合理,JS需要这些资源文件
  • 确保上线资源不缺失

要学习新事物的思想,搞明白新事物的设计,而不只是去使用

8. Webpack 文件资源加载器(file-loader)

图片、字体等文件无法用 JS 来表示,那我们如何处理?那就要用到文件资源加载器 file-loader
文件夹加载器的作用:把代码中涉及到的文件拷贝到输出目录,拷贝过去的路径作为该文件资源模块的返回值返回,这样代码中就能够获取到通过模块引用的文件路径了

  • 安装 file-loader
npm install file-loader -D
  • 使用示例
module: {
  rules: [
    // 文件加载器,把代码中涉及到的文件拷贝到输出目录,拷贝过去的路径作为该文件资源模块的返回值返回
    {
      test: /.jpg$/,
      use: ["file-loader"],
    },
  ],
}

9. Webpack URL 加载器(url-loader)

借助 img 标签我们能知道:img 标签的 src 属性能够使用 url 以及 base64 来展示一张图片

file-loader 的方式能够拷贝文件并生成文件路径

而除了这种方式,我们还能够将文件转为 base64 的方式呀,这样文件拷贝操作都可以省略了,文件转换的数据会直接写入到打包的代码里
所以 url-loader 就登场啦,它可以把任意类型的文件转为 DataUrl 这种格式(DataUrl 里包含了文件的二进制 base64 数据),这样我们就可以通过 DataUrl 表示任意类型的文件了。

  • 安装 url-loader
npm install url-loader -D
  • 使用示例
module: {
  rules: [
    // 文件加载器,把代码中涉及到的文件拷贝到输出目录,拷贝过去的路径作为该文件资源模块的返回值返回
    {
      test: /.jpg$/,
      use: ["url-loader"],
    },
  ],
}
  • 总结

如果文件体积过大就会导致我们的打包结果会非常大,从而影响应用的运行速度。我们的最佳实践就是对小文件使用 Data URL,减少应用发送请求的次数;大文件单独提取存放,提高应用的加载速度。

  • 最佳实践
module: {
  rules: [
    // 文件加载器,把代码中涉及到的文件拷贝到输出目录,拷贝过去的路径作为该文件资源模块的返回值返回
    {
        test: /.jpg$/,
        use: {
          loader: "url-loader",
          options: {
            limit: 7 * 1024,
          },
        },
      },
  ],
}

上述配置会把超出 7KB 文件单独提取存放,小于 7KB 文件转换为 Data URL 嵌入代码中

最佳实践里虽然我们只看到使用了 url-loader ,但实际上文件的提取存放仍然依赖 file-loader,因此需要安装 file-loader。

10. Webpack 常用加载器分类

  • 编译转换类

这类加载器会把我们代码中加载到的资源模块转换为 JavaScript 代码,如:css-loader 就是把我们的 css 文件转换为了 js 中的 css 模块,从而实现使用 JavaScript 运行我们的 CSS。

  • 文件操作类

这类加载器通常会把我们代码中加载到的资源模块拷贝到输出的目录,同时把这个文件的访问路径向外导出,如:file-loader。

  • 代码检查类

这类加载器通常作用是对我们所加载到的资源文件(如:代码)进行校验,这种加载器的目的是为了统一我们的代码风格,从而去提高代码质量。这类加载器一般不会修改我们生产环境的代码。
这就是我们日常经常使用的加载器的三个大类,后面我们使用到一个加载器时就要先思考一下它的分类是什么,特点和作用是什么,使用过程中需要注意什么。

11. Webpack 与 ES 2015

有很多人以为 Webpack 能够默认处理我们代码中的 import 和 export,那么它也会自动的编译 ES6 的代码吧。实则不然,Webpack 仅仅是对模块进行打包工作,所以它才会对它使用到的 import 和 export 进行相应的转换,它并不能够转换其它的 ES6 特性。
突破桎梏(八):前端模块化开发 - Webpack_第3张图片

能够看到 const 和箭头函数 ES6 新特性并没有被 Webpack 额外处理。
我们希望 Webpack 能够处理这些新特性怎么办呢?我们可以使用一个编译型 Loader:babel-loader。

  • 安装 babel-loader
// babel 只是一个转换平台,它依赖 Babel 核心库 @babel/core
// 我们还需安装用于去完成具体特性转换的插件 @babel/preset-env
npm install babel-loader @babel/core @babel/preset-env
  • 使用示例
module: {
  rules: [
    {
        test: /.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
          },
        },
      },
  ],
}
  • 总结

Wepack 只是打包工具,编译类加载器能够用来编译转换代码

12. Webpack 加载资源的方式

除了代码中的 import 能够去触发模块的加载,Webpack 中还提供了其它几种方式:

  • ES Modules 标准的 import 声明(一直在用的且最常用的)
  • CommonJS 标准的 require 函数,但需要注意导入结果需要使用导入的 default 属性去获取
  • AMD 标准的 define 函数和 require 函数
  • *样式代码中的 @import 指令和 url 函数
  • *HTML 代码中图片标签的 src 属性

也就是说,Webpack 兼容多个模块导入标准,我们的项目最好仅使用其中一个标准。

还需要提到的是,最后两个 * 指的是我们能够看到在项目的样式代码中还会使用 css-loader 中使用的 @import 指令和 url 函数,html-loader 使用中 HTML 代码中图片标签的 src 属性,这些操作都会触发模块加载,Webpack 在打包时就也会将其加入到打包过程中
示例如下:
突破桎梏(八):前端模块化开发 - Webpack_第4张图片

突破桎梏(八):前端模块化开发 - Webpack_第5张图片

这里 background.webp 就会被交给 url-loader 进行处理。

loader 更新很快,因此示例就不过多书写

13. Webpack 核心工作原理

Webpack 会根据我们的配置找到打包入口,一般都会是 js 文件,然后会根据 import 或 require 递归生成依赖树,稍后 Webpack 会遍历这个依赖树,每加载到一个依赖,就会根据 rules 配置的加载器来加载该依赖模块,最后就会把加载结果放到我们的输出目录。
我们能够看到 Webpack 最核心的机制就是 根据 rules 配置的 Loader 加载处理各种不同类型的文件,而 Webpack 自身实际上就仅仅是一个打包各个模块的工具了。

14. Webpack Loader 的工作原理(实现一个 markdown-loader)

我们使用一个示例来实践和叙述 Loader 的工作原理,这里我们实现一个能够在代码中直接引入 markdown 的 Loader。
markdown 通常是会先被转换为 html 后再呈现在页面上的,因此我们实现这个 Loader 的最终效果就是通过 import 引入一个 markdown 文件,输出结果就是该文件转换后的 HTML。
我们创建一个 js 模块,该模块导出一个函数,函数就是我们的 Loader 接收一个资源文件的处理过程,它的输入就是我们加载到的资源文件的内容,输出就是我们加工过后的结果:
webpack.config.js

const path = require("path");

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

src/main.js

import mdContent from "./about.md";
document.write(mdContent)

src/about.md

# 标题


**我是帅比**

markdown-loader.js

module.exports = (source) => {
  console.log(source);
  return "console.log('hello')";
};

如果只使用我们自己的 Loader ,那么该 Loader 工作后的结果必须是一段 JavaScript 代码

打包过程如下图所示:
突破桎梏(八):前端模块化开发 - Webpack_第6张图片

接下来我们使用 marked 对 markdown 语法进行解析并返回解析结果供我们 的代码使用

const marked = require("marked");
module.exports = (source) => {
  // 这里使用 JSON 来转义一次解析后的结果,以免出现语法错误
  return `export default ${JSON.stringify(marked(source))}`;
};

页面渲染如下图所示:
在这里插入图片描述

我们也可以导出 html 本身给其它 Loader 使用:
webpack.config.js

const path = require("path");

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

markdown.js

const marked = require("marked");
module.exports = (source) => {
  return marked(source);
};

页面渲染结果和上面一致,图就不贴了。
综上所述,Loader 的工作原理很简单:负责资源文件从输入到输出的转换。
当然,Webpack 能够对于同一个资源可以依次使用多个 Loader,类似管道的概念。

15. Webpack 插件机制基本作用

插件机制是 Webpack 另一个核心特性,插件的目的是为了增强 Webpack 自动化方面的能力,我们从上面的介绍能够知道 Loader 起到专注实现资源模块的加载的作用,从而实现 Webpack 对项目的整体打包,而 Plugin 则是用来解决除了资源加载以外其它的一些自动化工作,如:在项目打包前自动清除 dist 目录、拷贝静态文件(不参与打包过程的资源文件)至输出目录、压缩打包结果输出的代码。
总之,Webpack + Plugin 能够实现前端工程化当中绝大部分工作,这也是大部分同学理解 Webpack 等于 前端工程化的原因。

16. Webpack 常用插件

  • clean-webpack-plugin

自动清理输出目录的插件。我们之前每次打包都是覆盖掉 dist 目录,上次打包后的文件仍然会存在遗留文件不会清除掉,使用这个插件就能够保证我们项目打包前自动清理 dist 目录。

  • 安装
npm install clean-webpack-plugin -D
  • 使用示例
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = {
  ...
  plugins: [new CleanWebpackPlugin()],
  ...
};

  • html-webpack-plugin

自动生成使用 bundle.js 的 HTML。在这之前我们的 index.html 都是通过硬编码的方式存放在项目根目录下的,这种方式就会出现两个问题:1. html 中资源路径引用名问题;2. 项目发布时需要同时输出 dist 目录和 index.html 两个事物会麻烦些。而解决这两个问题的最佳方法就是通过 Webpack 输出 HTML 文件,也就是说让我们的 index.html 也参与到我们的构建过程中。

  • 安装
npm install html-webpack-plugin -D
  • 使用示例
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
  ...
  output: {
    publicPath: "./",  // 这里需要注意修改为当前目录路径
  },
  plugins: [new HtmlWebpackPlugin()],
  ...
};

Webpack 它是知道自己一共打包出了多少 bundle 的,那么它就可以通过该插件来自动地把所有打包出的 bundle 添加到 index.html 中,这样 html 也会输出到 dist 目录了,而且 html 中的路径引用也是 Webpack 自主注入进去的不会出现路径错误的问题了。
需要注意的是,通过该插件生成的 index.html 中的 内容有时候并不是我们需要的,比如我们希望自定义 Title、自定义元数据标签、自定义 dom 结构等,我们就可以通过修改 html-webpack-plugin 一些属性来进行自定义,示例如下:

const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
  ...
  output: {
    publicPath: "./",  // 这里需要注意修改为当前目录路径
  },
  plugins: [
  	new HtmlWebpackPlugin({
      title: "Webpack Plugin Sample",
      meta: {
        viewport: "width=device-width",
      },
    }),
  ],
  ...
};

生成的 index.html 结果如下:


<html>
  <head>
    <meta charset="utf-8">
    <title>Webpack Plugin Sampletitle>
  <meta name="viewport" content="width=device-width">head>
  <body>
  <script src="./main.js">script>body>
html>

如果我们希望大量修改生成全自定义的 index.html,我们可以通过 模板 的方式去生成,然后让 html-webpack-plugin 通过模板生成 index.html,示例如下:
webpack.config.js

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Webpack</title>
  </head>

  <body>
    <div class="container">
      <h1><%= htmlWebpackPlugin.options.title %></h1>
    </div>
  </body>
</html>

src/index.html


<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Webpacktitle>
  head>

  <body>
    <div class="container">
      
      <h1><%= htmlWebpackPlugin.options.title %>h1>
    div>
  body>
html>

除了这些内容,html-webpack-plugin 还具有其它很多强大的功能,这里就不再一一举例,感兴趣的话,你可以到文章末尾的链接查看更多信息。

  • copy-webpack-plugin

我们项目中还会存在许多不需要参与打包过程的静态资源文件,比如:网站图标 ico 文件等,我们就可以借助 copy-webpack-plugin。

  • 安装
npm install copy-webpack-plugin -D
  • 使用示例
const CopyWebpackPlugin = require("copy-webpack-plugin");
module.exports = {
  ...
  output: {
    publicPath: "./",  // 这里需要注意修改为当前目录路径
  },
  plugins: [
  	new CopyWebpackPlugin({
      patterns: ["public"],
    }),
  ],
  ...
};

以上讲述的常用插件都还存在许多强大功能,且社区中还有成千上百的插件,那么我们就需要掌握结合业务需要去查找所需的插件的能力。

17. Webpack 插件实例开发、插件总结

我们也能够看出,相比于 Loader,Plugin 拥有更宽 能力范围,因为 Loader 只是在模块加载的过程中工作,而 Plugin 则能够触及到 Webpack 工作的每一个环节。

那么,Plugin 是如何工作的?

其实也很简单,Plugin 插件机制其实就是在我们软件开发中最常见到的钩子机制,它有点类似于我们 Web 开发中的事件,在 Webpack 的工作过程中会有很多的环节,那么为了便于扩展,Webpack 几乎给每一个环节都埋下了一个钩子,这样的话,我们去开发插件就只需要在不同的环节节点上挂载不同的任务去扩展 Webpack 的能力,具体的一些预定义好的钩子我们可以参考 Webpack 官方文档:Webpack Hooks

Webpack 规定我们的钩子必须是一个函数或者一个包含 apply 方法的对象,这里我们来实现这样一个需求:去除打包后的 js 文件中无意义的注释字符串,具体实现如下:

class MyPlugin {
  // apply 方法在 Webpack 启动时自动调用
  // compiler 是 Webpack 工作过程中最核心的对象 其中包含了此次构建的所有配置信息
  // 我们也是通过这个对象来注册钩子函数的
  apply(compiler) {
    // tap 方法能够帮助我们在钩子上注册一个钩子函数
    // 这个方法接收两个参数:
    // 1. name 插件名称;
    // 2. 挂在到这个钩子上的函数
    // 函数接收一个参数 compilation 此次打包过程中的上下文,我们所有打包过程中产生的结果都会放到这个对象上。
    compiler.hooks.emit.tap('MyPlugin', compilation => {
      // compilation.assets 能够获取即将写入目录当中的资源文件信息
      // 它的类型为对象,对象中的键为资源文件的名称,值为资源对象
      // 我们能够通过 资源对象的 source 【方法】访问到资源文件的内容
      for (const name in compilation.assets) {
        if (name.endsWith('.js')) {
          // 获取内容
          const contents = compilation.assets[name].source()
          // 修改内容
          const withoutComments = contents.replace(/\/\*\*+\*\//g, '')
          // 覆盖内容,size 为 Webpack 内部要求必须的方法
          compilation.assets[name] = {
            source: () => withoutComments,
            size: () => withoutComments.length
          }
        }
      }
    })
  }
}
module.exports = {
  ...
  plugins: [
  	new MyPlugin()
  ],
  ...
};

上述实现主要考虑的就是该任务的执行时机,也就是我们应该把这个任务挂载到哪个钩子上面,这里我们明显需要在 Webpack 生成了打包结果后且还未生成具体文件前(即将向输出目录输出文件时),我们去实施去除注释的动作,我们在 Webpack 官方文档:Webpack Hooks 中找到了这个钩子:Emit (Executed right before emitting assets to output dir.),很符合我们的需求。

总结:Webpack 插件扩展是通过在生命周期的钩子中挂载任务函数实现的。

18. Webpack 开发体验问题

以上都是在讲 Webpack 基础配置和使用,但这些内容针对我们的开发环境还远远不够,比如我们仍旧要自己手动的编译、手动的刷新浏览器等等,这些都是我们在开发过程中极其影响体验、影响我们开发效率的问题。
那么,我们从我们自身开发需要出发,提出一些能够提升我们开发效率的需求:

  • 希望我们的服务能够以 HTTP 的方式运行,而不是文件的形式去预览,这样能够更接近生产环境的状态,且方便 ajax 请求的使用
  • 我们希望修改源代码后,Webpack 能够自动为我们的源代码完成编译,且浏览器能够及时显示最新的结果,那这样就可以大大减少我们开发过程中的重复操作
  • 我们需要能够提供 SourceMap 支持,使我们能够在运行过程中一旦出现错误,就可以通过错误的堆栈信息快速定位到源代码的位置便于我们调试应用

那么我们如何按照这些需求来增强 Webpack 的开发体验呢,我们接下来详细讲解这些内容。

19. Webpack 自动编译

webpack-cli 存在一种 watch 工作模式,它能够帮助我们解决自动编译的问题,它能够监听文件的变化,一旦文件发生变化它便会帮助我们自动的重新打包。
watch 监视模式使用示例如下:

npx webpack --watch

cli 不会立即结束打包,而是会监视文件变化来自动重新打包

20. Webpack 自动刷新浏览器

我们希望编译过后自动刷新浏览器,我们之前一直使用的 Browser-sync 启动 HTTP 服务工具,它就能够帮助我们实现自动刷新浏览器。
我一般都是安装在全局上,安装如下:

npm install browser-sync -g

使用实例如下:

browser-sync dist --files "**/*"

这时,我们通过 webpack watch 模式启动自动编译监听,再通过 browser-sync 启动服务监听自动刷新浏览器,我们修改源代码后就会发现确实实现了自动编译 + 自动刷新浏览器的需求。

但这些操作太麻烦了,我们需要同时使用两个工具,其次 Webpack 自动打包将内容写入磁盘 Browser-sync 再将内容从磁盘读出去 这样的效率会降低不少,毕竟多出了两步读写操作。

21. Webpack Dev Server

Webpack Dev Server 是 Webpack 官方推出的开发工具,它提供了用于开发的 HTTP Server,且集成了【自动编译】和【自动刷新浏览器】等对开发友好的功能,我们在这里就可以使用它来解决上面使用 browser-sync + webpack watch 效率降低的问题。

Webpack Dev Server 安装如下:

npm install webpack-dev-server -D

Webpack Dev Server 提供了内置命令,运行命令后会自动去使用 webpack 打包应用并启动一个 HTTP Server 且还会自动监听我们的代码变化 ,一旦源文件发生变化,他就会立即重新打包:

npx webpack-dev-server

它为了提高工作效率,它并没有将打包结果写入磁盘(我们可以通过观察到并没有输出 dist 目录来佐证),它会将打包结果暂时存放到内存中,http server 则会从内存中直接读出相应内容来显示,这样 Webpack Dev Server 就会减少很多不必要的磁盘读写操作,从而大大提高我们的构建效率。

我们也能够增加参数使之首次打包后自动启动浏览器:

npx webpack-dev-server --open

中间休息加同步

看了这么久累了吗?
我在实践这些内容时也累,心也累。
同步一下 webpack 相关的版本号吧,不然很有可能你跑不起来

{
	"webpack": "^5.2.0",
  "webpack-cli": "^3.3.12",
  "webpack-dev-server": "^3.11.0"
}

好了好了,再休息一会我们就继续吧,稍后我会把已经使用的配置也放出来同步一下哦~

辛苦啦!

22. Webpack 静态资源访问

Webpack Deb Server 默认会将构建结果输出的文件全部作为开发服务器的资源文件,也就是说只要通过 Webpack 打包输出的文件,我们都能够通过 HTTP 直接访问到,我们就会发现这个问题:静态资源文件并不会参与打包,这些静态资源就不能够直接访问到。那我们就需要额外的配置告诉 Webpack 哪些是不会参与到打包过程中的静态资源。

module.exports = {
  devServer: {
    contentBase: ['./public']
  }
}

上述配置能够给 Webpack Dev Server 指定额外静态资源路径

如果从上面看到这里我们可能会发现,我们在第 16 节 通过 CopyWebpackPlugin 插件已经将 public 目录输出到了 dist 目录,按道理来讲我们就应该在服务能够直接访问到这些静态资源的,事实上却也是这样的,但我们实际上只会将 CopyWebpackPlugin 插件功能在上线前的那次打包使用,平时的开发阶段是不会使用这个插件的,因为我们开发时会频繁执行打包任务的,如果频繁使用这个功能,打包构建效率就会降低了(实际开发过程中,我们会通过 env 环境变量来判断是否使用此插件)。

所以在平常注释掉 CopyWebpackPlugin 后,我们就需要使用 contentBase 来指定额外的静态资源路径了。

当然,contentBase 的真实作用不至于静态资源,它实质上起到的作用就是为 Webpack 额外为开发服务器指定查找资源目录。

23. Webpack Dev Server 代理 API

我们在开发阶段时,Webpack Dev Server 开发服务器会把我们的应用运行在 localhost 的本地端口上,且大部分情况下我们的应用会在上线后部署在后端接口同源地址下,我们在开发阶段时就会遇到由于和后端接口不同源导致的跨域请求问题,当然如果请求的API支持 CORS 跨域资源共享,跨域请求问题就能够被友好的解决掉,而一旦 API 不支持 CORS,这个问题就仍是我们需要亟待解决的。

如何解决开发阶段接口跨域问题:
突破桎梏(八):前端模块化开发 - Webpack_第7张图片

我们只需要在开发服务器当中去配置代理服务,也就是把我们的接口服务代理到本地的开发服务地址,Webpack Dev Server 就支持通过配置直接实现代理,它的配置示例如下:

module.exports = {
  ...
  devServer: {
    // proxy 专门添加代理服务配置的
    // 其中每一个 【键值对】 就是一个代理规则的配置
    // key 就是需要被代理的请求路径前缀,你可以理解为给API接口地址一个别名
    // 这样你在项目中使用 key 作为请求前缀,一旦项目中使用该前缀发生了一次请求
    // webpack dev server 就会自动将这个请求转发到 key 所对应的值中的 target 接口地址上去
    // 这就是代理请求,如下所示
    proxy: {
      "/api": {
        // http://localhost:8080/api/users -> https://api.github.com/api/users
        target: "https://api.github.com",
        // 在正常请求中存在一个值是主机名,代理请求中默认主机名为开发服务器地址即 localhost:8080
        // changeOrigin true 不使用 localhost:8080 作为请求 GitHub 的主机名
        // 而是原有状态:api.github.com。主机名是 HTTP 协议中的相关概念,不明白可以查一下。
        changeOrigin: true,
        // 代理成功后的请求路径上会携带 key 作为路径中的一部分,我们大部分情况下需要去掉
        // pathRewrite 能够替换掉转换后的代理地址中的某些关键字,如下所示
        // http://localhost:8080/api/users -> https://api.github.com/users
        pathRewrite: {
          "^/api": "",
        },
      },
    },
  },
}

24. Source Map 简述

通过构建编译之类的操作,我们可以开发阶段的源代码转换为能够在生产环境中运行的代码,这就会导致我们在生产环境中运行的代码与源代码之间存在着很大的差异(几乎完全不同),这种情况下我们如果要在生产环境调试或者定位错误信息就无从下手了:因为调试和报错都是基于运行代码的,而在生产环境下运行的代码都是经过编译转换后的代码。

Source Map(源代码地图)正是解决上述问题的最佳方案,它能够映射我们转换过后的代码与源代码之间的关系。也就是说,Source Map 相当于一根网线,它能够从编译后的代码位置顺着网线找到源代码的位置,这可给它牛X坏了,这可是代码的逆向解析呢。

许多第三方库都会自带 .map 结尾的 Source Map 文件,这些文件里存储的就是源代码和转换过后的代码之间的映射关系。Source Map 文件里主要有这样几种重要的属性,我以 Jquery-3.4.1.min.map 为示例来解释一下:

{
  // Source Map 版本号
  "version":3,
   // 转换之前源文件的名称。有可能是多个文件转换为一个文件所以类型是数组。
  "sources": ["jquery-3.4.1.js"],
    // 源代码当中使用的一些成员名称,我们编译压缩代码时会将成员名称简写为字母,这样能够压缩代码体积
    // 这里存放的就是源代码中的原始名称
  "names":[
    "global",
    "factory",
    "module",
    "exports",
    ...
  ],
    // Source Map 核心属性,一个 Base64-vlq 编码的字符串,记录的是转换后的代码字符和转换前代码的映射关系
    // 
    "mappings":";CAaA,SAAYA,EAAQC,GAEnB,·····",
}

Source Map 文件如何使用呢?
我们能够在源代码中通过一行注释的方式引用 Source Map 文件:

//# sourceMappingURL=jquery-3.4.1.min.map

我们打开开发人员工具时当发现源代码最后存在上面这行注释时就会自动请求指定的 Source Map 文件,然后根据这个文件的内容逆向解析出对应的源代码以便于我们调试,

总结:Source Map 解决了源代码与运行代码不一致所产生的调试的问题

25. Webpack 配置 Source Map

Webpack 当然有关于Source Map 的相关配置,但是它的配置里存在有许多种模式,这就导致我们一开始接触 Webpack Source Map 的配置就会比较懵,接下来我们就来看一下配置文件种如何进行配置,示例如下:

module.exports = {
	// devtool:配置开发过程中的辅助工具
  // 可以直接配置为 source-map,这样打包后会自动生成 map 文件
  // 源代码文件的最后一行也会出现上面我们讲到的注释引入-sourceMappingURL
  devtool: 'source-map',
}

上面这样配置实际中已经能够在浏览器中调试源代码了。

然而… Webpack 支持了 12 种不同方式的 Source Map 风格,每种 Source Map 风格的生成速度以及生成效果都各不相同,当然显而易见的:生成效果最好的一般生成速度最慢,生成速度最快的效果一般最差。

26. Webpack eval 模式的 Source Map

突破桎梏(八):前端模块化开发 - Webpack_第8张图片

图解:devtool 则是 webpack devtool 能够使用的模式值;build 为初次构建速度;rebuild 为监视模式重新打包速度;production 是否适合在生产环境使用;qualitity 表示生成的 Source Map 质量。

举例说明:eval 模式只能定位到文件且不生成 map 文件,原理是将编译的代码字符串使用 eval 函数运行,在代码字符串后面紧跟上 source URL 标识 该代码字符串源于哪个文件,如下行代码示例所示,在开发人员工具中打印:

eval('console.log("123") //# sourceURL=./foo/bar.js')

能够发现浏览器给出的定位位置为:./foo/bar.js,不再是 VM 虚拟机。

其它说明推荐博客:[webpack] devtool配置对比。

开发时推荐选择 cheap-module-eval-source-map,优势如下:

  • 定位到行就够了
  • 经过 Loader 转换过后的差异较大,我希望看到转换前的
  • 首次慢无所谓,重写打包相对较快

生产环境推荐选择 none,原因如下:

  • Source Map 会暴露源代码
  • 调试是开发阶段的事情,不应该在生产环境进行调试
  • 实现不行就使用 nosources-source-map,只定位文件和行不暴露源代码

27. Webpack 自动刷新问题

上面我们已经了解了 Webpack Dev Server 的基本配置和特性,它给我们提供了友好的开发环境和一个用来调试的开发服务器,使用它就能够是我们更专注于日常编码(自动监视->自动打包->自动刷新浏览器)。

但我们在当前已经配置好的环境里去开发时,就会发现在浏览器的自动刷新功能上存在一个问题:我们输入框里假设已经输入好了一些文字,我们需要在 CSS 文件里调试输入框内文字的样式,当我们修改了 CSS 文件触发了打包机制后,浏览器自动刷新了,我们会讶异的发现… CSS 更新了然而我们输入框内写好的文字却由于刷新导致数据丢失了,这并不能让我们友好的调试针对于代码更新前的任何动态的操作状态。

上述问题的核心在于:自动刷新导致页面状态丢失。

针对这个问题我们只能让页面不刷新,那我们如何在页面不刷新的前提下,模块也可以及时更新呢?我们下面了解一下 Webpack 是怎样解决这个问题的。

28. Webpack HMR(Hot Module Replacement 模块热更新)

热拔插:在一个正在运行的及其上随时插拔设备,机器本身不受设备的影响,且插入的设备能够立即开始工作。

模块热更新的热和热拔插的热是一个道理,模块热更新能够在应用运行过程中实时替换某个模块,应用的运行状态不会因此而改变。

还记得吗?我们在 27 节里遇到的问题就是在应用的运行过程中修改了某个模块,它会导致应用整体刷新从而使页面状态丢失,我们现在希望的就是实现只将刚刚修改的这个模块替换到应用当中,不必去完全刷新应用,这正是热更新所解决的问题。

HMR 是 Webpack 中最强大的功能之一,它能够极大程度的提高开发者的工作效率,我们如何在 Webpack 中使用 HMR 呢?直接上干货,命令如下:

npx webpack-dev-server --hot

当然,Webpack 配置文件里配置 HMR 的,示例如下:

const webpack = require("webpack");
module.exports = {
  plugins: [
    // Webpack 热更新 第一步,Webpack 内置插件
    new webpack.HotModuleReplacementPlugin(),
  ],
  devServer: {
    // Webpack 热更新 第二步
    hot: true,
  } 
}

通过上面配置我们再修改 html 、css 文件,就能够发现浏览器不会整体刷新了。

但是,我们尝试修改 js 文件会发现,浏览器仍然会整体刷新,那是因为 HMR 还需要我们配置一些其它东西才能够完全的正常工作,比如:Webpack 中的 HMR 需要我们手动处理模块热更新逻辑,也就是如何把热更新后的模块替换到页面当中。

那我们又有疑问了,为什么样式文件的热更新是开箱即用的,并不需要我们去额外配置,这是因为样式文件是经过 loader 处理的,而在 loader 中自带处理了 热更新的逻辑,比如 style-loader 里自带了 module.hot 热更新处理判断进行额外逻辑处理。

最主要还是因为样式更新只需要替换我们文件中的 style,而 js 是毫无规律的,函数、对象等等 都是 js 模块里不确定因素。

但是我们也能够发现在平常使用 vue、react 等框架时 webpack 是能够对 js 进行热更新的,原因很简单:框架下的开发,每种文件都是有规律的,那就会有通用的替换办法,且这种脚手架创建的项目内部都集成了 HMR 方案,并不需要我们自己手动额外处理。

总结:我们还需要手动处理 JS 模块修改后的热更新。

29. Webpack HMR API

我们能够使用 HMR 提供的 API 来进行手动处理如何把热更新后的模块替换到页面当中,这块我这里不过多陈述,直接上代码,这里的代码只放内部处理逻辑跑不起来,大家了解就好:
index.js

let lastHeading = heading
// 经过了这个逻辑,浏览器就不会整体刷新了
module.hot.accept('./heading.js', () => {
	// heading.js 模块更新的处理逻辑
  document.body.removeChild(lastHeading)
  const newHeading = createHeading()
  document.body.appendChild(newHeading)
  lastHeading = newHeading
})

上面能够发现 JS 处理逻辑真的不通用。

图片的热更新更简单,示例如下:

module.hot.accept('./better.jpg', () => {
	img.src = background
  console.log(background)
})

PS:HMR 注意事项

  • 处理 HMR 的代码报错会导致自动刷新

解决方法为如下配置 Webpack:

devServer: {
  // Webpack 热更新 第二步 hot 改为 hotOnly
  // 无论是否触发热替换,浏览器都不会自动刷新了
  hotOnly: true,
} 
  • module.hot 是 HotModuleReplacementPlugin 内置插件提供的
  • 代码中多了很多与业务无关的代码

生产环境打包后是会自动把手动处理的热更新逻辑删除的,不影响

30. Webpack 不同环境下的配置与优化

Hot Module Replacement 让我们在开发环境有更好的开发体验,然而随着我们开发体验的同时,我们通过 Webpack 打包出的文件结果也随之愈来愈显得臃肿,这正是因为 Webpack 为了在实现一些特性,它会自动往打包结果中添加一些额外的内容,例如 Source Map 和 HMR 都会往打包输出结果当中添加额外的代码来实现各自的功能,而这些额外的代码对于生产环境则是冗余的。
我们在生产环境注重的是更少量更高效的代码完成业务功能,即更注重运行效率,这和开发环境只注重开发效率不同,因此,Webpack 配置中的 mode 属性正是为了分开在多种环境下所注重各不相同方面来进行预设配置的。

当 mode 为 production 生产环境参数时,Webpack 就为我们带来了许多已经预设好的针对生产环境的配置。

同时,Webpack 也同时建议我们 为不同的工作环境创建不同的配置,以此来适应于不同的环境。

接下来我们尝试为不同的环境创建不同的 Webpack 配置。

我们平常在项目里也能发现项目会根据当前环境变量使用不同的环境配置,通常如下:

配置文件根据环境不同导出不同配置

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

// Webpack 也可以导出一个函数来进行配置
// 这个函数接收两个参数:env -> 通过 cli 传进的环境名参数 argv 运行; cli -> 传递的所有参数
module.exports = (env, argv) => {
  const config = {
    entry: "./src/index.js",
    output: {
      filename: `[name].js`,
      path: path.join(__dirname, "dist"),
    },
    // mode 环境变量
    mode: "none",
    devtool: "source-map",
    devServer: {
      port: 8080,
      open: true, // 自动打开浏览器
      hot: true,
      contentBase: ["./public"],
      proxy: {
        "/api": {
          target: "https://api.github.com",
          changeOrigin: true,
          pathRewrite: {
            "^/api": "",
          },
        },
      },
    },
    module: {
      rules: [
        {
          test: /.js$/,
          use: {
            loader: "babel-loader",
            options: {
              presets: ["@babel/preset-env"],
            },
          },
        },
        {
          test: /.css$/,
          use: ["style-loader", "css-loader"],
        },
        {
          test: /(.jpg|.webp)$/,
          use: {
            loader: "url-loader",
            options: {
              limit: 7 * 1024,
            },
          },
        },
      ],
    },
    plugins: [
      new HtmlWebpackPlugin({
        title: "Webpack Plugin Sample",
        meta: {
          viewport: "width=device-width",
        },
        template: "./src/index.html",
      }),
      new webpack.HotModuleReplacementPlugin(),
    ],
  };
  if (env === "production") {
    config.mode = "production";
    config.devtool = false;
    config.plugins = [
      ...config.plugins,
      new CleanWebpackPlugin(),
      new CopyWebpackPlugin({
        patterns: ["public"],
      }),
    ];
  }
  return config;
};

接着我们在命令行运行如下命令进行打包:

// 给 webpack 传递一个参数
// 我们能够根据 publish 目录中的文件是否有拷贝过来去判断是否生效
npx webpack --env production

这种方式只适合适用于中小型项目,因为一旦项目变得复杂我们的配置文件也会变得复杂起来。

不同环境对应不同配置文件

我们需要将不同环境下共同给的配置进行抽离,然后再在不同环境下独立出去的配置文件中对公共配置进行继承和补充。
我们需要首先安装一个用于合并 Webpack 配置项的库:

// 我们不能浅显的使用 Object.assign 亦或 lodash.merge 去合并
// webpack 专业的配置合并库让我们更方便的合并这些配置项
npm install webpack-merge --dev

webpack.common.js

// webpack 在不同环境下都被使用的 公共配置
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = (env, argv) => {
  const config = {
    entry: "./src/index.js",
    output: {
      filename: `[name].js`,
      path: path.join(__dirname, "dist"),
    },
    mode: "none",
    module: {
      rules: [
        {
          test: /.js$/,
          use: {
            loader: "babel-loader",
            options: {
              presets: ["@babel/preset-env"],
            },
          },
        },
        {
          test: /.css$/,
          use: ["style-loader", "css-loader"],
        },
        {
          test: /(.jpg|.webp)$/,
          use: {
            loader: "url-loader",
            options: {
              limit: 7 * 1024,
            },
          },
        },
      ],
    },
    plugins: [
      new HtmlWebpackPlugin({
        title: "Webpack Plugin Sample",
        meta: {
          viewport: "width=device-width",
        },
        template: "./src/index.html",
      }),
      new webpack.HotModuleReplacementPlugin(),
    ],
  };
  return config;
};

webpack.prod.js

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

module.exports = merge.merge(common(), {
  mode: "production",
  devtool: false,
  plugins: [
    new CleanWebpackPlugin(),
    new CopyWebpackPlugin({
      patterns: ["public"],
    }),
  ],
});

webpack.dev.js

const merge = require("webpack-merge");
import common from "./webpack.common";
module.exports = merge(common, {
  mode: "development",
  devtool: "source-map",
  devServer: {
    port: 8080,
    open: true, // 自动打开浏览器
    hot: true,
    contentBase: ["./public"],
    proxy: {
      "/api": {
        target: "https://api.github.com",
        changeOrigin: true,
        pathRewrite: {
          "^/api": "",
        },
      },
    },
  },
});

我们可以使用如下命令指定 Webpack 运行的配置文件:

npm run webpack --config webpack.prod.js

不要担心命令多的记不住,记得哦~,我们可以通过 npm scripts 配置来简化使用命令的:
package.json

{
  ...
  "scripts": {
    "build": "webpack --config webpack.prod.js"
  },
  ...
}

这样我们就只需要在命令行运行:

npm run build

31. Webpack 内置插件/内置功能

在 Webpack 4 之后,在生产环境下内置开启了许多自动优化的功能,而作为初学者的话,开箱即用就会导致我们忽略 许多我们应该了解的东西,那么我们就不能够仅仅通过 Webpack 配置去使用,而是应该多了解了解 Webpack 这些配置项的背后是如何优化我们的打包结果的,这样我们才能够学到许多,不至于我们在需要调优时不知所措。

DefinePlugin

为代码注入全局成员,例如向我们的代码全局注入 process.env.NODE_ENV,这样我们就可以在任何地方判断当前运行环境是否生产环境从而做一些特定的事情。
webpack.config.js

const webpack = require("webpack");
module.exports = {
  mode: "none",
  entry: './src/main.js',
  output:{
		filename: 'bundle.js'
	},
  plugins:[
    // DefinePlugin 接收一个对象参数
    // 该参数中所有属性都将被注入到我们代码中去
    // 需要注意的是,该参数的所有值都是 JS 代码片段,不是字符串 
    // !!!!!!!↑↑↑
  	new webpack.DefinePlugin({
    	API_BASE_URL: JSON.stringify("https://api.example.com")
    })
  ]
}

src/main.js

consile.log(API_BASE_URL)

我们把上面配置和文件打包后,就能够在打包结果中找到打包后的代码,这里我就不放图了。

Tree-shaking

Tree-shaking(摇树)的作用是用来“摇掉”代码中未引用的部分冗余代码,这部分的代码专业术语叫“未引用代码(dead-code)”。
Webpack 中就内置了这个功能,它能够自动检测到未引用代码,然后删除它们。示例如下:
components.js

export const Button = () => {
	return document.createElement('button')
  // 下面这行代码就是未引用代码
  console.log('dead-code')
}
export const Link = () => {
  return document.createElement('a')
}

index.js

import { Button } from './components'
// 我们这里只导入并使用了 Button
// 因此 components.js 中的 Link 也属于冗余代码
document.body.appendChild(Button())

我们使用命令进行打包,在打包结果中我们就会发现未引用代码和冗余代码都被自动剔除了,当然,这是在生产环境打包时 Webpack 才会自动运行的

npm run webpack --env production

总结:Tree-shaking 能够在生产环境打包时自动剔除未引用代码和冗余代码。Tree-shaking 并不是 Webpack 的某一配置选项,它是一组功能搭配使用后的优化效果,这组功能回在生产环境下自动开启。

如果使用 Babel loader 有可能导致 tree-shaking 失效,解决方法看后面

Tree-shaking 具体配置、运行过程剖析

webpack.config.js

module.exports = {
  // 不开启生产环境
	mode: 'none',
  entry: './src/index.js',
  output:{
  	filename: 'bundle.js'
  },
  // Webpack 内部优化功能的配置项
  optimization:{
    // usedExports 在输出结果中只导出外部使用了的成员,即过滤掉上面的 Link
  	usedExports: true,
    // 代码中未引用代码自动剔除并压缩
    minimize: true
  }
}

concatenateModules 合并模块

除了 usedExports 以外,我们还能够使用 concatenateModules 属性去继续优化我们的输出,它能够尽可能的将所有模块合并输出到一个函数中去。

module.exports = {
	...
  optimization:{
  	usedExports: true,
    minimize: true,
    // 可以暂时关闭 minimize 来对比效果
    // 能够发现 concatenateModules 会把所有模块整合到一个函数中去
    concatenateModules: true,
  }
}

开启 concatenateModules 后,既能提升我们应用的运行效率,还能够减少应用的代码体积。这个特性还被称作:Scope Hoisting (作用域提升),它是 Webpack 3 中的特性,配合 minimize 使用 代码体积会进一步减小

Tree-shaking & Babel

使用 Tree-shaking 的前提是 使用 ES Modules 组织我们的代码,也就是 Webpack 打包的代码必须使用 ESM 去实现的模块化。
我们知道 Webpack 在打包前会将所有的模块交给不同的 Loader 去处理 ,最后才会将所有 Loader 处理的结果打包到一起,而为了转换代码中 ECMAScript 新特性,很多时候我们都会选择 babel-loader 去处理,而在 Babel 转换我们的代码时它就大概会讲 ES Modules 转换未 CommonJS (这取决于我们有没有使用转换 ESM 的插件),我们前面使用的 @babel/preset-env 这个插件集合中就有这个插件,它会把我们代码中使用到的 ESM 的部分转换为 CommonJS,Webpack 在打包时它拿到的代码就会以 CommonJS 的方式组织的代码,所以 Tree-shaking 就会失效。
但是!最新版本的 babel-loader 内部已经处理了这部分逻辑,已经支持了 ESM 方式下也能够进行 Tree-shaking 的,所以这部分我就快速略过了,遇到这个问题的可以如下配置禁止转换 ESM :

module.exports = {
  ...
  module: {
    rules: [
      {
        test: /.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: [
              ["@babel/preset-env", { modules: false }]
            ],
          },
        },
      },
    ],
  },
  ...
};

sideEffects

它允许我们以配置的方式为我们的代码标识是否存在副作用(模块执行时除了导出成员之外所作的事情),一般在我们开发一个 npm 模块时才会用到。

Webpack 官网把它和 Tree-shaking 放在了一起,但实际没什么关系。

它的使用对于我自己的项目来讲是十分有用的,我通常会把一个文件夹下所有模块 通过 index.js 对外暴露出去,但实际上我所使用的模块可能就那么几个,而 index.js 又和所有模块存在 引用关系,那么那些实际上我没有使用的 模块也存在了引用关系,这将导致 Tree-shaking 没有办法把它们视作冗余代码,自然也不会被“摇掉”。

我们就可以通过使用 sideEffects 的方法,手动的为我们的代码进行标记配置哪些代码属于冗余代码。

webpack.config.js

module.exports = {
	...
  optimization: {
    // 开启这个功能
  	sideEffects: true  // production 模式下也会自动开启
  }
  ...
}

开启后,Webpack 会读取 package.json 中的 sideEffects,用来判断当前项目代码是否存在副作用,以此来剔除没有副作用代码中的冗余代码(这块目前只是了解)
package.json

{
...
  // false 所有代码均无副作用
	"sideEffects": false   
...
}

一定需要注意的是!!!
确保你的代码真的没有副作用!
确保你的代码真的没有副作用!
确保你的代码真的没有副作用!

假设你存在一个文件没有向外导出任何成员,但它内部向原型上挂接 一些方法(模块执行时除了导出成员之外做了一些事情),那么如果你开启了 sideEffects 为 false,这个文件大概是会被 Tree-shaking 视为冗余代码从而剔除掉的,这将影响你的应用的正常运行…

我们能够这样进行标识:
package.json

{
	...
	"sideEffects": [
  	'./src/extend.js'  // 没有导出但做了别的事情的文件
  ]
	...
}

这样 Tree-shaking 就不会影响 extend.js 文件内容了。

Code Splitting 代码分割

根据以上的 Webpack 配置来打包后,所有 代码最终都被打包到了一起,bundle 体积会非常的大,而我们的应用给在启动时并不是每个模块都会被用到的,这就会导致无论使用访问模块,我们的应用总是要加载完全所有的模块才开始正常工作,且这会浪费我们的流量和带宽。

我们需要优化一下,让我们的打包结果按照一定的规则去分离到多个 bundle 中,也就是**分包,**且能够根据应用的运行需要按需去加载这些模块,这会大大提高我们应用的响应速度及运行效率。

不过… 还记得我们前面说的需求让我们把零散的代码打包到了一起去么

现在我们又要根据规则进行代码分包,这不是打脸么这…

当然不是!

我们的代码不能够太零散,但也不能够太臃肿,我们项目中划分模块的颗粒度应该要非常细,有时候我们一个模块只是提供了一个工具函数,它并不能够形成一个 完整的功能单元,如果我们不把这些散落的模块合并到一起,那就有可能在加载一个小功能时加载非常多的模块,那么 HTTP 同域并行请求限制、请求延迟、请求的 Header 浪费带宽流量种种不利因素都说明模块打包是必要的,但当打包结果体积愈来愈大时,我们又会面临种种问题,Webpack 就能够按照我们设计的规则打包到不同的 bundle 中从而提高我们应用的响应速度。

Webpack 提供了两种方式进行代码分包:多入口打包、动态导入。

多入口打包

多入口打包通常适用于传统的多页应用程序,一个页面的对应一个打包入口,公共部分单独打包,配置示例如下:

const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  // entry 在这里是一个对象
  // 对象中每个键值对表示一个入口,key 是标识 value 是入口
  // 这里举个栗子,就不吃栗子了,因为实在吃不下了...
	entry:{
  	index: './src/index.js',
    album: './src/album.js'
  },
  // 一旦 entry 配置为多入口,那么输出的文件名也需要修改
  // 因为多个入口就意味着有多个打包结果
  output:{
    // [name] 占位符将被自动输出为入口名称
  	filename: '[name].bundle.js'
  },
  plugins:[
    // 之前介绍过,该插件会生成自动注入**所有打包结果**的 html
    // 那么我们能够通过插件的 chunks 属性来设置需要注入的 bundle
  	new HtmlWebpackPlugin({
    	title: 'Multi Entry',
      template: './src/index.html',
      filename: 'index.html',
      chunks: ['index']
    }),
    new HtmlWebpackPlugin({
    	title: 'Multi Entry',
      template: './src/album.html',
      filename: 'album.html',
      chunks: ['album']
    })
  ]
}

Split Chunks 提取公共模块

上面的打包方式会将一些公共部分重复打包,这个影响在项目上体积后影响会非常的大,因此我们需要将这些公共部分的代码提取出来,配置相当简单,示例如下:

module.exports = {
	optimization: {
    // splitChunks 会把公共代码提取成为公共模块
  	splitChunks : {
    	chunks: 'all'
    }
  }
}

按需加载

这里的按需加载指的是在我们应用运行过程中,需要用到某个模块时,再去加载这个模块,这会极大节省我们的带宽和流量。

Webpack 能够通过动态导入的方式来实现模块的按需加载,而且动态导入的模块会被自动分包,相比于多入口打包来讲,动态导入更加灵活,因为我们能够通过代码逻辑去控制我们什么时候需要加载什么模块。

Webpack 动态导入这块我并没有单独使用过,都是结合 Vue 项目去使用的,单页应用框架都会在项目路由应用组件通过动态导入的方式实现按需加载,这里就不举栗子了(连栗子都举不动了…)

魔法注释

默认通过动态导入产生的 bundle.js 文件的名称是随机的,如果我们希望文件名按照一定规则命名,我们能够通过魔法注释来给打包后的 bundle 文件命名,具体内容如下:

import (/* webpackChunkName: 'album' */'./album/album')

这里需要注意的是,相同的 ChunkName 会被打包到一个文件里去

其它内置插件/内置功能

其它还有许多 Webpack 内置的功能提供给我们使用,例如:MiniCssExtratPlugin(Css 提取并按需加载)、OptimizeCssAssetsWebpackPlugin(压缩输出 CSS 文件)等插件,建议根据业务需求在 Webpack 相关社区查询使用。

mini-css-extrat-plugin 建议在 CSS 文件大小超出 150 kb 后再使用(减少 http 请求)。
optimize-css-assets-webpack-plugin 在生产环境下使用,因为 css 等文件都需要单独配置压缩插件,它就是针对 css 压缩的插件,配置在 minimize 数组中会在 minimize 开启时使用。

32. Webpack 输出文件名 Hash

一旦我们的项目发生更新,资源文件发生改变,我们总是希望用户能够请求获取到最新的资源而不是老旧的,那么如果我们的资源文件名称始终一致,浏览器缓存就会生效,用户除非强制清除缓存刷新,否则在缓存过期前,用户始终获取的是浏览器缓存下的旧资源,而如果我们每次更新后资源文件名称不一致,浏览器就会一定会重新获取新的资源文件给用户。

Webpack 的 filename 支持三种 Hash 方式来配置输出动态文件名称的打包结果,具体介绍看下面详细。

Hash 任何地方发生改动,本次打包所有输出文件名称的 Hash 都会发生变化

基本配置如下:

module.exports = {
	...
  output: {
  	filename: '[name]-[hash].bundle.js'
  }
  ...
}

效果如下:

- dist
	- album-82e970a7e097a2ace2c9.bundle.js
	- album-82e970a7e097a2ace2c9.bundle.css
	- posts-82e970a7e097a2ace2c9.bundle.css
	- posts-82e970a7e097a2ace2c9.bundle.js
	- index.html
	- main-82e970a7e097a2ace2c9.bundle.js

Chunk Hash 分多路打包

基本配置如下:

module.exports = {
	...
  output: {
  	filename: '[name]-[chunkhash].bundle.js'
  }
  ...
}

效果如下:

- dist
	- album-82e970a7e097a2ace2c9.bundle.js
	- album-82e970a7e097a2ace2c9.bundle.css
	- posts-542ecf25f63e0bb267ne.bundle.css
	- posts-542ecf25f63e0bb267ne.bundle.js
	- index.html
	- main-0da32d8f14eca56d9fcf.bundle.js

我们能够看到,posts、album 共三路,每一路的 Hash 都一致,各路之间 hash 各不相同。

且此时一个文件发生修改,则打包输出的结果只会把修改的文件那一路的 hash 进行更新。

由于 main.js 导入了所有文件属于特殊情况,它无论哪一路发生变化,main.js 的 hash 都会发生变化。

Contenthash

由名自我们就看得出来,这个 hash 是根据文件内容生成的 hash,那么自然每个不同内容的文件都会有着自己独立的 hash,当文件内容发生更新后, Webpack 只会修改文件内容发生变化的文件 hash 名称。

看得出来,Contenthash 是解决浏览器文件缓存问题的最好的方式,因为精确到了文件级别。

33. Webpack 指定 Hash 长度

module.exports = {
	...
  output: {
    // 冒号 + 数字 即可
  	filename: '[name]-[chunkhash:8].bundle.js'
  }
  ...
}

突破桎梏(八):前端模块化开发 - Webpack_第9张图片

我是醒途,觉醒迷途,扬帆起航,从此星途璀璨,感谢各位江湖弟兄的:点赞、收藏和评论,我们下期见!

诸君昌隆!


文章持续更新,可以微信搜一搜「 醒途 」第一时间阅读,公众号刚刚起步,欢迎关注。

你可能感兴趣的:(大前端之突破桎梏,博客,前端,javascript,webpack,前端,大前端,程序人生)