webpack原理梳理

8次阅读

共计 29061 个字符,预计需要花费 73 分钟才能阅读完成。

webpack 设计模式

一切资源皆 Module

Module(模块)是 webpack 的中的关键实体。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块. 通过Loaders(模块转换器),用于把模块原内容按照需求转换成新模块内容.

事件驱动架构

webpack 整体是一个事件驱动架构,所有的功能都以 Plugin(插件) 的方式集成在构建流程中,通过发布订阅事件来触发各个插件执行。webpack 核心使用 tapable 来实现 Plugin(插件) 的注册和调用,Tapable 是一个事件发布 (tap) 订阅 (call) 库

概念

Graph 模块之间的 Dependency(依赖关系) 构成的依赖图

CompilerTapable实例)订阅了 webpack 最顶层的生命周期事件

ComplilationTapable实例)该对象由 Compiler 创建, 负责构建 Graph,Seal,Render… 是整个工作流程的核心生命周期, 包含 Dep Graph 遍历算法, 优化(optimize),tree shaking…

Compiler 和 Compilation 的区别在于:Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。

ResolverTapable实例)资源路径解析器

ModuleFactoryTapable实例)被 Resolver 成功解析的资源需要被这个工厂类被实例化成Module

ParserTapable实例)负责将 Module(ModuleFactory 实例化来的)转 AST 的解析器 (webpack 默认用 acorn), 并解析出不同规范的 require/import 转成 Dependency(依赖)

Template 模块化的模板. Chunk,Module,Dependency 都有各自的模块模板, 来自各自的工厂类的实例

bundlechunk区别:https://github.com/webpack/we…

bundle: 由多个不同的模块打包生成生成最终的 js 文件,一个 js 文件即是 1 个 bundle。

chunk: Graph 的组成部分。一般有 n 个入口 = n 个 bundle=graph 中有 n 个 chunk。但假设由于 n 个入口有 m 个公共模块会被重复打包,需要分离,最终 =n+ m 个 bundle=graph 中有 n + m 个 chunk

有 3 类 chunk:

  • Entry chunk: 包含 runtime code 的,就是开发模式下编译出的有很长的 /******/ 的部分(是 bundle)
  • Initial chunk: 同步加载,不包含 runtime code 的。(可能和 entry chunk 打包成一个 bundle, 也可能分离成多个 bundle)
  • Normal chunk:延迟加载 / 异步 的 module

chunk 的依赖图算法
https://medium.com/webpack/th…

整个工作流程

  1. Compiler 读取配置,创建Compilation
  2. Compiler创建 Graph 的过程:

    • Compilation读取资源入口
    • NMF(normal module factory)

      • Resolver 解析
      • 输出 NM
    • Parser 解析 AST

      • js json 用 acorn
      • 其他用Loader (执行 loader runner)
    • 如果有依赖, 重复步骤 2
  3. Compilation优化Graph
  4. Compilation渲染Graph

    • 根据 Graph 上的各类模块用各自的 Template 渲染

      • chunk template
      • Dependency template
    • 合成 IIFE 的最终资源

Tapable

钩子列表

钩子名 执行方式 要点
SyncHook 同步串行 不关心监听函数的返回值
SyncBailHook 同步串行 只要监听函数中有一个函数的返回值不为null, 则跳过剩下所有的逻辑
SyncWaterfallHook 同步串行 上一个监听函数的返回值可以传给下一个监听函数
SyncLoopHook 同步循环 当监听函数被触发的时候, 如果该监听函数返回 true 时则这个监听函数会反复执行, 如果返回 undefined 则表示退出循环
AsyncParallelHook 异步并发 不关心监听函数的返回值
AsyncParallelBailHook 异步并发 只要监听函数的返回值不为 null, 就会忽略后面的监听函数执行, 直接跳跃到callAsync 等触发函数绑定的回调函数, 然后执行这个被绑定的回调函数
AsyncSeriesHook 异步串行 不关心 callback 的参数
AsyncSeriesBailHook 异步串行 callback()的参数不为 null, 就会直接执行callAsync 等触发函数绑定的回调函数
AsyncSeriesWaterfalllHook 异步串行 上一个监听函数中的 callback(err,data) 的第二个参数, 可以作为下一个监听函数的参数

示例

// 创建一个发布订阅中心
let Center=new TapableHook()
// 注册监听事件
Center.tap('eventName',callback)
// 触发事件
Center.call(...args)
// 注册拦截器
Center.intercept({
    context,// 事件回调和拦截器的共享数据
    call:()=>{},// 钩子触发前
    register:()=>{},// 添加事件时
    tap:()=>{},// 执行钩子前
    loop:()=>{},// 循环钩子
})

Module

它有很多子类:RawModule, NormalModule ,MultiModule,ContextModule,DelegatedModule,DllModule,ExternalModule 等

ModuleFactory: 使用工厂模式创建不同的 Module, 有四个主要的子类:NormalModuleFactory,ContextModuleFactory , DllModuleFactory,MultiModuleFactory

Template

  • mainTemplate 和 chunkTemplate

    if(chunk.entry) {source = this.mainTemplate.render(this.hash, chunk, this.moduleTemplate, this.dependencyTemplates);
    } else {source = this.chunkTemplate.render(chunk, this.moduleTemplate, this.dependencyTemplates);
    }
    • 不同模块规范封装

      MainTemplate.prototype.requireFn = "__webpack_require__";
      MainTemplate.prototype.render = function(hash, chunk, moduleTemplate, dependencyTemplates) {var buf = [];
          // 每一个 module 都有一个 moduleId, 在最后会替换。buf.push("function" + this.requireFn + "(moduleId) {");
          buf.push(this.indent(this.applyPluginsWaterfall("require", "", chunk, hash)));
          buf.push("}");
          buf.push("");
          ... // 其余封装操作
      };
  • ModuleTemplate 是对所有模块进行一个代码生成
  • HotUpdateChunkTemplate 是对热替换模块的一个处理

webpack_require

