阅读 13645

万字长文系统梳理 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上暴露的一些常用的钩子。

钩子类型作用
runAsyncSeriesHook在编译器开始读取记录前执行
compilerSyncHook在一个新的compilation创建之前执行
compilationSyncHook在一次compilation创建后执行插件
makeAsyncSeriesHook完成一次编译之前执行
emitAsyncSeriesHook在生成到output目录之前执行,回调参数compilation
afterEmitAsyncSeriesHook在生成文件到output目录之后执行
assetEmittedAsyncSeriesHook生成文件的时候执行,提供访问产出文件信息的入口,回调参数fileinfo
doneAsyncSeriesHook一次编译完成后执行,回调参数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 也是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

  ParcelJavaScript打包工具中属于相对后来者,在其官网的测试中,其构建速度相较于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")
复制代码

上一篇

文章分类
前端
文章标签