Bigfish VSCode 插件開發實踐

🖼 前言

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

前天發佈了 Bigfish VSCode 插件,開發過程當中遇到了很多問題,除了官方文檔外,沒有一個很好的指南,索性將 VSCode 插件開發過程記錄下,讓後面的同窗能夠更好地開發 VSCode 插件,由於篇幅有限,講清楚得來個系列。css

同時也有一些思考,可不能夠用 umi 直接開發 VSCode 插件?html

🍱 快速開始

讓咱們從零開始開發一個插件吧,首先咱們須要先安裝一個 VSCode Insiders(相似 VSCode 開發版),這樣能夠在相對純淨的插件環境進行研發,同時建議用英文版,這樣在看 microsoft/vscode 源碼時,更容易定位到具體代碼。前端

初始化

這裏直接使用官方的腳手架生成,用 npx 不用全局 -g 安裝java

➜ 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] 窗口,這個窗口會加載咱們的插件 a.png 腳手架裏插件默認是輸入 『Hello World』而後右下角彈窗 image.png 至此,一個 VSCode 插件的初始化就完成啦 ~node

目錄結構

首先咱們從項目目錄結構來了解下插件開發,組織上和咱們 npm 庫基本同樣react

.
├── 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,但有幾個比較重要的屬性:webpack

  • main 就是插件入口,實際上就是 src/extension.ts 編譯出來的產物
  • contributes 能夠理解成 功能聲明清單,插件有關的命令、配置、UI、snippets 等都須要這個字段

插件入口

咱們來看一下 src/extension.ts git

// 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() {}

複製代碼

咱們只須要暴露 activate 和 deactivate 兩個生命週期方法,插件就能運行了。github

🎨 功能

做爲插件,提供哪些功能呢?這裏整理了一個思惟導圖,同時也能夠對照官方文檔來看: VSCode 插件功能​.png

這裏咱們以一個點擊『打開頁面』 彈出 webview 的例子,來串一下所用到的 VSCode 功能 webview.gif

插件清單聲明

插件清單聲明(Contribution Points)是咱們須要首先關注的,位於 package.json 的 contributes 屬性,這裏面能夠聲明 VSCode 大部分配置、UI 擴展、快捷鍵、菜單等。

爲了找到咱們對應配置項,VSCode 編輯器佈局圖會更直觀的感覺 image.png 根據例子,咱們須要在 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 的命令系統是一個很好的設計,優點在於:中心化註冊一次,多地扁平化消費

image.png

我我的以爲更重要的一點在於:

  • 先功能後交互: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.css 和 umi.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 了 umi-webview.gif

調試

這裏的調試分兩個:插件調試、webview 調試。

插件調試直接用 VSCode 內置的斷點,很是方便 image.png

webview 的調試咱們經過 command + shift + p 調用 Open Webview Developer Tools 來調試 webview image.png

支持 CloudIDE

CloudIDE 兼容 VSCode API,但也有一些不兼容的 API(如 vscode.ExtensionMode ),爲了保證同時兼容,用到了 CloudIDE 團隊寫的 @ali/ide-extension-check,可直接掃當前是否兼容 CloudIDE,這裏把它作成一個 ci 流程 image.png

Icon 圖標

爲了更好的體驗,可使用官網內置的圖標集,例如: image.png 只須要使用 $(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
    - ...
- node_modules 
 	- package.json
複製代碼

最後的包大小爲: 24 files, 1.11MB ,從 16.95M 到 1.11M ,直接秒級安裝。 Column-20200925 (1).png

預編譯依賴 & 安全性

以前一直想着把 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 調試便可,支持熱更新(待驗證)

有興趣的同窗能夠勾搭一塊兒討論,歡迎聯繫 chaolin.jcl@antgroup.com ~

📚 參考

相關文章
相關標籤/搜索