玩转 webpack 工作原理,手写一个 my-webpack

项目搭建

新建文件夹 my-webpack ,使用开发工具打开这个项目目录。

  • 执行 npm init -y,生成 package.json

  • 新建 bin 目录

    bin 目录代表着可执行文件,在 bin 下新建主程序执行入口 my-webpack.js

    #!/usr/bin/env node
    // 上面代码用来声明 执行环境 
    console.log("学习好嘞 我好困");
    复制代码
  • backage.json 配置 bin 字段

    {
      "bin": {
        // 声明指令 以及指令执行的文件
        "my-webpack": "./bin/my-webpack.js"
      },
    }
    复制代码
  • 执行 npm link 将当前项目链接到全局包中

    我们要想像 webpack 一样 全局使用 webpack 指令,必须要将包链接到全局中

  • 命令行执行 my-webpack,发现程序成功执行

    image.png

分析Bundle

新建一个项目完成一次简单的打包,对打包后得bundle文件进行分析

  • 搭建webpack项目
    • 新建 demo 目录,并在 demo 目录下执行 yarn init -y
    • 安装 webpack
      yarn add webpack webpack-cli -D
    • 新建业务模块 src/index.jssrc/moduleA.jssrc/moduleB.js
      // src/index.js  
      const moduleA = require("./moduleA.js")
      console.log("index.js,成功导入" + moduleA.content);
      
      // src/moduleA.js  
      const moduleB = require("./moduleB.js")
      console.log("moduleA模块,成功导入" + moduleB.content);
      module.exports = {
        content: "moduleA模块"
      }
      
      // src/moduleB.js  
      module.exports = {
        content: "moduleB模块"
      } 
      复制代码
    • 新建 webpack.config.js 配置打包参数
      const path = require("path")
      module.exports = {
        entry: "./src/index.js",
        output: {
          filename: "bundle.js",
          path: path.resolve("./build")
        },
        mode: "development"
      }
      复制代码
    • 新建 build-script 脚本,并执行 npm run build
      // package.json
      {
        "scripts": {
          "build": "webpack"
        },
      } 
      复制代码
  • 对打包后的 build/bundle.js 进行分析
    (() => {
      /**
       * 所有模块
       * 
       * 所有模块在 __webpack_modules__ 以键值对的形式等待加载,模块对象键为模块ID(路径) 值为模块的内容。 
       * 模块内通过webpack封装的 __webpack_require__ 函数进行加载其他模块
      */
      var __webpack_modules__ = ({
        "./src/index.js":
          (function (__unused_webpack_module, __unused_webpack_exports, __webpack_require__) {
            eval("const moduleA = __webpack_require__(/*! ./moduleA */ \"./src/moduleA.js\")\r\nconsole.log(\"this\", this);\r\nconsole.log(\"index.js,成功导入\" + moduleA.content);\n\n//# sourceURL=webpack://demo/./src/index.js?");
          }),
    
        "./src/moduleA.js":
          ((module, __unused_webpack_exports, __webpack_require__) => {
            eval("const moduleB = __webpack_require__( \"./src/moduleB.js\")\r\nconsole.log(\"moduleA模块,成功导入\" + moduleB.content);\r\nmodule.exports = {\r\n  content: \"moduleA模块\"\r\n}\n\n//# sourceURL=webpack://demo/./src/moduleA.js?");
          }),
    
        "./src/moduleB.js":
          ((module) => {
            eval("module.exports = {\r\n  content: \"moduleB模块\"\r\n}\n\n//# sourceURL=webpack://demo/./src/moduleB.js?");
          })
      });
    
      /**
       * 模块缓存
       * 
       * 每次加载新模块后会添加缓存,下次加载相同模块前会直接使用缓存,避免重复加载。
       */
      var __webpack_module_cache__ = {};
    
      /**
       * webpack封装的加载函数  
       * 
       * 该函数根据模块ID加载模块,加载前先判断 是否模块缓存中是否有缓存,有的话直接使用缓存,
       * 没有则添加缓存并从所有模块(__webpack_modules__)中,加载这个模块
       */
      function __webpack_require__(moduleId) {
        // 加载前判断是否有可以使用的缓存
        var cachedModule = __webpack_module_cache__[moduleId];
        if (cachedModule !== undefined) {
          return cachedModule.exports;
        }
        // 创建一个新的空模块 并添加进缓存中
        var module = __webpack_module_cache__[moduleId] = {
          exports: {}
        };
    
        // 执行模块方法,执行过程中会加载模块中的代码 并获取模块的导出内容 
        __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    
        // 返回模块最终导出的数据
        return module.exports;
      }
      
        /**
         * 这是bundle.js执行起点,通过 __webpack_require__ 导入配置入口文件的模块
         * 
         * 入口文件模块如果有依赖通过__webpack_require__ 继续导入,然后执行入口文件模块中的代码
         * "./src/index.js" 通过__webpack_exports__ 导入 "./src/moduleA.js","./src/moduleA.js" 通过__webpack_exports__ 导入"./src/moduleB.js"
         */
        var __webpack_exports__ = __webpack_require__("./src/index.js");
      /**
       * 执行流程
       * __webpack_require__ 会加载 __webpack_modules__中的某个模块,这个模块的执行方法会 递归调用 __webpack_require__ 加载下个模块知道所有模块加载完成
       */
    })();
    复制代码

    _webpack_require_ 进行分析

    _webpack_require_ 可以说是bundle中非关键的狗工具函数,通过递归调用这个函数导入所有依赖
    var __webpack_modules__ = ({
      "./src/index.js":
        (function (__unused_webpack_module, __unused_webpack_exports, __webpack_require__) {
          eval("const moduleA = __webpack_require__(/*! ./moduleA */ \"./src/moduleA.js\")\r\nconsole.log(\"this\", this);\r\nconsole.log(\"index.js,成功导入\" + moduleA.content);\n\n//# sourceURL=webpack://demo/./src/index.js?");
        }),
        ......
      }) 
        
    function __webpack_require__(moduleId) {
      // 缓存处理
      var cachedModule = __webpack_module_cache__[moduleId];
      if (cachedModule !== undefined) {return cachedModule.exports;}
      // 新建module对象并添加缓存
      var module = __webpack_module_cache__[moduleId] = {exports: {}};
    
      /**
       * webpack基于node环境
       * 执行__webpack_modules__的模块,传入module、module.exports并改变this指向,
       *这样就可以快乐的在模块中使用 this、module等变量了
       */
      __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    
      // 返回模块最终导出的数据
      return module.exports;
    }
    复制代码

