# 万字长文系统梳理 Webpack 基础(下)

# webpack 插件
# 构建流程
  ;webpack loader是负责不同类型文件的转译,将其转换为webpack能够接收的模块。而webpack插件则与loader有很大的区别,webpack插件是贯穿整个构建流程的,构建流程中的各个阶段会触发不同的钩子函数,在不同的钩子函数中做一些处理就是webpack插件要做的事情。
  ;webpack一次完整的打包构建流程如下。
- 初始化参数:将cli命令行参数与webpack配置文件合并、解析得到参数对象
- 加载插件:参数对象传给webpack初始化生成compiler对象,执行配置文件中的插件实例化语句(例如new HtmlWebpackPlugin()),为webpack事件流挂上自定义hooks
- 开始编译:执行compiler对象的run方法开始编译,每次run编译都会生成一个compilation对象
- 确定入口:触发compiler对象的make方法,开始分析入口文件
- 编译模块:从入口文件出发,调用loader对模块进行转译,再查找模块依赖的模块并转译,递归完成所有模块的转译
- 完成编译:根据入口和模块之间的依赖关系,组装成一个个的chunk,执行compilation的seal方法对每个chunk进行整理、优化、封装
- 输出资源:执行compiler的emitAssets方法把生成的文件输出到output的目录中

# 自定义插件
  ;webpack插件特点如下。
- 独立的js模块,暴露相应的函数
- 函数原型上的apply方法会注入compiler对象
- compiler对象上挂载了相应的- webpack钩子
- 事件钩子的回调函数里能拿到编译后的compilation对象,如果是异步钩子还能拿到相应的callback函数
class CustomDlugins {
  constructor() {}
  
  apply(compiler) {
    compiler.hooks.emit.tapAsync('CustomDlugins', (compilation, callback) => {})
  }
}
module.exports = CustomDlugins
  大多数面向用户的插件都首先在compiler上注册,如下为compiler上暴露的一些常用的钩子。
| 钩子 | 类型 | 作用 | 
|---|---|---|
| run | AsyncSeriesHook | 在编译器开始读取记录前执行 | 
| compiler | SyncHook | 在一个新的 compilation创建之前执行 | 
| compilation | SyncHook | 在一次 compilation创建后执行插件 | 
| make | AsyncSeriesHook | 完成一次编译之前执行 | 
| emit | AsyncSeriesHook | 在生成到 output目录之前执行,回调参数compilation | 
| afterEmit | AsyncSeriesHook | 在生成文件到 output目录之后执行 | 
| assetEmitted | AsyncSeriesHook | 生成文件的时候执行,提供访问产出文件信息的入口,回调参数 file、info | 
| done | AsyncSeriesHook | 一次编译完成后执行,回调参数 stats | 
自定义文件清单插件,打包后自动生成文件清单,记录文件列表、文件数量。
  根目录下包括package.json、webpack.config.js和src,src下包括main.js。
// package.json
{
  ...
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "webpack": "4.29.4",
    "webpack-cli": "3.2.3"
  }
}
// webpack.config.js
module.exports = {
  entry: './src/main.js',
  output: {
    filename: './[name].js',
  },
  plugins: [],
}
// src/main.js
console.log('hello world')
  然后继续在根目录下创建plugins文件夹,其中新建FileListPlugin.js文件,webpack.config.js中引入插件。
  注意此场景要在文件生成到dist目录之前进行,所以要注册的是compiler上的emit钩子。emit是一个异步串行钩子,用tapAsync来注册。
  ;emit的回调函数里可以拿到compilation对象,所有待生成的文件都在其assets属性上。通过compilation.assets获取文件列表,整理后将其写入新文件准备输出。
  最后再往compilation.assets添加新文件。
// plugins/FileListPlugin.js
class FileListPlugin {
  constructor(options) {
    this.filename = options && options.filename ? options.filename : 'FILELIST.md'
  }
  apply(compiler) {
    compiler.hooks.emit.tapAsync('FileListPlugin', (compilation, callback) => {
      const keys = Object.keys(compilation.assets)
      const length = keys.length
      var content = `# ${length} file${length > 1 ? 's' : ''} emitted by webpack\n\n`
      keys.forEach(key => {
        content += `- ${key}\n`
      })
      compilation.assets[this.filename] = {
        source: function () {
          return content
        },
        size: function () {
          return content.length
        },
      }
      callback()
    })
  }
}
module.exports = FileListPlugin
// webpack.config.js
const FileListPlugin = require('./plugins/FileListPlugin')
module.exports = {
  ...
  plugins: [
    new FileListPlugin({
      filename: 'filelist.md',
    }),
  ],
}
# 开发优化
# webpack 插件
# webpack-dashboard
  ;webpack-dashboard是用来优化webpack日志的工具。
  根目录下为webpack.config.js、package.json和src,src下包括main.js。
