资讯专栏INFORMATION COLUMN

FE.SRC-webpack原理梳理

cfanr / 1998人阅读

摘要:执行完成后会返回如下图的结果,根据返回数据把源码和存储在的属性上的回调函数中调用类生成,并根据生成依赖后回调方法返回类。

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...

整个工作流程

Compiler 读取配置,创建Compilation

Compiler创建Graph的过程:

Compilation读取资源入口

NMF(normal module factory)

Resolver 解析

输出NM

Parser 解析 AST

js json 用acorn

其他用Loader (执行loader runner)

如果有依赖, 重复步骤 2

Compilation优化Graph

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:()=>{},//循环钩子
})

更多示例 https://juejin.im/post/5abf33...

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.
(" + 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} */
this.contextTimestamps /** @type {Map} */
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 }>} */

this._assetEmittingWrittenFiles/** @private @type {Map} */
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("
"))
            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"u003Easdu003Cu002Fdivu003E";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)
//
asd

这个入口文件 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(负责添加