1. 程式人生 > 實用技巧 >Bigfish VSCode 外掛開發實踐

Bigfish VSCode 外掛開發實踐

原文連結

https://zhuanlan.zhihu.com/p/259344620

前言

Bigfish 是螞蟻集團企業級前端研發框架,基於umi微核心框架,Bigfish = umi +preset-react+ 內部 presets。

前天釋出了Bigfish VSCode 外掛,開發過程中遇到了不少問題,除了官方文件外,沒有一個很好的指南,索性將 VSCode 外掛開發過程記錄下,讓後面的同學可以更好地開發 VSCode 外掛,因為篇幅有限,講清楚得來個系列。

同時也有一些思考,可不可以用 umi 直接開發 VSCode 外掛?

快速開始

讓我們從零開始開發一個外掛吧,首先我們需要先安裝一個VSCode Insiders

(類似 VSCode 開發版),這樣可以在相對純淨的外掛環境進行研發,同時建議用英文版,這樣在看microsoft/vscode原始碼時,更容易定位到具體程式碼。

初始化

這裡直接使用官方的腳手架生成,用npx不用全域性-g安裝

➜ npx --ignore-existing -p yo -p generator-code yo code

     _-----_     ╭──────────────────────────╮
    |       |    │   Welcome to the Visual  │
    |--(o)--|    │   Studio Code Extension  │
   `---------´   │        generator!        │
    ( _´U`_ )    ╰──────────────────────────╯
    /___A___\   /
     |  ~  |
   __'.___.'__
 ´   `  |° ´ Y `

? What type of extension do you want to create? New Extension (TypeScript)
? What's the name of your extension? hello-world
? What's the identifier of your extension? hello-world
? What's the description of your extension?
? Initialize a git repository? Yes
? Which package manager to use? yarn

然後用 VSCode Insiders 開啟 hello-world 專案,點選 『Run Extension』會啟動一個 [Extension Development Host] 視窗,這個視窗會載入我們的外掛

腳手架裡外掛預設是輸入 『Hello World』然後右下角彈窗

至此,一個 VSCode 外掛初始化就完成啦 ~

目錄結構

首先我們從專案目錄結構來了解下外掛開發,組織上和我們 npm 庫基本一樣

.
├── CHANGELOG.md
├── README.md
├── .vscodeignore # 類似 .npmignore,外掛包裡不包含的檔案
├── out # 產物
│   ├── extension.js
│   ├── extension.js.map
│   └── test
│       ├── runTest.js
│       ├── runTest.js.map
│       └── suite
├── package.json # 外掛配置資訊
├── src
│   ├── extension.ts # 主入口檔案
│   └── test # 測試
│       ├── runTest.ts
│       └── suite
├── tsconfig.json
└── vsc-extension-quickstart.md

package.json

{
  "name": "hello-world",
    "displayName": "hello-world",
    "description": "",
    "version": "0.0.1",
    "engines": {
        "vscode": "^1.49.0"
    },
    "categories": [
        "Other"
    ],
    "activationEvents": [
    "onCommand:hello-world.helloWorld"
    ],
    "main": "./out/extension.js",
    "contributes": {
        "commands": [
            {
                "command": "hello-world.helloWorld",
                "title": "Hello World"
            }
        ]
    },
    "scripts": {
        "vscode:prepublish": "yarn run compile",
        "compile": "tsc -p ./",
        "lint": "eslint src --ext ts",
        "watch": "tsc -watch -p ./",
        "pretest": "yarn run compile && yarn run lint",
        "test": "node ./out/test/runTest.js"
    },
    "devDependencies": {}
}

VSCode 開發配置複用了 npm 包特性,詳見Fields,但有幾個比較重要的屬性:

  • main就是外掛入口,實際上就是src/extension.ts編譯出來的產物
  • contributes可以理解成 功能宣告清單,外掛有關的命令、配置、UI、snippets 等都需要這個欄位

外掛入口

我們來看一下src/extension.ts

// src/extension.ts

// vscode 模組不需要安裝,由外掛執行時注入
import * as vscode from 'vscode';

// 外掛載入時執行的 activate 鉤子方法
export function activate(context: vscode.ExtensionContext) {

    console.log('Congratulations, your extension "hello-world" is now active!');

  // 註冊一個命令,返回 vscode.Disposable 物件,該物件包含 dispose 銷燬方法
    let disposable = vscode.commands.registerCommand('hello-world.helloWorld', () => {
        // 彈出一個資訊框訊息
        vscode.window.showInformationMessage('Hello World from hello-world!');
    });

    // context 訂閱註冊事件
    context.subscriptions.push(disposable);
}

// 外掛被使用者解除安裝時呼叫的鉤子
export function deactivate() {}