// package.json
{
  ...
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "vue": "^2.6.12",
    "webpack": "4.29.4",
    "webpack-cli": "3.2.3",
    "webpack-dashboard": "^2.0.0"
  }
}
// webpack.config.js
const DashboardPlugin = require('webpack-dashboard/plugin')
module.exports = {
  entry: './src/main.js',
  output: {
    filename: './[name].js',
  },
  plugins: [new DashboardPlugin()],
  mode: 'development',
}
// src/main.js
import vue from 'vue'
console.log(vue)
  若要使webpack-dashboard生效,还要修改原有的启动命令。
// package.json
{
  ...
  "scripts": {
    "build": "webpack-dashboard -- webpack"
  }
}
  运行build命令后,控制台会打印如下内容,左上角Log为webpack本身的日志,左下角Modules则是此次参与打包的模块,可以查看模块的占用体积和比例,右下角Problems可以查看构建过程的警告和错误等。

# speed-measure-webpack-plugin
  ;speed-measure-webpack-plugin(SMP)可以分析出webpack整个打包过程中在各个loader和plugin上耗费的时间,根据分析结果可以找出哪些构建步骤耗时较长,以便于优化和反复测试。
  ;SMP使用时需要把它的wrap方法包裹在webpack的配置对象外面。
// webpack.config.js
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
module.exports = smp.wrap({
  entry: './src/main.js',
  output: {
    filename: './[name].js',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          cacheDirectory: true,
          presets: [['@babel/preset-env', { modules: false }]],
        },
      },
    ],
  },
})
// src/main.js
const fn = () => {
  console.log('hello world')
}
fn()
// package.json
{
  ...
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "webpack": "4.29.4",
    "webpack-cli": "3.2.3",
    "@babel/core": "^7.2.2",
    "@babel/preset-env": "^7.3.1",
    "babel-loader": "^8.0.5",
    "speed-measure-webpack-plugin": "^1.2.2"
  }
}
  运行build脚本打包后控制台输出如下,可以看出babel-loader转译时耗费了1.16秒。

# webpack-merge
  ;webpack-merge用于需要配置多种打包环境的项目。
若项目包括本地环境、生产环境,每个环境对应的配置都不同,但也有一些公共的部分,则需要将公共部分提取出来。
  根目录下为package.json、src和build,src下包括index.html、main.js,build下包括webpack.base.conf.js、webpack.dev.conf.js和webpack.prod.conf.js。
// package.json
{
  ...
  "scripts": {
    "dev": "webpack-dev-server --config=./build/webpack.dev.conf.js",
    "build": "webpack --config=./build/webpack.prod.conf.js"
  },
  "devDependencies": {
    "webpack": "4.29.4",
    "webpack-cli": "3.2.3",
    "webpack-dev-server": "3.1.14",
    "webpack-merge": "^4.1.4",
    "file-loader": "^1.1.6",
    "css-loader": "^0.28.7",
    "style-loader": "^0.19.0",
    "html-webpack-plugin": "3.2.0"
  }
}
// src/main.js
console.log('hello world')
// src/index.html
<html lang="zh-CN">
<body>
  <p>hello world</p>
</body>
</html>
其中开发环境和生产环境的公共配置如下。
// build/webpack.base.conf.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  entry: './src/main.js',
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif)$/,
        use: 'file-loader',
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
  ],
}
  开发环境的配置如下,其中webpack-merge在合并module.rules的过程中,会以test属性作为标识符,当发现有相同项出现时会以后面的规则覆盖前面的规则,如此就不必添加冗余代码。
  如下开发环境的loader包括file-loader、css-loader、babel-loader,其中css-loader和babel-loader覆盖了之前loader并开启了sourceMap。
// build/webpack.dev.conf.js
const baseConfig = require('./webpack.base.conf.js')
const merge = require('webpack-merge')
module.exports = merge.smart(baseConfig, {
  output: {
    filename: './[name].js',
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              sourceMap: true,
            },
          },
        ],
      },
    ],
  },
  devServer: {
    port: 3000,
  },
  mode: 'development',
})
生产环境配置如下。
// build/webpack.prod.conf.js
const baseConfig = require('./webpack.base.conf.js')
const merge = require('webpack-merge')
module.exports = merge.smart(baseConfig, {
  output: {
    filename: './[name].[chunkhash:8].js',
  },
  mode: 'production',
})
# 模块热替换
  自动刷新(live reload)即只要代码改动就会重新构建,再触发网页刷新。而webpack在此基础上又进了一步,可以在不刷新网页的前提下得到最新的代码改动,即模块热替换(Hot Module Replacement,HMR)。
