# Webpack

# 1. 解决的问题

# 1.1 模块化演进过程中出现的问题

  • 模块直接在全局工作,大量模块成员污染全局作用域;
  • 没有私有空间,所有模块内的成员都可以在模块外部被访问或者修改;
  • 一旦模块增多,容易产生命名冲突;
  • 无法管理模块与模块之间的依赖关系;
  • 在维护的过程中也很难分辨每个成员所属的模块。

# 1.2 Webpack解决的问题

  • 能够将散落的模块打包到一起;
  • 能够编译代码中的新特性;
  • 能够支持不同种类的前端资源模块。

# 2. 打包的过程

# 2.1 安装

npm init --yes
npm i webpack webpack-cli --save-dev

npx webpack --version

npx webpack
-D: --save-dev,仅在开发环境中使用
-S: --save,生产环境中要用到的

# 2.2 简单的打包

  • 三种打包模式:
    • production 模式下,启动内置优化插件,自动优化打包结果,打包速度偏慢;
    • development 模式下,自动优化打包速度,添加一些调试过程中的辅助插件;
    • none 模式下,运行最原始的打包,不做任何额外处理。
  • mode=none然后查看bundle.js文件
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  // ...内容
})()
// 立即执行函数

// r函数打上“esModule”的标记
__webpack_require__.r = (exports) => {
  if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
    Object.defineProperty(exports, Symbol.toStringTag, {
      value: 'Module' });
  	}
  Object.defineProperty(exports, '__esModule', { value: true });
};

// 保持原本的依赖关系 并且执行

# 3. Loader

  • webpack将所有文件都视为js,如果需要加载其他类型的文件,还需要额外的loader
  • css-loader + style-loader
    • 因为css-loader只是将.css文件中的代码作为字符串push到一个数组当中,但数组并未被使用
    • 而style-loader则是将上一步的结果转换为标签,使用于页面
// 仅使用css-loader打包后的bundle.js(部分)

function (cssWithMappingToString) {...}

var ___CSS_LOADER_EXPORT___ = _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_0___default()(function(i){return i[1]});
// Module
___CSS_LOADER_EXPORT___.push([module.id, "body {\n  margin: 0;\n  padding: 0;\n  background-color: #f80;\n}\n", ""]);
// 使用style-loader后bundle.js中新增的内容(部分)
function modulesToDom(list, options) {...}

function insertStyleElement(options) {...}
function removeStyleElement(style) {...}
function applyToTag(style, index, remove, obj) {...}
function addStyle(obj, options) {...}
function updateStyle(newObj) {...}
// webpack.config.js

const config = {
  mode: "none",
  entry: "./src/main.css",
  output: {
    filename: "bundle.js",
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
        // 注意这里是从后往前、按照逆序、分别执行loader
      },
    ],
  },
};

module.exports = config;

# 3. Plugin

# 3.1 基本使用

  • 插件机制:增强项目自动化构建方面的能力
  • Loader 只是在模块的加载环节工作,而插件的作用范围几乎可以触及 Webpack 工作的每一个环节
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");

const config = {
  mode: "none",
  entry: "./src/main.js",
  output: {
    filename: "bundle.js",
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: "04 Plugin",
      meta: {
        viewport: "width=device-width",
      },
      template: "./index.html",
    }),
    new HtmlWebpackPlugin({
      filename: "about.html",
    }),
    new CopyWebpackPlugin({
      patterns: ["public"],
    }),
  ],
};

module.exports = config;

# 3.2 自己编写插件

  • apply 方法:这个方法会在 Webpack 启动时被调用,它接收一个 compiler 对象参数,这个对象是 Webpack 工作过程中最核心的对象,里面包含了我们此次构建的所有配置信息,我们就是通过这个对象去注册钩子函数。
class RemoveCommentsPlugin {
  apply(complier) {
    console.log("RemoveCommentsPlugin Launch.");
    complier.hooks.emit.tap("RemoveCommentsPlugin", (compilation) => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        console.log(name);
        if (name.endsWith(".js")) {
          const contents = compilation.assets[name].source()
          const withoutComments = contents.replace(/\/\*\*+\*\//g, '')
          compilation.assets[name] = {
            source: () => withoutComments,
            size: () => withoutComments.length
          }
        }
      }
    });
  }
}

