零基礎(chǔ)理解 ESLint 核心原理
來自團隊 楊勁松 同學(xué)的分享
概述
本文將介紹 ESLint 的工作原理,內(nèi)容涉及 ESLint 如何讀取配置、加載配置,檢驗,修復(fù)的全流程。
為什么需要 ESLint
ESLint 相信大家都不陌生,如今前端工作越來越復(fù)雜,一個項目往往是多人參與開發(fā),雖然說每個人的代碼風(fēng)格都不一樣,但是如果我們完全不做任何約束,允許開發(fā)人員任意發(fā)揮,隨著項目規(guī)模慢慢變大,很快項目代碼將會成為不堪入目的??山,因此對于代碼的一些基本寫法還是需要有個約定,并且當(dāng)代碼中出現(xiàn)與約定相悖的寫法時需要給出提醒,對于一些簡單的約定最好還能幫我們自動修復(fù),而這正是 ESLint 要干的事情,下面引用一下 ESLint 官網(wǎng)的介紹。
「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.
也就是三部分:「找出代碼問題」,「自動修復(fù)」,「自定義規(guī)則」。ESLint 經(jīng)過許多年的發(fā)展已經(jīng)非常成熟,加上社區(qū)諸多開發(fā)者的不斷貢獻,目前社區(qū)也已經(jīng)積累了許多優(yōu)秀的代碼寫法約定,為了項目代碼的健康,也為了開發(fā)人員的身心健康,盡早地引入合適的 ESLint 規(guī)則是非常有必要的??。
ESLint 是如何工作的??
知其然更應(yīng)知其所以然,ESLint 是如何做到“讀懂”你的代碼甚至給你修復(fù)代碼的呢,沒錯,還是 AST(抽象語法樹),大學(xué)編譯原理課程里我們也學(xué)習(xí)過它,另外了解 Babel 或者 Webpack 的同學(xué)更應(yīng)該對 AST 很熟悉了。其中 ESLint 是使用 espree 來生成 AST 的。
概括來說就是,ESLint 會遍歷前面說到的 AST,然后在遍歷到「不同的節(jié)點」或者「特定的時機」的時候,觸發(fā)相應(yīng)的處理函數(shù),然后在函數(shù)中,可以拋出錯誤,給出提示。
讀取配置
ESLint 首先會從各種配置文件里讀取配置,例如 eslintrc 或者 package.json 中的 eslintConfig 字段中,也可以在使用命令行執(zhí)行 eslint 時指定任意一個配置文件。配置文件里的具體可配置項我們下面再詳細(xì)介紹,這里我們需要注意,
ESLint 會先讀取「給定目錄下最近的」一個配置文件。 如果相同目錄下存在多個配置文件,那這層目錄里只有一個配置文件會被讀取,其中 .eslintrc 的優(yōu)先級會高于 package.json 配置。 默認(rèn)會再繼續(xù)向外層文件夾「逐層讀取」配置文件,最終配置合并成一個。 其中如果多個配置文件里都配置了重復(fù)的字段的話,那離給定目錄「最近的配置會生效,」 我們也可以在配置文件中添加 root: true來阻止 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?數(shù)組中元素的順序決定優(yōu)先級的
????????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 是其他配置文件,秉著盡可能復(fù)用的原則,ESLint 允許我們使用插件中的配置或者是第三方模塊中的配置; parser 用于解析 AST; plugin 則是用戶自定義的插件,可以引入自己定義的規(guī)則,以及對非 js 文件的檢查和處理等。
extends 處理
ESLint 會遞歸地去讀取配置文件中的 extends。那問題來了,如果 extends 的層級很深的話,配置文件里的優(yōu)先級怎么辦????
_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,配置]。那這么看的話,自己本身的配置優(yōu)先級怎么還不如extends里的呢?別急,我們繼續(xù)往下看。ConfigArray 類里有一個extractConfig方法,當(dāng)所有配置都讀取完了,最終在使用的時候,都需要調(diào)用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配置},{外層配置},{內(nèi)層配置里的extends配置},{內(nèi)層配置}],這看起來跟我們理解的優(yōu)先級是完全相反的,而這里的getMatchedIndices 方法則會把數(shù)組順序調(diào)轉(zhuǎn)過來,這樣一來,整個順序就正常了??。調(diào)整完ConfigArray的順序后,createConfig方法則具體執(zhí)行了合并操作。
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;
}
這里分析一下具體的合并邏輯
對于 parser 和 processor 字段,后面的配置文件會覆蓋前面的配置文件。 對于 env,globals,parserOptions,settings 字段則會合并在一起,但是這里注意,只有當(dāng)后面的配置里存在前面沒有的字段時,這個字段才會被合并進來,如果前面已經(jīng)有了這個字段,那后面的相同字段會被摒棄。 例如 [{a: 1, b: 2}, {c: 3, b: 4}] 這個數(shù)組的合并結(jié)果則是 {a: 2, b: 2, c: 3}。 對于 rules 字段,同樣是前面的配置優(yōu)先級高于后面的,但是如果某個已存在的 rule 里帶了參數(shù),那么 rule 的參數(shù)會被合并。
把 extends 處理完后會繼續(xù)處理 parser 和 plugin 字段
parser 和 plugin 處理
這里 parser 和 plugin 都是以第三方模塊的形式加載進來的,因此如果我們要自定義的話,需要先發(fā)包,然后再引用。對于 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?則復(fù)用
????????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;
????????????}
????????}
????}
加載流程總結(jié)
整個加載配置涉及到多層文件夾的多個配置文件,甚至包括配置文件里的extends ,這里以一張流程圖來總結(jié)一下