依赖模块分析

_webpack_modules_ 保存了所有模块,最复杂的是所有模块的依赖关系,这个涉及js、nod的基础以及抽象语法树的概念。
webpack的工作就是模块分析、然后通过各种plugin、loader对文件进行处理最终生成 _webpack_modules_,实现webpack最大的难点就是模块分析。

这里为入口文件 "./src/index.js" 为例,分析模块依赖生成 _webpack_modules_

开始解析

回到之前的项目 my-webpack 主程序执行文件 bin/my-webpack.js

  • 读取打包配置文件、获取打包配置参数(入口、出口等)

    // my-webpack/bin/my-webpack.js
    #!/usr/bin/env node
    const path = require("path")
    
    // 1.导入打包配置文件,获取打包配置
    // 使用my-webpack工具对其他项目打包时,需要获取项目打包配置文件绝对路径
    const config = require(path.resolve("webpack.config.js"))
    console.log("config", config);
    复制代码
  • 回到 DEMO 项目,终端中输入指令 my-webpack 使用自己打包工具

    成功获取打包配置参数

    image.png

代码解析器

使用解析器根据配置参数 解析项目代码

  • 在工具 my-webpack 目录下新建 lib/Compiler.js

    使用面对对象思想,新建 Compiler

    // lib/Compiler.js  
    const path = require("path")
    const fs = require("fs")
    class Compiler {
      constructor(config) {
        this.config = config
        this.entry = config.entry
        // process.cwd可以获取node执行的文件绝对路径
        // 获取被打包项目的文件路径
        this.root = process.cwd()
      }
      // 根据传入的文件路径,解析文件模块
      depAnalyse(modulePath) {
        const file = this.getSource(modulePath)
        console.log("file", file);
      }
      // 传入文件路径,读取文件并返回
      getSource(path) {
        // 以“utf-8”编码格式 读取文件并返回
        return fs.readFileSync(path, "utf-8")
      }
      // 执行解析器
      start() {
        // 传入入口文件绝对路径 开始解析依赖
        // 注意:此处路径不能使用 __dirname,__dirname代表 工具库"my-webpack"根目录的绝  对路径  而不是要被打包项目的根目录路径
        this.depAnalyse(path.resolve(this.root, this.entry))
      }
    }
    module.exports = Compiler
    
    // bin/my-webpack.js  
    // 2. 导入解析器并新建实例  并执行构解析器
    const Compiler = require("../lib/Compiler")
    new Compiler(config).start()
    复制代码
  • 在被打包项目 DEMO 中,重新执行 my-webpack 成功读取入口文件

    image.png

抽象语法树

顺利读取到模块代码,可将模块代码转换成抽象语法树(ast),将 require 语法替换为自己封装加载函数 _webpack_require_

代码在线转为抽象语法树: astexplorer.net/

image.png

