6

webpack系列1:常见 loader 源码简析,以及动手实现一个 md2html-loader
webpack系列2:揭秘webpack 插件工作原理
webpack系列3:webpack 主流程源码阅读以及实现一个 webpack

简单概述 Webpack 整体运行流程

  1. 读取参数
  2. 实例化 Compiler
  3. entryOption 阶段,读取入口文件
  4. Loader 编译对应文件,解析成 AST
  5. 找到对应依赖,递归编译处理,生成 chunk
  6. 输出到 dist

webpack 打包主流程源码阅读

通过打断点的方式阅读源码,来看一下命令行输入 webpack 的时候都发生了什么?
P.S. 以下的源码流程分析都基于 webpack4

先附上一张自己绘制的执行流程图
webpack4 执行流程图

初始化阶段

  1. 初始化参数(webpack.config.js+shell options

webpack 的几种启动方式

  • 通过 webpack-cli执行 会走到 ./node_modules/.bin/webpack-cli(执行)
  • 通过 shell 执行webpack ,会走到 ./bin/webpack.js
  • 通过 require("webpack")执行 会走到 ./node_modules/webpack/lib/webpack.js

追加 shell 命令的参数,如-p , -w,通过 yargs 解析命令行参数
convert-yargs 把命令行参数转换成 Webpack 的配置选项对象
同时实例化插件 new Plugin()

  1. 实例化 Compiler

阅读完整源码点击这里:webpack.js

// webpack入口
const webpack = (options, callback) => {
  let compiler
  // 实例Compiler
  if (Array.isArray(options)) {
    // ...
    compiler = createMultiCompiler(options)
  } else {
    compiler = createCompiler(options)
  }
  // ...
  // 若options.watch === true && callback 则开启watch线程
  if (watch) {
    compiler.watch(watchOptions, callback)
  } else {
    compiler.run((err, stats) => {
      compiler.close((err2) => {
        callback(err || err2, stats)
      })
    })
  }
  return compiler
}

webpack 的入口文件其实就实例了 Compiler 并调用了 run 方法开启了编译,

  1. 注册 NodeEnvironmentPlugin 插件,挂载 plugin 插件,使用 WebpackOptionsApply 初始化基础插件

在此期间会 apply 所有 webpack 内置的插件,为 webpack 事件流挂上自定义钩子

源码仍然在webpack.js文件

const createCompiler = (rawOptions) => {
  // ...省略代码
  const compiler = new Compiler(options.context)
  compiler.options = options
  //应用Node的文件系统到compiler对象,方便后续的文件查找和读取
  new NodeEnvironmentPlugin({
    infrastructureLogging: options.infrastructureLogging,
  }).apply(compiler)
  // 加载插件
  if (Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      //  依次调用插件的apply方法(默认每个插件对象实例都需要提供一个apply)若为函数则直接调用,将compiler实例作为参数传入,方便插件调用此次构建提供的Webpack API并监听后续的所有事件Hook。
      if (typeof plugin === 'function') {
        plugin.call(compiler, compiler)
      } else {
        plugin.apply(compiler)
      }
    }
  }
  // 应用默认的Webpack配置
  applyWebpackOptionsDefaults(options)
  // 随即之后,触发一些Hook
  compiler.hooks.environment.call()
  compiler.hooks.afterEnvironment.call()
  // 内置的Plugin的引入,对webpack options进行初始化
  new WebpackOptionsApply().process(options, compiler)
  compiler.hooks.initialize.call()
  return compiler
}

编译阶段

  1. 启动编译(run/watch 阶段)

这里有个小逻辑区分是否是 watch,如果是非 watch,则会正常执行一次 compiler.run()

如果是监听文件(如:--watch)的模式,则会传递监听的 watchOptions,生成 Watching 实例,每次变化都重新触发回调。

如果不是监视模式就调用 Compiler 对象的 run 方法,befornRun->beforeCompile->compile->thisCompilation->compilation开始构建整个应用。

