从零学习Webpack核心机制Loader、Plugin到彩蛋原理分析

2,175 阅读10分钟

前言

一起深入Wepcak , 尽管 Vite 非常火爆确实很香,但是 Webpack 依然在企业占据主导地位以后我也不知道, 学习深入非常有必要,主要介绍loader / plugin 和原理分析,而那些配置只介绍部分常用的,我希望您能学会看文档,中文 or 英文我看不懂,以后必学

所以

一位程序员的职业生涯大约十年,只有人寿命的十分之一。前端项目只是你生活工作的一部分,而你却是它的全部,你是他的灵魂。请放下长时间的游戏、工作时的摸鱼。多学习来以最完美的状态好好陪你项目!

正文

文章底部小彩蛋,请你一步一步看过去!!

知识点

  • Webpack 前置基础
  • Loader 机制(手写一个)
  • Plugin 机制
  • 小彩蛋(介绍部分Webpack原理分析)

Webpack 前置基础(配置)

const path = require('path');

module.exports = {
  mode: "production", // "production" | "development" | "none"
  // Chosen mode tells webpack to use its built-in optimizations accordingly.

  entry: "./app/entry", // string | object | array
  // 这里应用程序开始执行
  // webpack 开始打包

  output: {
    // webpack 如何输出结果的相关选项

    path: path.resolve(__dirname, "dist"), // string
    // 所有输出文件的目标路径
    // 必须是绝对路径(使用 Node.js 的 path 模块)

    filename: "bundle.js", // string
    // 「入口分块(entry chunk)」的文件名模板(出口分块?)

    publicPath: "/assets/", // string
    // 输出解析文件的目录,url 相对于 HTML 页面

    library: "MyLibrary", // string,
    // 导出库(exported library)的名称

    libraryTarget: "umd", // 通用模块定义
    // 导出库(exported library)的类型

    /* 高级输出配置(点击显示) */
  },

  module: {
    // 关于模块配置

    rules: [
      // 模块规则(配置 loader、解析器等选项)

      {
        test: /\.jsx?$/,
        include: [
          path.resolve(__dirname, "app")
        ],
        exclude: [
          path.resolve(__dirname, "app/demo-files")
        ],
        // 这里是匹配条件,每个选项都接收一个正则表达式或字符串
        // test 和 include 具有相同的作用,都是必须匹配选项
        // exclude 是必不匹配选项(优先于 test 和 include)
        // 最佳实践:
        // - 只在 test 和 文件名匹配 中使用正则表达式
        // - 在 include 和 exclude 中使用绝对路径数组
        // - 尽量避免 exclude,更倾向于使用 include

        issuer: { test, include, exclude },
        // issuer 条件(导入源)

        enforce: "pre",
        enforce: "post",
        // 标识应用这些规则,即使规则覆盖(高级选项)

        loader: "babel-loader",
        // 应该应用的 loader,它相对上下文解析
        // 为了更清晰,`-loader` 后缀在 webpack 2 中不再是可选的
        // 查看 webpack 1 升级指南。

        options: {
          presets: ["es2015"]
        },
        // loader 的可选项
      },

      {
        test: /\.html$/,
        test: "\.html$"

        use: [
          // 应用多个 loader 和选项
          "htmllint-loader",
          {
            loader: "html-loader",
            options: {
              /* ... */
            }
          }
        ]
      },

      { oneOf: [ /* rules */ ] },
      // 只使用这些嵌套规则之一

      { rules: [ /* rules */ ] },
      // 使用所有这些嵌套规则(合并可用条件)

      { resource: { and: [ /* 条件 */ ] } },
      // 仅当所有条件都匹配时才匹配

      { resource: { or: [ /* 条件 */ ] } },
      { resource: [ /* 条件 */ ] },
      // 任意条件匹配时匹配(默认为数组)

      { resource: { not: /* 条件 */ } }
      // 条件不匹配时匹配
    ],

    /* 高级模块配置(点击展示) */
  },

  resolve: {
    // 解析模块请求的选项
    // (不适用于对 loader 解析)

    modules: [
      "node_modules",
      path.resolve(__dirname, "app")
    ],
    // 用于查找模块的目录

    extensions: [".js", ".json", ".jsx", ".css"],
    // 使用的扩展名

    alias: {
      // 模块别名列表

      "module": "new-module",
      // 起别名:"module" -> "new-module" 和 "module/path/file" -> "new-module/path/file"

      "only-module$": "new-module",
      // 起别名 "only-module" -> "new-module",但不匹配 "only-module/path/file" -> "new-module/path/file"

      "module": path.resolve(__dirname, "app/third/module.js"),
      // 起别名 "module" -> "./app/third/module.js" 和 "module/file" 会导致错误
      // 模块别名相对于当前上下文导入
    },
  },

  devtool: "source-map", // enum
  // 通过在浏览器调试工具(browser devtools)中添加元信息(meta info)增强调试
  // 牺牲了构建速度的 `source-map' 是最详细的。

  devServer: {
    proxy: { // proxy URLs to backend development server
      '/api': 'http://localhost:3000'
    },
    contentBase: path.join(__dirname, 'public'), // boolean | string | array, static file location
    compress: true, // enable gzip compression
    historyApiFallback: true, // true for index.html upon 404, object for multiple paths
    hot: true, // hot module replacement. Depends on HotModuleReplacementPlugin
    https: false, // true for self-signed, object for cert authority
    noInfo: true, // only errors & warns on hot reload
    // ...
  },

  plugins: [
    // ...
  ],
  // 附加插件列表


  /* 高级配置(点击展示) */
}