function __webpack_require__(moduleId) {
    // 1. 首先会检查模块缓存
    if(installedModules[moduleId]) {return installedModules[moduleId].exports;
    }
    
    // 2. 缓存不存在时,创建并缓存一个新的模块对象,类似 Node 中的 new Module 操作
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {},
        children: []};

    // 3. 执行模块,类似于 Node 中的:// result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 需要引入模块时,同步地将模块从暂存区取出来执行,避免使用网络请求导致过长的同步等待时间。module.l = true;

    // 4. 返回该 module 的输出
    return module.exports;
}

异步模块加载

__webpack_require__.e = function requireEnsure(chunkId) {var promises = [];
    var installedChunkData = installedChunks[chunkId];
    
    // 判断该 chunk 是否已经被加载,0 表示已加载。installChunk 中的状态:// undefined:chunk 未进行加载,
    // null:chunk preloaded/prefetched
    // Promise:chunk 正在加载中
    // 0:chunk 加载完毕
    if(installedChunkData !== 0) {
        // chunk 不为 null 和 undefined,则为 Promise,表示加载中,继续等待
        if(installedChunkData) {promises.push(installedChunkData[2]);
        } else {
            // 注意这里 installChunk 的数据格式
            // 从左到右三个元素分别为 resolve、reject、promise
            var promise = new Promise(function(resolve, reject) {installedChunkData = installedChunks[chunkId] = [resolve, reject];
            });
            promises.push(installedChunkData[2] = promise);

            // 下面代码主要是根据 chunkId 加载对应的 script 脚本
            var head = document.getElementsByTagName('head')[0];
            var script = document.createElement('script');
            var onScriptComplete;

            script.charset = 'utf-8';
            script.timeout = 120;
            if (__webpack_require__.nc) {script.setAttribute("nonce", __webpack_require__.nc);
            }
            
            // jsonpScriptSrc 方法会根据传入的 chunkId 返回对应的文件路径
            script.src = jsonpScriptSrc(chunkId);

            onScriptComplete = function (event) {
                script.onerror = script.onload = null;
                clearTimeout(timeout);
                var chunk = installedChunks[chunkId];
                if(chunk !== 0) {if(chunk) {var errorType = event && (event.type === 'load' ? 'missing' : event.type);
                        var realSrc = event && event.target && event.target.src;
                        var error = new Error('Loading chunk' + chunkId + 'failed.\n(' + errorType + ':' + realSrc + ')');
                        error.type = errorType;
                        error.request = realSrc;
                        chunk[1](error);
                    }
                    installedChunks[chunkId] = undefined;
                }
            };
            var timeout = setTimeout(function(){onScriptComplete({ type: 'timeout', target: script});
            }, 120000);
            script.onerror = script.onload = onScriptComplete;
            head.appendChild(script);
        }
    }
    return Promise.all(promises);
};

异步模块缓存

// webpack runtime chunk
function webpackJsonpCallback(data) {var chunkIds = data[0];
    var moreModules = data[1];
    var executeModules = data[2];

    var moduleId, chunkId, i = 0, resolves = [];
    // webpack 会在 installChunks 中存储 chunk 的载入状态,据此判断 chunk 是否加载完毕
    for(;i < chunkIds.length; i++) {chunkId = chunkIds[i];
        if(installedChunks[chunkId]) {resolves.push(installedChunks[chunkId][0]);
        }
        installedChunks[chunkId] = 0;
    }
    
    // 注意,这里会进行“注册”,将模块暂存入内存中
    // 将 module chunk 中第二个数组元素包含的 module 方法注册到 modules 对象里
    for(moduleId in moreModules) {if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {modules[moduleId] = moreModules[moduleId];
        }
    }

    if(parentJsonpFunction) parentJsonpFunction(data);

    // 先根据模块注册时的 chunkId,取出 installedChunks 对应的所有 loading 中的 chunk,最后将这些 chunk 的 promise 进行 resolve 操作
    while(resolves.length) {resolves.shift()();}

    deferredModules.push.apply(deferredModules, executeModules || []);

    return checkDeferredModules();};

保证 chunk 加载后才执行模块

function checkDeferredModules() {
    var result;
    for(var i = 0; i < deferredModules.length; i++) {var deferredModule = deferredModules[i];
        var fulfilled = true;
        // 第一个元素是模块 id,后面是其所需的 chunk
        for(var j = 1; j < deferredModule.length; j++) {var depId = deferredModule[j];
            // 这里会首先判断模块所需 chunk 是否已经加载完毕
            if(installedChunks[depId] !== 0) fulfilled = false;
        }
        // 只有模块所需的 chunk 都加载完毕,该模块才会被执行(__webpack_require__)if(fulfilled) {deferredModules.splice(i--, 1);
            result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
        }
    }
    return result;
}

Module 被 Loader 编译的主要步骤

  • webpack 的配置 options

    • 在 Compiler.js 中会为将用户配置与默认配置 (WebpackOptionsDefaulter) 合并,其中就包括了 loader 的默认配置 module.defaultRules (OptionsDefaulter 则是一个封装的配置项存取器,封装了一些特殊的方法来操作配置对象)
    //lib/webpack.js
    options = new WebpackOptionsDefaulter().process(options);
    compiler = new Compiler(options.context);
    compiler.options = options;
    /*options:{entry: {},// 入口配置
        output: {}, // 输出配置
        plugins: [], // 插件集合(配置文件 + shell 指令) 
        module: {loaders: [ [Object] ] }, // 模块配置
        context: // 工程路径
        ... 
    }*/
  • 创建 Module

    • 根据配置创建 Module 的工厂类 Factory(Compiler.js)
    • 通过 loader 的 resolver 来解析 loader 路径
    • 使用 Factory 创建 NormalModule 实例
    • 使用 loaderResolver 解析 loader 模块路径
    • 根据 rule.modules 创建 RulesSet 规则集
  • Loader 编译过程 ( 详见 Loader 章节)

    • NormalModule 实例.build() 进行模块的构建
    • loader-runner 执行编译 module