module.exports = RemoveCommentsPlugin;

# 4. Webpack工作过程

  • Webpack CLI 启动打包流程;
    • 实现的功能:将 CLI 参数和 Webpack 配置文件中的配置整合,得到一个完整的配置对象。
    • 大概过程:
      • 通过yargs模块解析 CLI 参数(即输入命令行的参数)
      • 然后将其转换为Webpack的配置选项对象
      • 接下来开始载入Webpack核心模块,并传入该配置选项对象
  • 载入 Webpack 核心模块,创建Compiler对象;
    • 校验外部传递过来的options参数是否符合要求,然后判断options的类型。在此过程中Webpack支持同时开启多路打包,内部也就会随之创建MultiComiler
    • plugin.call(compiler, compiler)plugin.apply(compiler)来注册已经配置的插件
    • 钩子(生命周期)中触发,例如compiler.hooks.environment.call()compiler.hooks.afterEnvironment.call()
  • 使用 Compiler 对象开始编译整个项目;
    • 触发了beforeRunrun两个钩子,然后最关键的是调用了当前对象的compile方法,真正开始编译整个项目
    • compile 方法内部主要就是创建了一个Compilation对象,Compilation字面意思是“合集”,可以理解为一次构建过程中的上下文对象,里面包含了这次构建中全部的资源和信息。
    • 紧接着触发了一个叫作make的钩子,进入最核心的make阶段:
      • 该阶段并没有直接调用对象的某个方法,而是利用事件触发机制,按照顺序依次触发先前设计好的各种事件。基于Tapable库实现的。
      • SingleEntryPlugin 中调用了 Compilation 对象的 addEntry 方法,开始解析入口;
      • addEntry 方法中又调用了 _addModuleChain 方法,将入口模块添加到模块依赖列表中;
      • 紧接着通过 Compilation 对象的 buildModule 方法进行模块构建;
      • buildModule 方法中执行具体的 Loader,处理特殊资源加载;
      • build 完成过后,通过 acorn 库生成模块代码的 AST 语法树;
      • 根据语法树分析这个模块是否还有依赖的模块,如果有则继续循环 build 每个依赖;
      • 所有依赖解析完成,build 阶段结束;
      • 最后合并生成需要输出的 bundle.js 写入 dist 目录。
  • 后半部分概括起来,就是:
    • 从入口文件开始,解析模块依赖,形成依赖关系树;
    • 递归依赖树,将每个模块交给对应的 Loader 处理;
    • 合并 Loader 处理完的结果,将打包结果输出到 dist 目录。

# 5. Webpack-dev-server

# 安装 webpack-dev-server
$ npm install webpack-dev-server --save-dev
# 运行 webpack-dev-server
$ npx webpack-dev-server
  • Webpack-dev-server并没有将打包结果写入到磁盘中,而是暂时存放在内存中,内部的HTTP Server也是从内存中读取这些文件的。减少很多不必要的磁盘读写操作,提高了整体的构建效率。

# 6. SourceMap

# 6.1 简单介绍

  • 一个.map的JSON格式文件,记录的是转换后和转换前的代码之间的映射关系
  • 主要的属性:
    • version:指定所使用的 Source Map 标准版本;
    • sources:记录的是转换前的源文件名称,因为有可能出现多个文件打包转换为一个文件的情况,所以这里是一个数组;
    • names:源代码中使用的一些成员名称,我们都知道一般压缩代码时会将我们开发阶段编写的有意义的变量名替换为一些简短的字符,这个属性中记录的就是原始的名称;
    • mappings:这个属性最为关键,它是一个叫作 base64-VLQ 编码的字符串,里面记录的信息就是转换后代码中的字符与转换前代码中的字符之间的映射关系。
  • Chrome浏览器中的开发人员工具中,Source中看到的文件就是通过这个方法/文件,逆向解析出来的源代码,因而便于定位错误发生在源码中的具体位置。

# 6.2 Webpack中的SourceMap

// ./webpack.config.js

module.exports = {
  devtool: 'source-map' // source map 设置
}
  • devtool字段对应的模式还有很多种,详情参见官方文档或者中文文档
  • 名字中带有 module 的模式,解析出来的源代码是没有经过 Loader 加工的,而名字中不带 module 的模式,解析出来的源代码是经过 Loader 加工后的结果。
  • 名字中带有 cheap 的模式,一般是阉割版,只定位行,不定位列
  • 名字中带有 eval 的模式,一般是用eval来执行代码,通过sourceURL来定位