在打包项目要进行抽象语法树的生成和语法装换需要用到两个包,分别是 @babel/parser@babel/traverse

  • 安装
    npm i @babel/parser @babel/traverse -S

  • 生成ast,并转换语法

    // my-webpack/lib/Compiler.js  
    // 导入解析器
    const parser = require("@babel/parser")
    // 导入转换器   es6导出需要.defult
    const traverse = require("@babel/traverse").default
    
    class Compiler {
      depAnalyse(modulePath) {
        const code = this.getSource(modulePath)
        // 将代码解析为ast抽象语法树
        const ast = parser.parse(code)
        /**
         * traverse用来转换语法,它接收两个参数 
         *   - ast:转换前的抽象语法树节点树  
         *   - options: 配置对象,里面包含各种钩子回调,traverse遍历语法树节点当节点满足某个钩子条件时  该钩子会被触发
         *     - CallExpression:
         */
        traverse(ast, {
          // 当某个抽象语法树节点类型为 CallExpression(表达式调用),会触发该钩子
          CallExpression(p) {
            console.log("该类型语法节点的 名称", p.node.callee.name);
          },
        })
      }
    }
    复制代码
  • 回到 DEMO 项目,执行 my-webpack 使用自己的工具库重新打包

    image.png

  • 替换代码中的关键字

    // my-webpack/lib/Compiler.js  
    traverse(ast, {
      // 当某个抽象语法树节点类型为 CallExpression(表达式调用),会触发该钩子
      CallExpression(p) {
        console.log("该类型语法节点的 名称", p.node.callee.name);
          if (p.node.callee.name === 'require') {
            // 修改require
            p.node.callee.name = "__webpack_require__"
    
            // 修改当前模块 依赖模块的路径 使用node访问资源必须是 "./src/XX" 的形式 
            let oldValue = p.node.arguments[0].value
            // 将"./xxx" 路径 改为 "./src/xxx"
            oldValue = "./" + path.join("src", oldValue)
    
            // 避免window的路径出现 "\"
            p.node.arguments[0].value = oldValue.replace(/\\/g, "/")
            console.log("路径", p.node.arguments[0].value);
          }
      },
    })
    复制代码
  • 回到 DEMO ,执行 my-webpack重新打包查看控制台输出

    image.png

生成源码

对AST进行处理后,可以通过 @babel/generator 生成代码

  • 安装
    npm i @babel/generator -S
  • 解析完AST后 构建代码
    // my-webpack/lib/Compiler.js  
    // 导入生成器
    const generator = require("@babel/generator").default
    
    class Compiler {
      depAnalyse(modulePath) {
        traverse(ast, {
          ......
        })
        // 将抽象语法树生成代码
        const sourceCode = generator(ast).code
        console.log("源码", sourceCode);
      }
    }
    复制代码
  • 回到 DEMO, 执行my-webpack 重新构建

image.png

递归构建依赖

前面在 Compiler 中,我们通过 depAnalyse 方法 传入模块路径将 ./src/index.js 模块中的语法进行转换、依赖模块的路径进行转换。这只解析了 "./src/index.js" 这一层,它的依赖模块并没有解析构建,所以我们要递归执行 depAnalyse 解析所有模块

class Compiler {
  depAnalyse(modulePath) {
    
    // 当前模块依赖数组,存放当前模块所以依赖的路径  
    let dependencies = []
   
    traverse(ast, {
      CallExpression(p) {
        if (p.node.callee.name === 'require') {
          p.node.callee.name = "__webpack_require__"
          let oldValue = p.node.arguments[0].value
          oldValue = "./" + path.join("src", oldValue)
          p.node.arguments[0].value = oldValue.replace(/\\+/g, "/")

          // 每解析require,就将依赖模块的路径放入 dependencies 中
          dependencies.push(p.node.arguments[0].value)
        }
      },
    })
    
    const sourceCode = generator(ast).code
    console.log("sourceCode", sourceCode);

    // 如果当前模块 有其他依赖的模块就 递归调用 depAnalyse 继续向下解析代码 直到没有任何依赖为止
    dependencies.forEach(e => {
      // 传入模块的绝对路径
      this.depAnalyse(path.resolve(this.root, depPath))
    })
  }
}
复制代码

image.png

获取所有模块

webpack打包的结果是将所有模块 以模块ID+模块执行函数的形式放一起的

class Compiler {
  constructor(config) {
    ......
    // 存放打包后的所有模块
    this.modules = {}
  }
  depAnalyse(modulePath) {
    traverse(ast, {.....})
    const sourceCode = generator(ast).code

    // 模块得ID处理为相对路径  
    let modulePathRelative = "./" + path.relative(this.root, modulePath)
    // 将路径中 "\" 替换为 "/"
    modulePathRelative = modulePathRelative.replace(/\\+/g, "/")
    // 当前模块解析完毕 将其添加进modules
    this.modules[modulePathRelative] = sourceCode
    
    dependencies.forEach(depPath => {......})
  }
   start() {
    this.depAnalyse(path.resolve(this.root, this.entry))
    // 获取最终解析结果
    console.log("module", this.modules);
  }
}
复制代码