Compiler

Compiler 源码

compiler.hooks

class Compiler extends Tapable {constructor(context) {super();
        this.hooks = {shouldEmit: new SyncBailHook(["compilation"]),// 此时返回 true/false。done: new AsyncSeriesHook(["stats"]),// 编译 (compilation) 完成。additionalPass: new AsyncSeriesHook([]),
            beforeRun: new AsyncSeriesHook(["compiler"]),//compiler.run() 执行之前,添加一个钩子。run: new AsyncSeriesHook(["compiler"]),// 开始读取 records 之前,钩入(hook into) compiler。emit: new AsyncSeriesHook(["compilation"]),// 输出到 dist 目录
            afterEmit: new AsyncSeriesHook(["compilation"]),// 生成资源到 output 目录之后。thisCompilation: new SyncHook(["compilation", "params"]),// 触发 compilation 事件之前执行(查看下面的 compilation)。compilation: new SyncHook(["compilation", "params"]),// 编译 (compilation) 创建之后,执行插件。normalModuleFactory: new SyncHook(["normalModuleFactory"]),//NormalModuleFactory 创建之后,执行插件。contextModuleFactory: new SyncHook(["contextModulefactory"]),//ContextModuleFactory 创建之后,执行插件。beforeCompile: new AsyncSeriesHook(["params"]),// 编译 (compilation) 参数创建之后,执行插件。compile: new SyncHook(["params"]),// 一个新的编译 (compilation) 创建之后,钩入(hook into) compiler。make: new AsyncParallelHook(["compilation"]),// 从入口分析依赖以及间接依赖模块
            afterCompile: new AsyncSeriesHook(["compilation"]),// 完成构建,缓存数据

            watchRun: new AsyncSeriesHook(["compiler"]),// 监听模式下,一个新的编译 (compilation) 触发之后,执行一个插件,但是是在实际编译开始之前。failed: new SyncHook(["error"]),// 编译 (compilation) 失败。invalid: new SyncHook(["filename", "changeTime"]),// 监听模式下,编译无效时。watchClose: new SyncHook([]),// 监听模式停止。}
    }
}

compiler 其他属性


this.name /** @type {string=} */
this.parentCompilation /** @type {Compilation=} */
this.outputPath = /** @type {string} */

this.outputFileSystem
this.inputFileSystem

this.recordsInputPath /** @type {string|null} */
this.recordsOutputPath  /** @type {string|null} */
this.records = {};
this.removedFiles //new Set();
this.fileTimestamps  /** @type {Map<string, number>} */
this.contextTimestamps /** @type {Map<string, number>} */
this.resolverFactory /** @type {ResolverFactory} */

this.options = /** @type {WebpackOptions} */
this.context = context;
this.requestShortener

this.running = false;/** @type {boolean} */
this.watchMode = false;/** @type {boolean} */

this._assetEmittingSourceCache /** @private @type {WeakMap<Source, { sizeOnlySource: SizeOnlySource, writtenTo: Map<string, number>}>} */

this._assetEmittingWrittenFiles/** @private @type {Map<string, number>} */

compiler.prototype.run(callback)执行过程

  • compiler.hooks.beforeRun
  • compiler.hooks.run
  • compiler.compile

    • params=this.newCompilationParams 创建 NormalModuleFactory,contextModuleFactory 实例。

      • NMF.hooks.beforeResolve
      • NMF.hooks.resolve 解析 loader 模块的路径(例如 css-loader 这个 loader 的模块路径是什么)
      • NMF.hooks.factory 基于 resolve 钩子的返回值来创建 NormalModule 实例。
      • NMF.hooks.afterResolve
      • NMF.hooks.createModule
    • compiler.hooks.compile.call(params)
    • compilation = new Compilation(compiler)

      • this.hooks.thisCompilation.call(compilation, params)
      • this.hooks.compilation.call(compilation, params)
    • compiler.hooks.make
    • compilation.hooks.finish
    • compilation.hooks.seal
    • compiler.hooks.afterCompile
      return callback(null, compilation)

Compilation

Compilation 源码
Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。

承接上文的compilation = new Compilation(compiler)

  • 负责组织整个打包过程,包含了每个构建环节及输出环节所对应的方法

    • 如 addEntry() , _addModuleChain() , buildModule() , seal() , createChunkAssets() (在每一个节点都会触发 webpack 事件去调用各插件)。
  • 该对象内部存放着所有 module,chunk,生成的 asset 以及用来生成最后打包文件的 template 的信息。

compilation.addEntry()主要执行过程

  • comilation._addModuleChain()

    • moduleFactory = comilation.dependencyFactories.get(Dep)
    • moduleFactory.create()

      • comilation.addModule(module)
      • comilation.buildModule(module)

        • afterBuild()

compilation.seal()主要执行过程

  • comilation.hooks.optimizeDependencies
  • 创建 chunks
  • 循环 comilation.chunkGroups.push(entrypoint)
  • comilation.processDependenciesBlocksForChunkGroups(comilation.chunkGroups.slice())
  • comilation.sortModules(comilation.modules);
  • 优化 modules
  • comilation.hooks.optimizeModules
  • 优化 chunks
  • comilation.hooks.optimizeChunks
  • 优化 tree
  • comilation.hooks.optimizeTree

    • comilation.hooks.optimizeChunkModules
    • comilation.sortItemsWithModuleIds
    • comilation.sortItemsWithChunkIds
    • comilation.createHash
    • comilation.createModuleAssets 添加到 compildation.assets[fileName]
    • comilation.hooks.additionalChunkAssets
    • comilation.summarizeDependencies
    • comilation.hooks.additionalAssets

