file

最近 VS Code 在 1.57 版推出了一個新功能是內建對於 JSDoc@link 語法的支援,其效果是可以將註解當中的符號與原始碼中真正的符號連結起來。如此一來,一方面我們可以在顯示出註解的時候、直接按下上面的連結來前往對應的符號,另一方面如果我們對符號進行重新命名的重構、則被連結的符號也會跟著更新,這對於維護註解文件來說真的很方便。

不過這個功能雖然方便,用起來卻還是有一點讓人不禁強迫症發作。首先,假如我要參照的是一個類別的成員,那我就必須連同類別一起寫出來,即便我的 JSDoc 是寫在同一個類別裡面也一樣:

class MyClass {
    public myProp: string;

    /** 這個方法跟 {@link MyClass.myProp} 有關 */
    public myMethod(): void { }
}

除此之外,如果連結的對象是一個方法,那寫連結的時候不應該把 () 也加上去,否則就沒有辦法正確地連結:

class MyClass {
    /** 這個屬性跟 {@link MyClass.myMethod} 有關 */
    public myProp: string;

    /** 這個方法跟 {@link MyClass.myProp} 有關 */
    public myMethod(): void { }
}

問題是我們在閱讀上其實會比較希望:1. 不用多餘地呈現出當前所在的類別,2. 函數或方法可以加上括號、比較清楚。確實 JSDoc 也提供了這樣的語法,允許我們用任意的替換文字來決定呈現結果:

class MyClass {
    /** 這個屬性跟 {@link MyClass.myMethod myMethod()} 有關 */
    public myProp: string;

    /** 這個方法跟 {@link MyClass.myProp myProp} 有關 */
    public myMethod(): void { }
}

如此一來,在 VS Code 中的呈現效果就會像這樣:

file

OK,確實當滑鼠移到符號上的時候看到的註解非常清楚了……但是註解的原始碼本身卻因此奇醜無比。難道沒辦法讓註解的原始碼也一樣跟著用好看一點的方式呈現嗎?

我是不知道會不會未來 VS Code 自己也加入了這個功能,但是在那之前,我想說就趁這個機會來自己寫一個 VS Code 的延伸模組(這是官方的用詞,底下簡稱外掛)來解決這項需求;我取名為 JSDoc Link。本篇就來分享其中的心得。

建立外掛專案

官方提供了一個很好的範本來讓我們可以快速上手建立外掛專案,詳情可以參見官方的教學文章。簡單來說,首先必須安裝 Yeoman(一個非常普及的鷹架工具,不過我個人可能更喜歡用 Sao 一點點,這個改天有空再談)以及 generator-code(哇靠,VS Code 是怎麼搶到「code」這個字的啊?簡直匪夷所思),然後執行 yo code、填一填問題就可以造出外掛專案了(會順便幫你執行 npm install,所以專案一打開馬上可以上路)。

注意:稍後會提到的發布工具 vsce 目前並不支援 PNPM;這件事有一個積了很久的 issue 但至今未有完美解法。所以暫時 VS Code 延伸模組的專案都只能用 NPM(或 Yarn 似乎也可以)來管理。

用 VS Code(當然)開啟專案之後,只要按下 F5 就可以進行偵錯執行;此時會打開一個新的 VS Code 視窗,你會看到標題列上面寫著「延伸模組開發主機」,這個特別的 VS Code 實體就是裝著我們正在開發中的外掛,於是就可以直接測試外掛的執行效果。

小秘訣 1:開發主機預設會打開前一次執行時停留的工作區(若有的話);如果按下 F5 之後發現狀態列一直在轉圈圈「正在建置…」卻沒有開啟 VS Code 視窗,這可能是因為你已經開啟了另一個 VS Code 視窗是跟「即將打開的開發主機」為相同工作區的。此時只要再按一次那個「正在建置…」,應該就會重新開啟一個空白的 VS Code 開發主機視窗了。

小秘訣 2:如果之後已經發佈了開發的外掛並且安裝到了自己的 VS Code 上頭,那麼再次執行這個開發主機的時候會不會正式版外掛跟開發版外掛之間有衝突?我實驗的結果是不會,此時在開發主機視窗裡面會自動以開發版的外掛取代掉正式版外掛的功能,可以放心。

撰寫外掛

曾聽過有人評論「VS Code 不是一個編輯器,是一個框架」,這個說法並不誇張,因為 VS Code 的外掛能做的事情實在太多了,我這邊連要講個大概都不可能(況且我也不需要,官方教學文件講得夠清楚了)。所以我這邊只針對我這個外掛中有用到的部份做簡略的解說就好。