我們只需要暴露activatedeactivate兩個生命週期方法,外掛就能運行了。

功能

作為外掛,提供哪些功能呢?這裡整理了一個思維導圖,同時也可以對照官方文件來看:

這裡我們以一個點選『開啟頁面』 彈出 webview 的例子,來串一下所用到的 VSCode 功能

外掛清單宣告

外掛清單宣告(Contribution Points)是我們需要首先關注的,位於package.jsoncontributes屬性,這裡面可以宣告 VSCode 大部分配置、UI 擴充套件、快捷鍵、選單等。

為了找到我們對應配置項,VSCode 編輯器佈局圖會更直觀的感受

根據例子,我們需要在Editor Groups裡新增一個按鈕,同時需要註冊一個命令,也就是如下配置:

{
  "contributes": {
      "commands": [
        {
           "command": "hello-world.helloWorld",
           "title": "Hello World"
        },
+       {
+           "command": "hello-webview.helloWorld",
+           "title": "開啟頁面"
+       }
      ],
+     "menus": {
+       "editor/title": [
+           {
+               "command": "hello-webview.helloWorld",
+               "group": "navigation@0"
+           }
+       ]
+   }
    }
}

其中 命令 和 選單 的型別如下,可以根據需求增加更多個性化配置,配置型別見menusExtensionPoint.ts#L451-L485

註冊命令(commands)

一個命令可以理解一個功能點,比如開啟 webview 就是一個功能,那麼我們使用vscode.commands.registerCommand註冊 開啟 webview 這個功能:

// src/extension.ts

export function activate(context: vscode.ExtensionContext) {
    context.subscriptions.push(
    vscode.commands.registerCommand('hello-webview.helloWorld', () => {

    })
  )
}

我們可以看下registerCommand方法定義:

/**
 * Registers a command that can be invoked via a keyboard shortcut,
 * a menu item, an action, or directly.
 *
 * Registering a command with an existing command identifier twice
 * will cause an error.
 *
 * @param command A unique identifier for the command.
 * @param callback A command handler function.
 * @param thisArg The `this` context used when invoking the handler function.
 * @return Disposable which unregisters this command on disposal.
 */
export function registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any): Disposable;

其中command要與我們前面package.json宣告的命令要一致,callback就是呼叫後做什麼事,返回的是一個Disposable型別,這個物件很有意思,可在外掛退出時執行銷燬dispose方法。

開啟 webview

這裡需要用到Webview API,因為有 webview,擴充套件了 VSCode UI 和互動,提供了更多的想象力

const panel = vscode.window.createWebviewPanel('helloWorld', 'Hello World', vscode.ViewColumn.One, {
    enableScripts: true,
});
panel.webview.html = `
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Hello World</title>
    </head>
    <body>
        <iframe width="100%" height="500px" src="https://www.yunfengdie.com/"></iframe>
    </body>
    </html>
`;
panel.onDidDispose(async () => {
    await vscode.window.showInformationMessage('關閉了 webview');
}, null, context.subscriptions);

這裡要注意的點是,html 中的本地 url 地址需要轉一道,不然無法執行,例如

- <script src="/bar.js"></script>
+ <script src="${panel.webview.asWebviewUri(vscode.Uri.file(path.join(__dirname, 'bar.js')))}"></script>

✈️ 進階

上面提到的功能只是 VSCode 功能的冰山一角,更多的功能遇到時查文件就會用了,這裡有幾點進階的部分。

命令系統

VSCode 的命令系統是一個很好的設計,優勢在於:中心化註冊一次,多地扁平化消費

我個人覺得更重要的一點在於:

  • 先功能後互動:VSCode 提供的 UI 和互動有限,我們可以先不用糾結互動,先把功能用命令註冊,再看互動怎麼更好
  • 靈活性:比如 VSCode 增加了一種新互動形式,只需要一行配置就可以接入功能,非常方便

另外官網也內建了一些命令,可直接通過vscode.commands.executeCommand使用。

when 上下文

如果希望在滿足特定條件,才開啟外掛某個功能/命令/介面按鈕,這時候可以藉助外掛清單裡的when上下文來處理,例如檢測到是 Bigfish 應用(hello.isBigfish)時開啟:

"activationEvents": [
  "*"
],
"contributes": {
  "commands": [
    {
      "command": "hello-world.helloWorld",
      "title": "Hello World",
    },
    {
      "command": "hello-webview.helloWorld",
      "title": "開啟頁面",
    }
  ],
  "menus": {
    "editor/title": [
      {
        "command": "hello-webview.helloWorld",
        "group": "navigation@0",
+       "when": "hello.isBigfish"
      }
    ]
  }
},

如果直接這樣寫,啟動外掛時,會看到之前的『開啟頁面』按鈕消失,這個值的設定我們用 VSCode 內建的setContext命令:

vscode.commands.executeCommand('setContext', 'hello.isBigfish', true);

這時候我們開啟就有按鈕了,關於狀態什麼時候設定,不同外掛有自己的業務邏輯,這裡不再贅述。

這裡的when可以有簡單的表示式組合,但是有個坑點是不能用(),例如:

- "when": "bigfish.isBigfish && (editorLangId == typescriptreact || editorLangId == typescriptreact)"
+ "when": "bigfish.isBigfish && editorLangId =~ /^typescriptreact$|^javascriptreact$/"

結合 umi

webview 的部分,如果單寫 HTML 明顯回到了 jQuery 時代,能不能將umi聯絡起來呢?實際上是可以的,只是我們需要改一些配置。

首先對 umi,

  1. devServer.writeToDist:需要在 dev 時寫檔案到輸出目錄,這樣保證開發階段有 js/css 檔案
  2. history.type:使用記憶體路由MemoryRouter,webview 裡是沒有 url 的,這時候瀏覽器路由基本是掛的。
import { defineConfig } from 'umi';

export default defineConfig({
  publicPath: './',
  outputPath: '../dist',
  runtimePublicPath: true,
  history: {
    type: 'memory',
  },
  devServer: {
    writeToDisk: filePath => ['umi.js', 'umi.css'].some(name => filePath.endsWith(name)),
  },
});

載入 webview,這時候就是把umi.cssumi.js轉下路徑:

this.panel.webview.html = `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
        <link rel="stylesheet" href="${this.panel.webview.asWebviewUri(
          vscode.Uri.file(path.join(distPath, 'umi.css')),
        )}" />
        <script>window.routerBase = "/";</script>
        <script>//! umi version: 3.2.14</script>
      </head>
      <body>
        <div id="root"></div>
        <script src="${this.panel.webview.asWebviewUri(vscode.Uri.file(path.join(distPath, 'umi.js')))}"></script>
      </body>
    </html>`;

然後就可以用我們的 umi 開發 webview 了

除錯

這裡的除錯分兩個:外掛除錯、webview 除錯。

外掛除錯直接用 VSCode 內建的斷點,非常方便

webview 的除錯我們通過command + shift + p呼叫Open Webview Developer Tools來除錯 webview

支援 CloudIDE

CloudIDE 相容 VSCode API,但也有一些不相容的 API(如vscode.ExtensionMode),為了保證同時相容,用到了 CloudIDE 團隊寫的 @ali/ide-extension-check,可直接掃當前是否相容 CloudIDE,這裡把它做成一個 CI 流程,自動化釋出、文件同步

Icon 圖示

為了更好的體驗,可以使用官網內建的圖示集,例如:

只需要使用$(iconIdentifier)格式來表示具體 icon

{
  "contributes": {
        "commands": [
            {
                "command": "hello-world.helloWorld",
                "title": "Hello World"
            },
           {
            "command": "hello-webview.helloWorld",
            "title": "開啟頁面",
+           "icon": "$(browser)",
           }
        ],
    }
}

但是在 CloudIDE 中,內建的不是 VSCode icon,而是antd Icon。為了同時相容 CloudIDE 和 VSCode,直接下載vscode-icons,以本地資源形式展現。

{
  "contributes": {
        "commands": [
            {
                "command": "hello-world.helloWorld",
                "title": "Hello World"
            },
        {
            "command": "hello-webview.helloWorld",
            "title": "開啟頁面",
+           "icon": {
+             "dark": "static/dark/symbol-variable.svg",
+             "light": "static/light/symbol-variable.svg"
+           },
        }
        ],
    }
}

打包、釋出

部署上線前需要註冊 Azure 賬號,具體步驟可以按官方文件操作。

包體積優化

腳手架預設的是tsc只做編譯不做打包,這樣從原始檔釋出到外掛市場包含的檔案就有:

- out
  - extension.js
  - a.js
  - b.js
  - ...
- dist
  - umi.js
  - umi.css
  - index.html
- node_modules # 這裡的 node_modules,vsce package --yarn 只提取 dependencies 相關包
    - ...
- package.json

那邊 Bigfish 外掛第一次打包是多大呢? 11709 files,16.95MB

為了繞過這個node_modules,思路是通過webpack將不進行 postinstall 編譯的依賴全打進extension.js裡,webpack 配置如下:

'use strict';

const path = require('path');