上面内容高级 CV 操作来自Webpack官网仅贴出常用配置!这个不是主体,进入写一个环节

  • entry:入口,Webpack 执行构建的第一步将从 entry 开始,可抽象成输入。
  • module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 entry 开始递归找出所有依赖的模块。
  • chunk:代码块,一个 chunk 由多个模块组合而成,用于代码合并与分割。
  • loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。

你得了解上面基本信息后,才可以进入下一步

Loader 机制(手写一个)

简单来说 loader 是一个 可以获取你入口文件源代码的一个函数,函数本身参数就是源代码。

实现一个读取图片的 loader 并没有你想象的那么难

  1. 获取图片的buffer
  2. 转base64 / 写入 buffer 生成图片

动手试试

// webpack.config.js
 module: {
        rules: [
            {
                test: /\.(png)|(jpg)|(gif)$/, use: [{
                    loader: "./loaders/img-loader.js",
                    options: {
                        limit: 3000, //3000字节以上使用图片,3000字节以内使用base64
                        filename: "img-[contenthash:5].[ext]"
                    }
                }]
            }
        ]
    }

获取模块配置项

在 Loader 中获取用户传入的 options,通过 loader-utils 的 getOptions 方法获取:

var loaderUtil = require("loader-utils")

function loader(buffer) { //给的是buffer
    console.log("文件数据大小:(字节)", buffer.byteLength);
    var { limit = 1000, filename = "[contenthash].[ext]" } = loaderUtil.getOptions(this);
    if (buffer.byteLength >= limit) {
        var content = getFilePath.call(this, buffer, filename);
    }
    else{
        var content = getBase64(buffer)
    }
    return `module.exports = \`${content}\``;
}

loader.raw = true; 
// 通过 exports.raw 属性告诉 Webpack 该 Loader 是否需要二进制数据

module.exports = loader;

// 获取base 64 格式
function getBase64(buffer) {
    return "data:image/png;base64," + buffer.toString("base64");
}
// 构建图片 生成路径
function getFilePath(buffer, name) {
    var filename = loaderUtil.interpolateName(this, name, {
        content: buffer
    });
    this.emitFile(filename, buffer);
    return filename;
}

上面通过 this.emitFile 进行文件写入

同步与异步

