- Theia 插件系统功能非常强大,这么大的工程,依然能保持高质量的代码和清晰的架构,值得思考和学习
- VSCode Extension API 和配置的定义规范且抽象
Eclipse Theia 是一个可扩展的平台,可以利用最先进的 Web 技术开发多语言的 Cloud & Desktop IDE。
名词解释
- Theia:可拓展的 Cloud & Desktop IDE 平台。
- Theia Extension:Theia 是由一系列 Extension 组成,Extension 提供了处理 widgets, commands, handlers 等的能力,在编译时加载。
- Theia Plugin:概念上类似于 VSCode Extension,由 Theia 的其中一个 Extension :theia/packages/plugin-ext 定义了 加载机制、运行环境及 API 等。兼容 VSCode Extension ,功能更为强大,运行时加载。
- VSCode Extension:VSCode 运行时加载的 extensions,基于 VSCode Extension API 实现,概念上类似于 Theia 的 Plugin,运行时加载。
VSCode Extension 可以看成是 Theia Plugin 的子集。Theia Extension 与 Plugin 的界线:核心的、抽象的、编译时加载的采用 Extension;业务的、具体的、运行时加载的采用 Plugin。
Theia Plugin 类型分为前端和后端(VSCode 只有后端),其中后端运行在独立的插件进程;前端运行在 Web Worker 上,通过 postMessage 和 Browser 主进程通信。这里的 Web Worker 有点像微信小程序架构里面的 App Service。
概述
拓展 Theia Plugin 能力,让业务方简单、灵活地深度定制 IDE 的功能和界面。
动机
Theia Plugin 拓展方式和 能力 和 VSCode Extension 类似,并不满足我们的需求:
- UI 定制能力非常薄弱:主界面仅提供了少量的按钮与菜单支持自定义。但很多实际场景都有非常强烈的 UI 需求以满足不同的业务能力。如:例如 Taro IDE 主界面需要大量的按钮菜单注入以及模拟器、调试器等预览面板。
- 配置化的 UI 定制方式无法满足定制需求:Theia Plugin 基于 Phosphor.js 实现布局系统,将定制能力限定在了 配置化 这一层,随着 IDE Core 不同场景的业务方越来越多,容易形成「配置地狱」,因此在保留配置化的同时,最好提供布局相关 API ,让业务方使用 JSX 自定义布局。(参考开天)
- 与内部业务、场景对接:如 ERP 登录认证、gitlab 仓库对接、团队协作与工作空间、监控/运营系统集成等。(参考 开天 + Eclipse Che)
因此,需要拓展 Theia 的插件系统。
原则
- 屏蔽 IoC/布局系统/Widgt 等复杂概念,让用户只需要拥有 VS Code 插件开发经验就能够开发 Tide 插件。
- 尽可能复用 VS Code Extension 相关的设计和 API ,尽可能参照 VS Code Extension API 现有的接口或规范进行拓展。
- 用户只需要拥有 React 开发经验就可以定制布局系统。
设计概览
设计总结
通过独立的 Extension 包拓展插件系统。
- 参考 eclipse-che-theia-plugin-ext,提供 tide-theia-plugin-ext 。
- 用户只需要加载 tide-theia-plugin-ext,就可以使用拓展的 API 及配置等。
提案参考 VS Code Extension 的拓展接口及规范,从配置、Command 、VS Code API 等方面拓展插件系统。
- 用户无额外学习成本,拥有 VS Code 插件开发经验就能够开发 Tide 插件。
- 拓展 VS Code API ,通过 tide namespace 暴露的方法和 interface 等。
- 拓展 package.json 配置,其中主要是 Contribution Points
- 拓展 Command ,包括 built-in advanced commands api 和 keyboard shortcuts
- 如有需要,拓展 task。
相对于 theia/vscode Namespace,提供 tide 的 Namespace 访问 Tide API。
- 和 Theia Plugin 解耦
整体设计图示
tide 项目结构
IDE Core 和 Taro IDE 暂时放在同一个项目 tide 里,建议参考:che-theia。
./
├── configs
├── examples
│ ├── browser-app
│ └── electron-app
├── extensions
│ ├── tide-theia-about
│ ├── tide-theia-plugin // Tide API 接口规范定义
│ ├── tide-theia-plugin-ext // 插件系统拓展实现
│ ├── tide-theia-user-preferences // 用户信息相关
│ ├── ...
└── plugins
├── dashboard-plugin
├── test-plugin
├── deploy-plugin
├── setting-plugin
└── ...
npm 包发布在 @tide
Scope 下。
VS Code Extension(概念上等同于 Theia 的 Plugin)能力是以下三种方式拓展:
详细设计
项目将参考 VS Code Extension 的拓展接口及规范,从配置、Command 、VS Code API 等方面拓展插件系统,其中,VSCode 最具代表性的拓展例子应该是 Tree View API,兼具以上三者方式。
Contribution Points 配置拓展
Contribution Points
是 package.json
中 contributes
字段的一系列 JSON 声明,插件通过注册 Contribution Points
来拓展 VSCode 的功能。
contributes
配置的处理可以分为配置扫描(scanner)和配置处理(handler)。主要在 plugin-ext 里实现。
scanner
plugin-ext/src/hosted/node/scanners/scanner-theia.ts 里的 TheiaPluginScanner
类实现了所有 package.json 配置的读取方法,包括 contribution 配置、activationEvents 配置等。
我们应该是不需要添加新的配置读取,所以不需要修改这里。
handler
contribution 最终配置的 handle 都是在 PluginContributionHandler
里注入实际 handler 类统筹处理的。
// packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts
export class PluginContributionHandler {
@inject(MenusContributionPointHandler) // 注入 Menu 相关 handler
private readonly menusContributionHandler: MenusContributionPointHandler;
@inject(KeybindingsContributionPointHandler) // 注入 Keybindings 相关 handler
private readonly keybindingsContributionHandler: KeybindingsContributionPointHandler;
// ...
handleContributions(clientId: string, plugin: DeployedPlugin): Disposable {
// ...
pushContribution('commands', () => this.registerCommands(contributions));
pushContribution('menus', () => this.menusContributionHandler.handle(plugin));
pushContribution('keybindings', () => this.keybindingsContributionHandler.handle(contributions));
if (contributions.views) {
for (const location in contributions.views) {
for (const view of contributions.views[location]) {
pushContribution(`views.${view.id}`,
() => this.viewRegistry.registerView(location, view) // 注册页面配置
);
}
}
}
// ...
}
registerCommandHandler(id: string, execute: CommandHandler['execute']): Disposable {}
registerCommand(command: Command): Disposable {}
// ...
}
拓展
和 API 拓展不同专门预留了拓展注入点 ExtPluginApiProvider
不同,Theia 代码里类似的并没有预留专门的接口,暂时采用以下步骤拓展:
- 定义
TidePluginContributionHandler
继承PluginContributionHandler
类 - 重写
handleContributions
方法 - 在 ContainerModule 里
rebind(TidePluginContributionHandler).to(PluginContributionHandler).inSingletonScope();
如果有更好的方式,请指正。
Command 拓展
Commands 触发 Theia/VSCode
的 actions。VSCode 代码里包含大量 built-in commands,你可以使用这些命令与编辑器交互、控制用户界面或执行后台操作。
Command 拓展可以参考:packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts
首先定义 XXXCommandsContribution
类实现 CommandContribution
,并注入对应的服务,如然后在 XXXCommandsContribution
中通过 commands.registerCommand
进行 Command 拓展,如:
// packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts
export class PluginVscodeCommandsContribution implements CommandContribution {
@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
registerCommands(commands: CommandRegistry): void {
commands.registerCommand({ id: 'openInTerminal' }, { // 注册命令
execute: (resource: URI) => this.terminalContribution.openInTerminal(new TheiaURI(resource.toString()))
});
}
}
然后 bind 到 container 即可:
bind(XXXCommandsContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(XXXCommandsContribution);
XXXCommandsContribution 会被注入到对应的 ContributionProvider,然后进行处理:
constructor(
@inject(ContributionProvider) @named(CommandContribution)
protected readonly contributionProvider: ContributionProvider<CommandContribution>
) { }
Command 可以传入对象作为参数,无法暴露接口和组件。
API 拓展
相对上面两种拓展方式,API 的拓展方式比较复杂。
方式一:plugin-ext-vscode 的方式
这种方式是 VSCode 采用的方式,通过修改 PluginLifecycle
里面的 backendInitPath
或 frontendInitPath
,这两个脚本类似于 preload 脚本,在插件加载前进行预加载,初始化插件环境。
具体是 VsCodePluginScanner 类里的 getLifecycle()
方法的 backendInitPath
。在这里 backendInitPath 被初始化为: backendInitPath: __dirname + '/plugin-vscode-init.js'
/**
* This interface describes a plugin lifecycle object.
*/
export interface PluginLifecycle {
startMethod: string;
stopMethod: string;
/**
* Frontend module name, frontend plugin should expose this name.
*/
frontendModuleName?: string;
/**
* Path to the script which should do some initialization before frontend plugin is loaded.
*/
frontendInitPath?: string; // 插件前端 preload
/**
* Path to the script which should do some initialization before backend plugin is loaded.
*/
backendInitPath?: string; // 插件后端 preload
}
然后在 PluginHostRPC 类里 new PluginManagerExtImpl()
实例时,在传入的 init 钩子中调用的 initContext
中通过 require()
方法加载。
注意:initContext
里面的 backendInitPath
来自于 PluginLifecycle
,并不是 ExtPluginApiProvider
。
// packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts
/**
* Handle the RPC calls.
*/
export class PluginHostRPC {
private apiFactory: PluginAPIFactory;
private pluginManager: PluginManagerExtImpl;
initialize(): void {
this.pluginManager = this.createPluginManager(envExt, storageProxy, preferenceRegistryExt, webviewExt, this.rpc);
}
initContext(contextPath: string, plugin: Plugin): any {
const { name, version } = plugin.rawModel;
console.log('PLUGIN_HOST(' + process.pid + '): initializing(' + name + '@' + version + ' with ' + contextPath + ')');
const backendInit = require(contextPath); // 加载 PluginLifecycle 的 backendInitPath
backendInit.doInitialization(this.apiFactory, plugin); // 调用 backendInitPath 脚本暴露的 doInitialization 方法
}
createPluginManager(){
const pluginManager = new PluginManagerExtImpl({
loadPlugin(plugin: Plugin): any {},
async init(raw: PluginMetadata[]): Promise<[Plugin[], Plugin[]]> {
let backendInitPath = pluginLifecycle.backendInitPath;
// if no init path, try to init as regular Theia plugin
if (!backendInitPath) {
backendInitPath = __dirname + '/scanners/backend-init-theia.js';
}
self.initContext(backendInitPath, plugin); // backendInitPath 来自于 pluginLifecycle
},
initExtApi(extApi: ExtPluginApi[]): void {
const extApiInit = require(api.backendInitPath); // 加载 ExtPluginApiProvider 注入的 backendInitPath
extApiInit.provideApi(rpc, pluginManager);
},
loadTests: extensionTestsPath ? async () => {}
})
}
}
而 backendInitPath 配置的 plugin-vscode-init.ts
文件提供了 doInitialization
方法,在 doInitialization
方法中通过 Object.assign
合并 Theia API 到 vscode namespace,添加简单的 API 和字段。
// packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts
export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIFactory, plugin: Plugin) => {
const vscode = Object.assign(apiFactory(plugin), { ExtensionKind }); // 合并 API
// use Theia plugin api instead vscode extensions
(<any>vscode).extensions = {
get all(): any[] {
return vscode.plugins.all.map(p => asExtension(p));
},
getExtension(pluginId: string): any | undefined {
return asExtension(vscode.plugins.getPlugin(pluginId));
},
get onDidChange(): theia.Event<void> {
return vscode.plugins.onDidChange;
}
};
}
这种方法本质是在插件加载前运行脚本,不涉及到 RPC,通过 Object.assign
合并简单的 API。
这种方式不如 ExtPluginApiProvider 的方式优雅,社区有人提里 PR 将其改成 ExtPluginApiProvider 的形式:Make "theia" and "vscode" contributed API's #8142,目前为止依然还没有被合并。
方式二:ExtPluginApiProvider 的方式
eclipse/che-theia 就是采用了这种方式,功能非常强大。具体可见:ChePluginApiProvider
Theia 官方文档没有提到这种方式,不过在 plugin-ext/doc 下倒是有一片简单的介绍文档:This document describes how to add new plugin api namespace
Che-Theia plug-in API 提供了 che 的 namespace。
首先声明 ExtPluginApiProvider 实现:
// extensions/eclipse-che-theia-plugin-ext/src/node/che-plugin-api-provider.ts
export class ChePluginApiProvider implements ExtPluginApiProvider {
provideApi(): ExtPluginApi {
return {
frontendExtApi: {
initPath: '/che/api/che-api-worker-provider.js',
initFunction: 'initializeApi',
initVariable: 'che_api_provider'
},
backendInitPath: path.join('@eclipse-che/theia-plugin-ext/lib/plugin/node/che-api-node-provider.js')
};
}
}
然后注入到 backend moudule:
// extensions/eclipse-che-theia-plugin-ext/src/node/che-backend-module.ts
bind(ChePluginApiProvider).toSelf().inSingletonScope();
bind(Symbol.for(ExtPluginApiProvider)).toService(ChePluginApiProvider);
这样,前端及后台都有了插件拓展的入口。
createAPIFactory
createAPIFactory 用于定义 Client API 接口,然后分别挂载到前端及后端插件运行时的 namespace。
createAPIFactory 方法的实现,和 Theia 源码中 packages/plugin-ext/src/plugin/plugin-context.ts
里 createAPIFactory 的实现一致:
export function createAPIFactory(rpc: RPCProtocol): CheApiFactory {
return function (plugin: Plugin): typeof che {}
}
前端入口 initializeApi
前端入口脚本 che-api-worker-provider.js
,实现并 export initializeApi
方法。在 initializeApi 中,传入 RPC,挂载到 che namespace。
// extensions/eclipse-che-theia-plugin-ext/src/plugin/webworker/che-api-worker-provider.ts
export const initializeApi: ExtPluginApiFrontendInitializationFn = (rpc: RPCProtocol, plugins: Map<string, Plugin>) => {
const cheApiFactory = createAPIFactory(rpc); // 核心在于 createAPIFactory
const handler = {
get: (target: any, name: string) => {
const plugin = plugins.get(name);
if (plugin) {
let apiImpl = pluginsApiImpl.get(plugin.model.id);
if (!apiImpl) {
apiImpl = cheApiFactory(plugin);
pluginsApiImpl.set(plugin.model.id, apiImpl);
}
return apiImpl;
};MainPluginApiProvider
}
ctx['che'] = new Proxy(Object.create(null), handler); // 直接挂载到 che namespace
};
后端入口 provideApi
后端入口脚本 che-api-node-provider.js
,代码里需要暴露 export provideApi()
。
后端也是通过 createAPIFactory 定义 Client API 接口。
export const provideApi: ExtPluginApiBackendInitializationFn = (rpc: RPCProtocol, pluginManager: PluginManager) => {
cheApiFactory = createAPIFactory(rpc);
plugins = pluginManager;
if (!isLoadOverride) {
overrideInternalLoad();
isLoadOverride = true;
}
};
然后在 overrideInternalLoad()
方法中改写 module._load
,使 require('@eclipse-che/plugin')
返回定义的 Client API。
function overrideInternalLoad(): void {
const module = require('module');
// save original load method
const internalLoad = module._load;
// if we try to resolve che module, return the filename entry to use cache.
module._load = function (request: string, parent: any, isMain: {}): any {
if (request !== '@eclipse-che/plugin') {
return internalLoad.apply(this, arguments);
}
apiImpl = cheApiFactory(plugin);
return apiImpl;
}
}
前后端 Client API 注入
Client API 可以看成是接口的定义,暴露到前后端运行时中,供插件调用。
new PluginManagerExtImpl()
传入的第一个参数 host 是 PluginHost 类型,其中的 initExtApi 等方法前端后台分别实现:
export interface PluginHost {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
loadPlugin(plugin: Plugin): any;
init(data: PluginMetadata[]): Promise<[Plugin[], Plugin[]]> | [Plugin[], Plugin[]]; // 初始化插件
initExtApi(extApi: ExtPluginApi[]): void; // 初始化从外部引入的前后端 API,ExtPluginApi 包含 frontendExtApi 或 backendInitPath
loadTests?(): Promise<void>;
}
initExtApi 前端,挂在 window 下。
initExtApi(extApi: ExtPluginApi[]): void {
if (api.frontendExtApi) {
ctx.importScripts(api.frontendExtApi.initPath);
ctx[api.frontendExtApi.initVariable][api.frontendExtApi.initFunction](rpc, pluginsModulesNames);
}
}
其中 const pluginsModulesNames = new Map<string, Plugin>();
插件的集合。
initExtApi 后端,直接 require,并运行 provideApi()
initExtApi(extApi: ExtPluginApi[]): void {
if (api.backendInitPath) {
const extApiInit = require(api.backendInitPath);
extApiInit.provideApi(rpc, pluginManager); // 调用 provideAp
}
}
Server API 的注入
Server API 可以看成是接口的实现,通过 MainPluginApiProvider 注入到 browser,监听 Client API 的 RPC 消息并触发对应处理方法。
MainPluginApiProvider 的实现应该包含新命名空间的 Plugin API 的 main(接口实现)部分。
/**
* Implementation should contains main(Theia) part of new namespace in Plugin API.
* [initialize](#initialize) will be called once per plugin runtime
*/
export interface MainPluginApiProvider {
initialize(rpc: RPCProtocol, container: interfaces.Container): void;
}
注入到浏览器的 HostedPluginSupport 中,然后在 initRpc方法一次调用注入 MainPluginApiProvider 的 initialize 方法进行初始化。
// packages/plugin-ext/src/hosted/browser/hosted-plugin.ts
@injectable()
export class HostedPluginSupport {
@inject(ContributionProvider)
@named(MainPluginApiProvider)
protected readonly mainPluginApiProviders: ContributionProvider<MainPluginApiProvider>;
protected initRpc(host: PluginHost, pluginId: string): RPCProtocol {
const rpc = host === 'frontend' ? new PluginWorker().rpc : this.createServerRpc(pluginId, host);
setUpPluginApi(rpc, this.container); // 初始化 VScode API 的 Server 端实现
this.mainPluginApiProviders.getContributions().forEach(p => p.initialize(rpc, this.container)); // 初始化外部注入的接口实现
return rpc;
}
}
简化的 API 通信架构图大致如下:
ExtPluginApiProvider 的拓展方式非常成熟优雅且功能强大,建议采用这一种。
Demo
见 Tide 项目 master 分支 extension/tide-theia-plugin-ext 模块。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。