      • comilation.hooks.optimizeChunkAssets
      • comilation.hooks.optimizeAssets
      • comilation.hooks.afterSeal

Plugin

插件可以用于执行范围更广的任务。包括:打包优化,资源管理,注入环境变量

plugin: 一个具有 apply 方法的 JavaScript 对象。apply 方法会被 compiler 调用,并且 compiler 对象可在整个编译生命周期访问。这些插件包通常以某种方式扩展编译功能。

编写 Plugin 示例

class MyPlugin{apply(compiler){compiler.hooks.done.tabAsync("myPlugin",(stats,cb)=>{const assetsNames=[]
            for(let assetName in stats.compilation.assets)
                assetNames.push(assetName)
            console.log(assetsNames.join("\n"))
            cb()})
        compiler.hooks.compilation.tap("MyPlugin",(compilation,params)=>{new MyCompilationPlugin().apply(compilation)
        })
    }
}

class MyCompilationPlugin{apply(compilation){
        compilation.hooks.additionalAssets.tapAsync('MyPlugin', callback => {download('https://img.shields.io/npm/v/webpack.svg', function(resp) {if(resp.status === 200) {compilation.assets['webpack-version.svg'] = toAsset(resp);
                    callback()}
                else 
                    callback(new Error('[webpack-example-plugin] Unable to download the image'))
                
            });
        });
    }
}

module.exports=MyPlugin

其他声明周期 hooks 和示例 https://webpack.docschina.org…

Resolver

在 NormalModuleFactory.js 的 resolver.resolve 中触发

hooks 在 WebpackOptionsApply.js 的 compiler.resolverFactory.hooks中。

可以完全被替换,比如注入自己的 fileSystem

Parser

在 CommonJSPulgin.js 的 new CommonJsRequireDependencyParserPlugin(options).appply(parser) 触发,调用 CommonJsRequireDependencyParserPlugin.js 的apply(parser),负责添加 Dependency,Template…

hooks 在 CommonJsPlugin.js 的 normarlModuleFactory.hooks.parser

Loader

在 make 阶段 build 中会调用 doBuild 去加载资源,doBuild 中会传入资源路径和插件资源去调用 loader-runner 插件的 runLoaders 方法去加载和执行 loader。执行完成后会返回如下图的 result 结果,根据返回数据把源码和 sourceMap 存储在 module 的_source 属性上;doBuild 的回调函数中调用 Parser 类生成 AST,并根据 AST 生成依赖后回调 buildModule 方法返回 compilation 类。

Loader 的路径

NormalModuleFactory 将 loader 分为 preLoader、postLoader 和 loader 三种

对 loader 文件的路径解析分为两种:inline loader 和 config 文件中的 loader。

require 的 inline loader 路径前面的感叹号作用:

  • ! 禁用 preLoaders (代码检查和测试, 不生成 module)
  • !! 禁用所有 Loaders
  • -!禁用 preLoaders 和 loaders,但不是 postLoaders

前面提到 NormalModuleFactory 中的 resolver 钩子中会先处理 inline loader。

最终 loader 的顺序:postinlinenormalpre

然而 loader 是从右至左执行的,真实的 loader 执行顺序是倒过来的,因此 inlineLoader 是整体后于 config 中 normal loader 执行的。

路径解析之 inline loader

  • 正则解析 loader 和参数

    //NormalModuleFactory.js
    let elements = requestWithoutMatchResource
        .replace(/^-?!+/, "")
        .replace(/!!+/g, "!")
        .split("!");
  • 将“解析模块的 loader 数组”与“解析模块本身”一起并行执行,用到了 neo-async 这个库(和 async 库类似,都是为异步编程提供一些工具方法,但是会比 async 库更快。)
  • 解析返回结果:

    [ 
        // 第一个元素是一个 loader 数组
        [ { 
            loader:
                '/workspace/basic-demo/home/node_modules/html-webpack-plugin/lib/loader.js',
            options: undefined
        } ],
        // 第二个元素是模块本身的一些信息
        {
            resourceResolveData: {context: [Object],
                path: '/workspace/basic-demo/home/public/index.html',
                request: undefined,
                query: '',
                module: false,
                file: false,
                descriptionFilePath: '/workspace/basic-demo/home/package.json',
                descriptionFileData: [Object],
                descriptionFileRoot: '/workspace/basic-demo/home',
                relativePath: './public/index.html',
                __innerRequest_request: undefined,
                __innerRequest_relativePath: './public/index.html',
                __innerRequest: './public/index.html'
            },
        resource: '/workspace/basic-demo/home/public/index.html'
        }
    ]

路径解析之 config loader

  • NormalModuleFactory 中有一个 ruleSet 的属性,相当于一个规则过滤器,会将 resourcePath 应用于所有的 module.rules 规则, 它可以根据模块路径名,匹配出模块所需的 loader。webpack 编译会根据用户配置与默认配置,实例化一个 RuleSet, 它包含:

    • 类静态方法normalizeRule() 将配置值转换为标准化的 test 对象, 其上还会存储一个 this.references 属性
    • 实例方法 exec() 每次创建一个新的 NormalModule 时都会调用 RuleSet 实例的.exec() 方法,只有当通过了各类测试条件,才会将该 loader push 到结果数组中。
  • references {map} key 是 loader 在配置中的类型和位置,例如,ref- 2 表示 loader 配置数组中的第三个。

pitch & normal

同一匹配 (test) 资源有多 loader 的时候:(类似先捕获, 再冒泡)

