淺析eslint原理
大 廠 技 術 堅 持 周 更 精 選 好 文
在前端開發過程中,eslint規範已經成為必不可少的一環,我們需要eslint來保證代碼規範,相對統一同學們的代碼風格,不然就會出現所有同學都隨意引入自己偏好的風格或者規範,讓所有人一起分擔引入規範的代價。
同時,有些lint規則可以避免bug的產生,在提高代碼可讀性的前提下,減少問題數量,將問題更多的暴露在開發階段。
一、eslint的規則
説起eslint,第一想到的就是eslints裏面的每條規則,我們通過以下簡單的配置就可以來控制規則的開啟及關閉。其中:0 1 2 分別對應 'off' 'warn' 'error';如果是個數組,第二個參數可以自定義配置。
{
"rules": {
"arrow-body-style" : 0, // 0 1 2
"quotes" : [ "error" , "single" ]
}
}
其中rules的每一個key就是對應的一條規則,透過使用去思考,eslint如何去實現的這條規則呢?
eslint的核心rules
eslint 的核心就是 rules,理解一個 rule 的結構對於理解 eslint 的原理和創建自定義規則非常重要。
我們看一下自定義eslint 規則 [1] 再結合目前已有的某條規則來分析
看一下最簡單的一條規則 no-with
module.exports = {
meta: { // 包含規則的元數據
// 指示規則的類型,值為 "problem"、"suggestion" 或 "layout"
type: "suggestion",
docs: { // 對 ESLint 核心規則來説是必需的
description: "disallow `with` statements", // 提供規則的簡短描述在規則首頁展示
// category (string) 指定規則在規則首頁處於的分類
recommended: true, // 配置文件中的 "extends": "eslint:recommended"屬性是否啟用該規則
url: "https://eslint.org/docs/rules/no-with" // 指定可以訪問完整文檔的 url
},
// fixable 如果沒有 fixable 屬性,即使規則實現了 fix 功能,ESLint 也不會進行修復。如果規則不是可修復的,就省略 fixable 屬性。
schema: [], // 指定該選項 這樣的 ESLint 可以避免無效的規則配置
// deprecated (boolean) 表明規則是已被棄用。如果規則尚未被棄用,你可以省略 deprecated 屬性。
messages: {
unexpectedWith: "Unexpected use of 'with' statement."
}
},
// create (function) 返回一個對象,其中包含了 ESLint 在遍歷 js 代碼的抽象語法樹 AST (ESTree 定義的 AST) 時,用來訪問節點的方法。
create(context) {
// 如果一個 key 是個節點類型或 selector,在 向下 遍歷樹時,ESLint 調用 visitor 函數
// 如果一個 key 是個節點類型或 selector,並帶有 :exit,在 向上 遍歷樹時,ESLint 調用 visitor 函數
// 如果一個 key 是個事件名字,ESLint 為代碼路徑分析調用 handler 函數
// selector 類型可以到 estree 查找
return {
// 入參為節點node
WithStatement(node) {
context.report({ node, messageId: "unexpectedWith" });
}
};
}
};
有兩部分組成:meta create;
meta:(對象)包含規則的元數據,包括 規則的類型,文檔,是否推薦規則,是否可修復等信息;
creat:(函數)返回一個對象其中包含了 ESLint 在遍歷 JavaScript 代碼的抽象語法樹 AST ( ESTree [2] 定義的 AST) 時,用來訪問節點的方法,入參為該節點。
-
如果一個 key 是個節點類型或 selector [3] ,在 向下 遍歷樹時,ESLint 調用 visitor 函數
-
如果一個 key 是個節點類型或 selector [4] ,並帶有
:exit
,在 向上 遍歷樹時,ESLint 調用 visitor 函數 -
如果一個 key 是個事件名字,ESLint 為 代碼路徑分析 [5] 調用 handler 函數
二、eslint 命令的執行
在package.json裏配置bin
"bin": {
"eslint": "bin/eslint.js" // 告訴 npm 你的命令是什麼
}
然後創建對應的文件
#!/usr/bin/env node
console.log("console.log output")
這就是eslint命令行的入口
(async function main() {
// 監聽異常處理
process.on("uncaughtException", onFatalError);
process.on("unhandledRejection", onFatalError);
// 如果參數有 --init 就執行初始化
if (process.argv.includes("--init")) {
await require("../lib/init/config-initializer").initializeConfig();
return;
}
// 否則就執行 檢查代碼的代碼
process.exitCode = await require("../lib/cli").execute(
process.argv,
process.argv.includes("--stdin") ? await readStdin() : null
);
}()).catch(onFatalError);
代碼檢查的函數是 cli.
execute()
****從lib中引入的cli對象。
三、eslint 執行的調用棧
execute() 函數
這是 eslint
的主要代碼執行邏輯,主要流程如下:
-
解析命令行參數,校驗參數正確與否及打印相關信息;
-
初始化 根據配置實例一個engine對象
CLIEngine
實例; -
engine.
executeOnFiles
讀取源代碼進行檢查,返回報錯信息和修復結果。
execute(args, text) {
if (Array.isArray(args)) {
debug("CLI args: %o", args.slice(2));
}
let currentOptions;
try {
// 先校驗參數 如果輸入 --halp 提示 --help,並通過options的配置給默認值
currentOptions = options.parse(args);
} catch (error) {
log.error(error.message);
return 2;
}
const files = currentOptions._;
const useStdin = typeof text === "string";
// ---省略很多---參數校驗及輸出
// ...
// 根據配置實例一個engine對象
const engine = new CLIEngine(translateOptions(currentOptions));
// report 就是最後的結果
const report = useStdin ? engine.executeOnText(text, currentOptions.stdinFilename, true) : engine.executeOnFiles(files);
// ...
// ---省略很多---參數校驗及輸出
return 0;
}
可以看到eslint就是在執行 engine.
executeOnFiles
(files)
之後獲得檢查的結果
executeOnFiles (files) 函數
可以看到eslint就是在執行 engine.
executeOnFiles
(files)
之後獲得檢查的結果;該函數主要作用是對一組文件和目錄名稱執行當前配置。
簡單看一下
executeOnFile
s
()
該函數輸入文件目錄,返回lint之後的結果
主要執行邏輯如下:
-
fileEnumerator 類,迭代所有的文件路徑及信息;
-
檢查是否忽略的文件,lint緩存 等等一堆操作;
-
調用 verifyText() 函數執行檢查
-
儲存lint之後的結果
/**
* Executes the current configuration on an array of file and directory names.
* @param {string[]} patterns An array of file and directory names.
* @returns {LintReport} The results for all files that were linted.
*/
executeOnFiles(patterns) {
// .....
// fileEnumerator 類,迭代所有的文件路徑及信息
for (const { config, filePath, ignored } of fileEnumerator.iterateFiles(patterns)) {
// ....... 檢查是否忽略的文件,緩存 等等一堆操作
// Do lint.
const result = verifyText({
text: fs.readFileSync(filePath, "utf8"),
filePath,
config,
cwd,
fix,
allowInlineConfig,
reportUnusedDisableDirectives,
extensionRegExp: fileEnumerator.extensionRegExp,
linter
});
results.push(result);
/*
* Store the lint result in the LintResultCache.
* NOTE: The LintResultCache will remove the file source and any
* other properties that are difficult to serialize, and will
* hydrate those properties back in on future lint runs.
*/
if (lintResultCache) {
lintResultCache.setCachedLintResults(filePath, config, result);
}
}
}
verifyText() 函數
其實就是調用了 linter. verifyAndFix() 函數
verifyAndFix() 函數
這個函數是核心函數,顧名思義 verify & fix
代碼核心處理邏輯是通過一個 do while 循環控制;以下兩個條件會打斷循環
-
沒有更多可以被fix的代碼了
-
循環超過十次
-
其中 verify 函數對源代碼文件進行代碼檢查,從規則維度返回檢查結果數組
-
applyFixes函數拿到上一步的返回,去fix代碼
-
如果設置了可以fix,那麼使用fix之後的結果 代替原本的text
/**
* This loop continues until one of the following is true:
*
* 1. No more fixes have been applied.
* 2. Ten passes have been made.
* That means anytime a fix is successfully applied, there will be another pass.
* Essentially, guaranteeing a minimum of two passes.
*/
do {
passNumber++; // 初始值0
// 這個函數就是 verify 在 verify 過程中會把代碼轉換成ast
debug(`Linting code for ${debugTextDescription} (pass ${passNumber})`);
messages = this.verify(currentText, config, options);
// 這個函數就是 fix
debug(`Generating fixed text for ${debugTextDescription} (pass ${passNumber})`);
fixedResult = SourceCodeFixer.applyFixes(currentText, messages, shouldFix);
/*
* 如果有 syntax errors 就 break.
* 'fixedResult.output' is a empty string.
*/
if (messages.length === 1 && messages[0].fatal) {
break;
}
// keep track if any fixes were ever applied - important for return value
fixed = fixed || fixedResult.fixed;
// 使用fix之後的結果 代替原本的text
currentText = fixedResult.output;
} while (
fixedResult.fixed &&
passNumber < MAX_AUTOFIX_PASSES // 10
);
在verify過程中,會調用 parse 函數,把代碼轉換成AST
// 默認的ast解析是espree
const espree = require("espree");
let parserName = DEFAULT_PARSER_NAME; // 'espree'
let parser = espree;
-
parse函數會返回兩種結果
-
{success: false, error: Problem} 解析AST成功
-
{success: true, sourceCode: SourceCode} 解析AST失敗
最終會調用 runRules() 函數
這個函數是代碼檢查和修復的核心方法,會對代碼進行規則校驗。
-
創建一個 eventEmitter 實例。是eslint自己實現的很簡單的一個事件觸發類 on監聽 emit觸發;
-
遞歸遍歷 AST,深度優先搜索,把節點添加到 nodeQueue。一個node放入兩次,類似於A->B->C->...->C->B->A;
-
遍歷 rules,調用 rule.create()(rules中提到的meta和create函數) 拿到事件(selector)映射表,添加事件監聽。
-
包裝一個 ruleContext 對象,會通過參數,傳給 rule.create(),其中包含 report() 函數,每個rule的 handler 都會執行這個函數,拋出問題;
-
調用 rule. create (ruleContext), 遍歷其返回的對象,添加事件監聽;(如果需要lint計時,則調用process. hrtime ()計時);
-
遍歷 nodeQueue,觸發當前節點事件的回調,調用 NodeEventGenerator 實例裏面的函數,觸發 emitter.emit()。
// 1. 創建一個 eventEmitter 實例。是eslint自己實現的很簡單的一個事件觸發類 on監聽 emit觸發
const emitter = createEmitter();
// 2. 遞歸遍歷 AST,把節點添加到 nodeQueue。一個node放入兩次 A->B->C->...->C->B->A
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
});
// 3. 遍歷 rules,調用 rule.create() 拿到事件(selector)映射表,添加事件監聽。
// (這裏的 configuredRules 是我們在 .eslintrc.json 設置的 rules)
Object.keys(configuredRules).forEach(ruleId => {
const severity = ConfigOps.getRuleSeverity(configuredRules[ruleId]);
// 通過ruleId拿到每個規則對應的一個對象,裏面有兩部分 meta & create 見 【編寫rule】
const rule = ruleMapper(ruleId);
// ....
const messageIds = rule.meta && rule.meta.messages;
let reportTranslator = null;
// 這個對象比較重要,會傳給 每個規則裏的 rule.create函數
const ruleContext = Object.freeze(
Object.assign(
Object.create(sharedTraversalContext),
{
id: ruleId,
options: getRuleOptions(configuredRules[ruleId]),
// 每個rule的 handler 都會執行這個函數,拋出問題
report(...args) {
if (reportTranslator === null) {
reportTranslator = createReportTranslator({
ruleId,
severity,
sourceCode,
messageIds,
disableFixes
});
}
const problem = reportTranslator(...args);
// 省略一堆錯誤校驗
// ....
// 省略一堆錯誤校驗
// lint的結果
lintingProblems.push(problem);
}
}
)
);
// 包裝了一下,其實就是 執行 rule.create(ruleContext);
// rule.create(ruleContext) 會返回一個對象,key就是事件名稱
const ruleListeners = createRuleListeners(rule, ruleContext);
/**
* 在錯誤信息中加入ruleId
* @param {Function} ruleListener 監聽到每個node,然後對應的方法rule.create(ruleContext)返回的對象中對應key的value
* @returns {Function} ruleListener wrapped in error handler
*/
function addRuleErrorHandler(ruleListener) {
return function ruleErrorHandler(...listenerArgs) {
try {
return ruleListener(...listenerArgs);
} catch (e) {
e.ruleId = ruleId;
throw e;
}
};
}
// 遍歷 rule.create(ruleContext) 返回的對象,添加事件監聽
Object.keys(ruleListeners).forEach(selector => {
const ruleListener = timing.enabled
? timing.time(ruleId, ruleListeners[selector]) // 調用process.hrtime()計時
: ruleListeners[selector];
// 對每一個 selector 進行監聽,添加 callback
emitter.on(
selector,
addRuleErrorHandler(ruleListener)
);
});
});
// 只有頂層node類型是Program才進行代碼路徑分析
const eventGenerator = nodeQueue[0].node.type === "Program"
? new CodePathAnalyzer(new NodeEventGenerator(emitter, { visitorKeys: sourceCode.visitorKeys, fallback: Traverser.getKeys }))
: new NodeEventGenerator(emitter, { visitorKeys: sourceCode.visitorKeys, fallback: Traverser.getKeys });
// 4. 遍歷 nodeQueue,觸發當前節點事件的回調。
// 這個 nodeQueue 是前面push進所有的node,分為 入口 和 離開
nodeQueue.forEach(traversalInfo => {
currentNode = traversalInfo.node;
try {
if (traversalInfo.isEntering) {
// 調用 NodeEventGenerator 實例裏面的函數
// 在這裏觸發 emitter.emit()
eventGenerator.enterNode(currentNode);
} else {
eventGenerator.leaveNode(currentNode);
}
} catch (err) {
err.currentNode = currentNode;
throw err;
}
});
// lint的結果
return lintingProblems;
執行節點匹配 NodeEventGenerator
在該類裏面,會根據前面 nodeQueque 分別調用 進入節點和離開節點,來區分不同的調用時機。
// 進入節點 把這個node的父節點push進去
enterNode(node) {
if (node.parent) {
this.currentAncestry.unshift(node.parent);
}
this.applySelectors(node, false);
}
// 離開節點
leaveNode(node) {
this.applySelectors(node, true);
this.currentAncestry.shift();
}
// 進入還是離開 都執行的這個函數
// 調用這個函數,如果節點匹配,那麼就觸發事件
applySelector(node, selector) {
if (esquery.matches(node, selector.parsedSelector, this.currentAncestry, this.esqueryOptions)) {
// 觸發事件,執行 handler
this.emitter.emit(selector.rawSelector, node);
}
}
四、總體運行機制
概括來説就是,ESLint 會遍歷前面説到的 AST,然後在遍歷到 「不同的節點」 或者 「特定的時機」 的時候,觸發相應的處理函數,然後在函數中,可以拋出錯誤,給出提示。