# 配置
  ;HMR需手动配置开启,如下配置会为每个模块绑定上module.hot对象,其中包含了HMR的API(例如可以对特定模块开启或关闭HMR等)。
// webpack.config.js
const webpack = require('webpack')
module.exports = {
  ...
  plugins: [new webpack.HotModuleReplacementPlugin()],
  devServer: {
    hot: true,
  },
}
  配置后还需要手动调用module.hot上的API来开启HMR。如下若main.js是应用的入口,则可以将调用HMR API的代码放在此入口中,那么main.js及其依赖的所有模块都会开启HMR。当发现模块有改动时,HMR会使应用在当前环境下重新执行main.js,但是页面本身不会刷新。
// main.js
...
if (module.hot) {
  module.hot.accept()
}
  若应用的逻辑比较复杂,则不推荐使用webpack的HMR,因为HMR触发过程中可能会有预想不到的问题,建议开发者使用第三方提供的HMR解决方案,例如vue-loader、react-hot-loader。
# 开启 HMR
  根目录下为webpack.config.js、package.json和src,src下包括main.js、index.html和utils.js。
// webpack.config.js
const webpack = require('webpack')
module.exports = {
  entry: './src/main.js',
  output: {
    filename: './[name].js',
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
  ],
  devServer: {
    hot: true,
  },
}
// package.json
{
  ...
  "scripts": {
    "dev": "webpack-dev-server"
  },
  "devDependencies": {
    "webpack": "4.29.4",
    "webpack-cli": "3.2.3",
    "webpack-dev-server": "3.1.14",
    "html-webpack-plugin": "3.2.0"
  }
}
// src/main.js
import { logToHtml } from './utils.js'
var count = 0
setInterval(() => {
  count += 1
  logToHtml(count)
}, 1000)
// src/utils.js
export function logToHtml(count) {
  document.body.innerHTML = `count: ${count}`
}
// src/index.html
<html lang="zh-CN">
<body></body>
</html>
  运行dev脚本命令后控制台输出如下,单击http://localhost:8080/打开html。

  ;html输出整数并每秒加1,修改utils.js如下,保存后查看html,页面刷新,之前计数的count重新开始由0每秒加1(未局部刷新)。
// src/utils.js
export function logToHtml(count) {
  document.body.innerHTML = `count update: ${count}`
}
  ;utils.js还原,main.js添加如下代码,开启HMR。
// src/main.js
...
if (module.hot) {
  module.hot.accept()
}
  然后再次修改utils.js,查看html未刷新,而是局部更新了,count数值也是在之前基础上加1。
  但是又会带来另一个问题,当前的html已经有了一个setInterval,而HMR后又会添加新的setInterval,并未对之前的进行清除,导致最后html上有不同的数字闪来闪去。
  为了避免此问题,当main.js发生改变则刷新整个页面,防止有多个定时器,但是对于其他模块则继续开启HMR。
// src/main.js
...
if (module.hot) {
  module.hot.decline()
  module.hot.accept(['./utils.js'])
}
# HMR 流程
  项目初次运行dev脚本,首先会进行构建打包,同时将如何更新模块和接收后是否更新模块的代码注入到bundle中。
  而bundle会被写入到内存中,不写入磁盘的原因是因为访问内存中的代码比访问磁盘中的文件快,并且也减少了代码写入文件的性能开销。

  紧接着webpack-dev-server使用express启动本地服务,让浏览器可以请求到本地资源。然后再启动websocket服务,用于建立浏览器和本地服务之间的双向通信。
  单击http://localhost:8081/在浏览器打开页面,此时页面建立与本地服务的websocket连接,同时本地服务会将刚才首次打包的hash值返回。

  页面获取到hash后,将此hash作为下一次请求服务端js和json的hash。
  修改页面代码,webpack监听到文件修改,重新开始打包编译。
  根据新生成文件名可以发现,上次输出的hash值会作为本次编译新生成的文件标识。依次类推,本次输出的hash值会被作为下次热替换的标识。

  编译完成后,本地服务通过websocket发送本次打包的hash给页面。

  页面获取到hash后,构造[hash].hot-update.json和[hash].hot-update.js,紧接着发出一次ajax请求,获取json文件,此json文件包括所有要更新的模块。然后再次通过jsonp请求,获取到最新的模块代码。
  其中json文件返回内容中,h表示本次新生成的hash值,用于下次文件热替换请求资源的前缀,c表示当前要热替换的文件对应的是main模块。

  ;js文件返回内容中则是本次修改的代码。

  页面接收到请求数据后,将会对新旧模块进行对比,决定是否更新模块。注意如果在热更新过程中出现错误,热更新将回退到live reload,即进行浏览器刷新来获取最新的打包代码。