  • 先顺序loader.pitch()(源码里是 PitchingLoaders 不妨称为 pitch 阶段)
  • 再倒序loader()(源码里是 NormalLoaders 不妨称为 normal 阶段).

这两个阶段(pitchnormal)就是 loader-runner 中对应的iteratePitchingLoaders()iterateNormalLoaders()两个方法。

如果某个 loader 在 pitch 方法中 return 结果,会跳过剩下的 loader。那么 pitch 的递归就此结束,开始从当前位置从后往前执行 normal

normal loaders 结果示例(apply-loader, pug-loader)

//webpack.config.js
test: /\.pug/,
use: [
    'apply-loader',
    'pug-loader',
]

先执行 pug-loader, 得到 Module pug-loader/index.js!./src/index.pug的 js 代码:

var pug = __webpack_require__(/*! pug-runtime/index.js */ "pug-runtime/index.js");

function template(locals) {var pug_html = "", pug_mixins = {}, pug_interp;pug_html = pug_html +"\\u003Cdiv class=\"haha\"\\u003Easd\\u003C\\u002Fdiv\\u003E";return pug_html;};
module.exports = template;

//# sourceURL=webpack:///./src/index.pug?pug-loader

再执行 apply-loader, 得到 Module "./src/index.pug" 的 js 代码:

var req = __webpack_require__(/*! !pug-loader!./src/index.pug */ "pug-loader/index.js!./src/index.pug");
module.exports = (req['default'] || req).apply(req, [])

//# sourceURL=webpack:///./src/index.pug?

此时假设在入口文件 ./src/index.js 引用

var html =__webpack_require__('./index.pug')
console.log(html)
//<div class="haha">asd</div>

这个入口文件 Module 的 js 代码:

module.exports = __webpack_require__(/*! ./src/index.js */"./src/index.js");
//# sourceURL=webpack:///multi_./src/index.js?

build 后可看到控制台输出的 1 个 Chunk,2 个 Module(1 个 fs 忽略),3 个中间 Module 和一些隐藏 Module

Asset    Size       Chunks             Chunk Names
main.js  12.9 KiB    main  [emitted]    main
Entrypoint main = main.js
[0] multi ./src/index.js 28 bytes {main} [built]
[1] fs (ignored) 15 bytes {main} [optional] [built]
[pug-loader/index.js!./src/index.pug] pug-loader!./src/index.pug 288 bytes {main} [built]
[./src/index.js] 51 bytes {main} [built]
[./src/index.pug] 222 bytes {main} [built]

pitching loaders 结果示例(style-loader, css-loader)

pitch: 顺序执行 loader.pitch,例:

//webpack.config.js
test: /\.css/,
use: [
    'style-loader',
    'css-loader',
]

style-loader(负责添加 <style> 到页面)

得到 Module ./src/a.css的 js 代码:

// Load styles
var content = __webpack_require__(/*! !css-loader/dist/cjs.js!./a.css */ "css-loader/dist/cjs.js!./src/a.css");
if(typeof content === 'string') content = [[module.i, content, '']];
// Transform styles
var options = {"hmr":true}
options.transform = undefined
options.insertInto = undefined;
// Add styles to the DOM
var update = __webpack_require__(/*! style-loader/lib/addStyles.js */ "style-loader/lib/addStyles.js")(content, options);
module.exports = content.locals;
//# sourceURL=webpack:///./src/a.css?

build 后可看到控制台输出的 1 个 Chunk,1 个最终 Module,3 个中间 Module, 和一些隐藏 Module

  Asset      Size       Chunks             Chunk Names
main.js     24.3 KiB    main  [emitted]     main
Entrypoint main = main.js
[0] multi ./src/index.js 28 bytes {main} [built]
[./node_modules/_css-loader@2.1.1@css-loader/dist/cjs.js!./src/a.css] 170 bytes {main} [built]
[./src/a.css] 1.12 KiB {main} [built]
[./src/index.js] 16 bytes {main} [built]
    + 3 hidden modules

其他 loader 解析:bundle loader , style-loader , css-loader , file-loader, url-loader
happypack

Loader 编译过程

loader 的内部处理流程: 流水线机制,即挨个处理每个 loader,前一个 loader 的结果会传递给下一个 loader。

loader 有一些主要的特性:同步 & 异步; pitch&normal; context

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

用 loader 编译 Module 的主要步骤

  • compilation.addEntry()方法中调用的 _addModuleChain() 会执行一系列的模块方法,其中对于未 build 过的模块,最终会调用到 NormalModule.doBuild() 方法。
  • loader 中的 this 其实是一个叫 loaderContext 的对象
  • doBuild() run Loaders 后将 js 代码通过 acorn 转为 AST (源码) Parser 中生产 AST 语法树后调用 walkStatements 方法分析语法树,根据 AST 的 node 的 type 来递归查找每一个 node 的类型和执行不同的逻辑,并创建依赖。

    • loadLoader.js 一个兼容性的模块加载器
    • LoaderRunner.js 核心

      • runLoaders()
      • iteratePitchingLoaders() 递归执行,并记录 loader 的 pitch 状态;loaderIndex++; 当达到最大的 loader 序号时,处理实际的 module(源码):
      // 递归执行每个 loader 的 pitch 函数,并在所有 pitch 执行完后调用 processResource
      if(loaderContext.loaderIndex >= loaderContext.loaders.length)
          return processResource(options, loaderContext, callback);
      • processResource() 将目标 module 当做 loaderContext 的一个依赖, 添加该模块为依赖和读取模块内容
      • iterateNormalLoaders()递归执行 normal,和 pitch 的流程大同小异,需要注意的是顺序是反过来的,从后往前。,loaderIndex–
    • 在 pitch 中返回值 除了跳过余下 loader 外,不仅会阻止 .addDependency() 触发(不将该模块资源添加进依赖),而且无法读取模块的文件内容。loader 会将 pitch 返回的值作为“文件内容”来处理,并返回给 webpack。

      • pitch 与 loader 本身方法的执行顺序
    • runSyncOrAsync() pitch 与 normal 的实际执行 (源码)

      context 上添加了 asynccallback函数.

      当我们编写 loader 调用 this.async()this.callback()时,会将 loader 变为一个异步的 loader,并返回一个异步回调,还可以直接返回一个 Promise。