DEMO 执行 my-webpack 重新查看打包结果

image.png

到此为止我们就通过 Compiler类 ,递归的调用 depAnalyse 方法解析项目所有模块,获取了 modules 成功构建了整个项目。

生成 _webpack_modules_

使用 webpack 打包生成 _webpack_modules_,所以模块是模块ID+模块执行函数存在的。我们可以使用 模板引擎(这是以ejs示例)将模块代码处理成模块执行函数

  • 安装
    npm i ejs -S

  • 新建渲染模板 my-webpack/template/output.ejs

    (() => {
      var __webpack_modules__ = ({
        // 使用模板语法进行遍历 k就是模块ID
        <% for (let k in modules) {%>
        "<%- k %>":
        (function (module, exports, __webpack_require__) {
          eval(`<%- modules[k]%>`);
        }),
        <%}%>
      });
    
      var __webpack_module_cache__ = {};
    
      function __webpack_require__(moduleId) {
    
        var cachedModule = __webpack_module_cache__[moduleId];
        if (cachedModule !== undefined) {
         return cachedModule.exports;
        }
    
        var module = __webpack_module_cache__[moduleId] = {
          exports: {}
        };
    
        __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
        return module.exports;
      }
    
      var __webpack_exports__ = __webpack_require__("<%-entry%>");
    }) ();
    复制代码
  • 使用模板渲染code,并写入到出口文件中

    // my-webpack/lib/Compiler.js  
    
    // 导入ejs
    const ejs = require("ejs")
    class Compiler {
      constructor(config) {...}
      depAnalyse(modulePath) {....}
      getSource(path) {...}
      
      start() {
       this.depAnalyse(path.resolve(this.root, this.entry))
       this.emitFile()
      }
      
      // 生成文件
      emitFile() {
        // 读取代码渲染依据的模板
        const template = this.getSource(path.resolve(__dirname, "../template/output.ejs"))
        // 传入渲染模板、模板中用到的变量
        let result = ejs.render(template, {
          entry: this.entry,
          modules: this.modules
        })
        // 获取输出路径 
        let outputPath = path.join(this.config.output.path,this.config.output.filename)
        // 生成bundle文件
        fs.writeFileSync(outputPath,result)
      }
    }
    复制代码
  • DEMO 项目中,重新执行 my-webpack

    注意 DEMO 项目中 webpack.config.js 得输出路径为 DEMO/build/bundle.js。你必须确保 DEMObuild 文件夹,否则使用 my-webpack 写入文件时可能会报错。

    image.png

自定义loader

前面我们实现了一个打包工具 my-webpack,它现在只能处理js文件。如果要处理其他文件或者对js代码进行操作需要借助 loader . loader 主要的功能就是将一段匹配规则的代码,进行加工处理生成最终代码进行输出。

loader的使用步骤

  • loader
  • 在webpack配置文件,配置到 modulerules中即可
  • 某些 loader 还需要配置一些其他参数(可选)

什么是loader

loader 模块就是一个函数,webpack 会向 loader 函数传入资源,loader 对这些资源进行处理后再将其返回出去。

我们在 webpack 项目中实现一个 loader

  • src/moduleB.js 导出内容进行处理

    将导出内容含有 "moduleB" 替换为 "MODULE-B"

    image.png

  • DEMO 项目中新建 loader/index.js

    // loader本质就是一个函数
    module.exports = function (resource) {
      // 对匹配到的资源进行处理并返回出去
      return resource.replace(/moduleB/g, "MODULE-B")
    }
    复制代码
  • DEMOwebpack.config.js 中,配置自定义 loader

    // DEMO/webpack.config.js
    module.exports = {
      module: {
        rules: [
          {
            test: /.js$/g,
            use: "./loader/index.js"
          }
        ]
      }
    }
    复制代码
  • 执行 npm run build,使用 webpack 打包项目 并执行打包后的代码

    image.png

loader执行顺序

使用loader处理某个资源会有两种情况:单个匹配规则多个loader多个匹配规则单个loader

单个匹配规则多个loader:

当单个匹配规则多个loader,loader执行顺序是从右侧到左侧执行

image.png

多个匹配规则单个loader:

当多个匹配规则单个loader,loader执行顺序是从下到上执行

image.png

loader种类

loader种类分为 前置内联正常后置