const { SyncHook, SyncBailHook, AsyncSeriesHook } = require('tapable')
class Compiler {
  constructor() {
    // 1. 定义生命周期钩子
    this.hooks = Object.freeze({
      // ...只列举几个常用的常见钩子,更多hook就不列举了,有兴趣看源码
      done: new AsyncSeriesHook(['stats']), //一次编译完成后执行,回调参数:stats
      beforeRun: new AsyncSeriesHook(['compiler']),
      run: new AsyncSeriesHook(['compiler']), //在编译器开始读取记录前执行
      emit: new AsyncSeriesHook(['compilation']), //在生成文件到output目录之前执行,回调参数: compilation
      afterEmit: new AsyncSeriesHook(['compilation']), //在生成文件到output目录之后执行
      compilation: new SyncHook(['compilation', 'params']), //在一次compilation创建后执行插件
      beforeCompile: new AsyncSeriesHook(['params']),
      compile: new SyncHook(['params']), //在一个新的compilation创建之前执行
      make: new AsyncParallelHook(['compilation']), //完成一次编译之前执行
      afterCompile: new AsyncSeriesHook(['compilation']),
      watchRun: new AsyncSeriesHook(['compiler']),
      failed: new SyncHook(['error']),
      watchClose: new SyncHook([]),
      afterPlugins: new SyncHook(['compiler']),
      entryOption: new SyncBailHook(['context', 'entry']),
    })
    // ...省略代码
  }
  newCompilation() {
    // 创建Compilation对象回调compilation相关钩子
    const compilation = new Compilation(this)
    //...一系列操作
    this.hooks.compilation.call(compilation, params) //compilation对象创建完成
    return compilation
  }
  watch() {
    //如果运行在watch模式则执行watch方法,否则执行run方法
    if (this.running) {
      return handler(new ConcurrentCompilationError())
    }
    this.running = true
    this.watchMode = true
    return new Watching(this, watchOptions, handler)
  }
  run(callback) {
    if (this.running) {
      return callback(new ConcurrentCompilationError())
    }
    this.running = true
    process.nextTick(() => {
      this.emitAssets(compilation, (err) => {
        if (err) {
          // 在编译和输出的流程中遇到异常时,会触发 failed 事件
          this.hooks.failed.call(err)
        }
        if (compilation.hooks.needAdditionalPass.call()) {
          // ...
          // done:完成编译
          this.hooks.done.callAsync(stats, (err) => {
            // 创建compilation对象之前
            this.compile(onCompiled)
          })
        }
        this.emitRecords((err) => {
          this.hooks.done.callAsync(stats, (err) => {})
        })
      })
    })

    this.hooks.beforeRun.callAsync(this, (err) => {
      this.hooks.run.callAsync(this, (err) => {
        this.readRecords((err) => {
          this.compile(onCompiled)
        })
      })
    })
  }
  compile(callback) {
    const params = this.newCompilationParams()
    this.hooks.beforeCompile.callAsync(params, (err) => {
      this.hooks.compile.call(params)
      //已完成complication的实例化
      const compilation = this.newCompilation(params)
      //触发make事件并调用addEntry,找到入口js,进行下一步
      // make:表示一个新的complication创建完毕
      this.hooks.make.callAsync(compilation, (err) => {
        process.nextTick(() => {
          compilation.finish((err) => {
            // 封装构建结果(seal),逐次对每个module和chunk进行整理,每个chunk对应一个入口文件
            compilation.seal((err) => {
              this.hooks.afterCompile.callAsync(compilation, (err) => {
                // 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,
                // 不然运行流程将会一直卡在这不往下执行
                return callback(null, compilation)
              })
            })
          })
        })
      })
    })
  }
  emitAssets() {}
}
  1. 编译模块:(make 阶段)
  • 从 entry 入口配置文件出发, 调用所有配置的 Loader 对模块进行处理,
  • 再找出该模块依赖的模块, 通过 acorn 库生成模块代码的 AST 语法树,形成依赖关系树(每个模块被处理后的最终内容以及它们之间的依赖关系),
  • 根据语法树分析这个模块是否还有依赖的模块,如果有则继续循环每个依赖;再递归本步骤直到所有入口依赖的文件都经过了对应的 loader 处理。
  • 解析结束后,webpack 会把所有模块封装在一个函数里,并放入一个名为 modules 的数组里。
  • modules 传入一个自执行函数中,自执行函数包含一个 installedModules 对象,已经执行的代码模块会保存在此对象中。
  • 最后自执行函数中加载函数(webpack__require)载入模块。