這個外掛要做的事情是「找出當前編輯器中特定模式的字串、並且在呈現上加以取代」,而之前我在這篇文章中曾經提到過的 i18n Ally 外掛也做了非常類似的事情:它會把翻譯字串代碼替換成實際的翻譯文字(的摘要)呈現在畫面上,因此我這個外掛的程式碼很大的程度上參考了該外掛的技巧(這就是活在開源時代的福利,很多東西都直接參考現成的就馬上能學會,再也不用翻遍文件找答案了)。

底下我直接針對重點的地方講解;完整的程式碼在 GitHub 上頭,讀者有興趣可以自己參照。

package.json

主要的重點在於:

    // 設定啟動條件為只要有跟 JS 或 TS 沾得上邊的東西被開啟的話,
    // 就啟動這個外掛。設成 "*" 當然也是一個可行的偷懶辦法,
    // 不過沒事最好還是不要隨便消耗裝這個外掛的使用者的裝置效能。
    "activationEvents": [
        "onLanguage:javascript",
        "onLanguage:javascriptreact",
        "onLanguage:typescript",
        "onLanguage:typescriptreact",
        "onLanguage:vue",
        "onLanguage:svelte",
        "workspaceContains:*.js",
        "workspaceContains:*.jsx",
        "workspaceContains:*.ts",
        "workspaceContains:*.tsx",
        "workspaceContains:*.vue",
        "workspaceContains:*.svelte"
    ],
    "contributes": {
        // 這個外掛完全不需要提供選項、命令等等的東西、
        // 純粹在背景中跑就好了,因此這邊為空
    },

extension.ts

extension.ts 是外掛的主體程式碼;這個外掛做的事情很精簡,因此一個檔案就搞定了,不用太複雜。

註冊事件

extension.ts 輸出了一個函數為 activate(),這個函數顧名思義就是在外掛啟動的時候會被執行,而在這裡面我們將來註冊我們要監聽的事件:

import * as vs from 'vscode';

const THROTTLE_DELAY = 800;

export function activate(context: vs.ExtensionContext): void {

    ...

    // 把等一下會看到的 process 函數做一個 throttle,以增進效能
    // 這個 throttle 是模仿 lodash 的作法簡化寫成的;
    // 原本我是直接引用 lodash,但是被嫌打包的檔案太多,就想說自己刻一個算了
    const throttledProcess = throttle(process, THROTTLE_DELAY);

    // 當切換了編輯器的時候當然要更新畫面。
    vs.window.onDidChangeActiveTextEditor(throttledProcess, null, context.subscriptions);

    // 只要選取範圍改變也都要更新;
    // 這包括輸入游標的移動、以及當然文字輸入也會觸發。
    // 我固然是要改變顯示效果,但我也一樣必須讓使用者仍然能編輯文字,
    // 因此只要使用者的選取範圍有包括到 @link 所在的那一列,
    // 就不要對那一列進行顯示轉換
    vs.window.onDidChangeTextEditorSelection(throttledProcess, null, context.subscriptions);

    // 改變當前檔案的時候也要更新畫面。
    vs.workspace.onDidChangeTextDocument(event => {
        const editor = vs.window.activeTextEditor;
        if(event.document == editor?.document) throttledProcess();
    }, null, context.subscriptions);

    // 啟動的時候總之先執行一次再說
    throttledProcess();
}

替換顯示文字的原理

VS Code 允許外掛在特定的文字上面加上裝飾(decoration),例如可以改變文字顏色、設置粗體斜體、加上底線外框等等,也可以在要裝是的文字的前面後面指定要插入某種東西(例如常見的應用是一個小圖示或色塊)。無論是要進行什麼樣的裝飾,首先要作的都是利用 window.createTextEditorDecorationType() 方法來產生一個「裝飾類別」。之後當我們呼叫 TextEditor.setDecorations() 方法的時候,編輯器會一口氣地把所有傳入的位置都加上指定類別的裝飾(且上次呼叫時加入的相同類別裝飾如果沒有再次列在傳入的位置之中,則那些裝飾會被移除,這個設計很方便,使得我們不用擔心裝飾會被重複加上去)。

然而,內建的 API 裡面並沒有「完全把特定的文字改顯示成另外的一些文字」這樣的機制存在。那 i18n Ally 是怎麼做到這個效果的?原來它使用了一個有點 hack 的技巧。關鍵在於宣告類似這樣的裝飾類別:

const hiddenDecorationType = vs.window.createTextEditorDecorationType({
    textDecoration: 'none; display:none;',
});