这四种loader执行顺序分别为: 前置 > 内联 > 正常 > 后置

前置loader、后置loader

前置loader后置loaderloaderenforce 字段控制,前置 loader 会在所有 loader 执行前执行,后置 loader 会在所有 loader 执行完成后执行

image.png

内联loader

使用内联 loader 解析文件,必须在 requireimport 引入的资源前面加上 loader ,多个 loader 要用 ! 隔开 例:

import Styles from 'style-loader!css-loader?modules!./styles.css'
复制代码

获取options配置

大部分 loader 只需要在 rules 里注册一下就可以使用,也有一些涉及复杂功能的 loader 需要配置 options 参数。例如,生成图片资源的 url-loader 设置生成base64 文件大小的阀值。

loader 中,this可以获取到上下文及webpack中的配置,通过 this.getOptions 可以获取 options 参数

  • 传入参数

     module: {
      rules: [
        {
          test: /.js$/g,
          use: {
            loader: "./loader/index.js",
            options: {
              target: /moduleB/g,
              replaceContent: "MD_B"
            }
          },
    
        },
      ]
    },
    复制代码
  • 自定义 loader 中使用参数

    // demo/loader/index.js  
    const loaderUtils = require("loader-utils")
    // loader本质就是一个函数
    module.exports = function (resource) {
      const { target, replaceContent } = this.getOptions()
      // 对匹配到的资源进行处理并返回出去
      return resource.replace(target, replaceContent)
    }
    复制代码
  • 执行 npm run build,使用 webpack 打包项目 并执行打包后的代码

    image.png

my-webpack添加loader功能

通过前面配置 loader 和手写 loader,可以发现 my-webpack添加 loader 功能主要是这几大步骤:

  • 读取 webpack 配置文件的 module.rules 配置项,进行倒叙迭代(rules每项匹配规则按倒序匹配)
  • 根据正则匹配到对应的文件类型,同时再批量导入 loader 函数
  • 倒序迭代调用所有 loader 函数
  • 最后返回处理的代码

代码部分(my-webpack/lib/Compiler.js)

  • 获取配置文件中 配置的所有 loader
    class Compiler {
      constructor(config) {
        ...
        // 获取所有loader
        this.rules = config.module && config.module.rules
      }
      ...
    }
    复制代码
  • 声明 useLoader 方法,在解析依赖时调用,传入解析的源码和模块路径
    class Compiler {
      ...
      depAnalyse(modulePath) {
        let code = this.getSource(modulePath)
        // 使用loader处理源码
        code = this.useLoader(code, modulePath)
        const ast = parser.parse(code)
        ...
      }
      // 使用loader
      useLoader(code, modulePath) {
        // 未配置rules 直接返回源码
        if (!this.rules) return code
        // 获取源码后 输出给loader ,倒序遍历loader  
        for (let index = this.rules.length - 1; index >= 0; index--) {
          const { test, use } = this.rules[index]
          // 如果当前模块满足 loader正则匹配  
          if (test.test(modulePath)) {
            /**
             * 如果单个匹配规则,多个loader,use字段为数组。单个loader,use字段为字符串或对象
             */
            if (use instanceof Array) {
              // 倒序遍历loader 传入源码进行处理
              for (let i = use.length - 1; i >= 0; i--) {
                let loader = use[i];
                /**
                 * loader 为字符串或对象
                 *   字符串形式:
                 *   use:["loader1","loader2"]
                 *   对象形式:
                 *   use:[
                 *     {
                 *        loader:"loader1",
                 *        options:....
                 *     }
                 *   ]
                 */
                let loaderPath = typeof loader === 'string' ? loader : loader.loader
                // 获取loader的绝对路径
                loaderPath = path.resolve(this.root, loaderPath)
                // loader 上下文
                const options = loader.options
                const loaderContext = {
                  getOptions() {
                    return options
                  }
                }
                // 导入loader
                loader = require(loaderPath)
                // 传入上下文 执行loader 处理源码
                code = loader.call(loaderContext, code)
              }
            } else {
              let loaderPath = typeof loader === 'string' ? use : use.loader
              // 获取loader的绝对路径
              loaderPath = path.resolve(this.root, loaderPath)
              // loader 上下文
              const loaderContext = {
                getOptions() {
                  return use.options
                }
              }
              // 导入loader
              let loader = require(loaderPath)
              // 传入上下文 执行loader 处理源码
              code = loader.call(loaderContext, code)
            }
          }
        }
        return code
      }
    }
    复制代码
  • 执行 my-webpack,使用 my-webpack 打包项目 并执行打包后的代码

自定义plugin

webpack 的插件接口可以让使用者触及到编译过程,插件可以将处理函数注册到编译过程的不同事件节点生命周期钩子上,当执行每个钩子时,插件可以访问到编译的当前状态。