const tsConfigPath = path.join(__dirname, 'tsconfig.json');
/** @type {import("webpack").Configuration} */
const config = {
  target: 'node',
  devtool: process.env.NODE_ENV === 'production' ? false : 'source-map',
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
  entry: './src/extension.ts',
  externals: {
    vscode: 'commonjs vscode',
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        loader: 'ts-loader',
        options: {
          transpileOnly: true,
          configFile: tsConfigPath,
        },
      },
    ],
  },
  output: {
    devtoolModuleFilenameTemplate: '../[resource-path]',
    filename: 'extension.js',
    libraryTarget: 'commonjs2',
    path: path.resolve(__dirname, 'out'),
  },
  resolve: {
    alias: {
      '@': path.join(__dirname, 'src'),
    },
    extensions: ['.ts', '.js'],
  },
  optimization: {
    usedExports: true
  }
};

module.exports = config;

.vscodeignore里加上node_modules,不發到市場,這樣包結構就變成了

- out
  - extension.js
- dist
    - umi.js
    - umi.css
    - index.html
- package.json

最後的包大小為: 24 files,1.11MB,從16.95M1.11M,直接秒級安裝。

Made by ChartCube

預編譯依賴 & 安全性

之前一直想著把 Bigfish core 包(@umijs/core)打到 外掛包裡,基本沒成功過,原因在於 core 依賴了fsevents,這個包要根據不同 OS 安裝時做編譯,所以沒辦法打到包裡:

- [fail] cjs (./src/extension.ts -> out/extension.js)Error: Build failed with 2 errors:
node_modules/fsevents/fsevents.js:13:23: error: File extension not supported:
node_modules/fsevents/fsevents.node
node_modules/@alipay/bigfish-vscode/node_modules/prettier/third-party.js:9871:10:
error: Transforming for-await loops to the configured target environment is not
supported yet

同時像一些內部的 sdk 包(@alipay/oneapi-bigfish-sdk)如果打進包,會有一定的安全風險,畢竟包是發到外部外掛市場。

解決這兩個問題,採用了動態引用依賴,直接引使用者專案已有的依賴(Bigfish 專案內建 oneapi sdk 包),這樣一是包體積小,二是包安全性高。

import resolvePkg from 'resolve-pkg';

// origin require module
// https://github.com/webpack/webpack/issues/4175#issuecomment-342931035
export const cRequire = typeof __webpack_require__ === "function" ? __non_webpack_require__ : require;

// 這樣引用是為了避免內部包洩露到 外部外掛市場
const OneAPISDKPath = resolvePkg('@alipay/oneapi-bigfish-sdk', {
  cwd: this.ctx.cwd,
});
this.OneAPISDK = cRequire(OneAPISDKPath);

釋出

直接用官方的vsce工具:

  • vsce publish patch:發 patch 版本
  • vsce package:輸出外掛包檔案.vsix

沒有打包依賴的外掛:

  • vsce publish patch --yarn:發 patch 版本,包含生產依賴的 node_modules
  • vsce package --yarn:輸出外掛包檔案.vsix,包含生產依賴的 node_modules

❓ 思考

幾乎每個 VSCode 外掛的開發方式都不一樣,缺少最佳實踐(commands、provider 註冊、services 的消費、webview 的開發等)

細思下來,能不能借鑑按 SSR 方案,其實僅用一個 umi 是可以編譯打包 VSCode 外掛 + webview 的(名子想了下,可能是vsue),覺得比較好的目錄結構是:

- snippets
- src
  - commands # 命令,根據檔名自動註冊
    - hello-world.ts
    - services # 功能建模,掛載到 ctx 上,通過 ctx.services 呼叫
    - A.ts
    - B.ts
  - providers # Provider 類,擴充套件 VSCode 預設互動、UI
    - TreeDataProvider.ts
  - utils # 工具類,ctx.utils.abc 呼叫
  - constants.ts
    - extension.ts
- static
    - dark
    - a.png
  - light
- webview # webview 應用
    - mock
    - src
    - pages
- test
- .umirc.ts # 同時跑 前端 和 外掛 編譯和打包
- package.json

umi 配置檔案可能就是:

export default defineConfig(
 {
  entry: './webview',
  publicPath: './',
  outputPath: './dist',
  history: {
    type: 'memory',
  },
  devServer: {
    writeToDisk: filePath => ['umi.js', 'umi.css'].some(name => filePath.endsWith(name)),
  },
  // VSCode 外掛打包相關配置
  vscode: {
    entry: './src',
    // 外掛依賴這個包,沒有則提示安裝(更多功能擴充套件)
    globalDeps: ['@alipay/bigfish'],
    // 全量打包
    // bundled: true,
  }
 }
)

最終外掛包結構為:

- dist
  - umi.js
  - umi.css
  - index.html
- out
  - extension.js
- package.json

開發過程只需要umi dev可將外掛端 + webview(如果有)同時編譯,直接 VSCode 除錯即可,支援熱更新(待驗證)

有興趣的同學可以勾搭一起討論,歡迎聯絡 [email protected] ~

參考