這邊利用了 VS Code 會把 textDecoration 屬性的內容完全照抄地寫進 CSS 裡頭的「漏洞」來「注入」任意的 CSS 進去。所以替換文字的真面目就是:先本原本的文字完全隱藏起來,待會再利用 DecorationOptions.after 去插入要替換的文字即可。

找出要替換的文字

底下是 process() 函數的開頭部份:

const supportedLang = ['javascript', 'typescript',
    'javascriptreact', 'typescriptreact', 'svelte', 'vue'];

function process(): void {
    const editor = vs.window.activeTextEditor;
    const document = editor?.document;
    if(!editor || !document) return;

    const lang = document.languageId;
    if(!supportedLang.includes(lang)) return;

    ...

這邊做的事情是確定當前開啟的檔案是支援的其中一種程式語言,不然就不要繼續處理。接下來 process() 做的事情就是先用 document.getText() 方法取得當前編輯器中的全部文字,找出裡面所有型如 /** ... */ 的部份,然後再去看這裡面有沒有型如 {@link text alt} 這樣的東西。找到了的話,我們就去產生一個 DecorationOptions 物件,其結構類似這樣:

{
    range: new vs.Range(start, end),
    renderOptions: {
        after: {
            color: linkColor,
            contentText: alt,
            fontStyle: 'normal'
        }
    }
}

其中 linkColor 是用內建方法去抓取當前主題中的連結文字顏色,以便顯示出來的效果跟當前使用者的佈景主題設定一致。

linkColor = new vs.ThemeColor('textLink.foreground');

另外注意到我設定了 fontStyle: 'normal',這是因為有些人會在佈景主題中設定註解要用斜體字顯示,但即便是那樣 @link 應該也還是要用一般字體,所以這邊我就自作主張地強制設定字體為一般字體了。

完成替換

找出全部要替換的目標之後,如前述地這邊再多做一項檢查,就是每一個替換目標有沒有包含在選取範圍所在的列上,沒有的話才真的要進行替換。

const selection = editor.selection;
const sets = decoratorSets.filter(d =>
    (selection.start.line > d.start.line || d.start.line > selection.end.line) &&
    (selection.start.line > d.end.line || d.start.line > selection.end.line)
);

在外掛裡面我定義了兩種裝飾類別,除了前述的 hiddenDecorationType 之外,還有另外一個:

const hoverEnableDecorationType = vs.window.createTextEditorDecorationType({
    textDecoration: 'none; display:inline-block; width:0; height:0; overflow:hidden;',
});

之所以要用這個是因為我實驗之後發現要用這樣的方式隱藏才能夠正確地把 hover 訊息的效果保留下來。於是 process() 函數所做的最後一件事就是把找到的替換位置依照性質分組成這兩種類別,然後再呼叫 editor.setDecorations() 方法完成替換。

這就是這個外掛所做的全部的事情。

上架外掛

上架外掛的部份官方教學文件也是講得滿仔細的,且官方工具 vsce 也算是滿好用的。跟 np 很類似,vsce 也是可以選擇 majorminorpatch 三種發佈等級,會自動增加 package.json 的版本號、認可並且上傳至 VS Code Marketplace。當然在發佈之前必須先註冊 Marketplace 和 Azure DevOps 的帳號,不過這些文件中都示範得很清楚,不贅述。

小秘訣 1:我的印象中,如果執行的是 vsce publish minor 的話(合理推測 major 應該也一樣),在發佈成功之後會順便自動開啟 GitHub 頁面來讓你填寫 Release 文案,但如果是 vsce publish patch 的話似乎就不會自動執行這個動作。你還是可以自己去 GitHub 填寫、如果想要的話,不過請千萬記得要先把認可 push 到 GitHub 上頭再做這個動作,否則到時候你真的要 push 的時候可能會遇到一堆問題(我曾經搞砸過一次,那後續的修正還有一點小麻煩,總之記得就對了)。

小秘訣 2:執行 vsce 之後會在 git 上面增加 tag,但是預設設定中 VS Code 並不會把 git tag 在 sync 的時候推送到遠端;如果要自動執行 tag 的推送,必須設定 git.followTagsWhenSynctrue

小秘訣 3:可以的話,盡量不要讓自己的外掛依賴別的套件以便縮小打包的大小;而如果非得要相依,那最好在建立專案的時候啟用「webpack 打包」的選項,如此一來就會幫你設定好打包機制、將相依性都包成一個檔案以方便發佈。


分享此頁至:
最後修改日期: 2023/03/21

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。