# 万字长文系统梳理 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,执行compilationseal方法对每个chunk进行整理、优化、封装
  • 输出资源:执行compileremitAssets方法把生成的文件输出到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 生成文件的时候执行,提供访问产出文件信息的入口,回调参数fileinfo
done AsyncSeriesHook 一次编译完成后执行,回调参数stats

  自定义文件清单插件,打包后自动生成文件清单,记录文件列表、文件数量。

  根目录下包括package.jsonwebpack.config.jssrcsrc下包括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.jspackage.jsonsrcsrc下包括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命令后,控制台会打印如下内容,左上角Logwebpack本身的日志,左下角Modules则是此次参与打包的模块,可以查看模块的占用体积和比例,右下角Problems可以查看构建过程的警告和错误等。

# speed-measure-webpack-plugin

  ;speed-measure-webpack-pluginSMP)可以分析出webpack整个打包过程中在各个loaderplugin上耗费的时间,根据分析结果可以找出哪些构建步骤耗时较长,以便于优化和反复测试。

  ;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.jsonsrcbuildsrc下包括index.htmlmain.jsbuild下包括webpack.base.conf.jswebpack.dev.conf.jswebpack.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-loadercss-loaderbabel-loader,其中css-loaderbabel-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对象,其中包含了HMRAPI(例如可以对特定模块开启或关闭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()
}

  若应用的逻辑比较复杂,则不推荐使用webpackHMR,因为HMR触发过程中可能会有预想不到的问题,建议开发者使用第三方提供的HMR解决方案,例如vue-loaderreact-hot-loader

# 开启 HMR

  根目录下为webpack.config.jspackage.jsonsrcsrc下包括main.jsindex.htmlutils.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作为下一次请求服务端jsjsonhash

  修改页面代码,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 shakinges6模块有算法优势的支持。所以一般开发应用用webpack,开发库的时候用RollUp

  与webpack一般项目内部安装不同,RollUp可以直接全局安装。

npm i rollup -g

  根目录下包括package.jsonrollup.config.jssrcsrc下为main.js。其中rollup.config.jsoutput.format为输出资源的模块形式,此特性是webpack不具备的。如下使用的是cjsCommonJs),除此之外还有amdesES Module)、umdiife(自执行函数)、systemSystemJs加载器格式)。

// 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,其处理大致流程如下。其中涉及大量的StringAST的转换,loader之间互不影响,各司其职,虽然可能会有部分冗余,但是有利于保持loader的独立性和可维护性。

          资源输入
             ↓
loader1   (String -> AST) --> 语法转换 --> (AST -> String)loader2   (AST -> String) <-- 语法转换 <-- (String -> AST)loader3   (String -> AST) --> 语法转换 --> (AST -> String)
                                                 ↓
                                              资源输出

  而Parcel未明确暴露loader的概念,其资源处理流程不像webpack可以对loader随意组合,也正是由此它不需要太多ASTString之间的转换。

  如下对于每一步来说,前面已解析过的AST,那么下一步直接使用上一步解析和转换好的AST即可,只用在最后一步再将AST转回String。对于一个庞大工程,解析AST非常耗时,优化此处将会节省很多时间。

           资源输入
              ↓
process1   (String -> AST) --> 语法转换
                                   (process1 返回的 AST)
process2                       语法转换
                                   (process2 返回的 AST)
process3                       语法转换 --> (AST -> String)
                                                  ↓
                                               资源输出

  ;Parcel也能直接全局安装。

npm i -g parcel-bundler

  根目录下包括package.jsonsrcsrc下为index.jsindex.html。其中Parcel是可以用html文件作为项目入口的,从html开始再进一步寻找依赖的资源。

  ;Parcel并没有属于自己的配置文件,而本质上是将配置进行了拆分,交给babelPostCss等特定的工具分别进行管理。比如.babelrcParcel在打包时就会采用它作为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) 同步更新,欢迎关注😉~

最后更新时间: 3/6/2022, 9:06:37 PM