webpack5四部曲---原理

94 阅读8分钟

原理

主要介绍webpack的执行流程,loader和plugin的原理

loader

用来编译其他类型的文件

种类

  1. pre: 前置loader
  2. normal: 普通loader
  3. inline: 行内loader
  4. post: 后置loader

四种loader执行顺序: pre> normal>inline>post

定义

内联方式

行内loader通过在import时配置

import style form `style-loader!css-loader?module./main.css`
​
!: 分隔各个loader
?: 传递的参数
​
除此之外,添加不同的前缀可以跳过其他类型的loader
import style form `!style-loader!css-loader?module./main.css`
!: 跳过normal loader
-!: 跳过normal pre loader
!!: 跳过ore naomal post loader
配置方式

通过enforce可以在webpack配置文件中定义pre,normal,post三种loader,不配置enforce默认为normal

module: {
    rules: [
        {
            test: /.js$/,
            loader: 'loader1',
            enforce: 'pre'
        }
    ]
}

开发loader

loader其实就是一个函数,接收test匹配到的文件内容,函数的返回值就是处理后的文件内容,webpack打包时会将匹配到的loader自动执行

module.exports = function(content,map,meta){
    return content
}
参数:
content: 文件内容
map: sourceMap
meta: 其他loader传递的参数

loader有四种,分别在不同场景下使用: 同步loader,异步 loader,raw loader,pitch loader

同步loader

只能执行同步任务的loader

//适合资源文件只用这个loader处理
module.exports = function(content,map,meta){
    return content
}
// 适合资源文件需要多个loader处理,可以将错误信息,文件内容,sourceMap,meta传递给下一个loader 
module.exports = function(content,map,meta){
    this.callback(err,content,map,meta)
}
异步loader

可以执行异步任务的loader,通过webpak提供的异步apithis.async实现

module.exports = function(content,map,mate){
    const callBack = this.async()
    setTimeout(() => {
        callBack(err,content,map,meta)
    },1000)
}
raw loader

主要用来处理图片字体图标等资源,和同步异步loader的区别在于:

  1. raw-loader的content参数是一个buffer数据流
  2. 导出时需要将raw属性指定为true
module.exports = function(content,map,meta){
    return content
}
module.export.raw = true
pitch loader

和同步异步loader的区别在于:

  1. 到处是需要暴露一个pitch方法
module.exports = function(content,map,meta){
    return content
}
module.export.pitch = function(remainingRequest){
    return //return会影响loader和其他pitch的执行顺序
}
参数:
remainingRequest: 接下来要处理的loder的文件地址和资源文件地址字符串
​
执行时机:
假如有三个loader和对应的三个pitch:
use:[1,2,3]
pitch:[p1,p2,p3]
loader和pitch执行顺序为: p1,p2,p3,3,2,1
如果某个pitch中return了,那么之后的pitch和loader都不会执行,转而执行上一个loadera,如果没有,则执行结束
如: p2中有return,完整执行顺序为: p1,p2,1

loader API

在loader中可以通过this访问一些内置方法: API列表

下面是一些常用API:

