7. 模块构建之解析_source获取dependencies

2022-11-16 17:36:37 浏览数 (1)

通过一个demo带你深入进入webpack@4.46.0源码的世界,分析构建原理,专栏地址,共有十篇。

  • 1. 从构建前后产物对比分析webpack做了些什么?
  • 2. webpack构建的基石: tapable@1.1.3源码分析
  • 3. webpack构建整体流程的组织:webpack -> Compiler -> Compilation
  • 4. 创建模块实例,为模块解析准备
  • 5. 路径解析:enhanced-resolve@4.5.0源码分析
  • 6. 模块构建之loader执行:loader-runner@2.4.0源码分析
  • 7. 模块构建之解析_source获取dependencies
  • 8. 从dependency graph 到 chunk graph
  • 9. 从chunk到最终的文件内容到最后的文件输出?
  • 10. webpack中涉及了哪些设计模式呢?

上一节经过loader的执行会返回原始经过loader改造后的内容,之后我们需要解析该内容以收集dependencies。

本文重点说如何从_source解析出模块依赖dependencies?基础是什么?


回顾下normalModule.build(...)

代码语言:javascript复制
// NormalModule.js
build(options, compilation, resolver, fs, callback) {
    //...
    return this.doBuild(options, compilation, resolver, fs, err => {
        const result = this.parser.parse(this._ast || this._source.source(), (err, result) => { /*...*/ })        
    })
}

在创建模块实例的的部分分析到this.parser的指向,现在它开始发挥作用了。

module.build -> doBuild -> this.parser.parse(),进入实例方法parse()

lib/Parser.js

代码语言:javascript复制
const acorn = require("acorn");
const acornParser = acorn.Parser;

class Parser extends Tapable {
   constructor(options, sourceType = "auto") {
       this.hooks = {
           // ... 各种hooks
       }
   }
 
   // ... 
   
   parse(source, initialState) {
     ast = Parser.parse(source, {
        sourceType: this.sourceType,
        onComment: comments
     });
        
     // ... 开始解析ast,从ast中解析出其余资源的引用
   }
 
   static parse(code, options) {
       //...
       ast = acornParser.parse(code, parserOptions);
   }
}   

实例方法parse先是调用静态方法parse解析js为AST,其内部实际使用acorn库来解析。 acorn用法参考

而后开始遍历整颗AST,遍历AST过程中会发布各种事件(如hooks.import等等),下面看下订阅的流程(主要是 XxxParserPlugin 插件)。

XxxParserPlugin插件注册(hooks.xxx.tap)

补充一下关于XxxParserPlugin的注册流程,在webpack此类插件有统一的注册形式,以ImportParserPlugin为例

代码语言:javascript复制
// WebpackOptionsApply
new HarmonyModulesPlugin(options.module).apply(compiler);

// HarmonyModulesPlugin.js 根据配置动态注册下面插件
new HarmonyDetectionParserPlugin().apply(parser);
new HarmonyImportDependencyParserPlugin(this.options).apply(parser);
new HarmonyExportDependencyParserPlugin(this.options).apply(parser);
new HarmonyTopLevelThisParserPlugin().apply(parser);

// HarmonyImportDependencyParserPlugin.js
// 添加各种相关依赖Dependency:HarmonyImportSideEffectDependency、//HarmonyImportSpecifierDependency 等等
parser.hooks.Xxx.tap('', () => {
    //...   
    parser.state.current.addDependency(dep); 
    //...
})