简而言之,自定义插件可以在 webpack 编译过程的声明周期钩子中,对源码进行处理,实现一些功能。

插件生命周期钩子

钩子 作用 参数
entryOption 在处理webpack选项的entry配置后调用 context,entry
afterPlugins 在初始化内部插件列表后调用 compiler
beforeRun 在运行Compiler之前调用 compiler
run 在Compiler开始工作时调用 compiler
emit 向asstes目录发射asstes时调用 compilation
done 编译完成后调用 stats
... ... ...

webpack插件的组成

  • 一个js命名函数
  • 在插件的 prototype 上定义一个 apply 方法
  • 指定一个绑定到 webpack 自身的事件钩子
    • webpack 内部那么多事件钩子都是通过 tabable(专注自定义事件触发与处理) 库实现的
  • 处理 webpack 内部实例的特定数据
  • 功能完成后调用 webpack 提供的回调

实现一个简单plugin

  • demo 项目新建 demo/plugin/helloWorldPlugin.js
// 1.声明一个命名函数
module.exports = class HelloWorldPlugin {
  // 2.函数的prototype上 必须要有apply方法
  apply(compiler) {
    // 3.通过hooks注册钩子回调  
    // 4.在done事件时,触发后面回调
    compiler.hooks.done.tap("HelloWorldPlugin", (stats) => {
      console.log("整个webpack打包结束");
    })

    // 5.在done事件时,触发后面回调
    compiler.hooks.emit.tap("HelloWorldPlugin", (stats) => {
      console.log("文件发射结束了");
    })
  }
}
复制代码
  • webpack.config.js 引入自定义插件
    const path = require("path")
    // 导入HelloWorldPlugin
    const HelloWorldPlugin = require("./plugin/helloWorldPlugin")
    module.exports = {
      entry: "./src/index.js",
      output: {
        filename: "bundle.js",
        path: path.resolve("./build")
      },
      module: {
        rules: [
          {
            test: /.js$/g,
            use: [{
              loader: "./loader/index.js",
              options: {
                target: /moduleB/g,
                replaceContent: "MD_B"
              }
            }]
          },
        ]
      },
      // 配置自定义插件
      plugins: [
        new HelloWorldPlugin()
      ],
      mode: "development"
    }
    复制代码
    • npm run build重新打包 demo

      image.png

实现 html-webpack-plugin

html-webpack-plugin 的作用就是复制指定的html模板,同时自动引入 bundle.js

如何实现?
1、编写一个自定义插件,注册 afterEmit 钩子
2、根据创建插件实例时传入的 template属性 来读取 html 模板
3、使用 cheerio插件 分析HTML,可以使用 jQuery 语法操作 html
4、遍历 webpack 打包生成的资源列表,如果有多个 bundle.js ,依次引入到 html 中。
5、输出生成的 html字符串dist 目录中

  • demo工程新建 src/index.html、新建 plugin/HTMLPlugin.js
    // demo/plugin/HTMLPlugin.js   
    const fs = require("fs")
    const cheerio = require("cheerio")
    module.exports = class HTMLPlugin {
       constructor(options) {
         /**
          * 1、通过配置对象获取HTML模板
          *    options.filename:模板输出的文件名  
          *    options.template: 目标模板输入路径
          */
         this.options = options
       }
       // 2、plugin中,必须有apply方法
       apply(compiler) {
         // 3、在afterEmit在资源发射结束时、获取bundle等资源  
         compiler.hooks.afterEmit.tap("HTMLPlugin", (compilation) => {
         // 4、读取传入的HTML目标模板,获取DOM结构字符串  
           const template = fs.readFileSync(this.options.template, "utf-8")
           /**
            * 5、`yarn add cheerio` 安装cheerio并引入进来
            *    通过cheerio操作 html的DOM结构,引入bundle等资源 
            */
           let $ = cheerio.load(template)
           console.log(Object.keys(compilation.assets));
           // 6、遍历所有资源 一次引入到html中
           Object.keys(compilation.assets).forEach(e => $('body').append(`<script src="./${e}"></script>`))
          // 7、将新的HTML字符串输出到 dist目录中
          fs.writeFileSync("./build/" + this.options.filename, $.html())
        })
      }
    }
    复制代码
  • 配置插件
    // demo/webpack.config.js  
    // 导入HTMLPlugin
    const HTMLPlugin = require("./plugin/HTMLPlugin")
    module.exports = {
      // 配置自定义插件
      plugins: [
        new HTMLPlugin({
          filename: "index.html",
          template: "./src/index.html"
        }),
        new HelloWorldPlugin(),
      ],
    }
    复制代码
  • npm run build 打包项目查看生成的 build/index.html

