零基礎理解 ESLint 核心原理

來自團隊 楊勁松 同學的分享

概述

本文將介紹 ESLint 的工作原理,內容涉及 ESLint 如何讀取配置、加載配置,檢驗,修復的全流程。

爲什麼需要 ESLint

ESLint 相信大家都不陌生,如今前端工作越來越複雜,一個項目往往是多人蔘與開發,雖然說每個人的代碼風格都不一樣,但是如果我們完全不做任何約束,允許開發人員任意發揮,隨着項目規模慢慢變大,很快項目代碼將會成爲不堪入目的💩山,因此對於代碼的一些基本寫法還是需要有個約定,並且當代碼中出現與約定相悖的寫法時需要給出提醒,對於一些簡單的約定最好還能幫我們自動修復,而這正是 ESLint 要乾的事情,下面引用一下 ESLint 官網的介紹。

  • 「Find Problems」:ESLint statically analyzes your code to quickly find problems. ESLint is built into most text editors and you can run ESLint as part of your continuous integration pipeline.

  • 「Fix Automatically」:Many problems ESLint finds can be automatically fixed. ESLint fixes are syntax-aware so you won't experience errors introduced by traditional find-and-replace algorithms.

  • 「Customize」:Preprocess code, use custom parsers, and write your own rules that work alongside ESLint's built-in rules. You can customize ESLint to work exactly the way you need it for your project.

也就是三部分:「找出代碼問題」「自動修復」「自定義規則」。ESLint 經過許多年的發展已經非常成熟,加上社區諸多開發者的不斷貢獻,目前社區也已經積累了許多優秀的代碼寫法約定,爲了項目代碼的健康,也爲了開發人員的身心健康,儘早地引入合適的 ESLint 規則是非常有必要的😊。

ESLint 是如何工作的🤔

知其然更應知其所以然,ESLint 是如何做到 “讀懂” 你的代碼甚至給你修復代碼的呢,沒錯,還是 AST(抽象語法樹),大學編譯原理課程裏我們也學習過它,另外瞭解 Babel 或者 Webpack 的同學更應該對 AST 很熟悉了。其中 ESLint 是使用 espree 來生成 AST 的。

概括來說就是,ESLint 會遍歷前面說到的 AST,然後在遍歷到 「不同的節點」 或者 「特定的時機」 的時候,觸發相應的處理函數,然後在函數中,可以拋出錯誤,給出提示。

讀取配置

ESLint 首先會從各種配置文件裏讀取配置,例如 eslintrc 或者 package.json 中的 eslintConfig 字段中,也可以在使用命令行執行 eslint 時指定任意一個配置文件。配置文件裏的具體可配置項我們下面再詳細介紹,這裏我們需要注意,

以下是讀取配置的核心代碼:

// Load the config on this directory.
        try {
            configArray = configArrayFactory.loadInDirectory(directoryPath);
        } catch (error) {
            throw error;
        }
        
        // 這裏如果添加了 root 字段將會中斷向外層遍歷的操作
        if (configArray.length > 0 && configArray.isRoot()) {
            configArray.unshift(...baseConfigArray);
            return this._cacheConfig(directoryPath, configArray);
        }

        // Load from the ancestors and merge it.
        const parentPath = path.dirname(directoryPath);
        const parentConfigArray = parentPath && parentPath !== directoryPath
            ? this._loadConfigInAncestors()
            : baseConfigArray;

        if (configArray.length > 0) {
            configArray.unshift(...parentConfigArray);
        } else {
            configArray = parentConfigArray;
        }


const configFilenames = [
     .eslintrc.js ,
     .eslintrc.cjs ,
     .eslintrc.yaml ,
     .eslintrc.yml ,
     .eslintrc.json ,
     .eslintrc ,
     package.json 
];

loadInDirectory(directoryPath, { basePath, name } = {}) {
        const slots = internalSlotsMap.get(this);
        // 這裏是以 configFilenames 數組中元素的順序決定優先級的
        for (const filename of configFilenames) {
            const ctx = createContext();
            if (fs.existsSync(ctx.filePath) && fs.statSync(ctx.filePath).isFile()) {
                let configData;
                try {
                    configData = loadConfigFile(ctx.filePath);
                } catch (error) {
                }
                if (configData) {
                    return new ConfigArray();
                }
            }
        }
        return new ConfigArray();
    }

加載配置

在上述的 configArrayFactory.loadInDirectory 方法中,ESLint 會依次加載配置裏的 extends, parser,plugin 等,其中

extends 處理

