前置知识

  • Tapable 包本质上是为我们更方面创建自定义事件和触发自定义事件的库,类似于Nodejs中的 EventEmitter Api

    Webpack 中的插件机制就是基于 Tapable 实现与打包流程解耦,插件的所有形式都是基于Tapable实现。

  • Webpack Node Api

    在前端日常使用的 npm run build 命令也是通过环境变量调用 bin 脚本去调用 Node Api 去执行编译打包。

  • Babel Webpack 内部的 AST 分析同样依赖于 Babel 进行处理

大致流程

整体我们将会从上边 5 个方面来分析 Webpack 打包流程:

  1. 初始化参数阶段 这一步会从我们配置的 webpack.config.js 中读取到对应的配置参数和 shell 命令中传入的参数进行合并得到最终打包配置参数。
  2. 开始编译准备阶段 这一步我们会通过调用 webpack() 方法返回一个 compiler 方法,创建我们的 compiler 对象,并且注册各个 Webpack Plugin。找到配置入口中的 entry 代码,调用 compiler.run() 方法进行编译。
  3. 模块编译阶段 从入口模块进行分析,调用匹配文件的 loaders 对文件进行处理。同时分析模块依赖的模块,递归进行模块编译工作。
  4. 完成编译阶段 在递归完成后,每个引用模块通过 loaders 处理完成同时得到模块之间的相互依赖关系。
  5. 输出文件阶段 整理模块依赖关系,同时将处理后的文件输出到 output 的磁盘目录中。

接下来让我们详细的去探索每一步究竟发生了什么。

创建目录

使用 tree 命令查看目录结构:

├── core // 存放将要实现的 webpack 核心代码 ├── example // 存放实例项目 │ └── src │ ├── entry1.js // 第一个入口文件 │ ├── entry2.js // 第二个入口文件 │ ├── index.js │ └── webpack.config.js // 配置文件 ├── loaders // 存放我们的自定义 loader ├── package.json └── plugins // 存放我们的自定义 plugin

初始化参数阶段

  • webpack传递打包参数有两种方式:
    1. cli命令行传递参数 webpack --mode=production
    2. webpack.config.js传递参数
  1. 步骤一、实现合并参数阶段

    // core/index.js
    const webpack = require('./webpack')
    const config = require('../example/webpack.config')
    
    // 步骤1: 初始化参数 根据配置文件和shell参数合成参数
    
    const compiler = webpack(config)
    
    // core/webpack.js
    function webpack(options) {
      // 合并参数 得到合并后的参数 mergeOptions
      const mergeOptions = _mergeOptions(options)
    }
    
    // 合并参数
    function _mergeOptions(options) {
      // process.argv.slice(2) ['--mode=production']
      const shellOptions = process.argv.slice(2).reduce((option, argv) => {
        const [key, value] = argv.split('=')
        if (key && value) {
          const parseKey = key.slice(2)
          option[parseKey] = value
        }
      }, {})
      console.log(shellOptions) // { mode: production }
      return {
        ...options,
        ...shellOptions,
      }
    }
    
    module.exports = webpack
    

    总结:合并参数的逻辑,是将外部传入的对象执行 shell 时的传入参数进行最终合并。

编译阶段

  • 在得到最终的配置参数之后,我们需要在 webpack() 函数中做以下几件事情:

    1. 通过参数创建 compiler 对象。我们看到官方案例中通过调用 webpack(options) 方法返回的是一个 compiler 对象。并且同时调用 compiler.run() 方法启动的代码进行打包。
    2. 注册我们定义的 webpack plugin 插件。
    3. 根据传入的配置对象寻找对应的打包入口文件。
  1. 步骤一、 创建 compiler 对象

    // core/index.js
    const webpack = require('./webpack')
    const config = require('../example/webpack.config')
    
    // 步骤1: 初始化参数 根据配置文件和shell参数合成参数
    // 步骤2: 调用Webpack(options) 初始化compiler对象
    // webpack()方法会返回一个compiler对象
    const compiler = webpack(config)
    // 调用run方法进行打包
    compiler.run((err, stats) => {
      if (err) {
        console.log(err, 'err')
      }
    })
    
    // core/webpack.js
    function webpack(options) {
      // 合并参数 得到合并后的参数 mergeOptions
      const mergeOptions = _mergeOptions(options)
      // 创建compiler对象
      const compiler = new Compiler(mergeOptions)
    
      return compiler
    }
    
    // Compiler类进行核心编译实现
    class Compiler {
      constructor(options) {
        this.options = options
      }
    
      // run方法启动编译 , 同时run方法接受外部传递的callback
    
      run(callback) {}
    }
    
    module.exports = Compiler
    
    • 总结:
      1. core/index.js 作为打包命令的入口文件,这个文件引用了我们自己实现的 webpack 同时引用了外部的 webpack.config.js(options)。调用 webpack(options).run() 开始编译。
      2. core/webpack.js 这个文件目前处理了参数的合并以及传入合并后的参数 new Compiler(mergeOptions),同时返回创建的 Compiler 实例对象。
      3. core/compiler,此时我们的 compiler 仅仅是作为一个基础的骨架,存在一个 run() 启动方法。
  2. 编写 Plugin

    在编写 Plugin 前,我们需要先来完善一下 compiler 方法:

    const { SyncHook } = require('tapable')
    
    class Compiler {
      constructor(options) {
        this.options = options
        // 创建plugin hooks
        this.hooks = {
          // 开始编译时的钩子
          run: new SyncHook(),
          // 输出 asset 到 output 目录之前执行 (写入文件之前)
          emit: new SyncHook(),
          // 在 compilation 完成时执行 全部完成编译执行
          done: new SyncHook(),
        }
      }
    
      // run方法启动编译
      // 同时run方法接受外部传递的callback
      run(callback) {}
    }
    
    module.exports = Compiler
    

    我们在 Compiler 这个类的构造函数中创建了一个属性 hooks,它的值是三个属性 runemitdone

    可以简单将 SyncHook() 方法理解称为一个 Emitter Event 类。当我们通过 new SyncHook() 返回一个对象实例后,我们可以通过 this.hook.run.tap('name',callback) 方法为这个对象上添加事件监听,然后在通过 this.hook.run.call() 执行所有 tap 注册的事件。

    // core/webpack.js
    function webpack(options) {
      // 合并参数 得到合并后的参数 mergeOptions
      const mergeOptions = _mergeOptions(options)
      // 创建compiler对象
      const compiler = new Compiler(mergeOptions)
    
      // 加载插件
      _loadPlugin(options.plugins, compiler)
    
      return compiler
    }
    
    // 加载插件
    function _loadPlugin(plugins, compiler) {
      if (plugins && Array.isArray(plugins)) {
        plugins.forEach((plugin) => {
          plugin.apply(compiler)
        })
      }
    }
    

    在创建完成 compiler 对象后,调用了 _loadPlugin 方法进行注册插件任何一个 webpack 插件都是一个类(当然类本质上都是 function 的语法糖),每个插件都必须存在一个 apply 方法。这个apply方法会接受一个compiler对象。

    本质:就是操作 compiler 对象从而影响打包结果进行。webpack 插件本质上就是通过发布订阅的模式,通过 compiler 上监听事件。然后再打包编译过程中触发监听的事件从而添加一定的逻辑影响打包结果。

    webpack5 / plugins / PluginA.js
    class PluginA {
      apply(compiler) {
        compiler.hooks.run.tap('PluginA', () => {
          console.log('PluginA')
        })
      }
    }
    
    1. 寻找 entry 入口

    当我们创建 compiler 后,调用 run 方法,会先触发开始编译的 plugin,然后确认人口

    // core/compiler.js
    const { SyncHook } = require('tapable')
    const { toUnixPath } = require('./utils')
    const path = require('path')
    // Compiler类进行核心编译实现
    class Compiler {
      constructor(options) {
        this.options = options
        // 相对路径跟context参数
        this.rootPath = this.options.context || toUnixPath(process.cwd())
        // 创建plugin hooks
        this.hooks = {
          // 开始编译时的钩子
          run: new SyncHook(),
          // 输出asset到output目录之前(写文件之前)
          emit: new SyncHook(),
          // 在compilation完成时只需,全部编译完成
          done: new SyncHook(),
        }
      }
    
      // run方法启动编译 , 同时run方法接受外部传递的callback
    
      run(callback) {
        // 调用run方法时,触发开始编译的plugin
        this.hooks.run.call()
        // 获取入口配置对象
        const entry = this.getEntry()
      }
    
      // 获取入口文件路径
      getEntry() {
        let entry = Object.create(null)
        const { entry: optionsEntry } = this.options
        if (typeof optionsEntry === 'string') {
          entry['main'] = optionsEntry // 默认解析成main
        } else {
          entry = optionsEntry
        }
        // 将entry变成绝对路径
        Object.keys(entry).forEach((key) => {
          const value = entry[key]
          if (!path.isAbsolute(value)) {
            // 转换为绝对路径的同时统一路径分隔符为 /
            entry[key] = toUnixPath(path.join(this.rootPath, value))
          }
        })
        return entry
      }
    }
    
    module.exports = Compiler
    
    // utils.js
    // 统一路径分隔符,主要是为了后续生成模块ID方便
    function toUnixPath(path) {
      return path.replace(/\\/g, '/')
    }
    