      只有 isSync 标识为 true 时,才会在 loader function 执行完毕后立即(同步)回调 callback 来继续 loader-runner。

Loader 的 this 对象(LoaderContext)属性清单

version:number 2// 版本
emitWarning(warning: Error)// 发出一个警告
emitError(error: Error)// 发出一个错误
resolve(context: String, request: String, callback: function(err, result: string)),// 像 require 表达式一样解析一个 request 
getResolve(),//?
emitFile(name: string, content: Buffer|string, sourceMap: {...}),// 产生一个文件
rootContext:'/home/seasonley/workplace/webpack-demo',// 从 webpack 4 开始,原先的 this.options.context 被改进为 this.rootContext
webpack:true,// 如果是由 webpack 编译的,这个布尔值会被设置为真(loader 最初被设计为可以同时当 Babel transform 用)
sourceMap:false,// 是否生成 source map
_module:[Object:NormalModule],
_compilation:[Object:Compilation],
_compiler:[Object:Compiler],
fs:[Object:CachedInputFileSystem],// 用于访问 compilation 的 inputFileSystem 属性。target:'web',// 编译的目标。从配置选项中传递过来的。示例:"web", "node"
loadModule(request: string, callback: function(err, source, sourceMap, module))],// 解析给定的 request 到一个模块,应用所有配置的 loader,并且在回调函数中传入生成的 source、sourceMap 和 模块实例(通常是 NormalModule 的一个实例)。如果你需要获取其他模块的源代码来生成结果的话,你可以使用这个函数。context: '/home/seasonley/workplace/webpack-demo/src',// 模块所在的目录。可以用作解析其他模块路径的上下文。loaderIndex: 0,// 当前 loader 在 loader 数组中的索引。loaders:Array
  [ { path: '/home/seasonley/workplace/webpack-demo/src/myloader.js',
      query: '',
      options: undefined,
      ident: undefined,
      normal: [Function],
      pitch: undefined,
      raw: undefined,
      data: null,
      pitchExecuted: true,
      normalExecuted: true,
      request: [Getter/Setter] } ],// 所有 loader 组成的数组。它在 pitch 阶段的时候是可以写入的。resourcePath: '/home/seasonley/workplace/webpack-demo/src/index.js',// 资源文件的路径。resourceQuery: '',// 资源的 query 参数。async(),// 告诉 loader-runner 这个 loader 将会异步地回调。返回 this.callback。callback(err,content,sourceMap,meta),/* 一个可以同步或者异步调用的可以返回多个结果的函数。如果这个函数被调用的话,你应该返回 undefined 从而避免含糊的 loader 结果。this.callback(
  err: Error | null,
  content: string | Buffer,
  sourceMap?: SourceMap,
  meta?: any
);
可以将抽象语法树 AST(例如 ESTree)作为第四个参数(meta),如果你想在多个 loader 之间共享通用的 AST,这样做有助于加速编译时间。*/
cacheable(flag),/* 设置是否可缓存标志的函数:cacheable(flag = true: boolean)
默认情况下,loader 的处理结果会被标记为可缓存。调用这个方法然后传入 false,可以关闭 loader 的缓存。一个可缓存的 loader 在输入和相关依赖没有变化时,必须返回相同的结果。这意味着 loader 除了 this.addDependency 里指定的以外,不应该有其它任何外部依赖。*/
addDependency(file),// 加入一个文件作为产生 loader 结果的依赖,使它们的任何变化可以被监听到。例如,html-loader 就使用了这个技巧,当它发现 src 和 src-set 属性时,就会把这些属性上的 url 加入到被解析的 html 文件的依赖中。dependency(file),// addDependency 的简写
addContextDependency(directory),//(directory: string)把文件夹作为 loader 结果的依赖加入。getDependencies(),//
getContextDependencies(),//
clearDependencies(),// 移除 loader 结果的所有依赖。甚至自己和其它 loader 的初始依赖。考虑使用 pitch。resource: [Getter/Setter],//request 中的资源部分,包括 query 参数。示例:"/abc/resource.js?rrr"
request: [Getter],/* 被解析出来的 request 字符串。"/abc/loader1.js?xyz!/abc/node_modules/loader2/index.js!/abc/resource.js?rrr"*/
remainingRequest: [Getter],//
currentRequest: [Getter],//
previousRequest: [Getter],//
query: [Getter],/**
  如果这个 loader 配置了 options 对象的话,this.query 就指向这个 option 对象。如果 loader 中没有 options,而是以 query 字符串作为参数调用时,this.query 就是一个以 ? 开头的字符串。使用 loader-utils 中提供的 getOptions 方法 来提取给定 loader 的 option。*/
data: [Getter]// 在 pitch 阶段和正常阶段之间共享的 data 对象。/*
Object.defineProperty(loaderContext, "data", {
    enumerable: true,
    get: function() {return loaderContext.loaders[loaderContext.loaderIndex].data;
    }
});
*/

编写 Loader

function myLoader(resource) {if(/\.js/.test(this.resource))
        return resource+';console.log(`wa js`);';
};
module.exports = myLoader
//webpack.config.js
var path = require('path');
module.exports = {
    mode: 'production',
    entry: ['./src/index.js'],
    output: {path: path.resolve(__dirname, './dist'),
        filename: '[name].js'
    },
    module: {
        rules: [
            {
                test: /index\.js$/,
                use: 'bundle-loader'
            }
        ]
    },
    resolveLoader: {modules: ['./src/myloader/'],
    }
};

webpack 源码分析方法

inspect-brk 启动的时候自动在第一行自动加上断点

  • node –inspect-brk ./node_modules/webpack/bin/webpack.js –config ./webpack.config.js
  • chrome 输入 chrome://inspect/

Tree Shaking

webpack 通过静态语法分析,找出了不用的 export,把他们改成 free variable(只是把 exports 关键字删除了,变量的声明并没有删除)

Uglify 通过静态语法分析,找出了不用的变量声明,直接把他们删了。

Watch

webpack-dev-server

当配置了 watch 时 webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了 MemoryFileSystem(memory-fs 插件)实例。

MemoryFileSystem 是个抽象的文件系统库,webpack 将该部分解耦,可进一步设置 redis 或 mongodb 作为文件系统,在多个 webpack 实例中共享资源

监控