compiler和compilation的区别

  • compiler对象 是值 webpack 在打包项目文件时,使用的工具
  • compilation对象 是指 webpack 每次打包时,每个每个阶段打包的产物

my-webpack添加plugin功能

tapable

webpack内部通过各种事件流串联插件,打包处理项目文件。而这些事件流机制的核心就是通过 tapable 实现的,tapable 就类似与 nodeevents 库,核心原理也是发布订阅。

添加plugin功能

我们这里只做简单的演示,只在关键节点设置几个钩子函数。webpack内部的实现远比我们的复杂的多,毕竟光钩子函数就几十个。

  • 安装
    yarn add tapable

  • 加载 plugin、声明钩子、执行钩子

    // my-webpack/lib/Compiler.js
    // 导入 tabable
    const { SyncHook } = require("tapable")
    
    class Compiler {
      constructor(config) {
        ......
        // 1、声明钩子  
        this.hooks = {
          compile: new SyncHook(),
          afterCompile: new SyncHook(),
          emit: new SyncHook(),
          afterEmit: new SyncHook(['modules']),
          done: new SyncHook()
        }
    
        // 2、获取所有插件对象,执行插件apply方法
        if (Array.isArray(this.config.plugins)) {
          this.config.plugins.forEach(e => {
            // 传入compiler 实例,插件可以在apply方法中注册钩子
            e.apply(this)
          })
        }
      }
      start() {
        // 开始解析前,执行 compile 钩子
        this.hooks.compile.call()
        this.depAnalyse(path.resolve(this.root, this.entry))
        // 分析结束后,执行 afterCompile 钩子 
        this.hooks.afterCompile.call()
        // 资源发射前,执行 emit 钩子
        this.hooks.emit.call()
        this.emitFile()
        // 资源发射完毕,执行 afterEmit 钩子
        this.hooks.afterEmit.call()
        // 解析结束,执行 done 钩子
        this.hooks.done.call()
      }
    }
    复制代码
  • helloWorldPlugin 注册钩子

    // demo/plugin/helloWorldPlugin.js
    module.exports = class HelloWorldPlugin {
      apply(compiler) {
        compiler.hooks.done.tap("HelloWorldPlugin", (stats) => {
          console.log("整个webpack打包结束");
        })
    
        compiler.hooks.emit.tap("HelloWorldPlugin", (stats) => {
          console.log("文件发射了");
        })
      }
    }
    复制代码
  • 执行 my-webpack, 重新进行打包

    image.png

最后

本文目的是通过实现webpack的基本功能,来了解webpack究竟 “是什么”以及它是怎么工作的,它的每一部分、每个功能可能会远比我们的更复杂。最后 贴上compiler 的代码

const path = require("path")
const fs = require("fs")
// 导入解析器
const parser = require("@babel/parser")
// 导入转换器   es6导出需要.defult
const traverse = require("@babel/traverse").default

// 导入生成器
const generator = require("@babel/generator").default

// 导入ejs
const ejs = require("ejs")