Tips: espree需要更換解析器
問題:espree無法識別 TypeScript 的一些語法,所以在我們項目中的 .eslintrc.json 裏才要配置
{
"parser": '@typescript-eslint/parser'
}
給eslint指定解析器,替代掉默認的解析器。
eslint
中涉及到規則的校驗源碼調用棧大致就如上分析,但其實 eslint
遠不止這些,還有很多可以值得學習的點,如:迭代文件路徑、fix修復文本、報告錯誤及自定義格式等等,歡迎感興趣的同學一起討論交流,也歡迎同學批評指正~
參考資料
https://zhuanlan.zhihu.com/p/53680918
https://juejin.cn/post/7054741990558138376
https://www.teqng.com/2022/03/14/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3-eslint-%E6%A0%B8%E5%BF%83%E5%8E%9F%E7%90%86/#ESLint_shi_ru_he_gong_zuo_de
參考資料
我們看一下自定義eslint 規則: https://eslint.bootcss.com/docs/developer-guide/working-with-rules
ESTree: https://github.com/estree/estree
selector: https://eslint.bootcss.com/docs/developer-guide/selectors
selector: https://eslint.bootcss.com/docs/developer-guide/selectors
代碼路徑分析: https://eslint.bootcss.com/docs/developer-guide/code-path-analysis
:heart: 謝謝支持
以上便是本次分享的全部內容,希望對你有所幫助^_^
喜歡的話別忘了 分享、點贊、收藏 三連哦~。
歡迎關注公眾號 ELab團隊 收貨大廠一手好文章~
我們來自字節跳動,是旗下大力教育前端部門,負責字節跳動教育全線產品前端開發工作。
我們圍繞產品品質提升、開發效率、創意與前沿技術等方向沉澱與傳播專業知識及案例,為業界貢獻經驗價值。包括但不限於性能監控、組件庫、多端技術、Serverless、可視化搭建、音視頻、人工智能、產品設計與營銷等內容。
歡迎感興趣的同學在評論區或使用內推碼內推到作者部門拍磚哦
字節跳動校/社招投遞鏈接: https://jobs.bytedance.com/campus/position?referral_code=BA6TQ9U
內推碼:BA6TQ9U
- 使用 WebAssembly 打造定製 JS Runtime
- 前端也要懂算法,不會算法也能微調一個 NLP 預訓練模型
- 聯機遊戲原理入門即入土 -- 入門篇
- Plasmo Framework:次世代的瀏覽器插件開發框架
- 深入理解 Mocha 測試框架:從零實現一個 Mocha
- Single Source of Truth:XCode SwiftUI 的界面編輯的設計理念
- 深入理解 D3.js 可視化庫之力導向圖原理與實現
- 淺析神經網絡 Neural Networks
- Cutter - Web視頻剪輯工具原理淺析
- 你可能需要一個四捨五入的工具函數
- 淺析eslint原理
- 最小編譯器the-super-tiny-compiler
- Git存儲原理及部分實現
- 淺談短鏈的設計
- Web組件構建庫-Lit
- 使用Svelte開發Chrome Extension
- Web3.0開發入門
- vscode插件原理淺析與實戰
- 深入淺出 Web Audio API
- 探祕HTTPS