本篇文章給大家分享一個(gè)VSCode插件開發(fā)實(shí)戰(zhàn),開發(fā)一個(gè)代碼診斷插件,分析一下基本原理,并一步步實(shí)現(xiàn),希望對(duì)大家有所幫助!
最近,我們內(nèi)部出了一份 Code Review 指南,但是 Code Review 過(guò)程非常占時(shí)間,大家不會(huì)太仔細(xì)去 review 代碼,因此想通過(guò)一個(gè)插件讓開發(fā)者在開發(fā)階段就能感知到寫法的錯(cuò)誤,做出的效果如下圖
接下來(lái)將介紹如何從 0 實(shí)現(xiàn)這么一個(gè)功能。
基本原理
Visual Studio Code 的編程語(yǔ)言功能擴(kuò)展是有 Language Server 來(lái)實(shí)現(xiàn)的,這很好理解,畢竟檢查語(yǔ)言功能是耗費(fèi)性能的,需要另起一個(gè)進(jìn)程來(lái)作為語(yǔ)言服務(wù),這就是 Language Server 語(yǔ)言服務(wù)器?!就扑]學(xué)習(xí):《vscode入門教程》】
Language Server 是一種特殊的 Visual Studio Code 擴(kuò)展,可為許多編程語(yǔ)言提供編輯體驗(yàn)。使用語(yǔ)言服務(wù)器,您可以實(shí)現(xiàn)自動(dòng)完成、錯(cuò)誤檢查(診斷)、跳轉(zhuǎn)到定義以及VS Code 支持的許多其他語(yǔ)言功能。
既然有了服務(wù)器提供的語(yǔ)法檢查功能,就需要客戶端去連接語(yǔ)言服務(wù)器,然后和服務(wù)器進(jìn)行交互,比如用戶在客戶端進(jìn)行代碼編輯時(shí),進(jìn)行語(yǔ)言檢查。具體交互如下:
當(dāng)打開 Vue 文件時(shí)會(huì)激活插件,此時(shí)就會(huì)啟動(dòng) Language Server,當(dāng)文檔發(fā)生變化時(shí),語(yǔ)言服務(wù)器就會(huì)重新診斷代碼,并把診斷結(jié)果發(fā)送給客戶端。
代碼診斷的效果是出現(xiàn)波浪線,鼠標(biāo)移上顯示提示消息,如果有快速修復(fù),會(huì)在彈出提示的窗口下出現(xiàn)快速修復(fù)的按鈕
動(dòng)手實(shí)現(xiàn)
了解了代碼診斷的基本原理之后,開始動(dòng)手實(shí)現(xiàn),從上面的基本原理可知,我們需要實(shí)現(xiàn)兩大部分的功能:
-
客戶端與語(yǔ)言服務(wù)器交互
-
語(yǔ)言服務(wù)器的診斷和快速修復(fù)功能
客戶端與語(yǔ)言服務(wù)器交互
官方文檔 提供了一個(gè)示例 – 用于純文本文件的簡(jiǎn)單語(yǔ)言服務(wù)器,我們可以在這個(gè)示例的基礎(chǔ)上去修改。
> git clone https://github.com/microsoft/vscode-extension-samples.git > cd vscode-extension-samples/lsp-sample > npm install > npm run compile > code .
首先在 client 建立服務(wù)器
// client/src/extension.ts export function activate(context: ExtensionContext) { ... const clientOptions: LanguageClientOptions = { documentSelector: [{ scheme: 'file', language: 'vue' }], // 打開 vue 文件時(shí)才激活 ... }; client = new LanguageClient(...); client.start(); }
接著在 server/src/server.ts 中,編寫于客戶端的交互邏輯,比如在客戶端文檔發(fā)生變化的時(shí)候,校驗(yàn)代碼:
// server/src/server.ts import { createConnection TextDocuments, ProposedFeatures, ... } from 'vscode-languageserver/node'; const connection = createConnection(ProposedFeatures.all); const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument); documents.onDidChangeContent(change => { // 文檔發(fā)生變化時(shí),校驗(yàn)文檔 validateTextDocument(change.document); }); async function validateTextDocument(textDocument: TextDocument): Promise<void> { ... // 拿到診斷結(jié)果 const diagnostics = getDiagnostics(textDocument, settings); // 發(fā)給客戶端 connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); } // 提供快速修復(fù)的操作 connection.onCodeAction(provideCodeActions); async function provideCodeActions(params: CodeActionParams): Promise<CodeAction[]> { ... return quickfix(textDocument, params); }
在完成上面客戶端與服務(wù)端交互之后,可以注意到這兩個(gè)方法 getDiagnostics(textDocument, settings)
和 quickfix(textDocument, params)
。 這兩個(gè)方法分別是為文檔提供診斷數(shù)據(jù)和快速修復(fù)的操作。
代碼診斷
整體流程
1. 將代碼文檔轉(zhuǎn)成 AST 語(yǔ)法樹
在處理客戶端傳遞過(guò)來(lái)的 Vue 代碼文本的,需要通過(guò) vue/compiler-dom 解析成三部分 ast 格式的數(shù)據(jù)結(jié)構(gòu),分別是 template、JS、CSS, 由于現(xiàn)在前端代碼使用的都是 TypeScript,JS 部分沒有解析成 AST,因此需要使用 babel/parser 去解析 TypeScript 代碼生成最終的 JS 的 AST 數(shù)據(jù)結(jié)構(gòu)。
const VueParser = require('@vue/compiler-dom'); // 該函數(shù)返回診斷結(jié)果客戶端 function getDiagnostics(textDocument: TextDocument, settings: any): Diagnostic[] { const text = textDocument.getText(); const res = VueParser.parse(text); const [template, script] = res.children; return [ ...analyzeTemplate(template), // 解析 template 得到診斷結(jié)果 ...analyzeScript(script, textDocument), // 解析 js 得到診斷結(jié)果 ]; } // 分析 js 語(yǔ)法 function analyzeScript(script: any, textDocument: TextDocument) { const scriptAst = parser.parse(script.children[0]?.content, { sourceType: 'module', plugins: [ 'typescript', // typescript ['decorators', { decoratorsBeforeExport: true }], // 裝飾器 'classProperties', // ES6 class 寫法 'classPrivateProperties', ], });
得到的 AST 語(yǔ)法樹結(jié)構(gòu)如下:
Template AST
JS AST
2. 遍歷語(yǔ)法樹對(duì)代碼校驗(yàn)
在得到代碼的語(yǔ)法樹之后,我們需要對(duì)每一個(gè)代碼節(jié)點(diǎn)進(jìn)行檢查,來(lái)判斷是否符合 Code Review 的要求,因此需要遍歷語(yǔ)法樹來(lái)對(duì)每個(gè)節(jié)點(diǎn)處理。
使用深度優(yōu)先搜索對(duì) template 的 AST 進(jìn)行遍歷:
function deepLoopData( data: AstTemplateInterface[], handler: Function, diagnostics: Diagnostic[], ) { function dfs(data: AstTemplateInterface[]) { for (let i = 0; i < data.length; i++) { handler(data[i], diagnostics); // 在這一步對(duì)代碼進(jìn)行處理 if (data[i]?.children?.length) { dfs(data[i].children); } else { continue; } } } dfs(data); } function analyzeTemplate(template: any) { const diagnostics: Diagnostic[] = []; deepLoopData(template.children, templateHandler, diagnostics); return diagnostics; } function templateHandler(currData: AstTemplateInterface, diagnostics: Diagnostic[]){ // ...對(duì)代碼節(jié)點(diǎn)檢查 }
而對(duì)于 JS AST 遍歷,可以使用 babel/traverse 遍歷:
traverse(scriptAst, { enter(path: any) { ... } }
3. 發(fā)現(xiàn)不合規(guī)代碼,生成診斷
根據(jù) ast 語(yǔ)法節(jié)點(diǎn)去判斷語(yǔ)法是否合規(guī),如果不符合要求,需要在代碼處生成診斷,一個(gè)基礎(chǔ)的診斷對(duì)象(diagnostics)包括下面幾個(gè)屬性:
-
range: 診斷有問(wèn)題的范圍,也就是畫波浪線的地方
-
severity: 嚴(yán)重性,分別有四個(gè)等級(jí),不同等級(jí)標(biāo)記的顏色不同,分別是:
- Error: 1
- Warning: 2
- Information:3
- Hint:4
-
message: 診斷的提示信息
-
source: 來(lái)源,比如說(shuō)來(lái)源是 Eslint
-
data:攜帶數(shù)據(jù),可以將修復(fù)好的數(shù)據(jù)放在這里,用于后面的快速修復(fù)功能
比如實(shí)現(xiàn)一個(gè)提示函數(shù)過(guò)長(zhǎng)的診斷:
function isLongFunction(node: Record<string, any>) { return ( // 如果結(jié)束位置的行 - 開始位置的行 > 80 的話,我們認(rèn)為這個(gè)函數(shù)寫得太長(zhǎng)了 node.type === 'ClassMethod' && node.loc.end.line - node.loc.start.line > 80 ); }
在遍歷 AST 時(shí)如果遇到某個(gè)節(jié)點(diǎn)是出現(xiàn)函數(shù)過(guò)長(zhǎng)的時(shí)候,就往診斷數(shù)據(jù)中添加此診斷
traverse(scriptAst, { enter(path: any) { const { node } = path; if (isLongFunction(node)) { const diagnostic: Diagnostic ={ severity: DiagnosticSeverity.Warning, range: getPositionRange(node, scriptStart), message: '盡可能保持一個(gè)函數(shù)的單一職責(zé)原則,單個(gè)函數(shù)不宜超過(guò) 80 行', source: 'Code Review 指南', } diagnostics.push(diagnostic); } ... } });
文檔中所有的診斷結(jié)果會(huì)保存在 diagnostics 數(shù)組中,最后通過(guò)交互返回給客戶端。
4. 提供快速修復(fù)
上面那個(gè)函數(shù)過(guò)長(zhǎng)的診斷沒辦法快速修復(fù),如果能快速修復(fù)的話,可以將修正后的結(jié)果放在 diagnostics.data
。換個(gè)例子寫一個(gè)快速修復(fù), 比如 Vue template 屬性排序不正確,我們需要把代碼自動(dòng)修復(fù)
// attributeOrderValidator 得到判斷結(jié)果 和 修復(fù)后的代碼 const {isGoodSort, newText} = attributeOrderValidator(props, currData.loc.source); if (!isGoodSort) { const range = { start: { line: props[0].loc.start.line - 1, character: props[0].loc.start.column - 1, }, end: { line: props[props.length - 1].loc.end.line - 1, character: props[props.length - 1].loc.end.column - 1, }, } let diagnostic: Diagnostic = genDiagnostics( 'vue template 上的屬性順序', range ); if (newText) { // 如果有修復(fù)后的代碼 // 將快速修復(fù)數(shù)據(jù)保存在 diagnostic.data diagnostic.data = { title: '按照 Code Review 指南的順序修復(fù)', newText, } } diagnostics.push(diagnostic); }
quickfix(textDocument, params)
export function quickfix( textDocument: TextDocument, params: CodeActionParams ): CodeAction[] { const diagnostics = params.context.diagnostics; if (isNullOrUndefined(diagnostics) || diagnostics.length === 0) { return []; } const codeActions: CodeAction[] = []; diagnostics.forEach((diag) => { if (diag.severity === DiagnosticSeverity.Warning) { if (diag.data) { // 如果有快速修復(fù)數(shù)據(jù) // 添加快速修復(fù) codeActions.push({ title: (diag.data as any)?.title, kind: CodeActionKind.QuickFix, // 快速修復(fù) diagnostics: [diag], // 屬于哪個(gè)診斷的操作 edit: { changes: { [params.textDocument.uri]: [ { range: diag.range, newText: (diag.data as any)?.newText, // 修復(fù)后的內(nèi)容 }, ], }, }, }); } } });
有快速修復(fù)的診斷會(huì)保存在 codeActions
中,并且返回給客戶端, 重新回看交互的代碼,在 documents.onDidChangeContent
事件中,通過(guò) connection.sendDiagnostics({ uri: textDocument.uri, diagnostics })
把診斷發(fā)送給客戶端。quickfix
結(jié)果通過(guò) connection.onCodeAction
發(fā)給客戶端。
import { createConnection TextDocuments, ProposedFeatures, ... } from 'vscode-languageserver/node'; const connection = createConnection(ProposedFeatures.all); const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument); documents.onDidChangeContent(change => { ... // 拿到診斷結(jié)果 const diagnostics = getDiagnostics(textDocument, settings); // 發(fā)給客戶端 connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); }); // 提供快速修復(fù)的操作 connection.onCodeAction(provideCodeActions); async function provideCodeActions(params: CodeActionParams): Promise<CodeAction[]> { ... return quickfix(textDocument, params); }
總結(jié)
實(shí)現(xiàn)一個(gè)代碼診斷的插件功能,需要兩個(gè)步驟,首先建立語(yǔ)言服務(wù)器,并且建立客戶端與語(yǔ)言服務(wù)器的交互。接著需要 服務(wù)器根據(jù)客戶端的代碼進(jìn)行校驗(yàn),把診斷結(jié)果放入 Diagnostics
,快速修復(fù)結(jié)果放在 CodeActions
,通過(guò)與客戶端的通信,把兩個(gè)結(jié)果返回給客戶端,客戶端即可出現(xiàn)黃色波浪線的問(wèn)題提示。