模块编译阶段

  • 编译阶段的准备工作:
    1. 通过 hooks.tap 注册 webpack 插件
    2. getEntry 方法获得各个入口的对象

接下来让我们继续完善 compiler.js

  • 在模块编译阶段,我们需要做的事件:

    1. 根据入口文件路径分析入口文件,对于入口文件进行匹配对应的 loader 进行处理入口文件。
    2. 将 loader 处理完成的入口文件使用 webpack 进行编译。
    3. 分析入口文件依赖,重复上边两个步骤编译对应依赖。
    4. 如果嵌套文件存在依赖文件,递归调用依赖模块进行编译。
    5. 递归编译完成后,组装一个个包含多个模块的 chunk。
    class Compiler {
      constructor(options) {
        this.options = options
        // 创建plugin hooks
        this.hooks = {
          // 开始编译时的钩子
          run: new SyncHook(),
          // 输出 asset 到 output 目录之前执行 (写入文件之前)
          emit: new SyncHook(),
          // 在 compilation 完成时执行 全部完成编译执行
          done: new SyncHook(),
        }
        // 保存所有入口模块对象
        this.entries = new Set()
        // 保存所有依赖模块对象
        this.modules = new Set()
        // 所有的代码块对象
        this.chunks = new Set()
        // 存放本次产出的文件对象
        this.assets = new Set()
        // 存放本次编译所有产出的文件名
        this.files = new Set()
      }
      // ...
    }
    

    通过给 compiler 构造函数中添加一些列属性来保存关于编译阶段生成的对应资源/模块对象。

  1. 根据入口文件路径分析入口文件

    class Compiler {
      constructor(options) {
        // 保存所有入口模块对象
        this.entries = new Set()
        // 保存所有依赖模块对象
        this.modules = new Set()
        // 所有的代码块对象
        this.chunks = new Set()
        // 存放本次产出的文件对象
        this.assets = new Set()
        // 存放本次编译所有产出的文件名
        this.files = new Set()
      }
    
      run(callback) {
        // 调用run方法时,触发开始编译的plugin
        this.hooks.run.call()
        // 获取入口配置对象
        const entry = this.getEntry()
        // 编译入口文件
        this.buildEntryModule(entry)
      }
    
      buildEntryModule(entry) {
        Object.keys(entry).forEach((entryName) => {
          const entryPath = entry[entryName] // 绝对路径
          const entryObj = this.buildModule(entryName, entryPath)
          this.entries.add(entryObj)
        })
      }
    
      buildModule(moduleName, modulePath) {
        return {}
      }
    }
    

    单个入口编译完成后,我们会在 buildModule 方法中返回一个对象。这个对象就是我们编译入口文件后的对象。

  2. buildModule 模块编译方法

    • 梳理一下 buildModule 方法它需要做哪些事情:

      1. buildModule 接受两个参数进行模块编译,第一个为 模块所属的入口文件名称,第二个为 需要编译的模块路径
      2. buildModule 方法要进行代码编译的前提就是,通过 fs 模块根据入口文件路径读取文件源代码
      3. 读取文件内容之后,调用所有匹配的 loader 对模块进行处理得到返回后的结果。
      4. 得到 loader 处理后的结果后,通过 babel 分析 loader 处理后的代码,进行代码编译。(这一步编译主要是针对 require 语句,修改源代码中 require 语句的路径)。
      5. 如果该入口文件没有依赖与任何模块(require 语句),那么返回编译后的模块对象。
      6. 如果该入口文件存在依赖的模块,递归 buildModule 方法进行模块编译。
      class Compiler {
        buildModule(moduleName, modulePath) {
          // 1. 读文件原始代码
          const originSourceCode = fs.readFileSync(modulePath, 'utf-8')
          // moduleCode为修改后的代码
          this.moduleCode = originSourceCode
          // 2. 调用loader进行处理
          this.handleLoader(modulePath)
          return {}
        }
      
        // 匹配loader处理
        handleLoader(modulePath) {
          const matchLoaders = []
          // 1. 获取所有传入的loader规则
          const rules = this.options.module.rules
          rules.forEach((loader) => {
            const testRule = loader.test
            if (testRule.test(modulePath)) {
              // 如果文件的路径被匹配
              // 仅考虑loader { test:/\.js$/g, use:['babel-loader'] }, { test:/\.js$/, loader:'babel-loader' }
              matchLoaders.push(loader.loader) // loader形式
            } else {
              matchLoaders.push(...loader.use) // use形式
            }
          })
          // 2. 倒叙执行loader传入源代码
          for (let i = matchLoaders.length - 1; i >= 0; i--) {
            // 目前我们外部仅支持传入绝对路径的loader模式
            // require引入对应loader
            const loaderFn = require(matchLoaders[i])
            // 通过loader同步处理我的每一次编译的moduleCode
            this.moduleCode = loaderFn(this.moduleCode)
          }
        }
      }
      

      通过 handleLoader 函数,对于传入的文件路径匹配到对应后缀的 loader 后,依次倒序执行 loader 处理我们的代码 this.moduleCode 并且同步更新每次 moduleCode。最终,在每一个模块编译中this.moduleCode 都会经过对应的 loader 处理。

  3. webpack 模块编译阶段

    经历过 loader 处理了我们的入口文件代码,并且得到了处理后的代码保存在了 this.moduleCode 中。此时,经过 loader 处理后我们就要进入 webpack 内部的编译阶段了。

    const { toUnixPath } = require('./utils')
    const path = require('path')
    const fs = require('fs')
    const parser = require('@babel/parser')
    const traverse = require('@babel/traverse').default
    const generator = require('@babel/generator').default
    const t = require('@babel/types')
    const tryExtensions = require('./utils/index')
    // Compiler类进行核心编译实现
    class Compiler {
      constructor(options) {
        this.options = options
        // 相对路径跟context参数
        this.rootPath = this.options.context || toUnixPath(process.cwd())
        // 创建plugin hooks
        this.hooks = {
          // 开始编译时的钩子
          run: new SyncHook(),
          // 输出asset到output目录之前(写文件之前)
          emit: new SyncHook(),
          // 在compilation完成时只需,全部编译完成
          done: new SyncHook(),
        }
    
        // 保存所有入口模块对象
        this.entries = new Set()
        // 保存所有依赖模块对象
        this.modules = new Set()
        // 所有的代码块对象
        this.chunks = new Set()
        // 存放本次产出的文件对象
        this.assets = new Set()
        // 存放本次编译所有产出的文件名
        this.files = new Set()
      }
    
      // run方法启动编译 , 同时run方法接受外部传递的callback
    
      run(callback) {
        // 调用run方法时,触发开始编译的plugin
        this.hooks.run.call()
        // 获取入口配置对象
        const entry = this.getEntry()
        // 编译入口文件
        this.buildEntryModule(entry)
      }
    
      buildEntryModule(entry) {
        Object.keys(entry).forEach((entryName) => {
          const entryPath = entry[entryName] // 绝对路径
          const entryObj = this.buildModule(entryName, entryPath)
          this.entries.add(entryObj)
        })
      }
    
      buildModule(moduleName, modulePath) {
        // 1. 读文件原始代码
        const originSourceCode = fs.readFileSync(modulePath, 'utf-8')
        // moduleCode为修改后的代码
        this.moduleCode = originSourceCode
        // 2. 调用loader进行处理
        this.handleLoader(modulePath)
        // 3. 调用webpack 进行模块编译,获得最终的module对象
        const module = this.handleWebpackCompiler(moduleName, modulePath)
        // 4. 返回对象的module
        return module
      }
    
      // 调用webpack进行模块编译
      handleWebpackCompiler(moduleName, modulePath) {
        // 将当前模块相对于项目启动根目录计算出相对路径 作为模块ID
        const moduleId = './' + path.posix.relative(this.rootPath, modulePath)
        // 创建模块对象
        const module = {
          id: moduleId,
          dependencies: new Set(), // 该模块所依赖模块绝对路径地址
          name: [moduleName], // 该模块所属的入口文件
        }
        // 调用babel分析我们的代码
        const ast = parser.parse(this.moduleCode, {
          sourceType: 'module',
        })
        // 深度优先 遍历语法树
        traverse(ast, {
          // 当遇到require语句时
          CallExpression: (nodePath) => {
            const node = nodePath.node
            if (node.callee.name === 'require') {
              // 获得源代码中引入模块相对路径
              const moduleName = node.arguments[0].value
              // 寻找模块绝对路径 当前模块路径+require()对应相对路径
              const moduleDirName = path.posix.dirname(modulePath)
              const absolutePath = tryExtensions(
                path.posix.join(moduleDirName, moduleName),
                this.options.resolve.extensions,
                moduleName,
                moduleDirName
              )
              // 生成moduleId - 针对于跟路径的模块ID 添加进入新的依赖模块路径
              const moduleId =
                './' + path.posix.relative(this.rootPath, absolutePath)
              // 通过babel修改源代码中的require变成__webpack_require__语句
              node.callee = t.identifier('__webpack_require__')
              // 修改源代码中require语句引入的模块 全部修改变为相对于跟路径来处理
              node.arguments = [t.stringLiteral(moduleId)]
              // 为当前模块添加require语句造成的依赖(内容为相对于根路径的模块ID)
              module.dependencies.add(moduleId)
            }
          },
        })
        // 遍历结束根据AST生成新的代码
        const { code } = generator(ast)
        // 为当前模块挂载新的生成的代码
        module._source = code
        // 返回当前模块对象
        return module
      }
    }
    module.exports = Compiler
    

    这一步我们关于 webpack 编译的阶段就完成了。

    • 针对于每一次文件编译,我们都会返回一个 module 对象
      1. id 属性,表示当前模块针对于 this.rootPath 的相对目录。
      2. dependencies 属性,它是一个 Set 内部保存了该模块依赖的所有模块的模块 ID。
      3. name 属性,它表示该模块属于哪个入口文件。
      4. _source 属性,它存放模块自身经过 babel 编译后的字符串代码。
    // 递归依赖深度遍历 存在依赖模块则加入
    module.dependencies.forEach((dependency) => {
      const depModule = this.buildModule(moduleName, dependency)
      // 将编译后的任何依赖模块对象加入到modules对象中去
      this.modules.add(depModule)
    })
    
    • “模块编译阶段”基本已经结束了,这一步我们对于所有模块从入口文件开始进行分析。
      1. 从入口出发,读取入口文件内容调用匹配 loader 处理入口文件。
      2. 通过 babel 分析依赖,并且同时将所有依赖的路径更换为相对于项目启动目录 options.context 的路径。
      3. 入口文件中如果存在依赖的话,递归上述步骤编译依赖模块。
      4. 将每个依赖的模块编译后的对象加入 this.modules
      5. 将每个入口文件编译后的对象加入 this.entries。