class Compilation extends Tapable {
    constructor(compiler) {
        super();
        this.hooks = {};
        // ...
        this.compiler = compiler;
        // ...
        // 构建生成的资源
        this.chunks = [];
        this.chunkGroups = [];
        this.modules = [];
        this.additionalChunkAssets = [];
        this.assets = {};
        this.children = [];
        // ...
    }
    //
    buildModule(module, optional, origin, dependencies, thisCallback) {
        // ...
        // 调用module.build方法进行编译代码,build中 其实是利用acorn编译生成AST
        this.hooks.buildModule.call(module);
        module.build( /**param*/ );
    }
    // 将模块添加到列表中,并编译模块
    _addModuleChain(context, dependency, onModule, callback) {
        // ...
        // moduleFactory.create创建模块,这里会先利用loader处理文件,然后生成模块对象
        moduleFactory.create({
            contextInfo: {
                issuer: "",
                compiler: this.compiler.name
            },
            context: context,
            dependencies: [dependency]
        }, (err, module) = > {
            const addModuleResult = this.addModule(module);
            module = addModuleResult.module;
            onModule(module);
            dependency.module = module;

            // ...
            // 调用buildModule编译模块
            this.buildModule(module, false, null, null, err = > {});
        });
    }
    // 添加入口模块,开始编译&构建
    addEntry(context, entry, name, callback) {
        // ...
        this._addModuleChain( // 调用_addModuleChain添加模块
        context, entry, module = > {
            this.entries.push(module);
        },
        // ...
        );
    }
    seal(callback) {
        this.hooks.seal.call();

        // ...
        //完成了Chunk的构建和依赖、Chunk、module等各方面的优化
        const chunk = this.addChunk(name);
        const entrypoint = new Entrypoint(name);
        entrypoint.setRuntimeChunk(chunk);
        entrypoint.addOrigin(null, name, preparedEntrypoint.request);
        this.namedChunkGroups.set(name, entrypoint);
        this.entrypoints.set(name, entrypoint);
        this.chunkGroups.push(entrypoint);

        GraphHelpers.connectChunkGroupAndChunk(entrypoint, chunk);
        GraphHelpers.connectChunkAndModule(chunk, module);

        chunk.entryModule = module;
        chunk.name = name;

        // ...
        this.hooks.beforeHash.call();
        this.createHash();
        this.hooks.afterHash.call();
        this.hooks.beforeModuleAssets.call();
        this.createModuleAssets();
        if (this.hooks.shouldGenerateChunkAssets.call() !== false) {
            this.hooks.beforeChunkAssets.call();
            this.createChunkAssets();
        }
        // ...
    }

    createHash() {
        // ...
    }

    // 生成 assets 资源并 保存到 Compilation.assets 中 给webpack写插件的时候会用到
    createModuleAssets() {
        for (let i = 0; i < this.modules.length; i++) {
            const module = this.modules[i];
            if (module.buildInfo.assets) {
                for (const assetName of Object.keys(module.buildInfo.assets)) {
                    const fileName = this.getPath(assetName);
                    this.assets[fileName] = module.buildInfo.assets[assetName];
                    this.hooks.moduleAsset.call(module, fileName);
                }
            }
        }
    }

