大家都知道,webpack在解析模块时,会分析出当前模块所有的依赖将其加入到当前模块的dependencies
依赖数组中,随后会对当前模块的依赖会逐个进行类似模块解析,从而完成整个模块的解析过程。需要指出的的是,模块的依赖分析过程是在loader处理后进行的,那么webpack是怎么分析出模块的依赖呢?这就引出了本文的主旨:webpack Parser
认识webpack Parser
webpack模块构建的主要过程包括模块资源及应用loader的地址resolver、loader处理模块内容以及对loader处理后的模块内容分析其的依赖,其中webpack Parser正是用来解析模块依赖的,其实现原理很简单,即:
通过ast(抽象语法树)来遍历整个模块的内容,从而解析出模块的依赖
webpack Parser的实现完全是基于ast(内部是通过acorn
来生成的),通过遍历获取的模块内容ast来分析依赖;另外,webpack Parser继承自tapable
,在遍历ast的过程中也提供各种不同时机的钩子给plugin作者做自定义的解析过程,在webpack plugin中是通过下面这种方式访问Parser实例:
compiler.hooks.normalModuleFactory.tap('MyPlugin', factory => {
factory.hooks.parser.for('javascript/auto').tap('MyPlugin',(parser, options) => {
parser.hooks.someHook.tap(/* ... */);
// or
parser.hooks.someHook.for('xxx').tap(/* ... */)
});
});
需要注意的是:
webpack Parser只对ast进行遍历,不要在Parser提供的钩子回调中对其进行转换操作
即使转换也不会生效,因为webpack是最终是通过js代码生成器JavascriptGenerator
配合依赖模板来生成最终的编译代码,它是基于loader处理后的源码而不是Parser解析后输出的ast来生成代码的。
下面从webapck的构建过程中Parser参与的部分来进行解读。
Parser的初始化
我们知道,webpack使用Parser对每个模块进行解析,这体现在NormalModule.build
方法中,它会在该方法的返回函数中调用Parser实例解析模块:
// NormalModule.build 方法
build(options, compilation, resolver, fs, callback) {
//...
return this.doBuild(options, compilation, resolver, fs, err => {
...
//
try {
// 这里会将 source 转为 AST,分析出所有的依赖,其中sourc为loader处理后的结果
const result = this.parser.parse(
this._ast || this._source.source(),
{ // 解析的当前state
current: this,
module: this,
compilation: compilation,
options: options
}, (err, result) => {...});
...
} catch (e) {
handleParseError(e);
}
})
}
那么这个Parser的实例是什么时候初始化的呢,这个是对模块构建的resolver后完成初始化的;具体是在webpack/lib/JavascriptModulesPlugin.js
插件中完成的,该插件会在webpack的整合用户配置的和系统内部配置注册的。webpack通过该插件会为不同的js模块注册不同的解析实例初始化,具体代码:
compiler.hooks.compilation.tap("JavascriptModulesPlugin",(compilation, {normalModuleFactory}) => {
// 所有js模块,包括CommonJS、AMD、ESM
normalModuleFactory.hooks.createParser.for("javascript/auto").tap("JavascriptModulesPlugin", options => {
return new Parser(options, "auto");
});
// CommonJS & AMD模块
normalModuleFactory.hooks.createParser.for("javascript/dynamic").tap("JavascriptModulesPlugin", options => {
return new Parser(options, "script");
});
// EcmaScript模块
normalModuleFactory.hooks.createParser.for("javascript/esm").tap("JavascriptModulesPlugin", options => {
return new Parser(options, "module");
});
...
})
可以看到该插件通过注册normalModuleFactory.hooks.createParser
钩子来实例化每个js模块的Parser实例,该钩子是在模块resovler之后初始化的,可以在NormalModuleFactory
的钩子函数中resolver钩子回调中看出,具体可以查看相关源码,细节就在展开。
这样每个js模块都包含有解析该模块对应的parser实例,正如上面NormalModule.build
中会调用Parser实例的parse
方法,它会对模块进行解析。下面就来说说如何解析模块
parse方法的处理流程
parse
方法是解析一个模块的入口方法,通过获取模块内容的ast并对其进行遍历,从而分析出代码的各种依赖;在这一过程中Parser根据ast的不同语句环境对外提供了大量钩子,用户可以使用相关的钩子对自己关心的语句或者声明标识符进行自定义解析处理。parse
方法代码如下:
parse(source, initialState) {
...
const oldScope = this.scope;
const oldState = this.state;
const oldComments = this.comments;
this.scope = {
topLevelScope: true,
inTry: false,
inShorthand: false,
isStrict: false,
definitions: new StackedSetMap(),
renames: new StackedSetMap()
};
const state = (this.state = initialState || {});
this.comments = comments;
if (this.hooks.program.call(ast, comments) === undefined) {
this.detectStrictMode(ast.body);
this.prewalkStatements(ast.body);
this.blockPrewalkStatements(ast.body);
this.walkStatements(ast.body);
}
this.scope = oldScope;
this.state = oldState;
this.comments = oldComments;
return state;
}
初始化编译的scope
对于每次parse
方法的调用都会生成本次解析相关的信息,记录在this.scope
中,解析完毕恢复到之前的scope
,其中scope
包括的属性如下:
this.scope = {
// 是否是顶级作用域,解析模块中的函数、class语句时 它们就是非顶级作用域
topLevelScope: true,
inTry: false, // 当前解析是否在try语句中
inShorthand: false, // 对象表达式中扩展运算符是否是缩减形式,即{a} = obj
isStrict: false, // 是否是严格模式
definitions: new StackedSetMap(), // 当前模块的定义的变量
renames: new StackedSetMap() // 当前模块可以重命名的变量
};
其中,对于父作用域下的子作用域,如顶级作用域中的定义的函数内部,在进入到函数内部时,会生成当前函数下的作用域:
this.scope = {
topLevelScope: false,
inTry: false,
inShorthand: false,
isStrict: topLevelScope.isStrict,
definitions: topLevelScope.definitions.createChild(),
renames: topLevelScope.renames.createChild()
};
Parser创建子作用域下用来记录标识符的definitions
和renames
时,会将其父作用域添加到自己的作用域栈中,这正是topLevelScope.definitions.createChild()
的作用,具体是利用数组的方式组织这层关系:
// 数组元素的上一个为其父作用域信息,下一个元素为其子作用域信息
[parentStack, childStack, grandsonStack, ...]
检测代码是否是严格模式
这主要体现在detectStrictMode
方法上,该方法主要检测的是当前代码块是否是严格模式,是的话就设置this.scope.isStrict = true
,具体代码如下:
detectStrictMode(statements) {
const isStrict =
statements.length >= 1 &&
statements[0].type === "ExpressionStatement" &&
statements[0].expression.type === "Literal" &&
statements[0].expression.value === "use strict";
if (isStrict) {
this.scope.isStrict = true;
}
}
设置this.scope.isStrict
的目的,决定生成的最终代码的变量定义是怎么处理的,例如严格模式下的var
定义的变量需要提升至作用域顶部;对于顶级作用域它也决定了是否在模块的其实位置追加use strict
。
简单说下webpack最终如何追加严格模式,在parse
方法中this.hooks.program.call(ast, comment)
会触发Parser的program
钩子,webpack在内置的UseStrictPlugin
插件中注册了该钩子,作用是用来检测文件是否有 use strict
,若有则增加一个 ConstDependency
依赖,该依赖在最终解析时会将该处的严格模式给删掉,show code:
parser.hooks.program.tap("UseStrictPlugin", ast => {
const firstNode = ast.body[0];
if (
firstNode &&
firstNode.type === "ExpressionStatement" &&
firstNode.expression.type === "Literal" &&
firstNode.expression.value === "use strict"
) {
// Remove "use strict" expression. It will be added later by the renderer again.
// This is necessary in order to not break the strict mode when webpack prepends code.
// @see https://github.com/webpack/webpack/issues/1970
const dep = new ConstDependency("", firstNode.range);
dep.loc = firstNode.loc;
parser.state.current.addDependency(dep);
// 它记录了是否在模块的起始位置追加use strict
parser.state.module.buildInfo.strict = true;
}
});
为啥解析模块时要删掉模块的use strict
,正如代码中的注释:webpack 在构建的代码,可能会在开头增加一些代码,这样会导致原本写在代码第一行的 "use strict"
不在第一行。
收集模块中的标识符
这块主要体现在prewalkStatements
和blockPrewalkStatements
两个方法上。
1、prewalkStatements
先来看看prewalkStatements
方法收集哪些语句中的标识符,其内部是针对单个语句调用prewalkStatement
方法,该方法内部实现是一个长长的swith-case语句,其收集的语句用一张图来描述,如下图