编译完成阶段

在将所有模块递归编译完成后,我们需要根据上述的依赖关系,组合最终输出的chunk模块。

~~~js
class Compiler {
    // ...
    buildEntryModule(entry) {
        Object.keys(entry).forEach((entryName) => {
        const entryPath = entry[entryName];
        // 调用buildModule实现真正的模块编译逻辑
        const entryObj = this.buildModule(entryName, entryPath);
        this.entries.add(entryObj);
        // 根据当前入口文件和模块的相互依赖关系,组装成为一个个包含当前入口所有依赖模块的chunk
        this.buildUpChunk(entryName, entryObj);
        });
        console.log(this.chunks, 'chunks');
    }

    // 根据入口文件和依赖模块组装chunks
    buildUpChunk(entryName, entryObj) {
        const chunk = {
        name: entryName, // 每一个入口文件作为一个chunk
        entryModule: entryObj, // entry编译后的对象
        modules: Array.from(this.modules).filter((i) =>
            i.name.includes(entryName)
        ), // 寻找与当前entry有关的所有module
        };
        // 将chunk添加到this.chunks中去
        this.chunks.add(chunk);
    }
    // ...
}
~~~

chunk它们分别拥有:

1. name:当前入口文件的名称
2. entryModule: 入口文件编译后的对象。
3. modules: 该入口文件依赖的所有模块对象组成的数组,其中每一个元素的格式和entryModule是一致的。