名称描述用法
this.async表示这个loader是一个异步回调const callBack = this.async
this.callback同步或者异步调用的并返回多个结果的函数this.callBack(this.callback( err,content, sourceMap?,meta?)
this.emitFile产生一个文件this.emitFile(name,content,sourceMap)
this.utils.contextify返回一个相对路径this.utils.contextify(this.context, this.resourcePath)
this.utils.absolutify返回一个绝对路径this.utils.absolutify(this.context, this.resourcePath)
this.getOptions(schema)获取loader配置中的optionsschema是该options的验证规则,不符合验证规则的options会失败
schema

一个JSON,用来规定loaderoptions结构: options类型,以及内部应有什么属性,属性是什么类型等

标准的JSON格式:
schema.json:
{
    "type":"object", // options是一个对象
    "properties": { // 描述对该对象应该有什么属性
        "key":{
            "type": 'key的类型是什么',
        }
    },
    "addittionalProperties":false // 是否允许追加属性
}

实战

官方提供的loader工具函数库:github,包含各种工具函数,可以直接使用

手写banner-loader

banner-loader是一个自动给文件添加作者的loader,主要初步熟悉以下loader的编写和api的用法

banner-loader.js:
const schema = require("./schema.json");
​
module.exports = function (content, map, meta) {
  const options = this.getOptions(schema);
  const header = `
  /* author: ${options.author} */
  `;
  return header + content;
};
​
options的验证规则./schema.json:
{
  "type": "object", // 一个对象
  "properties": {
    "author": { // 拥有author属性
      "type": "string" // 该属性值为type
    }
  },
  "addittionalProperties": false // 不支持其他添加其他字段
}
手写babel-loader

实现一个建议版本的babel-loader:

const babel = require("@babel/core");
const schema = require("./schema.json");
​
module.exports = function (content) {
  const callback = this.async();
  const options = this.getOptions(schema);
  babel.transform(content, options, function (err, result) {
    if (err) {
      callback(err);
    } else {
      callback(null, result.code);
    }
  });
};
​
./schema.json:
{
  "type": "object",
  "properties": {
    "presets": {
      "type": "array"
    }
  },
  "addittionalProperties": false
}
手写file-loader

file-loader用来处理图片字体等资源,可以查看开发模式下图片的打包结果,:

image-20240312222427272.png

可以看到图片的输出是: module.exports = "文件名",由于这些资源都是原封不同的输出,因此我们不需要对内容做什么操作,只需要输出对应的文件和代码就可以了

  1. 获取文件内容hash值
  2. 创建文件
  3. 返回拼接好的代码

借助loader-utils中interpolateName来获取文件hash名:

npm i loader-utils -D
const loaderUtils = require("loader-utils");module.exports = function (content) {
  const interpolatedName = loaderUtils.interpolateName(
    this,
    "[contenthash:10].[ext][query]",
    { content }
  );
  this.emitFile(interpolatedName, content);
  return `module.exports=${interpolatedName}`;
};module.exports.raw = true;
手写style-loader

style-loader功能: 动态创建style标签,将css-loader处理的js运行结果,插入style标签中,最后将style标签插入页面的head标签中

根据这个功能描述,很容易想到style-loader可以这样写:

module.exports = function(content) {
    const script = `
        const style = document.createElement('style`)
        style.innerHTML = content
        const head = document.querySelector("head")
        head.append(style)
    `
    return script
}
构建js,loader返回js,js在浏览器中执行,动态插入style标签

css内容需要经过css-loader处理其中的资源引入等,而css-loader返回的内容是一段js,需要将js执行才可以拿到解析后的css,上述步骤执行后会在style标签中插如js,并不能达到如期效果.

因此,应该先拿到css-loader输出的js,然后将js执行,最后将js执行结果放进style标签中,

拿到css-loader返回的js代码容易,但怎么执行这一段js代码,并拿到执行结果.整个打包过程中唯一可以执行js的方式便是返回js代码让浏览器执行了,那么如果能在构建出执行css-loader并拿到结果的js代码,就能直接将内容插入style标签中了,

那么现在的问题就是

  1. 如何在js中运行css-loader
  2. 什么时机做这些事情

这两个问题分别可以通过loader内联配置,pitch来实现:

  1. 内联配置需要知道loader的路径,在pitch中参数便是之后执行的loader和资源文件的地址
  2. 将绝对路径转换成相对路径
  3. 以内联的方式,引用css-loader
  4. 动态构建js
module.exports = function (content) {};
module.exports.pitch = function (remainingRequest) {
  // remainingRequest接下来要执行的loder路径(inline形式),这里将路径转换成相对路径,以便在js中能正常解析
  const path = remainingRequest
    .split("!")
    .map((path) => this.utils.contextify(this.context, path))
    .join("!");
​
  const script = `
  import style from "!!${path}" // 使用!!阻止之后的loader执行,这里已经将css-loader执行过了,之后的就不要在执行了
  const oStyle = document.createElement("style");
  oStyle.innerHTML = style;
  const head = document.querySelector("head");
  head.append(oStyle);
  `;
  return script;
};

plugin

插件就是在webpack执行过程中,特地给阶段触发的函数,用来增强webpack的功能

webpack执行机制

webpack执行过程就像一条流水线,按部就班的执行各个步骤,触发一系列钩子(hook).插件就是注册在钩子中的事件,随着钩子的触发自动运行,webpack执行时会创建两种对象compiler,compilation,包含了webpack所存在的钩子,官网钩子

compiler对象

首次启动时创建,整个打包流程只会创建一个,包含了完整的webpack配置信息

常用属性:

  • compiler.options: 本次启动时的所有配置文件
  • compiler.inputFileSystem/outputFileSystem: 可以进行文件操作
  • hooks:用来注册compiler对象不同种类的hook
compilation对象

每一次构建资源的时候都会创建,可以访问所有模块和依赖,一个compilation对象会对依赖图中所有模块,进行编译,在编译阶段模块会被加载,封存,优化,分块,哈希和重新创建

常用属性:

  • complation.modules: 可以访问所有模块,打包的每一个文件都是一个模块
  • complation.chunks: 多个module组成的代码块
  • complation.assets: 可以访问本次打包生成的所有文件结果
  • complation.hooks: 用来注册complation对象不同种类的hook
hook的类型

注册hook时,有三种方法,分别对应不同类型的hook,这里先总结一下:

  1. tap: 注册同步钩子,也可以注册异步钩子,但异步钩子内必须是同步代码
  2. tapAsync:以回调方式注册钩子
  3. tapPromise: 以Promise方式注册钩子
webpack声明周期简图

开始打包时,webpack首先创建compiler对象,然后开始执行compiler对象的各个钩子.处理资源时,创建compilation对象,并执行其钩子,compilation的钩子执行完毕,如果还有资源,则会重新创建一个compilation对象.直到资源处理完毕,继续执行compiler对象的钩子

生命周期.png

plugin的构造和执行

每个插件都是一个类,必须拥有两个方法contructor,apply,使用时引入然后new调用即可.

class TestPlugin {
  contructor() {
    console.log("插件的contructor");
  }
  apply(compiler) {
    console.log(compiler, "插件的apply方法");
  }
}
module.exports = TestPlugin;

插件内部执行流程:

  1. webpack读取配置文件,new调用插件,执行插件的contructor方法
  2. webpack创建compiler对象
  3. 遍历所有的plugins,执行插件的apply方法
  4. 继续执行剩下的编译流程,触发各个hook

注册hook

hook都是在compiler,compilation这两个对象上注册的,格式化语法为:

compiler.hooks.someHook.tap('MyPlugin', (params) => {
  /* ... */
});
tap: 取决于钩子的种类,异步代码的用tapAsync和tapPromise

回调函数的参数,每个钩子不尽相同,开发时看文档即可

钩子执行顺序示例
class HookPlugin {
  constructor() {}
​
  apply(compiler) {
    // 通过compiler和compilation对象注册钩子,语法为compiler.hooks.钩子名称.钩子类型(插件名,回调函数)
    compiler.hooks.environment.tap("HookPlugin", () => {
      console.log("这时注册在enviroment中的钩子");
    });
​
    // emit可注册多个事件,一个一个串行执行
    compiler.hooks.emit.tapAsync("HookPlugin", (compilation, callback) => {
      setTimeout(() => {
        console.log("这是emit中的异步钩子,第一个");
        callback();
      }, 1000);
    });
    compiler.hooks.emit.tapAsync("HookPlugin", (compilation, callback) => {
      setTimeout(() => {
        console.log("这是emit中的异步钩子,第二个");
        callback();
      }, 1000);
    });
​
    // make也可以注册多个事件,所有事件并行执行,
    compiler.hooks.make.tapPromise("HookPlugin", (compilation) => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          console.log("这是make中的钩子 第一个");
          resolve();
        }, 2000);
        compilation.hooks.buildModule.tap("HookPlugin", () => {
          console.log("这是compilation的钩子,第一个");
        });
      });
    });
​
    compiler.hooks.make.tapPromise("HookPlugin", (compilation) => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          console.log("这是make中的钩子,第二个");
          resolve();
        }, 1000);
      });
    });
  }
}
​
module.exports = HookPlugin;
​
//执行结果为:
这时注册在enviroment中的钩子
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是make中的钩子,第二个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是compilation的钩子,第一个
这是make中的钩子 第一个
这是emit中的异步钩子,第一个
这是emit中的异步钩子,第二个

node调试方式

有时想要查看compilercompilation对象的内容,在命令行很不方便,可以采用调试,然后浏览器控制台查看

  1. 添加指令

    package.json:
        "debug": "node --inspect-brk ./node_modules/webpack-cli/bin/cli.js"
    --inspect: 调试
    -brk: 文件第一行加一个断点,方便进入
    ./node_modules/webpack-cli/bin/cli.js: 要运行调试的文件
    
  2. 在代码中需要的地方添加debugger;

  3. 运行指令: npm run debug

  4. 打开浏览器控制台(随便一个页面),左上角可以看到一个node图标,点击即可进入调试页面

实战

banner-webpack-plugin

通过插件实现loader部分写的banner-loader,自动为文件加上作者信息

开始之前有两个点需要明确一下:

  1. 首先需要确定在哪个钩子中加作者信息:在文件输出前加上作者信息,这样可以避免压缩等操作将作者信息干掉,输出文件之前执行的钩子就是emit
  2. 第二步需要确定如何拿到打包后的文件,emit回调函数的参数compilationassets属性保存的就是对应的资源文件

那么实现逻辑可以是这样:

  1. emit钩子中注册事件,拿到compilation对象
  2. 构建作者信息字符换
  3. 通过compilation.assets获取到文件资源
  4. 拼接
class BannerPlugin {
  constructor(options = {}) {
    this.name = options.name;
  }
  apply(compiler) {
    compiler.hooks.emit.tapAsync("BannerPlugin", (compilation, callback) => {
      debugger;
      const code = `
/*
*author:${this.name}
*/
`;
      const keys = Object.keys(compilation.assets).filter((item) =>
        item.includes(".js")
      );
      keys.forEach((key) => {
          // 获取这个bandle的内容
        let source = compilation.assets[key].source();
        source = code + source;
​
        //重写这个文件的source方法和size方法
        compilation.assets[key] = {
          source() {
            return source;
          },
          size() {
            return source.length;
          },
        };
      });
      callback();
    });
  }
}
module.exports = BannerPlugin;
clean-webpack-plugin

实现output配置的clean:true功能,打包时自动删除之前的打包文件

思路:

  1. 执行时机的选择: 在文件即将输出前,才清空之前的旧文件,防止打包中途出错,却提前把旧文件删除了
  2. 获取到打包输出路径,可以通过compiler的options属性,该属性存放配置文件的所有内容
  3. 通过compiler.outputFileSystem对旧文件夹进行删除
class CleanPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync("CleanPlugin", (compilation, callback) => {
      const path = compiler.options.output.path;
      const fs = compiler.outputFileSystem;
      fs.rmdirSync(path, { recursive: true });
      callback();
    });
  }
}
module.exports = CleanPlugin;
analyze-webpack-plugin

一个可以分析打包后文件大小的插件,将文件大小情况输出到md文档中

  1. 确定执行时机,在打包输出之前执行
// 分析打包后资源大小情况,输出md文档class AnalyzePlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync("AnalyzePlugin", (compilation, callback) => {
      let text = `|资源名称|资源大小|
|---|---|`;
      const assets = compilation.assets;
      Object.keys(assets).forEach((key) => {
        console.log(key, assets[key].size());
        text += `\n|${key}:| ${(assets[key].size() / 1024).toFixed(2)}kb|`;
      });
​
      // 在输出资源中增加一个资源文件
      assets["analyze.md"] = {
        source() {
          return text;
        },
        size() {
          return text.length;
        },
      };
      callback();
    });
  }
}
​
module.exports = AnalyzePlugin;
inline-webpack-plugin

打包后的文件中,可能存在非常小的文件,小到不值得一个单独请求去请求,这时候我们希望将这个文件转换成内联的代码,从而减少请求,这个插件就是做这个功能的.

runtime文件(存储文件依赖的映射)为例:

当前的分包方式:

optimization: {
    splitChunks: {
      chunks: "all",
    },
    runtimeChunk: {
      name: (entrypoint) => `runtime~${entrypoint.name}.js`,
    },
},

当前的打包后runtime文件的引入方式为:

<script defer src="js/runtime~main.js.js"></script>
<script defer src="js/main.js"></script>

希望的方式是:

<script>
    直接放置js/runtime~main.js.js的代码
</script>
<script defer src="js/main.js"></script>

实现分析:

可以看出最后就是要修改打包后的html文件,需要取出runtime文件的内容,拼接出内联script然后放入html中.这个html文件是由html-webpack-plugin生成的,查看这个插件的文档,可以发现这个插件内部实现了自定义的几个钩子:beforeAssetTagGeneration(创建标签前触发) => alterAssetTags(创建标签后触发) => alterAssetTagGroups(将属于head,body的标签分组后触发) => afterTemplateExecution(在编译模板后触发) => beforeEmit(在提交输出前触发) => afterEmit在提交输出后触发,有了alterAssetTagGroups这个钩子,就可以拿到分好组的标签,从而对标签进行更改了,文档中也提供了使用示例,可以直接使用.

步骤分析:

  1. 插件的触发时机应该在输出文件前的emit hook中触发,这时候文件已经编译完成了,可以拿到分包之后的文件
  2. 触发后,借助html-webpack-plugin的钩子,拿到分好组的标签
  3. 遍历标签,找到需要修改的标签,进行修改,修改成内联script
  4. 两个标签分组都要遍历,因为不知道这个文件会放入head中还是body
  5. 最后在资源中删除已经内联过的script文件
const HtmlWebpackPlugin = require("safe-require")("html-webpack-plugin");
​
class InlinePlugin {
  constructor(fileRegx) {
    this.fileRegx = fileRegx; // 通过传参来指定要将哪些文件转换为内联script
  }
  // 注册钩子
  apply(compiler) {
    compiler.hooks.compilation.tap("InlinePlugin", (compilation) => {
      // 注册html-webpack-plugin钩子
      const hooks = HtmlWebpackPlugin.getHooks(compilation);
      hooks.alterAssetTagGroups.tap("InlinePlugin", (assets) => {
        // 获取head和body组并修改
        assets.headTags = this.getInlineText(
          assets.headTags,
          compilation.assets
        );
        assets.bodyTags = this.getInlineText(
          assets.bodyTags,
          compilation.assets
        );
      });
​
      // 删除转换好的文件
      hooks.afterEmit.tap("InlinePlugin", () => {
        Object.keys(compilation.assets).forEach((key) => {
          if (this.fileRegx.test(key)) {
            delete compilation.assets[key];
          }
        });
      });
    });
  }
​
  // 获取标签内容,并将组中元素转换成这种形式
  // {
  //   tagName: "script",
  //   innerText: assets[filePath].source(),
  //   closeTag: true,
  // };
  getInlineText(tags, assets) {
    return tags.map((tag) => {
      const isScript = (tag.tagName = "script");
      const filePath = tag.attributes?.src;
      const isTargetFile = this.fileRegx.test(filePath);
      if (isScript && filePath && isTargetFile) {
        return {
          tagName: tag.tagName,
          innerHTML: assets[filePath].source(),
          closeTag: true,
        };
      }
      return tag;
    });
  }
}
module.exports = InlinePlugin;