檢驗
經(jīng)過前面的步驟之后,基本上我們已經(jīng)獲取了所有需要的配置,接下來就會進入檢驗流程,主要對應(yīng)源碼中的 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));
????}
不管是哪個分支,他們大致都按照以下順序執(zhí)行:
??先處理 processor。 ????解析代碼,獲取 AST 和節(jié)點數(shù)組。 ??????跑規(guī)則 runRules。
下面我們對上面三個過程逐個介紹。
processor
processor 是在插件上定義的處理器,processor 能針對特定后綴的文件定義 preprocess 和 postprocess 兩個方法。其中 preprocess 方法能接受文件源碼和文件名作為參數(shù),并返回一個數(shù)組,且數(shù)組中的每一項就是需要被 ESLint 檢驗的代碼或者文件;通常我們使用 preprocess 從非 js 文件里提取出需要被檢驗的部分 js 代碼,使得非 js 文件也可以被 ESLint 檢驗。而 postprocess 則是可以在文件被檢驗完之后對所有的 lint problem 進行統(tǒng)一處理(過濾或者額外的處理)的。
獲取 AST
當(dāng)用戶沒有指定 parser 時,默認(rèn)使用 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
????????????);
這里推薦一個網(wǎng)站https://astexplorer.net/,它能方便讓我們查看一段代碼轉(zhuǎn)化出來的 AST 長什么樣

runRules
正如我們前面說到的,規(guī)則是 ESLint 的核心,ESLint 的工作全是基于一條一條的規(guī)則,ESLint 是怎么處理規(guī)則的,核心就在 runRules 這個函數(shù)中。首先會定義nodeQueue數(shù)組,用于收集 AST 所有的節(jié)點。注意每個 AST 節(jié)點都會被推進數(shù)組中兩次(進一次出一次)。
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 的名稱找到對應(yīng)的 rule 對象,注意,這里的兩個 rule 不完全一樣?!概渲弥械?rule」指的是在 eslintrc 等配置文件中的 rules 字段下的每個 rule 名稱,例如下面這些??

「rule 對象」則指的是 rule 的具體定義,簡單來說就是定義了某個 rule 的基本信息以及它的檢查邏輯,甚至是修復(fù)邏輯,我們在之后的 ESLint 實戰(zhàn)介紹中會具體講解它??傊?,這里每個被遍歷到的 rule 對象,ESLint 會為 rule 對象里的「AST 節(jié)點」添加相應(yīng)的監(jiān)聽函數(shù)。以便在后面遍歷 AST 節(jié)點時可以觸發(fā)相應(yīng)的處理函數(shù)。
//?這里的?ruleListeners?就是{[AST節(jié)點]:?對應(yīng)的處理函數(shù)}鍵值對
Object.keys(ruleListeners).forEach(selector?=>?{
????????????const?ruleListener?=?timing.enabled
??????????????????timing.time(ruleId,?ruleListeners[selector])
????????????????:?ruleListeners[selector];
????????????emitter.on(
????????????????selector,
????????????????addRuleErrorHandler(ruleListener)
????????????);
????????});
為所有的 rule 對象添加好了監(jiān)聽之后,就開始遍歷前面收集好的nodeQueue,在遍歷到的不同節(jié)點時相應(yīng)觸發(fā)節(jié)點監(jiān)聽函數(shù),然后在監(jiān)聽函數(shù)中調(diào)用方法收集所有的的 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
我們已經(jīng)獲取到所有的 lint 問題了,接下來會處理注釋里的命令,沒錯,相信大家都不陌生,就是 eslint-disable、eslint-disable-line 等,主要就是對前面的處理結(jié)果過濾一下,另外還要處理沒被用到的命令注釋等。
修復(fù)
接下來就是修復(fù)過程了,這里主要調(diào)用SourceCodeFixer類的applyFixes方法,而這個方法里,有調(diào)用了 attemptFix 來執(zhí)行修復(fù)操作。這里的 problem.fix實際上是一個對象,這個對象描述了修復(fù)的命令,類型是這樣的,{range: Number[]; text: string} 。這里我們只需要知道他是由規(guī)則的開發(fā)者定義的fix函數(shù)中返回的對象,所以這個對象描述的修復(fù)命令都由規(guī)則開發(fā)者決定。細(xì)節(jié)的我們將在之后的實戰(zhàn)篇里講解,這里不再展開。
/**
* 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 工作的大致流程就已經(jīng)介紹完了,下面以一張圖來總結(jié)一下整個流程:

