• 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 相似,并不满足咱们的需要:

  1. UI 定制能力十分单薄:主界面仅提供了大量的按钮与菜单反对自定义。但很多理论场景都有十分强烈的 UI 需要以满足不同的业务能力。如:例如 Taro IDE 主界面须要大量的按钮菜单注入以及模拟器、调试器等预览面板。
  2. 配置化的 UI 定制形式无奈满足定制需要:Theia Plugin 基于 Phosphor.js 实现布局零碎,将定制能力限定在了 配置化 这一层,随着 IDE Core 不同场景的业务方越来越多,容易造成「配置天堂」,因而在保留配置化的同时,最好提供布局相干 API ,让业务方应用 JSX 自定义布局。(参考开天)
  3. 与外部业务、场景对接:如 ERP 登录认证、gitlab 仓库对接、团队合作与工作空间、监控/经营系统集成等。(参考 开天 + Eclipse Che)

因而,须要拓展 Theia 的插件零碎。

准则

  1. 屏蔽 IoC/布局零碎/Widgt 等简单概念,让用户只须要领有 VS Code 插件开发教训就可能开发 Tide 插件。
  2. 尽可能复用 VS Code Extension 相干的设计和 API ,尽可能参照 VS Code Extension API 现有的接口或标准进行拓展。
  3. 用户只须要领有 React 开发教训就能够定制布局零碎。

设计概览

设计总结

  1. 通过独立的 Extension 包拓展插件零碎。

    • 参考 eclipse-che-theia-plugin-ext,提供 tide-theia-plugin-ext 。
    • 用户只须要加载 tide-theia-plugin-ext,就能够应用拓展的 API 及配置等。
  2. 提案参考 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。
  3. 绝对于 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 Pointspackage.jsoncontributes 字段的一系列 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.tsexport 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 代码里相似的并没有预留专门的接口,临时采纳以下步骤拓展:

  1. 定义 TidePluginContributionHandler 继承 PluginContributionHandler
  2. 重写 handleContributions 办法
  3. 在 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.tsexport 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 外面的 backendInitPathfrontendInitPath,这两个脚本相似于 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.tsexport 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.tsexport 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.tsexport 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 模块。

参考

  • Theia - Authoring Theia Plug-ins
  • eclipse/che-theia
  • VS Code API
  • Contribution Points
  • Compare Theia vs VS Code API
  • This document describes how to add new plugin api namespace
  • Tree View API
  • Commands
  • KAITIAN IDE 是如何构建扩大能力极强的插件体系的?