本文為來自 字節跳動-業務中台團隊 成員的文章,已授權 ELab 發布。
前言代碼是寫給人看的,所以一份好的代碼,是要讓水平不一的閱讀者,都能夠理解代碼的本意。每個人的代碼風格是不可能完全相同的,例如在一個文件里,有的以兩個空格做縮進,有的以四個空格做縮進,有的使用下劃線,有的使用駝峰,那麼它的閱讀體驗就會變得很差。
所以如何來對代碼進行約束,使團隊的代碼風格儘量統一,不產生更多的理解成本,是一個需要解決的問題。眾所周知,懶是社會生產力進步的源動力,所以...
在前端工程化的標準中有一項就是自動化,自動化當中就包括了代碼規範自動化。實現代碼規範自動化可以解放團隊生產力,提升團隊生產效率,balabla... 所以 ESlint、TSLint、StyleLint這些工程化插件應運而生。
而最近在筆者團隊也在統一不同的項目之間的規範差異,相信大家也都遇到了大段飄紅的現象,今天咱來簡單探究一下背後涉及到的原理。
What is ESLint/Lint?首先,提到ESlint,應該會想到兩種東西,一個是ESLint的npm包,也就是我們 devDep裡面的, 另一個是我們所安裝的比如VSCode的ESLint插件,那麼這兩個東西有什麼聯繫呢。
Npm包:是實際的lint規則以及我們執行lint的時候,控制代碼如何去進行格式化的。
Vscode插件: 實際指向我們項目的 /node_modules/eslint 或者全局的eslint,通過eslint的規則,告訴IDE,哪些地方需要飄紅。也就是說插件是在解析我們的打開的文件,同時和規則對比,是否存在eslint問題。以及可以通過我們的IDE配置,在不同的時機去執行我們的lint,比如保存自動格式化。
總而言之, eslint 規則就是對我們的代碼風格和代碼中潛在的一些錯誤和不規範用法的一個約束,通過npm包的形式引入項目,同時通過IDE的插件,讀取npm包規則,對我們的代碼進行錯誤提示,
How to Use it?如何在項目配置ESlint就不在本文贅述了。大多數腳手架其實都會給你初始化好基本的ESlint。涉及到的工具不同可能會有些許的不一樣,不過都大差不差。這段講一下ESLint中的主要配置項。如果有興趣深一步研究,可以移步eslint的官網文檔 [1],對默認的規則集[2]感興趣 也可以移步 。
打開一個eslintrc文件,一般來說,有幾個選項。這裡以json為例,來簡單說明下每個字段。
{"extends":'',//規則集繼承自某個規則集"root":'true',//找到這後,不再向上級目錄尋找//解析選項"parserOptions":{"ecmaVersion":6,//指定你想要使用的ECMAScript版本3/5/6/7/8/9"sourceType":"module",//'script'(default)or'module',標明你的代碼是模塊還是script"ecmaFeatures":{//是否支持某些feature,默認均為false"globalReturn":true,//是否允許全局return'impliedStrict':true,//是否為全局嚴格模式"jsx":true}},//自定義解析器,官方支持下列四種,也可以自己定義解析器。"parse":"espree"|"esprima"|'Babel-ESLint'|'@typescript-eslint/parser',"plugins":["a-plugin"],//第三方插件a"processor":"a-plugin/a-processor",//制定處理器為插件a的處理器"rules":{"eqeqeq":"error"}//指定一些全局變量,類似於global.d.ts的作用"globals":{"var1":"writable","var2":"readonly"}//忽略哪些文件"ignorePatterns":["src/**/*.test.ts","src/frontend/generated/*"]}eslint支持以下幾種格式的配置文件,如果同一個目錄下有多個配置文件,ESLint 只會使用一個。優先級順序如下:
同時 eslint 也支持對每個目錄配置不一樣的規則,對於mono倉庫下,可能每個repo的eslint都有些許的區別,這個時候我們就可以採用下面的目錄格式,根目錄下存在基本規則,子app下存在特定的規則。子rc是對父rc的一個override,但是如果我們在app/.eslintrc.js中設置了root:true,那麼對於test.js,父目錄中rc使用的規則,在app中不會生效。
packages├──package.json├──.eslintrc.js├──lib│└──test.js└─┬app├──.eslintrc.js└──test.jsWhy does it work? AST他是為什麼能夠生效的。這裡就要提到我們前端方方面面都要涉及到的AST了,感謝新時代。
ESLint是基於抽象語法樹來進行工作的,ESLint默認使用的編譯器(parse)是 Espree[3],通過它來解析我們的JS代碼生成AST,基於AST,我們就可以對我們的代碼進行檢查和修改了。
通常我們的Babel編譯分為下圖這幾步,編譯/轉換/生成。ESlint和它對比,只有第一步是一致的, 因為我們只需要拿到ast中的部分信息,同時直接在源碼中進行提示和操作就行,並不需要transform和後續的生成代碼。

現在我們通過demo來探究他背後的原理以及轉換的方式。首先,我們需要加載和解析我們的源代碼。這就是編譯器將我們的代碼轉換成AST樹的一個過程。因為已經全面擁抱typescript(主要是因為espree沒有類型註解,我難受),所以本文使用 @typescript-eslint/parser來作為我們的編譯器。這裡有個小坑,如果在VSCode安裝了import cost插件的話,他去解析這個parser會特別卡,所以可以暫時禁用。
constfoo="anthony"constbar="dst"importfsfrom'fs';importpathfrom'path';import*astsParserfrom'@typescript-eslint/parser';constfilePath=path.resolve('./src/test.ts')consttext=fs.readFileSync(filePath,"utf8")//編譯成AST這裡是不是和eslint的配置項對上了,沒錯就是透傳而已constast=tsParser.parse(text,{comment:true,//創建包含所有注釋的頂級注釋數組ecmaVersion:6,//JS版本////指定其他語言功能,//ecmaFeatures:{//jsx:true,//啟用JSX解析//globalReturn:true//在全局範圍內啟用return(當sourceType為「commonjs」時自動設置為true)//},loc:true,//將行/列位置信息附加到每個節點range:true,//將範圍信息附加到每個節點tokens:true//創建包含所有標記的頂級標記數組})然後我們將獲得的ast打印一下,簡單從下圖可以看到主要包含的內容。本地打印出來可能不太方便閱讀,也可以使用在線的工具[4],將解析器設置為@typescript-eslint/parser。相對於espree來說,ts解析多出來的部分中,比較關鍵的就是右圖這段,決定我們如何去解析他的類型。
AST 就是記錄了讀取源文件之後的文本內容的各個單位的位置信息,這樣我們就可以通過操作 AST 修改需要修改的內容,然後再根據修改後的 AST 信息進行修改對應的文本內容。比如我們把上文中的 const 關鍵字修改成 let ,那麼我們就先對 AST 對應的const內容進行修改為 let ,得到修改之後的 AST 數據,再根據修改後的 AST 數據去修改對應的文本內容。所謂的修改就是字符串替換,因為我們已經知道了對應的位置信息。
SourceCode但是根據上面我們可以看到,直接根據ast去查找然後比對替換,效率是很低的,而且嵌套比較深。這個時候ESlint是怎麼幹的呢?他生成了一個新的結構用於我們操作,也就是SourceCode。有興趣進一步探究可以自行查閱源碼的 sourcecode/source_code.js部分。簡單來說,就是構建了一個SourceCode實例,接受兩個參數,原文text 和解析後的ast,然後返回我們一個包含茫茫多方法的實例對象。

我們在demo項目中裝一個eslint 然後引入SourceCode,看看構造後的對象是個什麼玩意。
import{SourceCode}from'eslint';//....//constsourceCode=newSourceCode(text,ast);//這打個斷點,看看sourceCode結構
好了 前置的一些知識我們已經介紹的差不多了。接下來 結合實際的rules demo來進行講解。
規則模版相信如果有寫過vscode插件的同學應該對 Yeoman 不陌生,eslint也有提供基於Yeoman的一套腳手架用於生成模版。
首先全局安裝eslint 的腳手架,npm install -g yo generator-eslint,然後通過下面的一些交互式命令行操作來初始化我們的操作。
通過初始化,我們可以看到一個以下的文件的殼子,我們在裡面添加一些我們上面所講到的東西。打開我們生成的規則模版文件,同時在裡面添加一些規則和提示(注意,這裡我的寫法不規範,我將兩種無關規則放在了一個規則文件里)。
"usestrict";/**@type{import('eslint').Rule.RuleModule}*/module.exports={meta:{type:'problem',//`problem`,`suggestion`,or`layout`docs:{description:"xxxx",recommended:false,url:null,//URLtothedocumentationpageforthisrule},messages:{temp:'不樣你用字面量作為函數的參數傳入',novar:'不樣你用var聲明',noExport:'退出時執行這個'},fixable:'code',//Or`code`or`whitespace`schema:[],//Addaschemaiftherulehasoptions},create(context){//variablesshouldbedefinedhereconstsourceCode=context.getSourceCode();return{ArrowFunctionExpression:(node)=>{if(node.callee.name!=='abcd')return;if(!node.arguments)return;node.arguments.forEach((argNode,index)=>{argNode.type==="Literal"&&context.report({node,messageId:'temp',fix(fixer){constval=argNode.value;conststatementString=`constval${index}=${val}\n`;return[fixer.replaceTextRange(node.arguments[index].range,`val${index}`),fixer.insertTextBeforeRange(node.range,statementString)]}})})},"Program:exit"(node){context.report({node,messageId:"noExport",});},VariableDeclaration(node){if(node.kind==='var'){context.report({node,messageId:'novar',fix(fixer){constvarToken=sourceCode.getFirstToken(node)returnfixer.replaceText(varToken,'let')}})}}};},}; 關鍵函數在這個demo裡面,我們看到幾個東西,一個是create函數的參數 context 以及他的返回值,還有就是context上提供的report方法 以及report接受的fix參數。這幾個加起來,形成了我們一條規則的校驗邏輯,通過遍歷,我們到了某個ast節點,如果某個ast節點滿足了我們所寫的某條規則,我們進行report,同時提供一個修複函數,修複函數通過token或者range來決定對某處進行文本替換。
接下來,挨個來講解這些東西,首先是context的上下文形成,這個沒有什麼好說的,其實就是創建了一個對象,然後提供了一些一些方法,供我們在插件中訪問上下文使用,然後對於每個rule都在createRuleListener中都創建了一個listener,這裡我們在後面串整體流程時還會再過一遍。
接着是report 方法,簡單分析下這塊代碼,其實就是通過一系列的操作,然後往lintingProblem這個數組裡面推了一個problem。這個problem包含一些錯誤信息,ast信息等等。
最後是我們的fix,我們上面用到的所有replace方法,其實都殊途同歸,最後回到了這裡,大道至簡,簡單的slice和+=完成了我們的修復動作。

基本上一個插件涉及到的核心幾個東西,都簡單解釋了下。現在我們來串一串整體檢測和修復的流程,也就是源碼中linter.js中的runRules方法。
整體流程
我們在跑規則的時候,肯定需要的是對ast進行遍歷,同時做一些操作。首先做了一個什麼操作呢,調用了一個實例方法Traverser.traverse,傳入了ast和一個對象,包含enter、leave和visitorKeys。這個函數的作用就是進行一個遞歸遍歷,同時在遍歷的時候通過enter和leave我們在隊列中存儲了兩個相同的節點,一個是進入時,一個是退出時,方便我們後續處理。這裡涉及到一個設計模式,訪問者模式(用於數據和操作解耦),通過在遍歷時加上isEntering,可以讓我們決定是在進入時還是退出時執行訪問者邏輯。
接着我們需要把我們的所有規則都給像上面講的給創建成ruleListener,然後在我們的nodeQueue後續遍歷時,觸發某些邏輯。當然,這裡大家可能都想到了訂閱發布模式,這個也是在我們整個邏輯中比較重要的一環,遍歷時,通過emit推送消息,然後讓ruleListener決定是否需要執行某些邏輯,所以,我們需要對Listener訂閱上某些事件。
接下來,就是對我們的nodeQueue遍歷了,通過我們節點上打上的標,來決定是在執行進入邏輯還是離開邏輯。這裡我就不展開講具體的細節了,其實簡單理解就是通過enter和leave的時候去觸發不同的visitor的動作。
限制於時間因素,本文成文比較倉促,可能會有一些知識點的缺失或者不對,敬請大家斧正。同時本文僅是初步的探索了其背後的原理,根據原理,後續可以做的一些例如eslint插件等等並沒有詳細的闡述。大家下來可以自行探索。
最後送大家一句話,linus 說的,也是我比較信奉的一句話。talk is cheap, show me the code,想了解一個東西,最好的辦法就是簡單實現它。我相信大家在解析完它的流程後,都能夠簡單實現一個eslint的小demo,以及能夠上手寫一寫eslint-plugin。
❤️ 謝謝支持以上便是本次分享的全部內容,希望對你有所幫助^_^
喜歡的話別忘了 分享、點讚、收藏 三連哦~。
歡迎關注公眾號 ELab團隊 收貨大廠一手好文章~
我們是誰?
我們是字節跳動業務中台,通過持續建設通用的基礎產品技術能力,賦能字節包括抖音、電商、頭條、西瓜等全系產品的業務創新。
我們的職責
1、為業務提供設備、帳號、推送、短信、郵件、LBS、數據引>擎、輿情、眾包標註、技術中間件等多類型基礎服務/功能;
2、面向內部關鍵業務場景(如抖音、電商、生活服務、廣告、VR/AR等),提供綜合性行業解決方案;面向外部市場需求,提供部分toB產品;
3、作為公司中台建設推進者,我們致力於提升中台治理水平,為字節內部各中台團隊提供平台/數據/架構的解決方案。
可憑內推碼投遞 字節跳動-業務中台 相關崗位哦~
官網文檔 : https://eslint.org/docs/latest/user-guide/configuring/configuration-files
[2]默認的規則集: https://eslint.org/docs/latest/rules/
[3]Espree: https://github.com/eslint/espree
[4]在線的工具: https://astexplorer.net/
[5]官網: https://eslint.org/docs/latest/developer-guide/working-with-rules
[6]unicode bom: https://en.wikipedia.org/wiki/Byte_order_mark