    createChunkAssets() {
        asyncLib.forEach(
        this.chunks, (chunk, callback) = > {
            // manifest是数组结构,每个manifest元素都提供了 `render` 方法,提供后续的源码字符串生成服务。至于render方法何时初始化的,在`./lib/MainTemplate.js`中
            let manifest = this.getRenderManifest()
            asyncLib.forEach(
            manifest, (fileManifest, callback) = > {...
                source = fileManifest.render()
                this.emitAsset(file, source, assetInfo)
            }, callback)
        }, callback)
    }
}
class SingleEntryPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(
      'SingleEntryPlugin',
      (compilation, { normalModuleFactory }) => {
        compilation.dependencyFactories.set(
          SingleEntryDependency,
          normalModuleFactory
        )
      }
    )

    compiler.hooks.make.tapAsync(
      'SingleEntryPlugin',
      (compilation, callback) => {
        const { entry, name, context } = this

        const dep = SingleEntryPlugin.createDependency(entry, name)
        compilation.addEntry(context, dep, name, callback)
      }
    )
  }

  static createDependency(entry, name) {
    const dep = new SingleEntryDependency(entry)
    dep.loc = { name }
    return dep
  }
}

概括一下 make 阶段单入口打包的流程,大致为 4 步骤

  1. 执行 SingleEntryPlugin(单入口调用 SingleEntryPlugin,多入口调用 MultiEntryPlugin,异步调用 DynamicEntryPlugin),EntryPlugin 方法中调用了 Compilation.addEntry 方法,添加入口模块,开始编译&构建
  2. addEntry 中调用 _addModuleChain,将模块添加到依赖列表中,并编译模块
  3. 然后在 buildModule 方法中,调用了 NormalModule.build,创建模块之时,会调用 runLoaders,执行 Loader,利用 acorn 编译生成 AST
  4. 分析文件的依赖关系逐个拉取依赖模块并重复上述过程,最后将所有模块中的 require 语法替换成 webpack_require 来模拟模块化操作。
从源码的角度,思考一下, loader 为什么是自右向左执行的,loader 中有 pitch 也会从右到左执行的么?

runLoaders 方法调用 iteratePitchingLoaders 去递归查找执行有 pich 属性的 loader ;若存在多个 pitch 属性的 loader 则依次执行所有带 pitch 属性的 loader ,执行完后逆向执行所有带 pitch 属性的 normalnormal loader 后返回 result,没有 pitch 属性的 loader 就不会再执行;若 loaders 中没有 pitch 属性的 loader 则逆向执行 loader;执行正常 loader 是在 iterateNormalLoaders 方法完成的,处理完所有 loader 后返回 result

