01 引言
代碼規範是軟件開發領域經久不衰的話題。在前端領域中,說到代碼規範,我們會很容易想到檢查代碼縮進、尾逗號以及分號等等,除此之外,代碼規範還包括了針對特殊場景定製化的檢查。JavaScript 代碼規範檢查工具包括 JSLint、JSHint、ESLint、FECS 等等,樣式代碼規範檢查工具主要爲 StyleLint。
02 背景
san-native 是百度 APP 內部的一套動態 NA 視圖框架,利用 JS 引擎驅動 NA 端渲染,使得 web 前端工程師可以十分方便的編寫原生移動應用,一套代碼多端運行。隨着百度 APP 中越來越多的業務開始接入 san-native,在此過程中,經常遇到 h5 中的一些樣式屬性以及事件在 san-native 中不支持,不按照 san-native 中內置組件嵌套規則的代碼導致渲染結果不符合預期。比如下面一段.san 文件中的代碼存在多處錯誤會導致端上渲染不正常甚至導致 crash:
-
第 2 行:在 san-native 中不支持行內樣式 flex-basis
-
第 3 行:在 san-native 中不支持滾動事件 on-scroll
-
第 6 行:在 san-native 中文本節點 span 不允許嵌套 img
-
第 7 行:內置組件 lottie-view 必須要有 source 或者 src 屬性
-
第 23 行:在 san-native 中不支持 display:inline
<template>
<div style="background-color:#fff; flex-basis:100px">
<div on-scroll="onScroll" class="{{$style['demolist-wrapper']}}">
內容
</div>
<span><img />san-native中span不允許嵌套img</span>
<lottie-view />
</div>
</template>
<script>
export default {
components: {},
onScroll() {
// do something
},
initData() {
return {};
}
}
</script>
<style lang="less" module>
.demolist-wrapper {
display: inline;
}
03 抽象語法數(AST)
首先我們需要了解代碼檢測的主角 —— 抽象語法樹 (Abstract Syntax Tree)。在計算機科學中,抽象語法樹簡稱 AST,它是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構。
將字符串源碼轉換成 AST 的工具稱爲解析器,常見的 Javascript 解析器有 @babel/parser,espree,acorn 等,樣式解析器有 postcss,cssTree 等。AST 的生成有兩個步驟:
-
詞法分析(分詞):將整個代碼字符串分割成最小語法單元數組
-
語法分析:在分詞基礎上建立分析語法單元之間的關係
我們可以通過在線工具 [1] 查看一段代碼的 AST,比如下圖所示的 AST,圖中用到的解析器爲 @babel/eslint-parsre,右側所示的對象爲左側代碼對應的 AST,該 AST 的根節點 type 爲 Program,其 body 中有兩個子節點,分別爲 import 以及 export 對應的語法節點,其 type 分別爲:ImportDeclaration 與 ExportDefaultDeclaration。每個節點中 range 表示當前節點對應的代碼在字符串源碼中的開始與結束位置,loc 爲開始與結束位置的行列信息。
04eslint-plugin-san-native
介紹 eslint-plugin-san-native 插件的實現之前,我們會先介紹 ESlint 中的規則 (rule),ESlint 配置與複用方案以及 ESlint 的運行原理,最後介紹插件如何實現以及關鍵的技術點。
ESlint 中的規則(rule)
上文中已經通過簡單的例子介紹了抽象語法樹的結構,並且在引言部分已經簡述了 ESlint 檢測代碼的核心思想,即對 AST 進行處理從而定位不符合規定的代碼,在 ESlint 中對 AST 進行處理的實體就是這裏所說的規則(rule),下面給出了一個規則的示例代碼:
module.exports = {
meta: {
type: "problem",
docs: {...},
schema: [],
messages: {readonlyMember: "The members of '{{name}}' are read-only."}
},
create(context) {
return {
ImportDeclaration(node) {
context.report({
node: node,
messageId: "readonlyMember",
data: {name: 'xxx'}
});
}
};
}
};
-
meta 中,通過 type 標記規則的類型,docs 包含了規則的文檔鏈接等信息,schema 則表示了配置規則應該遵守的約定,messages 包含了錯誤信息。
-
create 函數:
返回的對象中具有一個名爲 ImportDeclaration 的方法,我們將該方法稱之爲 import 語法節點的訪問器 (visitor),即在 ESlint 對整個 AST 遍歷的過程中,訪問到 import 語法節點的時候會調用所有名稱爲 ImportDeclaration 的 visitor。
create 函數接收的參數爲 context 對象,該對象上掛載了 ESlint/ 自定義解析器爲 rule 提供的方法以及用戶配置文件中的自定義配置信息,更多的屬性與方法見官方文檔。這裏我們調用 context.report 將錯誤信息以及對應的語法節點提供給 ESLint。
ESlint 配置的複用方案
上文介紹了 ESlint 中的規則,在實際的工程應用中我們可以通過對規則進行定製化的配置來滿足特定的需求,但是如果每啓動一個項目,我們都需要進行相同的配置,勢必會帶來一定的時間成本。ESLint 提供了全面、靈活的配置能力,可以配置解析器、規則、環境、全局變量等等;可以快速引入另一份配置,和當前配置層疊組合爲新的配置;還可以將配置好的規則集發佈爲 npm 包,在工程內快速應用。接下來,我們以 @ecomfe/eslint-config 爲例看看如何高效的實現配置的複用,下圖爲該代碼庫的目錄結構:
在 @ecomfe/eslint-config 中,每個 js 文件都是一份 eslint 的配置,根目錄下的入口文件 index.js 爲基礎配置 (base),其他文件夾可以看作是對基礎配置的擴展,比如 san 文件夾下是關於 san 的一些規則的配置,在實際的項目中可以通過下面的方式引入:
module.exports = {
extends: [
'@ecomfe/eslint-config',
'@ecomfe/eslint-config/san', // 注意順序
// 或者選擇嚴格模式
// '@ecomfe/eslint-config/san/strict',
],
};
module.exports = {
parser: 'xxx',
parserOptions: {
parser: 'yyy',
sourceType: 'module'
},
plugins: [],
env: {},
rules: {
'indent-legacy': 'off',
}
};
在這樣一個配置文件中,各個字段的含義如下:
-
parser:用於申明自定義 parser,該 parser 會將文件內容轉換成 AST
-
parserOptions:自定義 parser 的配置項
-
plugins:申明使用的 ESlint 插件,這部分會在後面 ESlint 工作原理介紹
-
env:申明檢測所處的環境,該選項用於引入一組預定義的全局變量
-
rules:對規則的配置
ESlint 中插件與配置文件的區別
上文中依次介紹了 ESlint 的規則的實現以及 ESlint 配置的複用,本節我們說明插件與配置文件之間的區別,ESlint 插件的入口文件示例代碼如下:
module.exports = {
rules: {
'no-style-float': require('./rules/no-style-float'),
// ...
},
processors: {
'.san': require('./processor'),
// ...
},
configs: {
always: {
plugins: ['@baidu/san-native'],
rules: {
'@baidu/san-native/no-style-float': 'error',
}
},
// ...
}
};
-
rules:包含了該插件所有的規則的具體實現
-
processors:這裏我們定義了專門用於處理.san 文件字符串源碼以及檢查信息的 processor
-
configs:包含了一些配置,可以看到與 @ecomfe/eslint-config 中的配置文件類似,具備 plugins 選項,以及對 rules 的配置。
需要說明的是:爲了複用上面 configs.always
的配置,我們可以在項目的.eslintrc.*
文件中 extends
選項加上如下代碼:
module.exports = {
extends: ['plugin:@baidu/san-native/always']
};
-
plugin 插件主要涉及自定義規則的具體實現,同時還能夠提供配置
-
extend 配置主要涉及規則的具體配置
ESlint 的工作原理
接下來介紹 ESlint 是如何處理各種配置文件的,以及插件與配置文件中各字段在 ESlint 中的作用。ESlint 提供了命令行的方式來檢測某個文件的代碼,比如,我們想對.san 文件進行檢查,那麼可以通過下面的命令來實現:
eslint --ext .san src/app/component/animate/index.san
從上圖當中我們可以看到,文件的字符串內容首先會被插件的 prepocess 處理,然後處理的結果被 parser 解析成對應的 AST,然後遍歷 AST 的同時執行每個規則提供的方法,最後得到的檢測結果會被 postprocess 處理。因此 ESlint 插件中的 processors 屬性給開發者提供了操作字符串源碼以及處理檢測結果的能力。接下來分析 ESlint 配置中的一些字段在檢測過程中的作用:
處理.eslintrc.*配置文件
-
extends:以【配置的複用方案】中的代碼爲例,其中 @ecomfe/eslint-config 以及 @ecomfe/eslint-config/san 會被合併,按照在數組中的順序,相同的選項,後者的配置會覆蓋前者。比如,前者的配置中 parser: @babel/eslint-parser 會被後者的 parser: 'san-eslint-parser' 替換,plugins,rules 都會被收集到一個對象當中,收集的規則爲當前配置中的 rules 會被當前配置中 extends,plugins 的配置規則覆蓋。
-
root:由於在讀取配置文件的時候,會根據傳入的 filePath 依次往上查找對應的文件,並將配置文件解析出來。當某個配置文件設置了 root 爲 true 的時候,會停止繼續向上搜索。
-
plugins:該字段決定了最終處理某類文件的 processor 是哪個插件提供的,舉例來說,當 A 與 B 中都申明瞭對.san 文件處理的 processor,如果按照下面的配置,ESlint 最終會用 B 插件提供的 processor 處理.san 文件,如果將 plugins 字段中兩個插件互換位置,則 ESlint 最終會用 A 插件提供的 processor。總的來說,ESlint 會從 plugins 字段申明的插件中從後向前找到第一個用於處理.san 文件的 processor。
module.exports = {
plugins: [
"A",
"B"
],
extends: [
"plugin:A/base",
"plugin:B/base"
]
};
將文件內容解析成 AST
-
parser 選項:當使用了自定義的 parser 之後,那麼文件的內容將會被自定義 parser 解析成 AST,否則會使用默認的 parser,即 eslint 提供的 espree
-
parserOptions:該選項是當前 parser 的 option,每一個 rule 以及自定義 parser 都能夠獲取到該選項的值。
收集與執行 rule 生成的 visitor
當解析器將字符串解析成 AST 之後,在遍歷 AST 的過程中會根據當前的節點類型執行對應的一些列提前註冊好的 vistor。
-
調用每個 rule 的 create 函數收集 visitor:create 函數必須返回一個 visitor,即 key 爲 AST 節點類型,value 爲一個接收該節點的函數,ESlint 將 create 函數返回的 vistor 註冊到內部維護的訂閱發佈器上。
-
遍歷 AST:遍歷的過程中會通過訂閱發佈器執行執行收集過程中註冊的所有 vistor
eslint-plugin-san-native 的實現
經過上述 ESlint 的工作原理的瞭解之後,我們開始介紹如何實現 eslint-plugin-san-native 來解決我們的問題,以下面的單文件組件爲例:
<template>
<div></div>
</template>
<script>
import Test from './index.san';
export default {}
</script>
<style lang="less">
.a {}
</style>
-
如何將.san 文件代碼解析成 AST:顯然無法直接使用 @babel/eslint-parser 或者 @typescript-eslint/parser 來進行解析,因此我們需要用自定義解析器來處理,這部分的工作 san-eslint-parser 已經幫我們處理了,後續在規則實現的時候進行分析。
-
如何處理 <style> 中的樣式:由於 san-eslint-parser 不會處理.san 文件中 <style> 部分代碼,因此我們需要單獨處理,這裏藉助 postcss 對 <style> 部分進行解析。
項目構建
構建的項目目錄結構如下
入口文件 eslint-plugin-san-native/lib/index.js 的代碼如下:
module.exports = {
rules: {
'no-style-float': require('./rules/no-style-float'),
// ...
},
processors: {
'.san': require('./processor'),
// ...
},
configs: {
always: {
plugins: ['@baidu/san-native'],
rules: {
'@baidu/san-native/no-style-float': 'error',
}
},
// ...
}
};
下面我們分別實現 processor 以及 rule。
processor 的實現
根據 ESlint 工作原理可知,ESlint 在獲取到字符串源碼的時候,會先利用插件提供的 preprocess 處理字符串源碼,接着利用 parser 解析成 ast,然後將各個 ast 節點交給 rule 處理,接着處理後的檢測結果交給 postprocess 處理,最後再執行 fix。因此,從 preprocess 到 postprocess 的過程中,處理的文件內容是不變的(ast 會被 Object.freeze 處理),因此,我們可以在 preprocess 中將.san 中的 <style> 獲取之後,利用 postcss 將其解析成 ast,並存儲起來供後續所有 rule 共享。
獲取 <style> 對應的 AST
const postcss = require('postcss');
const syntaxs = {
less: require('postcss-less'),
sass: require('postcss-sass'),
scss: require('postcss-scss')
};
const processor = postcss([() => {}]);
module.exports = {
getAst(syntax, content, plugins) {
let ast = null;
try {
ast = syntax ? processor.process(content, {syntax}).sync(): processor.process(content).sync();
} catch (error) {}
return ast;
},
getStyleContentInfo(text) {
const lines = text.split('\n');
const content = /()([\s\S]*?)<\/style>/gi.exec(text);
const langMatch = /\slang\s*=\s*("[^"]*"|'[^']*')/.exec(content[1]);
const lang = langMatch[1].replace(/["'\s]/gi, '');
const astFn = lang ? this.getAst : this.getAst.bind(null, syntaxs[lang]);
return {
startLine: lines.indexOf(content[1]),
ast: astFn(content[3]),
startOffset: text.indexOf(content[3])
};
}
};
processor
const {styleAstCache} = require('./utils/cache');
module.exports = {
preprocess(code, filename) {
// 所有.san 都會處理
styleAstCache.storeAst(styleHelper.getStyleContentInfo(code));
return [code];
},
postprocess(messages) {
// 清除數據
styleAstCache.storeAst(null);
return messages[0];
}
};
規則的實現
由於規則的實現依賴於自定義 parser 提供的 ast,因此我們需要先對 san-eslint-parser 的原理有一定的瞭解,那麼我們將現分析其原理,然後介紹幾類規則的實現方案。
san-eslint-parser
自定義 parser 需要提供 parseForESLint 方法,我們這裏只關注該方法返回結果中的部分屬性 (更多屬性見官方):
-
ast:AST 根節點
-
services:自定義 parser 爲 rule 提供的服務,每條規則可以通過 context.parserServices 訪問到
ast
san-eslint-parser 會將我們.san 的文件內容利用分成三個 block,其中利用 parserOPtions.parser 指定的解析器來處理 script 部分的內容,script 中如果是 JavaScript 代碼則 parserOPtions.parser 爲 @babel/eslint-parser,如果是 Typescript 代碼則爲 @typescript-eslint/parser。style 部分不會處理,template 部分當作 HTML 來解析。
上圖所示爲自定義 parser 生成的 AST,根節點的 type 爲 Program,根節點的 body 屬性存儲了 script 代碼的 ast,根節點上的 templateBody 爲 template 部分的 ast。由於 ESlint 只會遍歷根節點以及 body 上的節點,因此如果我們想爲 templateBody 註冊 visitor,那麼可以通過 services 來實現。
services
san-eslint-parser 會在 services 屬性上定義三個方法,我們只關注其中一個,簡化後的代碼如下:
let emitter = null; // 發佈訂閱器
function defineTemplateBodyVisitor(templateBodyVisitor) {
let scriptVisitor = {};
if (!emitter) {
emitter = new EventEmitter();
scriptVisitor["Program"] = node => {
traverseNodes(rootAST.templateBody)
};
}
for (const selector of Object.keys(templateBodyVisitor)) {
emitter.on(selector, templateBodyVisitor[selector]);
}
return scriptVisitor;
}
-
收集每條 rule 中針對 templateBody 的 visitor:該方法接收的參數爲 templateBodyVisitor,該對象存儲了所有針對 templateBody 的 visitor,這些 visitor 會註冊到自定義 parser 內部的發佈訂閱器上。
-
返回一個針對 script 對應的 AST 的 visitor:該 visitor 的 type 爲 Program,根據上文中 ESlint 工作原理一節,我們可以知道,ESlint 在遍歷 AST 的過程中,當遇到類型爲 Program 的根節點時,會執行該 visitor,並且該 visitor 會調用自定義 parser 內部方法對 templateBody 存儲的 AST 進行遍歷。
因此,我們可以利用上述方法在每條 rule 中編寫相關的 visitor 來處理 templateBody 中不同 type 類型語法節點,如下代碼所示:
module.exports = {
meta: {...},
create(context) {
return context.parserServices.defineTemplateBodyVisitor(context, {
'VElement'(node) {
// do something
},
'VText'(node) {
// do something
}
});
}
};
在 template 模板中,我們需要檢測某個標籤上的事件,內聯樣式,必選屬性三種內容,爲了避免重複代碼,希望通過配置的方式實現規則。首先定義內置組件的描述信息,舉例來說:
{
"name": "lottie-view", // 標籤名稱
"events": [ // 支持的事件
"click",
"touchstart",
"touchmove",
"touchend",
"touchcancel",
"layout",
"longclick",
"pressin",
"pressout",
"firstmeaningfulpaint",
"animationfinish",
"downloadfinish"
],
"attributes": {
"required": [],
"oneOf": [["src", "source"]], // 必須的可選屬性
"content": {}
},
"style": {
"required": [],
"notsurpport": []
},
"nestedTag": [] // 允許的子標籤
}
在上文中已經介紹瞭如何在規則中通過編寫相關的 visitor 來處理 templateBody 中不同 type 類型語法節點,因此我們只需要對節點的相關數據進行一些判斷,就可以實現代碼檢測。判斷的邏輯這裏不再介紹,只貼上一個 VElement 中需要關注的節點屬性:
上圖中是下面標籤對應的節點數據,爲了獲取標籤的屬性,我們可以從 startTag.attributes 中獲取,可以看到其中屬性名稱爲 style 的節點數據。
<div style="background-color:#fff; flex:1">...</div>
對於樣式規則來說,我們需要同時檢測 tempate 上的內聯樣式,也需要檢測 <style> 塊中的樣式代碼,簡化後的規則代碼如下:
module.exports = {
meta: {...},
create(context) {
return context.parserServices.defineTemplateBodyVisitor(context, {
'VElement[name="template"]'(node) {
const {ast: result, startLine, startOffset} = styleAstCache.getAst();
if (result && result.root) {
result.root.walkDecls(decl => {
// do something
});
}
},
VAttribute(node) {
const name = utils.getName(node);
if (name == null) {
return;
}
if (name === 'style' && node.value && node.value.value) {
let styleValArr = inlineStyleParser(node.value.value);
styleValArr.forEach(decl => {
// do something
});
}
}
});
}
};
在 vscode 中的檢測效果
在每一條規則中,當發現不符合規則的代碼時,我們可以通過 context.report 將對應的 ast 語法節點 / 位置信息以及錯誤信息提供給 ESlint
// 提供節點
context.report({
node: node,
messageId: "..."
});
// 提供位置信息:loc
context.report({
loc: node.loc,
message: "..."
});
05stylelint-plugin-san-native
到此,我們介紹瞭如何開發 ESlint 插件檢測.san/.js/.ts 文件中的 san 組件,下面介紹如何開發 StyleLint 插件來檢測.less/.sass/.scss/.styl 文件中的樣式代碼。StyleLint 提供了類似 ESlint 的配置方式,可以在配置文件.stylelintrc.* 中 extends 多個配置文件,對單個 rule 進行配置,支持通過編寫插件實現自定義的規則,支持使用 processor 在開始檢測之前對源碼字符串進行修改,並在結果輸出之前對檢測結果進行修改。
StyleLint 工作原理
下圖爲 StyleLint 的工作流程圖,這裏的 processor.code 相當於 ESlint 中的 preprocess,而 processor.result 相當於 ESlint 中的 postprocess。StyleLint 與 ESlint 的工作原理非常相似,從整體上來說,processor.code 與 processor.result 之間的過程與 ESLint 有區別,StyleLint 中會遍歷所有 rules,然後將 AST 根節點交給每個 rule 進行遍歷,而不像 ESlint 中需要自己遍歷 AST。
StyleLint 規則
從上文 StyleLint 的工作流程分析可以知道,StyleLint 的規則接收一個 AST 根節點以及配置數據,因此其規則示例代碼如下:
module.exports = function rule(primary, secondary, context) {
return (root, result) => {};
};
"rules": {
"block-no-empty": null, // primary 爲 null
"color-no-invalid-hex": true, // primary 爲 true
"comment-empty-line-before": [
"always", // primary 爲 always
{"ignore": ["stylelint-commands", "between-comments"]} // secondary
]
}
StyleLint 插件
只需要將 rule 利用 StyleLint 提供的方法處理後即可生成一個插件,並且需要提供 ruleName 以及 messages
const stylelint = require("stylelint");
const ruleName = "plugin/xxx";
const messages = stylelint.utils.ruleMessages(ruleName, {
expected: "Expected ..."
});
module.exports = stylelint.createPlugin(
ruleName,
function (primary, secondary, context) {
return function (root, result) {
// ...
stylelint.utils.report({/* .o. */});
};
}
);
module.exports.ruleName = ruleName;
module.exports.messages = messages;
構建的項目目錄結構如下:
其中入口文件 index.js 的簡化代碼如下:
module.exports = [
stylelint.createPlugin(...),
stylelint.createPlugin(...),
// ...
]
同時提供了兩份配置文件分別爲:always.js 以及 temporary.js,下面爲 always.js 的代碼:
module.exports = {
plugins: ['.'],
rules: {
'@baidu/stylelint-plugin-san-native/no-flex-basis': true,
// ...
}
};
在實際工程項目的.stylelintrc.js 中可以通過 extends 字段複用配置文件,比如:
module.exports = {
extends: [
'@baidu/stylelint-plugin-san-native/always',
'@baidu/stylelint-plugin-san-native/temporary'
],
rules: {}
const {utils} = require('stylelint');
const getDeclarationValue = require('stylelint/lib/utils/getDeclarationValue');
const declarationValueIndex = require('stylelint/lib/utils/declarationValueIndex');
const valueParser = require('postcss-value-parser');
const meta = {
styleName: 'justify-content',
message: `Only some values of '${styleName}' are supported in sna-native`,
surrpportValue: ['flex-start', 'flex-end', 'center', 'space-between', 'space-around']
};
const ruleName = `stylelint-plugin-san-native/valid-justify-content`;
const messages = utils.ruleMessages(ruleName, {
expected: () => meta.message
});
module.exports = function rule(primary) {
return (root, result) => {
const validOptions = utils.validateOptions(result, ruleName, {primary});
if (!validOptions || !primary) { return; }
root.walkDecls(decl => {
// 將declaration語法節點上屬性鍵值對解析成AST
const parsed = valueParser(getDeclarationValue(decl));
// 遍歷每個屬性值對應的節點
parsed.walk(node => {
if (meta.surrpportValue.indexOf(node.value) < 0) {
utils.report({
// 獲取declaration語法節點中屬性值部分在與declaration語法節點開始位置的偏移量
index: declarationValueIndex(decl) + node.sourceIndex,
message: messages.expected(),
node: decl,
ruleName,
result
});
}
});
});
};
};
module.exports.ruleName = ruleName;
module.exports.messages = messages;
在對代碼進行分析之前,我們需要了解 postcss 返回的 AST 的兩個關鍵點:
1.屬性聲明與賦值會被解析成類型爲 declaration 的語法節點,舉例如下:
justify-content: baseline;
2.可以通過 AST 上的 walkDecls 方法獲取 AST 樹中的每個類型爲 declaration 的語法節點,該方法是由 postcss 提供,更多的方法可見 postcss 官方文檔
上面代碼中 rule 函數利用 root.walkDecls 遍歷語法樹中的 declaration 語法節點,並且每個 declaration 語法節點會被傳入 root.walkDecls 接收的回調函數中,在該回調函數中如果發現屬性值在 san-native 中不支持,則需要通過 stylelint.utils.report 將錯誤信息,發生錯誤的節點,以及屬性值偏移量,當前規則名稱傳遞給 StyleLint,這樣 StyleLint 才能夠定位到不規範代碼的位置。同時藉助編輯器插件將不符合代碼規範的代碼高亮出來,以 vscode 爲例,進行如下的高亮提示:
06 總結
至此,我們介紹瞭如何實現 ESlint 以及 StyleLint 的插件來檢測 san-native 項目中不符合規定的代碼,並從底層原理的角度上介紹了插件裏各個字段以及方法在檢測過程中的作用,希望能對大家有所幫助。
文章看完,還不過癮?
更多精彩內容歡迎關注百度開發者中心公衆號