可以看到,prewalkStatements
收集标识符的语句有很多,对于具有块级别的语句如BlockStatement
、ForStatement
等最终会落到VariableDeclartion
和FuctionDeclaration
上。
来看看VariableDeclartion
如何收集变量的,遇到变量声明时,会调用prewalkVariableDeclaration
:
prewalkVariableDeclaration(statement) {
if (statement.kind !== "var") return; // 不会收集const和let声明的变量标识符
this._prewalkVariableDeclaration(statement, this.hooks.varDeclarationVar);
}
而 _prewalkVariableDeclaration
方法执行真正的变量收集。
_prewalkVariableDeclaration(statement, hookMap) {
for (const declarator of statement.declarations) {
switch (declarator.type) {
case "VariableDeclarator": {
this.enterPattern(declarator.id, (name, decl) => {
let hook = hookMap.get(name);
if (hook === undefined || !hook.call(decl)) {
hook = this.hooks.varDeclaration.get(name);
if (hook === undefined || !hook.call(decl)) {
// 分别收集定义的标识符,和可以重命名的标识符
this.scope.renames.set(name, null);
this.scope.definitions.add(name);
}
}
});
break;
}
}
}
}
而FunctionDeclaration
语句收集变量则更简单:
prewalkFunctionDeclaration(statement) {
if (statement.id) {
this.scope.renames.set(statement.id.name, null);
this.scope.definitions.add(statement.id.name);
}
}
FunctionDeclaration
收集函数定义的标识符为啥不进入到函数内部进行收集呢,这涉及到定义的标识符作用域归属问题,若进入函数体内就是函数scope的标识符定义,那么问题来了,Parser为啥不收集箭头函数的变量标识符呢,答案是 箭头函数的出现都是以表达式形式,遍历语句时不会对其进收集,而这正是walkStatements
语句的功能。
2、blockPrewalkStatements
上面prewalkStatements
收集的语句有很多,但是对于通过es6中const
、let
定义的变量标识符是无法收集的,这正是blockPrewalkStatements
负责收集的,该方法只负责收集以下几种情况的变量标识符,这几种情况都可以通过const
和let
的方式定义,除了ClassDeclaration
之外。