当执行 watch 时会实例化一个 Watching 对象,监控和构建打包都是 Watching 实例来控制;在 Watching 构造函数中设置变化延迟通知时间(默认 200),然后调用_go 方法;webpack 首次构建和后续的文件变化重新构建都是_执行_go 方法,在__go 方法中调用 this.compiler.compile 启动编译。webpack 构建完成后会触发 _done 方法,在 _done 方法中调用 this.watch 方法,传入 compilation.fileDependencies 和 compilation.contextDependencies 需要监控的文件夹和目录;在 watch 中调用 this.compiler.watchFileSystem.watch 方法正式开始创建监听。

Watchpack

在 this.compiler.watchFileSystem.watch 中每次会重新创建一个 Watchpack 实例,创建完成后监控 aggregated 事件和触发 this.watcher.watch(files.concat(missing), dirs.concat(missing), startTime)方法,并且关闭旧的 Watchpack 实例;在 watch 中会调用 WatcherManager 为每一个文件所在目录创建的文件夹创建一个 DirectoryWatcher 对象,在 DirectoryWatcher 对象的 watch 构造函数中调用 chokidar 插件进行文件夹监听,并且绑定一堆触发事件并返回 watcher;Watchpack 会给每一个 watcher 注册一个监听 change 事件,每当有文件变化时会触发 change 事件。
在 Watchpack 插件监听的文件变化后设置一个定时器去延迟触发 change 事件,解决多次快速修改时频繁触发问题。

触发

当文件变化时 NodeWatchFileStstem 中的 aggregated 监听事件根据 watcher 获取每一个监听文件的最后修改时间,并把该对象存放在 this.compiler.fileTimestamps 上然后触发 _go 方法去构建。

在 compile 中会把 this.fileTimestamps 赋值给 compilation 对象,在 make 阶段从入口开始,递归构建所有 module,和首次构建不同的是在 compilation.addModule 方法会首先去缓存中根据资源路径取出 module,然后拿 module.buildTimestamp(module 最后修改时间)和 fileTimestamps 中的该文件最后修改时间进行比较,若文件修改时间大于 buildTimestamp 则重新 bulid 该 module,否则递归查找该 module 的的依赖。
在 webpack 构建过程中是文件解析和模块构建比较耗时,所以 webpack 在 build 过程中已经把文件绝对路径和 module 已经缓存起来,在 rebuild 时只会操作变化的 module,这样可以大大提升 webpack 的 rebuild 过程。

模块热更新 (HMR) 机制

https://github.com/lihongxun9…

当完成编译的时候,就通过 websocket 发送给客户端一个消息(一个 hash 和 一个 ok)

向 client 发送一条更新消息 当有文件发生变动的时候,webpack 编译文件,并通过 websocket 向 client 发送一条更新消息

//webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {// 当完成编译的时候,就通过 websocket 发送给客户端一个消息(一个 `hash` 和 一个 `ok`)
    this._sendStats(this.sockets, stats.toJson(clientStats)); 
});

回顾 webpack 整体详细流程

webpack 主要是使用 Compiler 和 Compilation 类来控制 webpack 的整个生命周期,定义执行流程;他们都继承了 tabpable 并且通过 tabpable 来注册了生命周期中的每一个流程需要触发的事件。

webpack 内部实现了一堆 plugin,这些内部 plugin 是 webpack 打包构建过程中的功能实现,订阅感兴趣的事件,在执行流程中调用不同的订阅函数就构成了 webpack 的完整生命周期。

其中:[event-name]代表 事件名

[— 初始化阶段 —]

  • 初始化参数:webpack.config.js / shell+yargs(optimist) 获取配置options
  • 初始化 Compiler 实例 (全局只有一个, 继承自 Tapable, 大多数面向用户的插件,都是首先在 Compiler 上注册的)

    • Compiler:存放输入输出配置+编译器 Parser 对象
    • Watching():监听文件变化
  • 初始化 complier 上下文,loader 和 file 的输入输出环境
  • 初始化础插件WebpacOptionsApply()(根据 options)
  • [entry-option] : 读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备
  • [after-plugins] : 调用完所有内置的和配置的插件的 apply 方法。
  • [after-resolvers] : 根据配置初始化完 resolver,resolver 负责在文件系统中寻找指定路径的文件。
  • [environment] : 开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。
  • [after-environment]

[—- 构建 Graph 阶段 1—-]

入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理

  • [before-run]
  • [run]启动一次新的编译

    - 使用信息 `Compiler.readRecords(cb)`
    - 触发 `Compiler.compile(onCompiled)` (开始构建 options 中模块)
    - 创建参数 `Compiler.newCompilationParams()`
  • [normal-module-factory] 引入 NormalModule 工厂函数
  • [context-module-factory] 引入 ContextModule 工厂函数
  • [before-compile]执行一些编译之前需要处理的插件
  • [compile]

    - 实例化 `compilation` 对象
        - `Compiler.newCompilation(params)`
        - `Compiler.createCompilation()`
    
      该对象负责组织整个编译过程,包含了每个构建环节对应的方法。对象内部保留了对 `compile` 的引用,供 plugin 使用,并存放所有 modules,chunks,assets(对应 entry),template。根据 test 正则找到导入,并分配唯一 id
  • [this-compilation]触发 compilation 事件之前
  • [compilation]通知订阅的插件,比如在 compilation.dependencyFactories 中添加依赖工厂类等操作

