作者:柳千

VS Code 自 2015 年推出以來,由于其優越的性能表現和簡潔的 UI 設計,迅速得到了開發者的青睐,特别是人數衆多的前端開發者。究其原因,是因為 VS Code 優秀的插件架構,通過多程序的模型隔離了插件運作環境,插件程序無法影響主程序的運作效率,這也使得 VS Code 有良好的穩定性表現。
今年下半年,有幸參與了開天 IDE 共建項目組, 打造阿裡生态體系内的公共 IDE 底層。插件生态是其中最為重要的一環,能夠無縫繼承 VS Code 的插件生态對開天 IDE 來說非常重要的一部分。本文簡要闡述了 VS Code 插件模型,從實際場景出發在這套體系之上初步建構出開天特有的插件擴充能力。
插件模型
從圖中可以看出,VS Code 插件系統是由一個 Node.js 程序管理插件的啟停及生命周期,插件程序與主程序之間是通過程序間 RPC 通信來實作插件邏輯相關調用。
編寫過 VS Code 插件的同學一定知道,VS Code 插件必須要引入一個名為 vscode 的子產品,這個子產品隻是一份 VS Code 插件 API 的類型聲明檔案,而插件程序在啟動時由 host 程序負責 API 注入。VS Code 會劫持預設 require 的行為,針對名為 vscode 的子產品會傳回一個定義好的對象。
// VS Code 插件程序源碼
function defineAPI(factory: IExtensionApiFactory,extensionPaths: TernarySearchTree<IExtensionDescription>,extensionRegistry: ExtensionDescriptionRegistry): void {
// each extension is meant to get its own api implementation
const extApiImpl = new Map<string, typeof vscode>();
let defaultApiImpl: typeof vscode;
// 已被全局劫持過的 require
const node_module = <any>require.__$__nodeRequire('module');
const original = node_module._load;
// 重寫 Module.prototype._load 方法
node_module._load = function load(request: string, parent: any, isMain: any) {
// 子產品名不是 vscode 調用原方法傳回子產品
if (request !== 'vscode') {
return original.apply(this, arguments);
}
// 這裡會為每一個插件生成一份獨立的 API
const ext = extensionPaths.findSubstr(URI.file(parent.filename).fsPath);
if (ext) {
let apiImpl = extApiImpl.get(ext.id);
if(!apiImpl){
// factory 函數會傳回所有 API
apiImpl = factory(ext, extensionRegistry);
extApiImpl.set(ext.id, apiImpl);
}
return apiImpl;
}
/* 省略部分代碼 */
}
}
運作時由插件 host 程序加載插件子產品,并傳入 context 參數:
// 插件程序僞代碼
const extModule = require('path/to/extension.js');
const extContext = createVSCodeExtensionContext();
const result = await extModule.activate(extContext
相容 VS Code 插件系統
VS Code 經過多年的疊代, 插件系統已經非常成熟, 有句話說站在巨人的肩膀上才能看得更遠。事實上社群已經有許多相容(類似) VS Code 插件系統的 IDE,譬如很多同學熟悉的 Eclipse Theia,以及 Vim 黨 veonim (Neovim)。甚至還有國人開發的以 VS Code LSP 協定為基礎,針對 Neovim 提供跨語言一緻補全體驗的 coc.nvim。
開天 IDE 天然相容 VSCode 插件系統。實作層面,我們可以将 vscode 子產品當成一份協定,在前端實作 vscode 子產品的 API,提供給插件一樣的接口與調用行為。在雲端環境下,IDE 的服務端會運作在一個容器或者伺服器上,前端通過 WebSocket 連接配接到服務端,而服務端的插件邏輯和上面基本一緻,插件在調用相關的 API 時,服務端會轉發消息到前端完成一次調用。
面臨的問題
雖然 VS Code 插件系統已經足夠完善,但其 UI 定制能力非常薄弱,主界面僅提供了少量的按鈕與菜單支援自定義。但很多實際場景都有非常強烈的 UI 需求以滿足不同的業務能力。一些公司基于開源 IDE 或是自研的方案來實作 UI 定制,在這個前提下,僅僅相容 VS Code 已有插件顯然無法滿足這些需求。例如支付寶小程式和微信小程式等 IDE 主界面都需要大量的按鈕菜單注入以及模拟器等預覽面闆。雖然 VS Code 提供的一些功能如 QuickPick,Webview 等可以滿足這些需求,但 QuickPick 略微繁瑣的互動體驗以及 Webview 的展現形式都令人如鲠在喉。
微信開發者工具
支付寶小程式 IDE
擴充之上
針對這些需求我們對插件系統做了更多的擴充,除了 Node.js 的插件運作時,開天 IDE 還支援工具欄,左右側面闆,以及底部面闆的 React 元件注入。
在這個基礎上,有幾個可選的方案來實作 UI 定制。
元件注入
IDE 通過暴露注入點的方式提供 UI 插槽,插件需要提供 browser 子產品,調用 API 并指定注入位置等資訊。
IDE 需要提供插件自定義的 Service 接口以供擴充元件調用。在這種場景下,一個插件被分為兩部分,browser 子產品負責界面注入及少量的業務邏輯,node 子產品負責提供 API 給元件調用,API 由 IDE 托管并以 props 的形式傳入擴充元件。
// Componentexport
const ToolBarComponent = (props) => {
props.service.alert('hello world.');
return (
<button>click me.</button>
);
}
// config
export default {
toolBar: {
position: POSITION.LEFT,
component: ToolBarComponent,
}
}
擴充插槽可以以 DOM 節點的方式提供,但其 UI 風格較難統一,由于是開發者自行編寫的元件,可能由于部門及業務差異,元件樣式風格差異較大。另外在元件調用 Node 子產品提供的 Services 時,onClick 等事件對象會作為參數傳遞給對應方法,但由于是 RPC 調用,無法對這一類參數進行序列化,需要将 Services 方法做一次包裝,調用方要顯式的傳遞需要的參數過去。
擴充點提供者與消費者
這種方案不直接注入元件,轉為由特定的一類插件實作一組插槽,可以自定 API 和參數,普通插件可以作為插槽消費者。例如 ToolBar 部分,A 插件聲明為一個插槽容器,提供一組 API,其它元件調用其 API 注冊按鈕等部件。IDE 需要實作插件之間互相通路的能力,同時對插槽提供者做特定區分。其中普通插件無法對樣式做修改,容器插件應該提供一些特定的元件,普通插件需要注冊 Command 或者以傳入回調函數的方式處理相關的事件。容器插件應該有較大的 UI 自由度,可以提供 browser 子產品聲明插槽位置,同時應該有内置的如按鈕,輸入框等基本元件。
容器插件:
// 容器插件 browser
import * as kaitian from 'kaitian';
import { ButtonBasic, InputBasic } from './components';
kaitian.registerSlotContainer({
position: kaitian.POSITION.LEFT,
container: Slot,
components: [
{
type: 'input-basic',
contributions: [
{
name: 'onchange',
type: 'function',
},
{
name: 'icon',
type: 'string',
},
{
name: 'label',
type: 'string',
},
{
name: 'position',
type: 'number',
}
],
component: InputBasic,
},
{
type: 'button-basic',
contributions: [
{
name: 'onclick',
type: 'function',
},
{
name: 'icon',
type: 'string',
},
{
name: 'label',
type: 'string',
},
{
name: 'position',
type: 'number',
}
],
component: ButtonBasic,
}
]
});
// 元件容器
class Slot extends React.Component {
registerComponents(configs) {
this.componentMaps = configs;
}
componentDidMount() {
const {kaitianExtendSet} = this.props;
if (kaitianExtendSet) {
// 給 node 子產品調用
kaitianExtendSet.set({
registerComponents: this.registerComponents,
});
}
}
render(){
return (
<div>
{this.componentMaps.map((com) => {
const Component = components.get(com.type);
<Component icon={com.icon} label={com.label} handler={(...args) => props.kaitianExtendService.eventHandler(id, ...args)} />
})}
</div>
);
}
}
// 容器插件 node
export function activate(context) {
const { componentProxy, registerExtendModuleService } = context;
const eventHandlerMap = new Map();
return {
registerComponents: (configs) => {
const serializeConfig = configs.map((config) => {
if(config.type === 'button-basic') {
const id = randomId();
// 事件回調放在 node 層,UI 事件隻發 id 和 args到 node 層。
eventHandlerMap.set(id, config.onclick);
return {...config, onclick: id};
}
// ...
});
componentProxy.container.registerComponents(configs);
},
eventHandler: (id, ...args) => {
const handler = eventHandlerMap.get(id);
handler(...args);
};
}
}
消費者插件:
// node
import * as kaitian from 'kaitian';
function activate(context) {
const container = kaitian.getSlotContainer('basic-slot-container');
contaier.activate(context);
.then((registry) => {
registry.registerComponents([
{
type: 'button-basic',
onclick: (args) => {
kaitian.window.showInformationMessage('Hello World');
// ...
},
icon: 'basic btn',
label: '按鈕',
position: 0,
}
]);
})
}
容器插件提供的元件和 API 需要一定的通用性,理想情況下應該是以業務方一個小組為基本單元,編寫自有的容器插件,其餘業務插件通過調用容器插件注冊的方式實作 UI 自定義。實作上這種方案略微複雜,對容器插件開發者和使用者來說都有一定的學習成本,API 設計需要盡可能簡潔易懂。
這種方案隻需要容器插件提供 UI 層面一些基礎的元件模闆,消費者插件不需要編寫 UI 代碼(browser 子產品),完全運作在 Node.js 程序中,相比之下安全性和穩定性更高,而且基于容器插件的能力,可以針對各種業務形态定制不同風格的 UI 樣式,統一插件的風格。
這兩種方案各有優劣,在目前版本的開天 IDE 中都可以實作,一般情況下具有獨立功能與 UI 的插件推薦使用第一種方式直接注入元件,而一些需要多個插件組合的複雜場景下,建議使用容器/消費者的方式,減小 UI 部分代碼的重複工作量,統一風格。基于容器/消費者的插件 UI 定制方案目前已經在内部場景中上線探索并驗證。
未來
工具的價值展現在是否能一定程度上解決問題進而提升工作效率,在這條路上我們才剛剛開始,還有許多未知的挑戰在等着我們。我們希望能以 IDE 為基礎打通研發體系,實作真正的全鍊路雲端開發。
關注「Alibaba F2E」
把握阿裡巴巴前端新動向