// eval 举例

eval("console.log('foo')")
// foo       VM54:1
eval("console.log('foo') //# sourceURL=./foo/bar.js")
// foo       ./foo/bar.js:1

# 虽然现在可能因为eval的安全隐患,不能在浏览器的F12-console中运行上述代码了

# 7. 模块热替换(Hot Module Replacement,HRM)

# 7.1 基本概念

  • 在应用运行过程中,实时的去替换掉应用中的某个模块,而应用的运行状态不会因此而改变,eg.编辑器中的内容,在页面因功能代码改变而刷新后仍然保留。
// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
  // ...
  devServer: {
    // 开启 HMR 特性,如果资源不支持 HMR 会 fallback 到 live reloading
    hot: true
    // 只使用 HMR,不会 fallback 到 live reloading
  },
  plugins: [
    // ...
    // HMR 特性所需要的插件
    new webpack.HotModuleReplacementPlugin()
  ]
}
  • 但是仅凭以上代码,只能实现「样式文件/CSS」的热更新,但「JS文件」不行。原因:
    • HMR 并不像 Webpack 的其他特性一样可以开箱即用,需要有一些额外的操作。
    • 样式文件是经过 Loader 处理的,在 style-loader 中就已经自动处理了样式文件的热更新。
    • CSS可以将新模块直接替换旧的,但是JS花样繁多,并没有通用的替换方法。
    • 当然,Vue、React脚手架搭建的项目,貌似是可以实现JS热替换的,框架内部本身就实现了替换操作。

# 7.2 使用方法

// ./src/main.js
import createEditor from './editor'
import logo from './icon.png'
import './global.css'

const img = new Image()
img.src = logo
document.body.appendChild(img)

const editor = createEditor()
document.body.appendChild(editor)

// HMR --------------------------------
// 两个参数,模块的路径,以及该模块更新后调用的函数
let lastEditor = editor
module.hot.accept('./editor', () => {
  // 当 editor.js 更新,自动执行此函数
  // 临时记录更新前编辑器内容
  const value = lastEditor.innerHTML
  // 移除更新前的元素
  document.body.removeChild(lastEditor)
  // 创建新的编辑器
  // 此时 createEditor 已经是更新过后的函数了
  lastEditor = createEditor()
  // 还原编辑器内容
  lastEditor.innerHTML = value
  // 追加到页面
  document.body.appendChild(lastEditor)
})

# 7.3 HotOnly

  • 如果处理热替换的代码(处理函数)中有错误,结果也会导致自动刷新,例子如下:
// ./src/main.js
// ... 其他代码
module.hot.accept('./editor', () => {
  // 刻意造成运行异常
  undefined.foo()
})
  • 因为 HMR 过程报错导致 HMR 失败,HMR 失败过后,会自动回退到自动刷新,页面一旦自动刷新,控制台中的错误信息就会被清除,
  • 通过 hotOnly 的方式来解决,如果热替换失败不会使用自动刷新。
// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
  // ...
  devServer: {
    // 只使用 HMR,不会 fallback 到 live reloading
    hotOnly: true
  },
  plugins: [
    // ...
    // HMR 特性所需要的插件
    new webpack.HotModuleReplacementPlugin()
  ]
}

# 8. Tree Shaking

  • 最早是Rollup提出,检测代码中未引用代码(dead-code),然后自动移除它们。
  • Tree-shaking 并不是指 Webpack 中的某一个配置选项,而是一组功能搭配使用过后实现的效果,这组功能在生产模式下都会自动启用,所以使用生产模式打包就会有 Tree-shaking 的效果。
  • 涉及的优化功能:
    • usedExports - 打包结果中只导出外部用到的成员;
    • minimize - 压缩打包结果。
// ./webpack.config.js
module.exports = {
  // ... 其他配置项
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 尽可能合并每一个模块到一个函数中
    concatenateModules: true,
    // 压缩输出结果
    minimize: false
  }
}

# 9. sideEffects

  • 实现的功能:允许用户通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间。
  • 模块的副作用:指的就是模块执行的时候除了导出成员,是否还做了其他的事情。