ESLint 會遞歸地去讀取配置文件中的 extends。那問題來了,如果 extends 的層級很深的話,配置文件裏的優先級怎麼辦?🤔️

_loadExtends(extendName, ctx) {
        ...
        return this._normalizeConfigData(loadConfigFile(ctx.filePath), ctx);
}

_normalizeConfigData(configData, ctx) {
        const validator = new ConfigValidator();

        validator.validateConfigSchema(configData, ctx.name || ctx.filePath);
        return this._normalizeObjectConfigData(configData, ctx);
    }
    
*_normalizeObjectConfigData(configData, ctx) {
        const { files, excludedFiles, ...configBody } = configData;
        const criteria = OverrideTester.create();
        const elements = this._normalizeObjectConfigDataBody(configBody, ctx);
    }

*_normalizeObjectConfigDataBody({extends: extend}, ctx) {
        const extendList = Array.isArray(extend) ? extend : [extend];
        ...
        // Flatten `extends`.
        for (const extendName of extendList.filter(Boolean)) {
            yield* this._loadExtends(extendName, ctx);
        }
        
        yield {

            // Debug information.
            type: ctx.type,
            name: ctx.name,
            filePath: ctx.filePath,

            // Config data.
            criteria: null,
            env,
            globals,
            ignorePattern,
            noInlineConfig,
            parser,
            parserOptions,
            plugins,
            processor,
            reportUnusedDisableDirectives,
            root,
            rules,
            settings
        };
        
}

可以看到,這裏是先遞歸處理 extends,完了再返回自己的配置,所以最終得到的 ConfigArray 裏的順序則是 [配置中的 extends,配置]。那這麼看的話,自己本身的配置優先級怎麼還不如extends裏的呢?別急,我們繼續往下看。ConfigArray 類裏有一個extractConfig方法,當所有配置都讀取完了,最終在使用的時候,都需要調用extractConfig把一個所有的配置對象合併成一個最終對象。

extractConfig(filePath) {
        const { cache } = internalSlotsMap.get(this);
        const indices = getMatchedIndices(this, filePath);
        const cacheKey = indices.join( , );

        if (!cache.has(cacheKey)) {
            cache.set(cacheKey, createConfig(this, indices));
        }

        return cache.get(cacheKey);
}

function getMatchedIndices(elements, filePath) {
    const indices = [];

    for (let i = elements.length - 1; i >= 0; --i) {
        const element = elements[i];

        if (!element.criteria || (filePath && element.criteria.test(filePath))) {
            indices.push(i);
        }
    }

    return indices;
}

剛剛我們說了,我們通過之前的操作得到的 ConfigArray 對象裏,各個配置對象的順序其實是 [{外層配置裏的 extends 配置},{外層配置},{內層配置裏的 extends 配置},{內層配置}],這看起來跟我們理解的優先級是完全相反的,而這裏的getMatchedIndices 方法則會把數組順序調轉過來,這樣一來,整個順序就正常了😊。調整完ConfigArray的順序後,createConfig方法則具體執行了合併操作。

function createConfig(instance, indices) {
    const config = new ExtractedConfig();
    const ignorePatterns = [];

    // Merge elements.
    for (const index of indices) {
        const element = instance[index];

        // Adopt the parser which was found at first.
        if (!config.parser && element.parser) {
            if (element.parser.error) {
                throw element.parser.error;
            }
            config.parser = element.parser;
        }

        // Adopt the processor which was found at first.
        if (!config.processor && element.processor) {
            config.processor = element.processor;
        }

        // Adopt the noInlineConfig which was found at first.
        if (config.noInlineConfig === void 0 && element.noInlineConfig !== void 0) {
            config.noInlineConfig = element.noInlineConfig;
            config.configNameOfNoInlineConfig = element.name;
        }

        // Adopt the reportUnusedDisableDirectives which was found at first.
        if (config.reportUnusedDisableDirectives === void 0 && element.reportUnusedDisableDirectives !== void 0) {
            config.reportUnusedDisableDirectives = element.reportUnusedDisableDirectives;
        }

        // Collect ignorePatterns
        if (element.ignorePattern) {
            ignorePatterns.push(element.ignorePattern);
        }

        // Merge others.
        mergeWithoutOverwrite(config.env, element.env);
        mergeWithoutOverwrite(config.globals, element.globals);
        mergeWithoutOverwrite(config.parserOptions, element.parserOptions);
        mergeWithoutOverwrite(config.settings, element.settings);
        mergePlugins(config.plugins, element.plugins);
        mergeRuleConfigs(config.rules, element.rules);
    }

    // Create the predicate function for ignore patterns.
    if (ignorePatterns.length > 0) {
        config.ignores = IgnorePattern.createIgnore(ignorePatterns.reverse());
    }

    return config;
}