// 导入 tabable
const { SyncHook } = require("tapable")
class Compiler {
  constructor(config) {
    this.config = config
    this.entry = config.entry
    // process.cwd可以获取node执行的文件绝对路径
    // 获取被打包项目的文件路径
    this.root = process.cwd()

    // 存放打包后的所有模块
    this.modules = {}

    // 获取所有loader
    this.rules = config.module && config.module.rules

    // 1、声明钩子  
    this.hooks = {
      compile: new SyncHook(),
      afterCompile: new SyncHook(),
      emit: new SyncHook(),
      afterEmit: new SyncHook(['modules']),
      done: new SyncHook()
    }

    // 2、获取所有插件对象,执行插件apply方法
    if (Array.isArray(this.config.plugins)) {
      this.config.plugins.forEach(e => {
        // 传入compiler 实例,插件可以在apply方法中注册钩子
        e.apply(this)
      })
    }
  }
  // 根据传入的文件路径,解析文件模块
  depAnalyse(modulePath) {
    let code = this.getSource(modulePath)
    // 使用loader处理源码
    code = this.useLoader(code, modulePath)

    // 将代码解析为ast抽象语法树
    const ast = parser.parse(code)

    // 当前模块依赖数组,存放当前模块所以依赖的路径  
    let dependencies = []

    /**
     * traverse用来转换语法,它接收两个参数 
     *   - ast:转换前的抽象语法树节点树  
     *   - options: 配置对象,里面包含各种钩子回调,traverse遍历语法树节点当节点满足某个钩子条件时  该钩子会被触发
     *     - CallExpression:
     */
    traverse(ast, {
      // 当某个抽象语法树节点类型为 CallExpression(表达式调用),会触发该钩子
      CallExpression(p) {
        if (p.node.callee.name === 'require') {
          // 修改require
          p.node.callee.name = "__webpack_require__"

          // 修改当前模块 依赖模块的路径 使用node访问资源必须是 "./src/XX" 的形式
          let oldValue = p.node.arguments[0].value
          // 将"./xxx" 路径 改为 "./src/xxx"
          oldValue = "./" + path.join("src", oldValue)

          // 避免window的路径出现 "\"
          p.node.arguments[0].value = oldValue.replace(/\\+/g, "/")

          // 每解析require,就将依赖模块的路径放入 dependencies 中
          dependencies.push(p.node.arguments[0].value)
        }
      },
    })

    const sourceCode = generator(ast).code

    // 模块得ID处理为相对路径  
    let modulePathRelative = "./" + path.relative(this.root, modulePath)
    // 将路径中 "\" 替换为 "/"
    modulePathRelative = modulePathRelative.replace(/\\+/g, "/")
    // 当前模块解析完毕 将其添加进modules
    this.modules[modulePathRelative] = sourceCode


    // 如果当前模块 有其他依赖的模块就 递归调用 depAnalyse 继续向下解析代码 直到没有任何依赖为止
    dependencies.forEach(depPath => {
      // 传入模块的绝对路径
      this.depAnalyse(path.resolve(this.root, depPath))
    })
  }
  // 传入文件路径,读取文件
  getSource(path) {
    // 以“utf-8”编码格式 读取文件并返回
    return fs.readFileSync(path, "utf-8")
  }
  // 执行解析器
  start() {
    // 开始解析前,执行 compile 钩子
    this.hooks.compile.call()
    // 传入入口文件绝对路径 开始解析依赖
    // 注意:此处路径不能使用 __dirname,__dirname代表 工具库"my-webpack"根目录的绝对路径  而不是要被打包项目的根目录路径
    this.depAnalyse(path.resolve(this.root, this.entry))
    // 分析结束后,执行 afterCompile 钩子 
    this.hooks.afterCompile.call()
    // 资源发射前,执行 emit 钩子
    this.hooks.emit.call()
    this.emitFile()
    // 资源发射完毕,执行 afterEmit 钩子
    this.hooks.afterEmit.call()
    // 解析结束,执行 done 钩子
    this.hooks.done.call()
  }
  // 生成文件
  emitFile() {
    // 读取代码渲染依据的模板
    const template = this.getSource(path.resolve(__dirname, "../template/output.ejs"))
    // 传入渲染模板、模板中用到的变量
    let result = ejs.render(template, {
      entry: this.entry,
      modules: this.modules
    })
    // 获取输出路径 
    let outputPath = path.join(this.config.output.path, this.config.output.filename)
    // 生成bundle文件
    fs.writeFileSync(outputPath, result)
  }
  // 使用loader
  useLoader(code, modulePath) {
    // 未配置rules 直接返回源码
    if (!this.rules) return code
    // 获取源码后 输出给loader ,倒序遍历loader  
    for (let index = this.rules.length - 1; index >= 0; index--) {
      const { test, use } = this.rules[index]
      // 如果当前模块满足 loader正则匹配  
      if (test.test(modulePath)) {
        /**
         * 如果单个匹配规则,多个loader,use字段为数组。单个loader,use字段为字符串或对象
         */
        if (use instanceof Array) {
          // 倒序遍历loader 传入源码进行处理
          for (let i = use.length - 1; i >= 0; i--) {
            let loader = use[i];
            /**
             * loader 为字符串或对象
             *   字符串形式:
             *   use:["loader1","loader2"]
             *   对象形式:
             *   use:[
             *     {
             *        loader:"loader1",
             *        options:....
             *     }
             *   ]
             */
            let loaderPath = typeof loader === 'string' ? loader : loader.loader
            // 获取loader的绝对路径
            loaderPath = path.resolve(this.root, loaderPath)
            // loader 上下文
            const options = loader.options
            const loaderContext = {
              getOptions() {
                return options
              }
            }
            // 导入loader
            loader = require(loaderPath)
            // 传入上下文 执行loader 处理源码
            code = loader.call(loaderContext, code)
          }
        } else {
          let loaderPath = typeof loader === 'string' ? use : use.loader
          // 获取loader的绝对路径
          loaderPath = path.resolve(this.root, loaderPath)
          // loader 上下文
          const loaderContext = {
            getOptions() {
              return use.options
            }
          }
          // 导入loader
          let loader = require(loaderPath)
          // 传入上下文 执行loader 处理源码
          code = loader.call(loaderContext, code)
        }
      }
    }
    return code
  }
}
module.exports = Compiler
复制代码

猜你喜欢

转载自juejin.im/post/7033354994430853156