webpack/lib/dependencies目录下的文件主要都是和依赖相关,主要工作形式如上述呈现。WebpackOptionsApply文件中注册模块化相关的插件(CommonJsPluginHarmonyModulesPluginRequireEnsurePlugin、...),该插件内会动态注册解析依赖的插件XxxParserPlugin,在XxxParserPlugin会注册各种parser.hooks.xxx上的钩子以接受解析ast过程发布的各种事件,然后在相应事件中进行依赖(XxxDependency)的添加(addDependency

小结

import { logA } from './a.js'为例,这句话对应 ImportDeclartion,会进入prewalkImportDeclaration,而后发布hooks.import事件。而在HarmonyImportDependencyParserPlugin就有该事件的注册,然后调用addDependency添加依赖。

对于esm规范来说,WebpackOptionsApply注册HarmonyModulesPlugin后,在HarmonyModulesPlugin注册各种解析插件即XxxParserPlugin,在XxxParserPlugin订阅各种ast解析过程相关的钩子,然后添加依赖。

基础准备

JavaScript程序无非就是一些列可以可执行**语句**的集合。而语句是由表达式组成。表达式(expression)在JavaScript中是短语,那么语句(statement)就是JavaScript整句或命令。正如英文是用句号作结尾来分隔语句,JavaScript语句是以分号结束。表达式计算出一个值,但语句用来执行以使某件事发生。“使某件事发生” 的一个方法是计算带有副作用的表达式。诸如赋值和函数调用这些有副作用的表达式,是可以作为单独的语句的,这种把表达式当做语向的用法也称做表达式语向(expression statement)。类似的语句还有声明语句(declaration statement),声明语句用来声明新变量或定义新函数。

另外在遍历的过程中需要知道一个标识符(变量名、函数名)是否有在当前当前作用域链中被定义过,由于各种模块化机制提供的标识符(如require)都是全局的,对于此类标识符的判断的前提是在当前作用域链中访问不到,所以在解析过程中会有一个变量收集的过程,每个作用域都会有一个作用域对象用来存储当前作用域可以访问的标识符,JavaScript语言使用的词法作用域。

阅读本章节要求熟悉这些知识点,才能更好的理解为何这么实现,下面简单介绍一下这两个部分。

表达式、语句

参考书籍:JavaScript权威指南(第六版):第二章 - 第五章

表达式(expression)

  • 直接量

直接量(Literal)就是程序中直接使用的数据值(数字、字符串、布尔值、null、正则等),Parser在解析过程中会涉及到代码优化等操作,这些优化依赖一些计算,而计算的基石是直接量,比如

代码语言:javascript复制
// 构建前
'function' === 'function' ? console.log(1) : console.log(2)
// 构建后
true ? console.log(1) : undefined

可以理解直接量是表达式的基石,因为表达式的计算最终是基于直接量的。

  • 简单表达式、复杂表达式

最简单的表达式是原始表达式(简单表达式),包含直接量(常量)、变量、关键字(如this)。与之对应的是复杂表达式,复杂表达式由简单表达式组成。比如函数调用表达式(CallExpression)由易表示函数的表达式和参数表达式构成。将简单表达式组合成复杂表达式的最常用方法就是使用运算符(条件,逻辑,等等)进行连接。

复杂表达式分为很多种类型

代码语言:javascript复制
ObjectExpression //  { two: 1 * 2 }
ArrayExpression // [{ flag: false }, 'a'   'b'] // [ObjectExpression, BinaryExpression]
CallExpression // 调用表达式,如fun()
LogicalExpression // 逻辑表达式,如 a && b
AssignmentExpression // 赋值表达式,a = b
ConditionalExpression // 条件表达式, a ? c : d
FunctionExpression // 函数表达式,!function(){}、(function(){})等
// .....

三者关系逐级递进

语句(statement)

语句由表达式组成,最简单的语句肯定是表达式语句即一个表达式构成的语句,表达式放在了语句的位置就构成了表达式语句,如下,下面是一个表达式语句,该表达式语句由一个二元表达式(BinaryExpression)组成

除了表达式语句的其他语句如:

代码语言:javascript复制
// 声明语句(简称:声明)
var a = ''; // VariableDeclaration 变量声明语句
function log(){}; // FunctionDeclaration 函数声明语句

IfStatement // if(expression) statement
ForInStatement // for(variable in object) statement
WhileStatement // while(expression) statement
//...等等

用花括号将多条语句联合在一起就可以形成一条复合语句,比如一段js文件脚本,一个函数(其函数体就构成符合语句),for/while循环等等的花括号内的构成的语句。

小结

表达式计算出一个值,但语句用来执行以使某件事发生。 语句最终是由表达式组成。因此在parser的解析过程,先是walkStatement,当statement中解析出expressionwalkExpression

比如:statement.test进入表达式的解析,statement.consequent和statement.alternate继续语句的解析

变量的查找(作用域: scope)

参考书籍:you don't know js: scope & closures

标识符与作用域

作用域是通过标识符名称查询变量的一组规则。但是,通常会有多于一个的作用域需要考虑。

就像一个代码块儿或函数被嵌套在另一个代码块儿或函数中一样,作用域嵌套在其他的作用域中。所以,如果在直接作用域中找不到一个变量的话,引擎就会访问下一个外层作用域,如此继续直到找到这个变量或者到达最外层作用域(也就是全局作用域),这种嵌套的作用域,实际上就是作用域链

不过我们实际解析过程中是会忽略全局作用域的,全局作用域是有引擎提供的,我们在解析过程中只关心当前_source代码中自身构成的作用域链。

词法作用域

作用域的工作方式有两种占统治地位的模型。其中的第一种是最最常见,在绝大多数的编程语言中被使用的。它称为 词法作用域。另一种仍然被一些语言(比如 Bash 脚本,Perl 中的一些模式,等等)使用的模型,称为 动态作用域词法作用域是 JavaScript 所采用的作用域模型。

词法作用域意味着作用域是由编写时变量/函数被声明的位置的决定的。编译器的词法分析阶段实质上可以知道所有的标识符是在哪里和如何声明的,并在执行期间预测它们将如何被查询。

词法作用域可以静态分析出来,这为后面基于作用域链的变量查找提供了理论基础。

函数作用域、块作用域

函数作用域:实质上对外部作用域“隐藏”了这个函数内部作用域包含的任何变量或函数声明。

函数作用域的问题

代码语言:javascript复制
var foo = true;

if (foo) {
    var bar = foo * 2;
    console.log(bar);
}

我们仅在 if 语句的上下文环境中使用变量 bar,所以我们将它声明在 if 的内部是有些道理的。然而,当使用 var 时,我们在何处声明变量是无关紧要的,因为它们将总是属于外围作用域

从将信息隐藏在函数中,到将信息隐藏在我们代码的块中,块作用域是一种扩展了早先的“最低权限暴露原则”的工具。

letconst提供了将变量声明限制在一个内的方式。比如我们可以将伤处var bar -> let/const bar,则在if外围的作用域就访问不到该变量了。

声明提升

引擎实际上将会在它解释执行你的 JavaScript 代码之前编译它。编译过程的一部分就是找到所有的声明,并将它们关联在合适的作用域上,这也是词法作用域的核心。

在你的代码的任何部分被执行之前,所有的声明,变量和函数,都会首先被处理。

var a = 2; 时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a; 和 a = 2;。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在原处。

变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端。这就产生了“提升”这个名字。提升是 以作用域为单位的,如函数内部声明的变量不会提升到外层作用域。函数声明会被提升,函数表达式不会。

小结

Parser部分执行逻辑可以看成是编译执行两个阶段:

下面的prewalkStatementsblockPrewalkStatements主要做的事情的是变量收集。区别在于prewalkStatements收集有声明提升的变量和函数,而blockPrewalkStatements收集块级作用域的变量,这个过程就是上面说的编译阶段做的事情

walkStatements则是从遍历所有语句,然后从语句进入到表达式(walkExpression),从语句到表达式,遍历到所有的代码细节。而这个递归遍历的阶段就像是上面说的执行阶段

代码语言:javascript复制
// webpack/lib/Parser.js
parse(source, initialState) {
 //...
 this.prewalkStatements(ast.body);
 this.blockPrewalkStatements(ast.body);
 this.walkStatements(ast.body);
 //...
}

实际上这里Parsre就是要模拟JavaScript的执行过程,来准确的收集Dependency。

AST遍历和依赖收集过程

变量收集(prewalkStatement & blockPrewalkStatement)

prewalkStatement做的主要事情:

  • renames & definitions:找到指定的声明语句(function/var)提升到当前作用域顶部的,var/const/let/function/class都是声明语句,但是只有var和function声明的变量有声明提升,其余的都没有,因此在该方法中只会收集function和var声明的变量
  • 收集import/export依赖

先看下哪些语句

上面介绍let/const a,export class A中的声明都不会发生声明提升;class、let/const 声明变量的作用域 ,参考you don't know js;

针对这类声明通过blockPrewalkStatement进行块级作用域变量声明

blockPrewalkStatement

ast node type

演示

解释

ExportDefaultDeclaration、ExportNamedDeclaration

export default class A {}export const/let a = 'a'

export语句后面跟块级作用域的变量声明,递归进入后面的语句

ClassDeclaration、VariableDeclaration

class A{}, const/let a

收集变量到renames和definitions

为什么要区分这两种变量类型,如下例:

代码语言:javascript复制
var a = 'a'
{ 
    // 当既然怒这个块的时候访问的a,应该是这个块的,因此块级作用域需要区分,不能和外层作用域混在一起了
    let a = 'a';
    
    console.log(a);
}

小结

按照是否会发生变量声明提升分别进行变量的收集。

从语句到表达式(walkStatement)

语句(复合语句)由语句和表达式构成

对比prewalkStatement的switch-case的情况:

walkStatement 多了下面分支(需要进入,但是本身没有变量提升相关的场景):

代码语言:javascript复制
ClassDeclaration、
ExpressionStatement、
ReturnStatement、
ThrowStatement

少了下面分支(不需要进入了):

代码语言:javascript复制
ExportAllDeclaration //(export * from 'xxx')
ImportDeclaration

walkStatement的switch-case处理:

VariableDeclaration

还有一个VariableDeclaration,是这个部分唯一有实质性处理的语句,下面我们具体看下这个语句的处理

代码语言:javascript复制
walkVariableDeclaration(statement) {
   for (const declarator of statement.declarations) {
      switch (declarator.type) {
         case "VariableDeclarator": {
             // case 1 的处理
             // case 2 的处理
            break;
         }
      }
   }
}
case 2

总共有两个部分:其中case 2和之前的处理是一样的,比如针对如下例子,走walkPattern、walkExpression等

代码语言:javascript复制
// case 2的处理逻辑
this.walkPattern(declarator.id);
if (declarator.init) this.walkExpression(declarator.init);

demo示例

case 1
代码语言:javascript复制
// case 1的处理逻辑
const renameIdentifier = declarator.init && this.getRenameIdentifier(declarator.init);
if (renameIdentifier && declarator.id.type === "Identifier") {
   const hook = this.hooks.canRename.get(renameIdentifier);
   if (hook !== undefined && hook.call(declarator.init)) {
      // renaming with "var a = b;"
      const hook = this.hooks.rename.get(renameIdentifier);
      if (hook === undefined || !hook.call(declarator.init)) {
         this.scope.renames.set(
            declarator.id.name,
            this.scope.renames.get(renameIdentifier) || renameIdentifier
         );
         this.scope.definitions.delete(declarator.id.name);
      }
      break;
   }
}

demo示例

getRenameIdentifier主要是用来判断declarator.init是否是标识符,显然这里的require是的,其次就是这里最重要的两个钩子hooks.canRename和hooks.rename,canRename和rename都是HookMap类型,具体的钩子实例是通过key来区分的,这里key就是require。在CommonJsPlugin有注册该这个关键字的钩子,如下:

代码语言:javascript复制
// CommonJsPlugin.js

parser.hooks.canRename.for("require").tap("CommonJsPlugin", () => true);
parser.hooks.rename.for("require").tap("CommonJsPlugin", expr => {
   // define the require variable. It's still undefined, but not "not defined".
   const dep = new ConstDependency("var require;", 0);
   dep.loc = expr.loc;
   parser.state.current.addDependency(dep);
   return false;
});

canRename的返回结果说明当前标识符时可以被重命名的,因此会进入下面的rename阶段,CommonJsPlugin会在这里添加一个ConstDependency,这里的目的是在当前构建产物的顶部添加var require,其目的是为了方式访问未定义的变量导致异常。scope.renames.set重命名a的为require,即当下次在该作用域中访问a时实际会获取到require,由于a被重命名,因此认为在当前作用域没有定义该变量,所有从definitions中删除。至于这里重命名的作用后面在walkExpression部分会碰到。

小结

  1. 别名处理
  2. 除了walkStatement就是walkExpression

表达式的处理(walkExpression)

最简单的表达式是 “原始表达式”(primary expression)。原始表达式是表达式的最小单位它们不再包含其他表达式。JavaScript中的原始表达式包含常量或直接量、关键字和变量。

我们通常会通过运算符(条件,一元,二元,逻辑等等)将简单表达式组合为复合表达式,因此对于复合表达式的处理,一样是递归的调用walkExpression来处理子表达式,如下:

代码语言:javascript复制
// 表达式类型 -> 子表达式所在属性
ArrayExpression -> elements // 如 walkExpressions(expression.elements);
AwaitExpression -> argument
BinaryExpression -> left & right
ConditionalExpression -> test & consequent
SequenceExpression -> expressions
SpreadElement -> argument 
TaggedTemplateExpression -> tag & quasi.expressions
TemplateLiteral -> expressions
UpdateExpression -> argument
YieldExpression -> argument
LogicalExpression -> left、right
ObjectExpression -> properties

ast node type

示例

解释

ClassExpression

const A = class {}; // ClassExpressionclass B {}; // ClassDelcaration

类表达式与类声明语句,这里的处理同类声明语句处理一致,见walkStatement关于ClassDeclaration的处理

ArrowFunctionExpression

this.inFunctionScope(false, ...) // hasThis

最大的区别在于箭头函数,箭头函数没有自己的this,因此在创建函数作用域时不添加this关键字

FunctionExpression

function log_1(){...}; // 函数声明var log_2 = function log_3() {...} // 函数表达式

函数表达式中函数名(log_3)没有声明提升不会被收集到renames,因此这里主动将函数名(log_3)存储到params中,目的是为了函数体内可以正常获取到该变量,如果获取不到则说明这个变量没有被定义过(!this.scope.definitions.has()),对于未被定义的标识符的处理流程是有区别的。

ThisExpression, MemberExpression, NewExpression, UnaryExpression, Identifier

这里的几个表达式都存在通过不同的钩子(hooks.expression、hooks.new,hooks.typeof)来收集依赖,如下:ThisExpression: UnaryExpression:

【案例解释 - ThisExpression】: esm顶层作用域的this被替换为undefined,HarmonyTopLevelThisParserPlugin注册expression.for(this)钩子,有个前提是当前文件要被识别为esm,左侧demo中上面的import语句就是做这个事情。;【案例解释 - UnaryExpression】:CommonJsPlugin注册了hooks.typeof.for('require'),会添加ConstDepenency依赖用于替换typeof require 为 "function"【总结】:1. scope.defintions(作用域链)中找不到该标识符,才可能收集依赖Dependency,通常和模块化关键字(require等)相关,或者如DefinePlugin提供的标识符替换等,用户自己显示定义的变量显然没有收集依赖的意义。2. 原始表达式(如这里的Identifier和ThisExpression)不会递归下去,而这里的其他表达式属于复合表达式(NewExpression和UnaryExpression)会继续递归下去,如

AssignmentExpression

处理场景如:a = b(属于表达式);而var a = b属于声明语句因此在walkStatement中处理

同walkStatement中的VariableDeclaration处理几乎一致,不再赘述;

CallExpression

该表达式是针对函数调用的场景,这里区分为三种场景,case 1是立即执行函数iife,case 2是动态导入模块import(),在后面的acorn版本中会被解析为ImportExpression,case 3普通函数的调用;下面逐个分析每个case

CallExpression

代码语言:javascript复制
walkCallExpression(expression) {
   if (expression.callee.type === "MemberExpression" && expression.callee.object.type === "FunctionExpression" && !expression.callee.computed &&
      (expression.callee.property.name === "call" || expression.callee.property.name === "bind") && expression.arguments.length > 0
   ) {
      // (function(…) { }.call/bind(?, …))
      this._walkIIFE( expression.callee.object, expression.arguments.slice(1), expression.arguments[0]);
   } else if (expression.callee.type === "FunctionExpression") {
      // (function(…) { }(…))
      this._walkIIFE(expression.callee, expression.arguments, null);
   } else if (expression.callee.type === "Import") {
        // import(...)
   } else {
     // 普通函数调用
   }
}

看到一共四个分支,前两个是iife场景,后面分别是import(),和普通函数调用。

case 1: iife 立即执行函数

看到iife有细分为两个场景:call和bind会改变this和入参的指向,因此会将call/bind归为一类需要特出处理,另外一种就是普通的立即函数调用;针对iife的场景,会进入方法 _walkIIFE处理

先看下_walkIIFE的入参

代码语言:javascript复制
_walkIIFE(functionExpression, options, currentThis) {...}
// functionExpression表示函数表达式;
// options是函数表达式的实参;
// currentThis显示指定了函数内部的this指向

下面看下两种立即执行函数的区别在哪?

  • 第一种 (function(…) { }.call/bind(?, …))
代码语言:javascript复制
this._walkIIFE( expression.callee.object, // 函数表达式,因为是MemberExpression,所以取object
expression.arguments.slice(1), // 参数表示函数表达式的实参
expression.arguments[0]); // 函数表达式内部this的指向

第二种 (function(…) { }(…))

代码语言:javascript复制
this._walkIIFE(expression.callee, // 函数表达式
expression.arguments, // 参数表示函数表达式的实参
null); // 没有显示指定 this

二者都显示执行了实参,但是后者没有显示指定this

下面进入_walkIIFE看下具体的实现,该方法实际和FuntionExpression的处理很像,主要的差别在于参数的处理。由于iife的场景下指定了实参this,因此_walkIIFE多了一步关于实参和this的处理即重命名处理,通过下面的具体例子来看一下

demo以及构建前后的内容对比

代码语言:javascript复制
// 原始代码 ----- 
(function () {
    const aMod = this('./a')
    console.log(aMod)
}).bind(require)

// 构建后的代码 ----- 
var require;

(function () {
    const aMod = __webpack_require__(1) 
    console.log(aMod)
}).bind(require)

看到函数内部的cjdLoad被转为了__webpack_require__,之所以做到这一点就是这里静态分析出了this指向了require,实际解析的时候所有this的调用都会当做require调用

对比·iiffeFunctionDeclaration的处理逻辑,红色虚框实际上这里的核心逻辑,二者是相同的,区别在于iife在前面做了很多准备工作:绿色、蓝色、红色背景代码片段。

绿色背景代码片段:针对thisoptions每个变量调用renameArgOrThis来获取重命名后的标识符,关于重命名的逻辑在walkStatement中的VariableDeclaration部分说过,这里也是相同的逻辑。比如上面实例中就会获取this的重命名为require

蓝色背景代码片段:将表达式中的函数名保存到参数中,以便在函数体中可以通过作用域链访问到,这个在上面的FunctionExpression有解释原因。

红色背景代码片段:在inFunctionScope创建完当前函数作用域后,在当前作用域对象中将上述获取的重命名设置到scope.renames中。这里会设置thisrequire

这么做的好处是,当在函数内部获取到某个标识符时可以获取到原始指向(重命名的标识符),比如这里在函数体内部当再次解析到this实际就是解析require。这就解释了为什么产物中的this被替换为__webpack_require__

case 2: import('xxx')
代码语言:javascript复制
let result = this.hooks.importCall.call(expression);
if (result === true) return;

if (expression.arguments) this.walkExpressions(expression.arguments);

这里主要是添加了一个钩子的发布,表示调用import(),专为此设计的,豪不豪吧,很重要的钩子。在webpck5中由于升级了acorn版本,import()CallExpression解析为了ImportExpression

代码语言:javascript复制
// ImportParserPlugin.js
const ImportDependenciesBlock = require("./ImportDependenciesBlock");

parser.hooks.importCall.tap("ImportParserPlugin", expr => {
    //...
    const depBlock = new ImportDependenciesBlock(...);
    parser.state.current.addBlock(depBlock);
    //...
}

// ImportDependenciesBlock.js
class ImportDependenciesBlock extends AsyncDependenciesBlock {
    constructor(request, range, groupOptions, module, loc, originModule) {
        //...
        const dep = new ImportDependency(request, originModule, this);
        this.addDependency(dep);
    }
};

parser.state.current指向当前的模块(NormalModule),该类继承自 DependenciesBlock,提供了addBlock方法用来保存 AsyncDependencyBlock 对象到blocks属性,该属性只是用来记录异步引入的模块(如这里的ImportDependenciesBlock)信息。

代码语言:javascript复制
NormalModule extends Module
Module extends DependenciesBlock

class DependenciesBlock {
    //...
    addBlock(block) {  // block: AsyncDependencyBlock类型
       this.blocks.push(block);
       block.parent = this;
    }
    //...
}

AsyncDependencyBlock可以认为是依赖的一种,也可以理解成一个异步模块引入的一个分离点或标识(这一点在后面初步构造chunk graph时即buildChunkGraph中会有体现),具体的异步依赖模块如这里的ImportDependency是作为AsyncDependencyBlock的依赖。

case 3 常规函数调用

该case场景下有一个关键的钩子,hooks.call(HookMap类型),各种模块化相关的plugin都有进行注册,各自注册key不一样,如下:

代码语言:javascript复制
// 插件 // 关键字
RequireEnsureDependenciesBlockParserPlugin // require.ensure 
RequireIncludeDependencyParserPlugin // require.include 
RequireContextDependencyParserPlugin // require.context 
RequireResolveDependencyParserPlugin // require.resolve、require.resovleWeak 
RequireJsStuffPlugin // require.config、requirejs.config 
AMDRequireDependenciesBlockParserPlugin // require 
AMDDefineDependencyParserPlugin // define 
CommonJsRequireDependencyParserPlugin // require、module.require 
HarmonyImportDependencyParserPlugin // imported var 
HarmonyDetectionParserPlugin // define、exports 
CompatibilityPlugin // require 
SystemPlugin // System.import 

实际上这里和上面import('xxx')的目的是一样的,主要针对其他模块化机制的调用方式,举个例子如下

webpack早期提供的模块异步化引入的方式 require.ensure

代码语言:javascript复制
require.ensure(['./a.js'], function (module) {
    console.log(module)
})

RequireEnsureDependenciesBlockParserPlugin中有注册hooks.call.for('require.ensure')

代码语言:javascript复制
apply(parser) {
   parser.hooks.call.for("require.ensure")
      .tap("RequireEnsureDependenciesBlockParserPlugin", expr => {
            //...
            const dep = new RequireEnsureDependenciesBlock(...);
            const old = parser.state.current;
            parser.state.current = dep; // 为了收集dep的依赖
            //...
            old.addBlock(dep); // 添加 block
            //...
            parser.state.current = old;
       });
}

看到这里实际上和上面的import('xxx')逻辑几乎一致。

evaluate

举一个例子说明以下evaluate的作用

在这个阶段还有一个重要的类BasicEvaluatedExpression,这个类提供了一些类型判断和基础的计算能力

demo

代码语言:javascript复制
// 构建前
'function' === 'function' ? console.log(1) : console.log(2)
// 构建后
true ? console.log(1) : undefined

// 复杂一点的例子: if(typeof require === 'function') // => if(true)
// 有兴趣的同学可以debug一下

首先这一句整体是一个条件表达式即ConditionalExpression,对于条件表达式

ConstPlugin

代码语言:javascript复制
// parser.hooks.expressionConditionalOperator的回调
expression => {
  const param = parser.evaluateExpression(expression.test);
  const bool = param.asBool();
  if (typeof bool === "boolean") {
     if (expression.test.type !== "Literal") {
        const dep = new ConstDependency(` ${bool}`, param.range);
        //...
     }
     const dep = new ConstDependency("undefined", branchToRemove.range);
    //...
  }
}

parser.evaluateExpression(expression.test);会将'function' === 'function'解析为一个BasicEvaluatedExpression,该类提供了asBool()方法计算布尔结果,显然这里返回true。

这部分逻辑其实很清楚,一共添加了两个依赖分别是

代码语言:javascript复制
// 将 expression.test 部分替换为实际的布尔值(通过ConstDependency依赖实现)
const dep = new ConstDependency(` ${bool}`, param.range); // 注意:设置了替换的范围

// 替换dead branch部分的内容为`undefined`(通过ConstDependency依赖实现)
const dep = new ConstDependency("undefined", branchToRemove.range); 注意:设置了替换的范围

至于ConstDependency是如何替换的,后面在说代码生成的章节会说到。

下面重点看下parser.evaluateExpression(expression.test);的执行逻辑

代码语言:javascript复制
evaluateExpression(expression) {
    //...
    // 这里expression.type是 BinaryExpression (二元表达式)
    const hook = this.hooks.evaluate.get(expression.type);
    const result = hook.call(expression);
    //...
}

主要是 hooks.evaluate,在Parser.js文件的开始部分一大段代码都是关于改钩子的注册

代码语言:javascript复制
this.hooks.evaluate.for("BinaryExpression").tap("Parser", expr => {
  // 枚举了所有的二元运算符,并给出计算结果
})
this.hooks.evaluate.for("LogicalExpression").tap("Parser", expr => {...})
this.hooks.evaluate.for("UnaryExpression").tap("Parser", expr => {...})
// 等等...

this.hooks.evaluate.for("Literal").tap("Parser", expr => {
   switch (typeof expr.value) {
      case "number": //...
      case "string": //...
      case "boolean": //...
   }
    //... null、正则
});

// Literal、Identifier、ThisExpression、MemberExpression、CallExpression、
// TemplateLiteral、ConditionalExpression、ArrayExpression等等

其中Literal提供了基础类型的BasicEvaluatedExpression,如 字符串、数字、布尔值、null、正则等,其余复杂表达式是在其基础上进行计算的。

比如我们这里expression.test中的===是二元运算符,expression.test是BinaryExpression则会走如下逻辑

代码语言:javascript复制
this.hooks.evaluate.for("BinaryExpression").tap("Parser", expr => {
    //...
    } else if (expr.operator === "==" || expr.operator === "===") {
       left = this.evaluateExpression(expr.left);
       right = this.evaluateExpression(expr.right);
       //...
       res = new BasicEvaluatedExpression(); // 构造一个实例
       if (left.isString() && right.isString()) {
          return res.setBoolean(left.string === right.string); // 设置类型及其值
       } else if //...
    } else if (expr.operator === "!=" || expr.operator === "!==") {
    //...
})

先是估算expr.leftexpr.right,二者都是Literal会在evaluate.for("Literal")中返回并设置会设置类型为字符串以及值。

代码语言:javascript复制
// this.hooks.evaluate.for("Literal")回调中的片段
case "string":
   return new BasicEvaluatedExpression()
      .setString(expr.value) // 设置类型和值

然后再根据二者的类型进行比对,比如:如果都是字符串,然后用严格等于比对,setBoolean即设置了类型也设置了值,这里设置为了布尔类型以及值为true。

总结

我们的demo中,在前面已经贴过main.js被解析后有哪些依赖,这里不再展示。

主要就是将js转为AST,然后递归遍历AST(从语句到表达式到标识符),在关键位置发布相应的hooks,订阅函数通过hooks订阅在回调中调用addDependency添加相应的依赖。

参考

  • 如何读懂ECMAScript规范,Understanding ECMAScript (译)

0 人点赞