這裏分析一下具體的合併邏輯

extends 處理完後會繼續處理 parserplugin 字段

parser 和 plugin 處理

這裏 parserplugin 都是以第三方模塊的形式加載進來的,因此如果我們要自定義的話,需要先發包,然後再引用。對於 plugin,通常約定的包名格式是 eslint-plugin-${name} ,而在在配置中可以把包名中的 eslint-plugin 前綴省略。

_loadParser(nameOrPath, ctx) {
        try {
            const filePath = resolver.resolve(nameOrPath, relativeTo);
            return new ConfigDependency({
                definition: require(filePath),
                ...
            });
        } catch (error) {
            // If the parser name is  espree , load the espree of ESLint.
            if (nameOrPath ===  espree ) {
                debug( Fallback espree. );
                return new ConfigDependency({
                    definition: require( espree ),
                    ...
                });
            }
            return new ConfigDependency({
                error,
                id: nameOrPath,
                importerName: ctx.name,
                importerPath: ctx.filePath
            });
        }
    }
    
    _loadPlugin(name, ctx) {
        const request = naming.normalizePackageName(name,  eslint-plugin );
        const id = naming.getShorthandName(request,  eslint-plugin );
        const relativeTo = path.join(ctx.pluginBasePath,  __placeholder__.js );

        // Check for additional pool.
        // 如果已有的 plugin 則複用
        const plugin =
            additionalPluginPool.get(request) ||
            additionalPluginPool.get(id);

        if (plugin) {
            return new ConfigDependency({
                definition: normalizePlugin(plugin),
                filePath:   , // It's unknown where the plugin came from.
                id,
                importerName: ctx.name,
                importerPath: ctx.filePath
            });
        }

        let filePath;
        let error;
        filePath = resolver.resolve(request, relativeTo);

        if (filePath) {
            try {
                const startTime = Date.now();
                const pluginDefinition = require(filePath);
                return new ConfigDependency({...});
            } catch (loadError) {
                error = loadError;
            }
        }
    }

加載流程總結

整個加載配置涉及到多層文件夾的多個配置文件,甚至包括配置文件裏的extends ,這裏以一張流程圖來總結一下

檢驗

經過前面的步驟之後,基本上我們已經獲取了所有需要的配置,接下來就會進入檢驗流程,主要對應源碼中的 Lint 類的 verify 方法。這個 verify 方法裏主要也就是做一些判斷然後分流到其他處理方法裏。

verify(textOrSourceCode, config, filenameOrOptions) {
        const { configType } = internalSlotsMap.get(this);
        if (config) {
            if (configType ===  flat ) {
                let configArray = config;
                if (!Array.isArray(config) || typeof config.getConfig !==  function ) {
                    configArray = new FlatConfigArray(config);
                    configArray.normalizeSync();
                }
                return this._distinguishSuppressedMessages(this._verifyWithFlatConfigArray(textOrSourceCode, configArray, options, true));
            }

            if (typeof config.extractConfig ===  function ) {
                return this._distinguishSuppressedMessages(this._verifyWithConfigArray(textOrSourceCode, config, options));
            }
        }
        if (options.preprocess || options.postprocess) {
            return this._distinguishSuppressedMessages(this._verifyWithProcessor(textOrSourceCode, config, options));
        }
        return this._distinguishSuppressedMessages(this._verifyWithoutProcessors(textOrSourceCode, config, options));
    }

不管是哪個分支,他們大致都按照以下順序執行:

下面我們對上面三個過程逐個介紹。

processor

processor 是在插件上定義的處理器,processor 能針對特定後綴的文件定義 preprocess 和 postprocess 兩個方法。其中 preprocess 方法能接受文件源碼和文件名作爲參數,並返回一個數組,且數組中的每一項就是需要被 ESLint 檢驗的代碼或者文件;通常我們使用 preprocess 從非 js 文件裏提取出需要被檢驗的部分 js 代碼,使得非 js 文件也可以被 ESLint 檢驗。而 postprocess 則是可以在文件被檢驗完之後對所有的 lint problem 進行統一處理(過濾或者額外的處理)的。

獲取 AST

當用戶沒有指定 parser 時,默認使用 espree,若有指定 parser 則使用指定的 parser。

        let parser = espree;
        if (typeof config.parser ===  object  && config.parser !== null) {
            parserName = config.parser.filePath;
            parser = config.parser.definition;
        } else if (typeof config.parser ===  string ) {
            if (!slots.parserMap.has(config.parser)) {
                return [{
                    ruleId: null,
                    fatal: true,
                    severity: 2,
                    message: `Configured parser '${config.parser}' was not found.`,
                    line: 0,
                    column: 0
                }];
            }
            parserName = config.parser;
            parser = slots.parserMap.get(config.parser);
        }
        
        const parseResult = parse(
                text,
                languageOptions,
                options.filename
            );