[—- 构建 Graph 阶段 2—-]

  • [make]是 compilation 初始化完成触发的事件

    • 通知在 WebpackOptionsApply 中注册的 EntryOptionPlugin 插件
    • EntryOptionPlugin 插件使用 entries 参数创建一个单入口(SingleEntryDependency)或者多入口(MultiEntryDependency)依赖,多个入口时在 make 事件上注册多个相同的监听,并行执行多个入口
    • tapAsync 注册了一个 DllEntryPlugin, 就是将入口模块通过调用compilation.addEntry() 方法将所有的入口模块添加到编译构建队列中,开启编译流程。
    • 随后在 addEntry 中调用 _addModuleChain 开始编译。在 _addModuleChain 首先会生成模块,最后构建。在 _addModuleChain 中根据依赖查找对应的工厂函数,并调用工厂函数的 create 来生成一个空的 MultModule 对象,并且把 MultModule 对象存入 compilation 的 modules 中后执行 MultModule.build,因为是入口 module,所以在 build 中没处理任何事直接调用了afterBuild;在afterBuild 中判断是否有依赖,若是叶子结点直接结束,否则调用 processModuleDependencies 方法来查找依赖
    • 上面讲述的 afterBuild 肯定至少存在一个依赖,processModuleDependencies方法就会被调用;processModuleDependencies根据当前的 module.dependencies 对象查找该 module 依赖中所有需要加载的资源和对应的工厂类,并把 module 和需要加载资源的依赖作为参数传给 addModuleDependencies 方法;在 addModuleDependencies 中异步执行所有的资源依赖,在异步中调用依赖的工厂类的 create 去查找该资源的绝对路径和该资源所依赖所有 loader 的绝对路径,并且创建对应的 module 后返回;然后根据该 module 的资源路径作为 key 判断该资源是否被加载过,若加载过直接把该资源引用指向加载过的 module 返回;否则调用 this.buildModule 方法执行 module.build 加载资源;build 完成就得到了 loader 处理过后的最终 module 了,然后递归调用afterBuild,直到所有的模块都加载完成后 make 阶段才结束。
    • 在 make 阶段 webpack 会根据模块工厂(normalModuleFactory)的 create 去实例化 module;实例化 moduel 后触发 this.hooks.module 事件,若构建配置中注册了 DllReferencePlugin 插件,DelegatedModuleFactoryPlugin 会监听 this.hooks.module 事件,在该插件里判断该 moduel 的路径是否在 this.options.content 中,若存在则创建代理 module(DelegatedModule)去覆盖默认 module;DelegatedModule 对象的 delegateData 中存放 manifest 中对应的数据(文件路径和 id),所以 DelegatedModule 对象不会执行 bulled,在生成源码时只需要在使用的地方引入对应的 id 即可。
    • make 结束后会把所有的编译完成的 module 存放在 compilation 的 modules 数组中,通过单例模式保证同样的模块只有一个实例,modules 中的所有的 module 会构成一个图。
  • [before-resolve]准备创建 Module
  • [factory]根据配置创建 Module 的工厂类 Factory(Compiler.js) 使用 Factory 创建 NormalModule 实例 根据 rule.modules 创建 RulesSet 规则集
  • [resolver]通过 loader 的 resolver 来解析 loader 路径
  • [resolve]使用 loaderResolver 解析 loader 模块路径
  • [resolve-step]
  • [file]
  • [directory]
  • [resolve-step]
  • [result]
  • [after-resolve]
  • [create-module]
  • [module]
  • [build-module] NormalModule 实例.build() 进行模块的构建
  • [normal-build-loader] acron 对 DSL 进行 AST 分析
  • [program] 遇到 require 创建依赖收集; 异步处理依赖的 module,循环处理依赖的依赖
  • [statement]
  • [succeed-module]

[—- 优化 Graph—-]

  • compilation.seal(cb)根据之前收集的依赖,决定生成多少文件,每个文件的内容是什么. 对每个 module 和 chunk 整理,生成编译后的源码,合并, 拆分, 生成 hash, 保存在 compilation.assets,compilation.chunk

    • [seal]密封已经开始。不再接受任何 Module
    • [optimize] 优化编译. 触发 optimizeDependencies 类型的一些事件去 优化依赖(比如 tree shaking 就是在这个地方执行的)

      • 根据入口 module 创建 chunk,如果是单入口就只有一个 chunk,多入口就有多个 chunk;
      • 根据 chunk 递归分析查找 module 中存在的异步导 module,并以该 module 为节点创建一个 chunk,和入口创建的 chunk 区别在于后面调用模版不一样。
      • 所有 chunk 执行完后会触发 optimizeModules 和 optimizeChunks 等优化事件通知感兴趣的插件进行优化处理。
      • createChunkAssets 生产 assets给 chunk 生成 hash 然后调用 createChunkAssets 来根据模版生成源码对象. 所有的 module,chunk 任然保存的是通过一个个 require 聚合起来的代码,需要通过 template 产生最后带有 __webpack__reuqire() 的格式。

        • createChunkAssets.jpg
      • 根据 chunks 生产 sourceMap使用 summarizeDependencies 把所有解析的文件缓存起来,最后调用插件生成 soureMap 和最终的数据
      • 把 assets 中的对象生产要输出的代码assets 是一个对象,以最终输出名称为 key 存放的输出对象,每一个输出文件对应着一个输出对象
  • [after-optimize-assets]资产已经优化
  • [after-compile] 一次 Compilation 执行完成。

[—- 渲染 Graph—-]

  • [should-emit] 所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。

Compiler.emitAssets()

  • [emit]

    • 按照 output 中的配置项异步将将最终的文件输出到了对应的 path 中
    • output:plugin 结束前,在内存中生成一个 compilation 对象文件模块 tree,枝叶节点就是所有的 module(由 import 或者 require 为标志,并配备唯一 moduleId), 主枝干就是所有的 assets,也就是我们最后需要写入到 output.path 文件夹里的文件内容。
    • MainTemplate.render()ChunkTemplate.render() 处理入口文件的 module 和 非首屏需异步加载的 module
    • MainTemplate.render()

      • 处理不同的模块规范 Commonjs,AMD…
      • 生成好的 js 保存在 compilation.assets 中

[asset-path]

[after-emit]

[done]

  • if needAdditionalPass

    • needAdditionalPass()

      • 回到 compiler.run
  • else this.emitRecords(cb)
  • 调用户自定义 callback

[failed] 如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

参考资料

webpack loader 机制源码解析

【webpack 进阶】你真的掌握了 loader 么?- loader 十问

webpack 源码解析

webpack tapable 原理详解

webpack4 源码分析

随笔分类 – webpack 源码系列

webpack the confusing parts

细说 webpack 之流程篇

正文完
 0