webpack進階之Parser

你們都知道,webpack在解析模塊時,會分析出當前模塊全部的依賴將其加入到當前模塊的dependencies依賴數組中,隨後會對當前模塊的依賴會逐個進行相似模塊解析,從而完成整個模塊的解析過程。須要指出的的是,模塊的依賴分析過程是在loader處理後進行的,那麼webpack是怎麼分析出模塊的依賴呢?這就引出了本文的主旨:webpack Parserjavascript

認識webpack Parser

webpack模塊構建的主要過程包括模塊資源及應用loader的地址resolver、loader處理模塊內容以及對loader處理後的模塊內容分析其的依賴,其中webpack Parser正是用來解析模塊依賴的,其實現原理很簡單,即:java

經過ast(抽象語法樹)來遍歷整個模塊的內容,從而解析出模塊的依賴webpack

webpack Parser的實現徹底是基於ast(內部是經過acorn來生成的),經過遍歷獲取的模塊內容ast來分析依賴;另外,webpack Parser繼承自tapable,在遍歷ast的過程當中也提供各類不一樣時機的鉤子給plugin做者作自定義的解析過程,在webpack plugin中是經過下面這種方式訪問Parser實例:git

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(/* ... */)
  });
});
複製代碼

須要注意的是:es6

webpack Parser只對ast進行遍歷,不要在Parser提供的鉤子回調中對其進行轉換操做github

即便轉換也不會生效,由於webpack是最終是經過js代碼生成器JavascriptGenerator配合依賴模板來生成最終的編譯代碼,它是基於loader處理後的源碼而不是Parser解析後輸出的ast來生成代碼的。web

下面從webapck的構建過程當中Parser參與的部分來進行解讀。express

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模塊註冊不一樣的解析實例初始化,具體代碼:ide

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兩個方法上。

一、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語句的功能。

二、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有不少這種修改最終代碼的模板依賴。

相關文章
相關標籤/搜索