需要补充一点:
es6定义的变量具有块级作用域,但是在解析模块作用域定义的变量时,它们是会被当成当前块所在作用的变量。
例如,ForStatement
中使用es6定义的变量,他们属于该语句ForStatement
所在scope中的变量定义
遍历解析ast语句
虽然上面两个方法也有遍历ast语句的作用,但是只是做到 “点到为止”,不够全面;什么意思?因为上面的两个方法只是收集当前作用域下定义的变量标识符,只会遍历变量定义标识符出现的位置,例如FunctionDeclaration
、ClassDelcaration
等,他们不会深入到该声明的内部中去遍历。
walkStatements
更关注的是语句解析,它会遍历ast并解析每条语句,包括语句中的标识符、函数调用,成员调用、条件运算等等,从而摸清整个模块的内部情况;同时解析每条语句的时候对外抛出了相应的钩子,用户注册这些钩子可以自定义解析过程。walkStatements
内部调用walkStatement
,其内部也是一个大大的switch-case,它解析的语句如下图:

其中,每个ast的语句(statement)可能包含有一个或多个ast的表达式(expression),此时解析表达式会用到walkExpression
方法,它与walkStatement
类似针对不同的表达式类型来进行解析,内部也是一个大大的switch-case语句;与walkStatement
不同的是,解析表达式时可能需要对表达式进行计算,计算的结果可能会得到表达式的值,最终生成的源码会直接输出表达式的值。例如,webpack内部开发环境下DefinePlugin
定义的全局变量设置 process.env.NODE_ENV = 'development'
// 开发者写的源码
var isDev = process.env.NODE_ENV === 'development'
// 开发环境下 webpack对上述表达式解析计算之后得到的构建代码
var isDev = 'development' === 'development'
下面看看walkStatemen
对FunctionDeclaration
的解析处理,其内部是调用walkFunctionDeclaration
方法处理的,代码如下:
walkFunctionDeclaration(statement) {
const wasTopLevel = this.scope.topLevelScope;
this.scope.topLevelScope = false; // 进入函数内部解析,作用域非顶级
// inFunctionScope方法是创建一个函数的作用域
this.inFunctionScope(true, statement.params, () => {
for (const param of statement.params) {
// 对参数解析,参数标识符是函数作用域的定义的标识符
this.walkPattern(param);
}
if (statement.body.type === "BlockStatement") {
// 函数体的解析与顶级作用域ast.body的解析一样,需要探测严格模式、当前作用域的标识符收集以及函数体解析
this.detectStrictMode(statement.body.body);
this.prewalkStatement(statement.body);
this.walkStatement(statement.body);
} else {
this.walkExpression(statement.body);
}
});
this.scope.topLevelScope = wasTopLevel; // 函数解析完毕恢复作用域
}
除此之外,ClassDeclaration
也会创建自己的解析作用域。
walkStatement
方法在解析ast语句时对语句中出现的其他语句又会递归的调用该方法,但是随着ast的遍历深入,最终会通过调用walkExpression
来解析ast语句中的表达式的,而walkExpression
解析的终态是:Identifier
和ThisExpression
。
看一下Identifier
的实现:
walkIdentifier(expression) {
if (!this.scope.definitions.has(expression.name)) {
const hook = this.hooks.expression.get(
this.scope.renames.get(expression.name) || expression.name
);
if (hook !== undefined) {
const result = hook.call(expression);
if (result === true) return;
}
}
}
ThisExpression
的实现:
walkThisExpression(expression) {
const expressionHook = this.hooks.expression.get("this");
if (expressionHook !== undefined) {
expressionHook.call(expression);
}
}
至此,一个语句解析过程走到这里算是结束了,在此过程中解析的各种语句和表达式,Parser对外提供不同语句或者表达式解析时对应的钩子,用户关注这些钩子可以干一些事情。
例如,webpack内部HarmonyDetectionParserPlugin
在解析import
语句中的变量标识符时,会对其进行重命名为imported var
,它怎么做到的呢?这正是利用了Parser在解析到这块时会对外提供了importSpecifier
的钩子,而HarmonyDetectionParserPlugin
内部注册了importSpecifier
钩子,从而导入标识符的重命名,具体代码在:
parser.hooks.importSpecifier.tap(
"HarmonyImportDependencyParserPlugin",
(statement, source, id, name) => {
// 先删掉之前收集的import语句中的变量标识符,为啥要删掉?
// 因为Parser的大部分钩子都是针对free variables触发,也就是当前作用域没有定义
parser.scope.definitions.delete(name);
parser.scope.renames.set(name, "imported var"); // 重命名
...
return true;
});
用webpack Parser可以做什么
webpack Parser是对模块ast进行遍历,所以可以获取到更细粒度的模块内容信息,例如模块是否调用了未定义的变量标识符,模块依赖了哪些第三方js模块、是否有指定成员的调用等等,基于此,我们可以利用webpack Parser提供的钩子做一些特殊的事情,最典型的应用:
分析模块的依赖或者根据不同的情况为模块添加依赖。
我们来看看webpack内部分析ES6模块依赖的思路,具体代码可以参考webpack/lib/dependencies/HarmonyImportDependencyParserPlugin.js
文件。
parser.hooks.import.tap("HarmonyImportDependencyParserPlugin",(statement, source) => {
parser.state.lastHarmonyImportOrder =
(parser.state.lastHarmonyImportOrder || 0) + 1;
const clearDep = new ConstDependency("", statement.range);
clearDep.loc = statement.loc;
parser.state.module.addDependency(clearDep);
const sideEffectDep = new HarmonyImportSideEffectDependency(
source,
parser.state.module,
parser.state.lastHarmonyImportOrder,
parser.state.harmonyParserScope
);
sideEffectDep.loc = statement.loc;
parser.state.module.addDependency(sideEffectDep);
return true;
});
分析es6模块的依赖,需要关注当前模块import
语句,这些语句指明了模块的依赖源,所以它注册Parser对外提供的import
钩子,通过该钩子回调可以拿到依赖的模块,我们拿下面语句来说明,该import
钩子回调做了两件事:
import {createPage} from 'mpxjs/core'
-
添加
ConstDependency
依赖const clearDep = new ConstDependency("", statement.range);
该依赖初始化时第一个参数为
""
,那么会在生成模块的最终输出代码时删除import
语句,具体是通过statement.range
指定删除的范围 -
添加
HarmonyImportSideEffectDependency
依赖该依赖会最终生成新的符合webpack模块导入语句替换原始的
import
语句,类似这样:/* harmony import */ var _mpxjs_core__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(3);
可能同学会有疑问,上面例子中的
mpxjs/core
模块是怎么加到依赖中的呢,其实还是取决于HarmonyImportSideEffectDependency
。webpack内部在解析该依赖HarmonyImportSideEffectDependency
所对应的模块时,为其设置的依赖工厂dependencyFactories
为NormalModuleFactory
的实例:// HarmonyModulesPlugin插件为HarmonyImportSideEffectDependency添加依赖工厂和依赖模板 compiler.hooks.compaliation.tap('HarmonyModulesPlugin', (compilation, {normalModuleFactory}) => { compilation.dependencyFactories.set( HarmonyImportSideEffectDependency, normalModuleFactory ); // 替换import原始语句的内容就是该依赖模块干的事情 compilation.dependencyTemplates.set( HarmonyImportSideEffectDependency, new HarmonyImportSideEffectDependency.Template() ); })
所以,在解析该依赖对应的模块时就会使用
NormalModuleFactory
工厂重新走模块解析的流程,即create->build->addModule->processDepModule
,从而完成该依赖的解析。
可以看出,webpack通过Parser解析模板,可以为模块添加依赖,这些依赖大部分通过依赖模板来修改webpack最终生成的代码,webpack内部webpack/lib/dependencies
有很多这种修改最终代码的模板依赖。