# 打包工具
# RollUp
  ;RollUp (opens new window) 也是JavaScript模块打包器,其更专注于JavaScript的打包,在通用性上不及webpack。但是相较于其他打包工具,RollUp总能打包出更小更快的包。RollUp对于代码的tree shaking和es6模块有算法优势的支持。所以一般开发应用用webpack,开发库的时候用RollUp。
  与webpack一般项目内部安装不同,RollUp可以直接全局安装。
npm i rollup -g
  根目录下包括package.json、rollup.config.js和src,src下为main.js。其中rollup.config.js中output.format为输出资源的模块形式,此特性是webpack不具备的。如下使用的是cjs(CommonJs),除此之外还有amd、es(ES Module)、umd、iife(自执行函数)、system(SystemJs加载器格式)。
// package.json
{
  ...
  "scripts": {
    "build": "rollup -c rollup.config.js"
  }
}
// rollup.config.js
module.exports = {
  input: 'src/main.js',
  output: {
    file: 'dist/bundle.js',
    format: 'cjs',
  },
}
// src/main.js
console.log('hello world')
  运行build脚本,根目录dist下输出bundle.js。可以明显看到打包出来的bundle非常干净,RollUp并未添加额外的代码,而同样的源代码,webpack打包会额外添加很多代码。
// dist/bundle.js
'use strict'
console.log('hello world')
  此外tree shaking特性最开始是由RollUp实现的,基于对ES6 Module的静态分析,找出没有被引用的模块,最后将其从生成的bundle中排除。
# Parcel
  ;Parcel (opens new window) 在JavaScript打包工具中属于相对后来者,在其官网的测试中,其构建速度相较于webpack快了好几倍,并且是零配置开箱即用的。
  ;Parcel在打包速度的优化上主要做了三件事,包括利用worker来并行执行任务、文件系统缓存、资源编译处理流程优化。
  其中前两件webpack也有,比如webpack在资源压缩时可以利用多核同时压缩多个资源,babel-loader会将编译结果缓存到项目隐藏目录下,通过文件的修改时间和状态来判断是否使用上次编译的缓存。
  ;webpack通过loader来处理不同类型的资源,loader本质是一个函数,其输入输出都是字符串。例如babel-loader,输入ES6+的内容,语法转换后输出为ES5。其大致过程为将ES6字符串内容解析为AST(抽象语法树)、对AST进行语法转换、生成ES5代码并返回字符串。
  若是在babel-loader后再添加多个loader,其处理大致流程如下。其中涉及大量的String与AST的转换,loader之间互不影响,各司其职,虽然可能会有部分冗余,但是有利于保持loader的独立性和可维护性。
          资源输入
             ↓
loader1   (String -> AST) --> 语法转换 --> (AST -> String)
                                                 ↓
loader2   (AST -> String) <-- 语法转换 <-- (String -> AST)
                 ↓
loader3   (String -> AST) --> 语法转换 --> (AST -> String)
                                                 ↓
                                              资源输出
  而Parcel未明确暴露loader的概念,其资源处理流程不像webpack可以对loader随意组合,也正是由此它不需要太多AST与String之间的转换。
  如下对于每一步来说,前面已解析过的AST,那么下一步直接使用上一步解析和转换好的AST即可,只用在最后一步再将AST转回String。对于一个庞大工程,解析AST非常耗时,优化此处将会节省很多时间。
           资源输入
              ↓
process1   (String -> AST) --> 语法转换
                                  ↓ (process1 返回的 AST)
process2                       语法转换
                                  ↓ (process2 返回的 AST)
process3                       语法转换 --> (AST -> String)
                                                  ↓
                                               资源输出
  ;Parcel也能直接全局安装。
npm i -g parcel-bundler
  根目录下包括package.json和src,src下为index.js和index.html。其中Parcel是可以用html文件作为项目入口的,从html开始再进一步寻找依赖的资源。
  ;Parcel并没有属于自己的配置文件,而本质上是将配置进行了拆分,交给babel、PostCss等特定的工具分别进行管理。比如.babelrc,Parcel在打包时就会采用它作为ES6代码解析的配置。
// package.json
{
  ...
  "scripts": {
    "dev": "parcel ./src/index.html",
    "build": "parcel build ./src/index.html"
  }
}
// src/index.html
<html lang="zh-CN">
<body>
  <p>hello world</p>
  <script src="./index.js"></script>
</body>
</html>
// src/index.js
console.log('hello world')
# 🎉 写在最后
🍻伙伴们,如果你已经看到了这里,觉得这篇文章有帮助到你的话不妨点赞👍或 Star (opens new window) ✨支持一下哦!
手动码字,如有错误,欢迎在评论区指正💬~
你的支持就是我更新的最大动力💪~
GitHub (opens new window) / Gitee (opens new window)、GitHub Pages (opens new window)、掘金 (opens new window)、CSDN (opens new window) 同步更新,欢迎关注😉~