此时编译完成我们拼装chunk的环节就圆满完成。

输出文件阶段

~~~js
run(callback) {
    // 调用run方法时,触发开始编译的plugin
    this.hooks.run.call()
    // 获取入口配置对象
    const entry = this.getEntry()
    // 编译入口文件
    this.buildEntryModule(entry)
    // 导出列表;之后将每个chunk转化称为单独的文件加入到输出列表assets中
    this.exportFile(callback);
}

exportFile(callback) {
    const output = this.options.output;
    // 根据chunks生成assets内容
    this.chunks.forEach((chunk) => {
        const parseFileName = output.filename.replace('[name]', chunk.name);
        // assets中 { 'main.js': '生成的字符串代码...' }
        this.assets.set(parseFileName, getSourceCode(chunk));
    });
    // 调用Plugin emit钩子
    this.hooks.emit.call();
    // 先判断目录是否存在 存在直接fs.write 不存在则首先创建
    if (!fs.existsSync(output.path)) {
        fs.mkdirSync(output.path);
    }
    // files中保存所有的生成文件名
    this.files = Object.keys(this.assets);
    // 将assets中的内容生成打包文件 写入文件系统中
    Object.keys(this.assets).forEach((fileName) => {
        const filePath = path.join(output.path, fileName);
        fs.writeFileSync(filePath, this.assets[fileName]);
    });
    // 结束之后触发钩子
    this.hooks.done.call();
    callback(null, {
        toJson: () => {
            return {
                entries: this.entries,
                modules: this.modules,
                files: this.files,
                chunks: this.chunks,
                assets: this.assets,
            };
        },
    });
}
~~~
  • exportFile 做了如下几件事:
    1. 首先获取配置参数的输出配置,迭代我们的 this.chunks,将 output.filename 中的[name]替换称为对应的入口文件名称。同时根据 chunks 的内容为 this.assets 中添加需要打包生成的文件名和文件内容。
    2. 将文件写入磁盘前调用 plugin 的 emit 钩子函数。
    3. 判断 output.path 文件夹是否存在,如果不存在,则通过 fs 新建这个文件夹。
    4. 将本次打包生成的所有文件名(this.assets 的 key 值组成的数组)存放进入 files 中去。
    5. 循环 this.assets,将文件依次写入对应的磁盘中去。
    6. 所有打包流程结束,触发 webpack 插件的 done 钩子。
    7. 同时为 NodeJs Webpack APi 呼应,调用 run 方法中外部传入的 callback 传入两个参数。

总的来说: this.assets 做的事情也比较简单,就是通过分析 chunks 得到 assets 然后输出对应的代码到磁盘中。

this.assets 这个 Map 中每一个元素的 value 是通过调用 getSourceCode(chunk)方法来生成模块对应的代码的。