Loader 有同步和异步之分,上面的 Loader 都是同步的 Loader,因为它们的转换流程都是同步的,转换完成后再返回结果。但有些场景下转换的步骤只能是异步完成的,例如你需要通过网络请求才能得出结果,如果采用同步的方式 网络请求 就会阻塞整个构建,导致构建非常缓慢。

module.exports = function(source) {
  // 告诉 Webpack 本次转换是异步的,Loader 会在 callback 中回调结果
  var callback = this.async();
  someAsyncOperation(source, function(err, result, sourceMaps, ast) {
    // 通过 callback 返回异步执行后的结果
    callback(err, result, sourceMaps, ast);
  });
};

缓存加速

在有些情况下,有些转换操作需要大量计算非常耗时,如果每次构建都重新执行重复的转换操作,构建将会变得非常缓慢。为此,Webpack 会默认缓存所有 Loader 的处理结果,也就是说在需要被处理的文件或者其他依赖的文件没有发生变化时,是不会重新调用对应的 Loader 去执行转换操作的。

如果你想让 Webpack 不缓存该 Loader 的处理结果,可以这样:

module.exports = function(source) {
  // 关闭该 Loader 的缓存功能
  this.cacheable(false);
  return source;
};

知道了 Webpack 核心loader 再来介绍一下 plugin

Plugin 机制

Plugin 可以干的活比Loader更多,更复杂,其本质是一个Class类

插件的基本结构 plugins 是可以用自身原型方法 apply 来实例化的对象。apply 只在安装插件被 Webpack 的 compiler 执行一次。apply 方法传入一个 webpck compiler 的引用,来访问编译器回调。

class HelloPlugin {
  // 在构造函数中获取用户给该插件传入的配置
  constructor(options) {
    // ...
  }
  // Webpack 会调用 HelloPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply(compiler) {
    // 在 emit 阶段插入钩子函数,用于特定时机处理额外的逻辑
    compiler.hooks.emit.tap('HelloPlugin', compilation => {
      // 在功能流程完成后可以调用 Webpack 提供的回调函数
    });
    // 如果事件是异步的,会带两个参数,第二个参数为回调函数,在插件处理完成任务时需要调用回调函数通知 Webpack,才会进入下一个处理流程
    compiler.plugin('emit', function(compilation, callback) {
      // 支持处理逻辑
      // 处理完毕后执行 callback 以通知 Webpack
      // 如果不执行 callback,运行流程将会一致卡在这不往下执行
      callback();
    });
  }
}
module.exports = HelloPlugin;

使用插件时,只需要将它的实例放到 Webpack 的 Plugins 数组配置中:

const HelloPlugin = require('./hello-plugin.js');
module.exports = {
  plugins: [new HelloPlugin({ options: true })],
};

先来分析以下 Webpack Plugin 的工作原理:

  1. 读取配置的过程中会先执行 new HelloPlugin(options) 初始化一个 HelloPlugin 获得其实例
  2. 初始化 compiler 对象后调用 HelloPlugin.apply(compiler) 给插件实例传入 compiler 对象
  3. 插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin(事件名称, 回调函数) 监听到 Webpack 广播出来的事件,并且可以通过 compiler 对象去操作 Webpack

apply的阶段你可以调用 compiler钩子

webpack的hoosk钩子其实是使用tapable直接注册在不同的阶段的,所以我们进行下一步分析

小彩蛋(介绍部分Webpack原理分析)

Webpack 本质是一个打包构建工具,我们不妨思考一下,它为我们做了什么。

  1. 读取webpack.config.js配置文件,找到入口
  2. 获取入口文件中的源代码 分析抽象语法树(babel实现)
  3. 分析过程 静态分析代码执行上下文和使用情况, 标记是否Tree Shaking
  4. 核心的loader 和 plugin 在读取配置过程中执行函数,tapable注入钩子函数
  5. 最后输出在配置文件中的出口目录中