出自文章你真的掌握了 loader 么?- loader 十问(https://juejin.im/post/5bc1a73df265da0a8d36b74f)

Compiler 和 Compilation 的区别

webpack 打包离不开 CompilerCompilation,它们两个分工明确,理解它们是我们理清 webpack 构建流程重要的一步。

Compiler 负责监听文件和启动编译
它可以读取到 webpack 的 config 信息,整个 Webpack 从启动到关闭的生命周期,一般只有一个 Compiler 实例,整个生命周期里暴露了很多方法,常见的 run,make,compile,finish,seal,emit 等,我们写的插件就是作用在这些暴露方法的 hook 上

Compilation 负责构建编译。
每一次编译(文件只要发生变化,)就会生成一个 Compilation 实例,Compilation 可以读取到当前的模块资源,编译生成资源,变化的文件,以及依赖跟踪等状态信息。同时也提供很多事件回调给插件进行拓展。

完成编译

  1. 输出资源:(seal 阶段)

在编译完成后,调用 compilation.seal 方法封闭,生成资源,这些资源保存在 compilation.assets, compilation.chunk,然后便会调用 emit 钩子,根据 webpack config 文件的 output 配置的 path 属性,将文件输出到指定的 path.

  1. 输出完成:done/failed 阶段

done 成功完成一次完成的编译和输出流程。
failed 编译失败,可以在本事件中获取到具体的错误原因
在确定好输出内容后, 根据配置确定输出的路径和文件名, 把文件内容写入到文件系统。

emitAssets(compilation, callback) {
    const emitFiles = (err) => {
      //...省略一系列代码
      // afterEmit:文件已经写入磁盘完成
      this.hooks.afterEmit.callAsync(compilation, (err) => {
        if (err) return callback(err)
        return callback()
      })
    }

    // emit 事件发生时,可以读取到最终输出的资源、代码块、模块及其依赖,并进行修改(这是最后一次修改最终文件的机会)
    this.hooks.emit.callAsync(compilation, (err) => {
      if (err) return callback(err)
      outputPath = compilation.getPath(this.outputPath, {})
      mkdirp(this.outputFileSystem, outputPath, emitFiles)
    })
  }

然后,我们来看一下 webpack 打包好的代码是什么样子的。

webpack 输出文件代码分析

未压缩的 bundle.js 文件结构一般如下:

;(function (modules) {
  // webpackBootstrap
  // 缓存 __webpack_require__ 函数加载过的模块,提升性能,
  var installedModules = {}

  /**
   * Webpack 加载函数,用来加载 webpack 定义的模块
   * @param {String} moduleId 模块 ID,一般为模块的源码路径,如 "./src/index.js"
   * @returns {Object} exports 导出对象
   */
  function __webpack_require__(moduleId) {
    // 重复加载则利用缓存,有则直接从缓存中取得
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports
    }
    // 如果是第一次加载,则初始化模块对象,并缓存
    var module = (installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
    })

    // 把要加载的模块内容,挂载到module.exports上
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    )
    module.l = true // 标记为已加载

    // 返回加载的模块,直接调用即可
    return module.exports
  }
  // 在 __webpack_require__ 函数对象上挂载一些变量及函数 ...
  __webpack_require__.m = modules
  __webpack_require__.c = installedModules
  __webpack_require__.d = function (exports, name, getter) {
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {
        enumerable: true,
        get: getter,
      })
    }
  }
  __webpack_require__.n = function (module) {
    var getter =
      module && module.__esModule
        ? function getDefault() {
            return module['default']
          }
        : function getModuleExports() {
            return module
          }
    __webpack_require__.d(getter, 'a', getter)
    return getter
  }
  // __webpack_require__对象下的r函数
  // 在module.exports上定义__esModule为true,表明是一个模块对象
  __webpack_require__.r = function (exports) {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, {
        value: 'Module',
      })
    }
    Object.defineProperty(exports, '__esModule', {
      value: true,
    })
  }
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty.call(object, property)
  }
  //  从入口文件开始执行
  return __webpack_require__((__webpack_require__.s = './src/index.js'))
})({
  './src/index.js': function (
    module,
    __webpack_exports__,
    __webpack_require__
  ) {
    'use strict'
    eval(
      '__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleA */ "./src/moduleA.js");\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_moduleA__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var _moduleB__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./moduleB */ "./src/moduleB.js");\n/* harmony import */ var _moduleB__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_moduleB__WEBPACK_IMPORTED_MODULE_1__);\n\n\n\n//# sourceURL=webpack:///./src/index.js?'
    )
  },
  './src/moduleA.js': function (module, exports) {
    eval('console.log("moduleA")\n\n//# sourceURL=webpack:///./src/moduleA.js?')
  },
  './src/moduleB.js': function (module, exports) {
    // 代码字符串可以通过eval 函数运行
    eval('console.log("moduleB")\n\n//# sourceURL=webpack:///./src/moduleB.js?')
  },
})

上述代码的实现了⼀个 webpack_require 来实现⾃⼰的模块化把代码都缓存在 installedModules ⾥,代码⽂件以对象传递进来,key 是路径,value 是包裹的代码字符串,并且代码内部的 require,都被替换成了 webpack_require,代码字符串可以通过 eval 函数去执行。

bundle.js 能直接运行在浏览器中的原因在于输出的文件中通过 webpack_require 函数定义了一个可以在浏览器中执行的加载函数来模拟 Node.js 中的 require 语句。

