FE.SRC-webpack原理梳理

Posted seasonley

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了FE.SRC-webpack原理梳理相关的知识,希望对你有一定的参考价值。

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/webpack.js.org/issues/970

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/the-chunk-graph-algorithm-week-26-29-7c88aa5e4b4e

整个工作流程

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

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

Module

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

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

Template

  • mainTemplate 和 chunkTemplate
    js if(chunk.entry) { source = this.mainTemplate.render(this.hash, chunk, this.moduleTemplate, this.dependencyTemplates); } else { source = this.chunkTemplate.render(chunk, this.moduleTemplate, this.dependencyTemplates); }
    • 不同模块规范封装
      js 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
    //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/api/compilation-hooks/

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和参数
    js //NormalModuleFactory.js let elements = requestWithoutMatchResource .replace(/^-?!+/, "") .replace(/!!+/g, "!") .split("!");
  • 将“解析模块的loader数组”与“解析模块本身”一起并行执行,用到了neo-async这个库(和async库类似,都是为异步编程提供一些工具方法,但是会比async库更快。)
  • 解析返回结果:
    js [ // 第一个元素是一个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/[email protected]@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/lihongxun945/diving-into-webpack/blob/master/7-hmr.md

当完成编译的时候,就通过 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 之流程篇

以上是关于FE.SRC-webpack原理梳理的主要内容,如果未能解决你的问题,请参考以下文章

JVM技术专题 带你梳理分析虚拟机栈映射源代码的流程「原理篇」

NVDIMM编程模型原理大概推导梳理

NVDIMM编程模型原理大概推导梳理

NVDIMM编程模型原理大概推导梳理

NVDIMM编程模型原理大概推导梳理

NVDIMM编程模型原理大概推导梳理