其实我们简易分析一下,也是非常好理解的

// 首先定义 Compiler
class Compiler {
  constructor(options) {
    // Webpack 配置
    const { entry, output } = options;
    // 入口
    this.entry = entry;
    // 出口
    this.output = output;
    // 模块
    this.modules = [];
  }
  // 构建启动
  run() {
    // ...
  }
  // 重写 require 函数,输出 bundle
  generate() {
    // ...
  }
}

使用 @babel/parser@babel/traverse两个库分析源代码抽象语法树,找出所用模板依赖

编译过程

整个过程可大致分为三个步骤

初始化

这个阶段,webpack会将CLI参数、配置文件、默认配置 进行融合,形成一个最终配置对象(依托于第三方库yargs完成)

主要是为了接下来的编译阶段做必要准备,可以简单理解为初始化阶段主要用于产生一个最终的配置

编译


创建chunk

chunk:webpack在内部构建过程中的概念,,表示通过某个入口找到所有依赖的统称

根据入口模块(默认为./src/index.js)创建一个chunk

每个chunk至少有两个属性:

  • name:默认为main
  • id:唯一编号,开发环境和name相同,生产环境是一个数字,从0开始

构建所有依赖模块

2020-01-09-12-32-38.png

AST在线测试工具:astexplorer.net/

简图

2020-01-09-12-35-05.png

产生chunk assets

第二步完成之后,chunk中会产生一个模块列表,其中包含了模块id模块转换后的代码

接下来,webpack会根据配置为chunk生成一个资源列表,chunk assets,资源列表可以理解为是生成到最终文件的文件名和文件内容

2020-01-09-12-39-16.png

chunk hash是根据所有chunk assets的内容生成的一个hash字符串

hash:一种算法,具体有很多类,特点是将一个任意长度的字符串转换成一个固定长度的字符串,可以保证原始内容不变,产生的hash字符串就不变

简图

2020-01-09-12-43-52.png


合并chunk assets

将多个chunk的assets合并到一起,并产生一个中的hash

2020-01-09-12-47-43.png

输出

此步骤非常简单,webpack将利用node中的fs模块(文件处理模块),根据编译产生的总的assets,生成相应的文件。

2020-01-09-12-54-34.png

总过程

2020-01-09-15-51-07.png

设计术语

  1. module:模块,分割的代码单元,webpack中的模块可以是任何内容的文件,不仅限于JS
  2. chunk:webpack内部构建模块的块,一个chunk中包含多个模块,这些模块是从入口模块通过依赖分析得来的
  3. bundle:chunk构建好模块后会生成chunk的资源清单,清单中的每一项就是一个bundle,可以认为bundle就是最终生成的文件
  4. hash:最终的资源清单所有内容联合生成的hash值
  5. chunkhash:chunk生成的资源清单内容联合生成的hash值
  6. chunkname:chunk的名称,如果没有配置则使用main
  7. id:通常指chunk的唯一编号,如果在开发环境下构建,和chunkname相同;如果是生产环境下构建,则使用一个从0开始的数字进行编号

tapable本质是一个javascript小型库, 内部是发布订阅模式。类似Node的EventEmitter。

Webpack 本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是 Tapable,Webpack 中最核心的负责编译的 Compiler 和负责创建 bundles 的 Compilation 都是 Tapable 的子类,并且实例内部的生命周期也是通过 Tapable 库提供的钩子类实现的。

总结

Webpack 本质是一个事件流机制的打包构建工具,从读取配置到分析语法树注册事件流输出文件的一个过程。其核心的loader 本质也是一个可以获取到源代码的一个函数,plugin则是一个可以获取到整个事件生命周期的一个类~ 至此你应该对webpack有了更加深刻的认识,本文跳过了许多的细节问题,介绍核心知识,所以你还是得多看看配置的文档!

往期文章

【重拾落叶】Javascript执行期上下文、预编译

【重拾落叶】浏览器如何完整获取一个页面?(加载篇)