webpack进阶之Parser

4,106 阅读13分钟

大家都知道,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创建子作用域下用来记录标识符的definitionsrenames时,会将其父作用域添加到自己的作用域栈中,这正是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"不在第一行。

收集模块中的标识符

这块主要体现在prewalkStatementsblockPrewalkStatements两个方法上。

1、prewalkStatements

先来看看prewalkStatements方法收集哪些语句中的标识符,其内部是针对单个语句调用prewalkStatement方法,该方法内部实现是一个长长的swith-case语句,其收集的语句用一张图来描述,如下图

可以看到,prewalkStatements收集标识符的语句有很多,对于具有块级别的语句如BlockStatementForStatement等最终会落到VariableDeclartionFuctionDeclaration上。

来看看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中constlet定义的变量标识符是无法收集的,这正是blockPrewalkStatements负责收集的,该方法只负责收集以下几种情况的变量标识符,这几种情况都可以通过constlet的方式定义,除了ClassDeclaration之外。

需要补充一点:

es6定义的变量具有块级作用域,但是在解析模块作用域定义的变量时,它们是会被当成当前块所在作用的变量。

例如,ForStatement中使用es6定义的变量,他们属于该语句ForStatement所在scope中的变量定义

遍历解析ast语句

虽然上面两个方法也有遍历ast语句的作用,但是只是做到 “点到为止”,不够全面;什么意思?因为上面的两个方法只是收集当前作用域下定义的变量标识符,只会遍历变量定义标识符出现的位置,例如FunctionDeclarationClassDelcaration等,他们不会深入到该声明的内部中去遍历。

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'

下面看看walkStatemenFunctionDeclaration的解析处理,其内部是调用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解析的终态是:IdentifierThisExpression

看一下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所对应的模块时,为其设置的依赖工厂dependencyFactoriesNormalModuleFactory的实例:

    // 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有很多这种修改最终代码的模板依赖。