這裏推薦一個網站 https://astexplorer.net/,它能方便讓我們查看一段代碼轉化出來的 AST 長什麼樣

runRules

正如我們前面說到的,規則是 ESLint 的核心,ESLint 的工作全是基於一條一條的規則,ESLint 是怎麼處理規則的,核心就在 runRules 這個函數中。首先會定義nodeQueue數組,用於收集 AST 所有的節點。注意每個 AST 節點都會被推進數組中兩次(進一次出一次)。

Traverser.traverse(sourceCode.ast, {
        enter(node, parent) {
            node.parent = parent;
            nodeQueue.push({ isEntering: true, node });
        },
        leave(node) {
            nodeQueue.push({ isEntering: false, node });
        },
        visitorKeys: sourceCode.visitorKeys
    });

然後就會遍歷所有配置中的 rule,並通過 rule 的名稱找到對應的 rule 對象,注意,這裏的兩個 rule 不完全一樣。「配置中的 rule」指的是在 eslintrc 等配置文件中的 rules 字段下的每個 rule 名稱,例如下面這些👇

「rule 對象」則指的是 rule 的具體定義,簡單來說就是定義了某個 rule 的基本信息以及它的檢查邏輯,甚至是修復邏輯,我們在之後的 ESLint 實戰介紹中會具體講解它。總之,這裏每個被遍歷到的 rule 對象,ESLint 會爲 rule 對象裏的「AST 節點」添加相應的監聽函數。以便在後面遍歷 AST 節點時可以觸發相應的處理函數。

// 這裏的 ruleListeners 就是{[AST節點]: 對應的處理函數}鍵值對
Object.keys(ruleListeners).forEach(selector => {
            const ruleListener = timing.enabled
                ? timing.time(ruleId, ruleListeners[selector])
                : ruleListeners[selector];

            emitter.on(
                selector,
                addRuleErrorHandler(ruleListener)
            );
        });

爲所有的 rule 對象添加好了監聽之後,就開始遍歷前面收集好的nodeQueue,在遍歷到的不同節點時相應觸發節點監聽函數,然後在監聽函數中調用方法收集所有的的 eslint 問題。

nodeQueue.forEach(traversalInfo => {
        currentNode = traversalInfo.node;

        try {
            if (traversalInfo.isEntering) {
                eventGenerator.enterNode(currentNode);
            } else {
                eventGenerator.leaveNode(currentNode);
            }
        } catch (err) {
            err.currentNode = currentNode;
            throw err;
        }
    });

applyDisableDirectives

我們已經獲取到所有的 lint 問題了,接下來會處理註釋裏的命令,沒錯,相信大家都不陌生,就是 eslint-disableeslint-disable-line 等,主要就是對前面的處理結果過濾一下,另外還要處理沒被用到的命令註釋等。

修復

接下來就是修復過程了,這裏主要調用SourceCodeFixer類的applyFixes方法,而這個方法裏,有調用了 attemptFix 來執行修復操作。這裏的 problem.fix實際上是一個對象,這個對象描述了修復的命令,類型是這樣的,{range: Number[]; text: string} 。這裏我們只需要知道他是由規則的開發者定義的fix函數中返回的對象,所以這個對象描述的修復命令都由規則開發者決定。細節的我們將在之後的實戰篇裏講解,這裏不再展開。

    /**
     * Try to use the 'fix' from a problem.
     * @param {Message} problem The message object to apply fixes from
     * @returns {boolean} Whether fix was successfully applied
     */
    function attemptFix(problem) {
        const fix = problem.fix;
        const start = fix.range[0];
        const end = fix.range[1];

        // Remain it as a problem if it's overlapped or it's a negative range
        if (lastPos >= start || start > end) {
            remainingMessages.push(problem);
            return false;
        }

        // Remove BOM.
        if ((start < 0 && end >= 0) || (start === 0 && fix.text.startsWith(BOM))) {
            output =   ;
        }

        // Make output to this fix.
        output += text.slice(Math.max(0, lastPos), Math.max(0, start));
        output += fix.text;
        lastPos = end;
        return true;
    }

至此,ESLint 工作的大致流程就已經介紹完了,下面以一張圖來總結一下整個流程:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wzFh_dvB13hq9OV3pC955w