总结一下,生成的 bundle.js只包含一个立即调用函数(IIFE),这个函数会接受一个对象为参数,它其实主要做了两件事:

  1. 定义一个模块加载函数 webpack_require。
  2. 使用加载函数加载入口模块 "./src/index.js",从入口文件开始递归解析依赖,在解析的过程中,分别对不同的模块进行处理,返回模块的 exports

所以我们只需要实现 2 个功能就可以实现一个简单的仿 webpack 打包 js 的编译工具

  1. 从入口开始递归分析依赖
  2. 借助依赖图谱来生成真正能在浏览器上运行的代码

实现一个简单的 webpack

接下来从 0 开始实践一个 Webpack 的雏形,能够让大家更加深入了解 Webpack

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser') //解析成ast
const traverse = require('@babel/traverse').default //遍历ast
const { transformFromAst } = require('@babel/core') //ES6转换ES5
module.exports = class Webpack {
  constructor(options) {
    const { entry, output } = options
    this.entry = entry
    this.output = output
    this.modulesArr = []
  }
  run() {
    const info = this.build(this.entry)
    this.modulesArr.push(info)
    for (let i = 0; i < this.modulesArr.length; i++) {
      // 判断有依赖对象,递归解析所有依赖项
      const item = this.modulesArr[i]
      const { dependencies } = item
      if (dependencies) {
        for (let j in dependencies) {
          this.modulesArr.push(this.build(dependencies[j]))
        }
      }
    }
    //数组结构转换
    const obj = {}
    this.modulesArr.forEach((item) => {
      obj[item.entryFile] = {
        dependencies: item.dependencies,
        code: item.code,
      }
    })
    this.emitFile(obj)
  }
  build(entryFile) {
    const conts = fs.readFileSync(entryFile, 'utf-8')
    const ast = parser.parse(conts, {
      sourceType: 'module',
    })
    //  console.log(ast)
    // 遍历所有的 import 模块,存入dependecies
    const dependencies = {}
    traverse(ast, {
      //  类型为 ImportDeclaration 的 AST 节点,
      // 其实就是我们的 import xxx from xxxx
      ImportDeclaration({ node }) {
        const newPath =
          './' + path.join(path.dirname(entryFile), node.source.value)
        dependencies[node.source.value] = newPath
        // console.log(dependencies)
      },
    })
    // 将转化后 ast 的代码重新转化成代码
    // 并通过配置 @babel/preset-env 预置插件编译成 es5
    const { code } = transformFromAst(ast, null, {
      presets: ['@babel/preset-env'],
    })
    return {
      entryFile,
      dependencies,
      code,
    }
  }
  emitFile(code) {
    //生成bundle.js
    const filePath = path.join(this.output.path, this.output.filename)
    const newCode = JSON.stringify(code)
    const bundle = `(function(modules){
           // moduleId 为传入的 filename ,即模块的唯一标识符
            function require(moduleId){
                function localRequire(relativePath){
                   return require(modules[moduleId].dependencies[relativePath]) 
                }
                var exports = {};
                (function(require,exports,code){
                    eval(code)
                })(localRequire,exports,modules[moduleId].code)
                return exports;
            }
            require('${this.entry}')
        })(${newCode})`
    fs.writeFileSync(filePath, bundle, 'utf-8')
  }
}

调用

let path = require('path')
let { resolve } = path
let webpackConfig = require(path.resolve('webpack.config.js'))
let Webpack = require('./myWebpack.js')

const defaultConfig = {
  entry: 'src/index.js',
  output: {
    path: resolve(__dirname, '../dist'),
    filename: 'bundle.js',
  },
}
const config = {
  ...defaultConfig,
  ...webpackConfig,
}

const options = require('./webpack.config')
new Webpack(options).run()

输入到浏览器看一下执行结果
my-webpack执行结果

参考:

  1. 快狗打车:实现一个简单的 webpack
  2. tapable 详解+webpack 流程
  3. 本文调试以及打断点用到的源码

frank
223 声望135 粉丝