Eclipse Theia 揭秘之拓展机制篇

前言

VS Code 之所以是最流行的开发者工具,与其强大的插件生态是分不开的,VS Code 生态内有各种增强功能的 VS Code Extensions,Theia 在 VS Code 拓展机制上又进一步设计,增加基于 Extension 和 

Plug-ins 两种不同的拓展方法,本文将对 Theia 插件拓展机制进行详细说明,在理解 Theia 的拓展机制之前,会先介绍 VS Code 拓展的一些基础作为铺垫,从而更容易理解 Theia 为啥有更强大的拓展能力。

VS Code Extensions

Visual Studio Code 在设计上充分考虑了可扩展性,从 UI 到编辑体验,几乎可以通过扩展 API 对 VS Code 的每个部分进行自定义和增强。实际上,VS Code 的许多核心功能都是作为扩展构建的,并使用相同的扩展API。

拓展的开放能力

从 VS Code 官网 Extensions Capabilities Overview 文档中我们可以看到,VS Code 拓展的能力建立在Contribution Points和 VS Code API 之上,这些能力分为六大类:通用能力、主题、声明性语言功能、程序语言功能、工作区拓展、调试。

通用功能:可以在任何扩展中使用的核心功能。

  • 注册命令,配置,键绑定或上下文菜单项
  • 存储工作区或全局数据
  • 显示通知消息
  • 使用快速选择收集用户输入
  • 打开系统文件选择器,让用户选择文件或文件夹
  • 使用 Progress API 指示长时间运行的操作

主题主题化控制VS Code的外观,包括编辑器中源代码的颜色和VS Code UI的颜色。

  • 更改源代码的颜色
  • 更改 VS Code 用户界面的颜色
  • 将现有的 TextMate 主题移植到 VS Code
  • 添加自定义文件图标

声明性语言功能:声明性语言功能增加了对编程语言的基本文本编辑支持,例如括号匹配,自动缩进和语法突出显示。 这是声明式完成的,无需编写任何代码。 

  • 常用 JavaScript 代码片段
  • 提供新编程语言信息
  • 添加或替换编程语言的语法
  • 通过语法注入扩展现有语法
  • 将现有的 TextMate 语法移植到 VS Code

程序语言功能:编程语言功能添加了丰富的编程语言支持,例如悬停,转到定义,诊断错误,IntelliSense 和CodeLens,这些语言功能通过公开 vscode.languages.* API,扩展可以直接使用这些 API,也可以编写语言服务器,并使用 VS Code 语言服务器库适配。

  • 添加悬停显示 API 的用法示例
  • 使用诊断报告源代码中的拼写错误或 linter 错误
  • 注册 HTML 的新代码格式化程序
  • 提供丰富的上下文感知 IntelliSense
  • 添加对语言的折叠,面包屑和轮廓支持

工作区拓展:在 VS Code Workbench 界面中添加自定义组件和视图。

  • 将自定义上下文菜单操作添加到文件资源管理器
  • 在侧边栏中创建一个新的交互式 TreeView
  • 定义一个新的活动栏视图
  • 在状态栏中显示新信息
  • 使用 WebView API 呈现自定义内容
  • 贡献源代码控制提供程序

调试:通过编写将 VS Code 的调试 UI 连接到特定调试器或运行时的 Debugger Extensions,支持调试特定的运行时。

  • 通过 Debug Adapter implementation 将 VS Code 的调试 UI 连接到调试器或运行时
  • 指定调试器扩展支持的语言
  • 为调试器使用的调试配置属性提供丰富的 IntelliSense 和悬停信息
  • 提供调试配置摘要

VS Code 还提供了一组 Debug Extension API,可以使用它在任何 VS Code 调试器之上实现与调试相关的功能。

  • 基于动态创建的调试配置启动调试会话
  • 跟踪调试会话的生命周期
  • 以编程方式创建和管理断点

为了确保 VS Code 的稳定性和性能,还对扩展进行了限制,例如,扩展不能访问 VS Code UI 的 DOM。

拓展的生命周期

VS Code 插件的形态和一个 npm 包非常相似,需要在项目的根目录添加 package.json,并且在其中增加一些 VS Code 的配置,其中最主要的设置是 Activation Events(插件的激活时机) 和 contribution points (插件的能力)。

一个 VS Code 的生命周期中有两个钩子函数,activate 函数和 deactivate 函数,这两个函数需要在插件 npm 模块的入口文件 export 出去给 VS Code 加载并解析插件时调用。

Eclipse Theia 揭秘之拓展机制篇_第1张图片

其中,activate 会在 vscode 认为合适的时机调用,并且在插件的运行周期内只调用一次,因此在 activate 函数中开始启动插件的逻辑是一个非常合适的时机。deactivate 函数会在插件卸载之前调用,如果你的卸载逻辑中存在异步操作,那么只需要在 deactivate 函数中 retuen 一个 Promise 对象,VS Code 会在 Promise resolve 时才正式将插件卸载掉。

可以看到在 activate 函数执行之前,还有 onLanguage 等事件的描述,实际上这些就是声明在插件 package.json 文件中的 Activation Events。声明这些 Activation Events 后,VS Code 就会在适当的时机回调插件中的 activate 函数。VS Code 之所以这么设计,是为了节省资源开销,只在必要的时候才激活你的插件。当然如果你的插件非常重要,不希望在某个事件之后才被激活,你可以声明 Activation Events 为 * 这样 VS Code 就会在启动的时候就开始回调 activate 函数。

拓展的 Contribution Points

Contribution Points 是在 package.json 扩展清单的 contributes 字段中进行的一组 JSON 声明,扩展注册了贡献点,以扩展 Visual Studio Code 中的各种功能。以下是所有可用贡献点的列表:

configuration 用户设置/工作区设置的配置 configurationDefaults 默认编辑器配置
commands 命令 menus 菜单选项
keybindings 快捷键 languages 编程语言支持
debuggers 调试器 breakpoints 断点
grammars 语法高亮 themes 主题
iconThemes 文件图标主题 productIconThemes 产品图标主题
snippets 代码片段 jsonValidation JSON 配置文件校验
views 自定义视图 viewsContainers 自定义视图容器
problemMatchers 问题匹配器模式 problemPatterns 命名问题模式
taskDefinitions 定义任务 colors 定义颜色名称
typescriptServerPlugins TypeScript 服务器插件 resourceLabelFormatters 申明 URL Scheme

Theia Extensions

Theia Extension 入门示例

在深入探讨技术细节之前,让我们从概念上了解扩展在 Theia 中的工作方式。Theia 应用程序由 Extensions 组成。 Extensions 为特定功能提供了一组 Widgets,Commands,Handlers 等。 Theia 本身提供了许多扩展程序,例如 editors,terminals,project等。要创建基于 Theia 的可执行工具或 IDE,需选择和开发需要的拓展。

自定义的 IDE 中的拓展可以是独立方式添加功能,也可以是以松耦合的方式依赖另外一个拓展实现功能,Theia使用 Inversify.js 依赖项注入上下文,通过 DI 容器处理拓展之间的依赖关系。简而言之,这就像一个全局公告板,拓展可以去修改上面的内容。

Eclipse Theia 揭秘之拓展机制篇_第2张图片

每个 Extension 都是通过 npm 包的形式提供给 Theia Application 集成。VS Code 中 Contribution Points 比较局限,只能做一些基础的配置及使用特定 API 进行有限操作,而 Theia 中定义了大量的 contribution 接口,通过实现 *Contribution 类型的接口扩展可以为应用增加很多功能。

Theia Extension 主要有三部分的内容:

  • package.json 中常规配置及 Theia 特定的配置;
  • 与 Theia Framework 及其他 Extension 关联的 DI 模块;
  • 自定义 Extension 的实现。

在 Theia 中一个 Extension 定义了一个或者多个 DI 模块,需要在 Extension 的 package.json 的 theiaExtensions 字段中,例如 monaco 模块的配置:

{
  "keywords": [
      "theia-extension"
  ],
  ...
  "theiaExtensions": [
      {
        "frontend": "lib/browser/monaco-browser-module",
        "frontendElectron": "lib/electron-browser/monaco-electron-module"
      }
   ]
}

第一个是关键字 "theia-extension",将 npm 包标记为 Theia 扩展,Theia 将在启动时遍历所有的 npm 包,并处理标记为扩展包。theiaExtensions 字段枚举拓展的模块,每个模块都是拓展程序的入口,模块将扩展与 Theia提供的现有扩展(即平台)连接起来。根据代码的运行环境,总共有四种类型的 DI 模块。

export interface Extension {
    frontend?: string;
    frontendElectron?: string;
    backend?: string;
    backendElectron?: string;
}

根据 《Authoring Theia Extensions》的文档创建 Extension 工程。

npm install -g yo generator-theia-extension
mkdir theia-hello-world-extension
cd theia-hello-world-extension
yo theia-extension # select the 'Hello World' option and complete the prompts

我们可以看到 theia-hello-world-extension 插件是一个独立的 npm 包,package.json 文件通过 theiaExtensions 字段注册模块。

{
  "name": "theia-hello-world-extension",
  ...
  "theiaExtensions": [
    {
      "frontend": "lib/browser/theia-hello-world-extension-frontend-module"
    }
  ]
}

theia-hello-world-extension-frontend-module.ts 实现如下:

import { TheiaHelloWorldExtensionCommandContribution, TheiaHelloWorldExtensionMenuContribution } from './theia-hello-world-extension-contribution';
import {
    CommandContribution,
    MenuContribution
} from "@theia/core/lib/common";

import { ContainerModule } from "inversify";

export default new ContainerModule(bind => {
    // add your contribution bindings here
    bind(CommandContribution).to(TheiaHelloWorldExtensionCommandContribution);
    bind(MenuContribution).to(TheiaHelloWorldExtensionMenuContribution);
});

在 ContainerModule 中绑定 CommandContribution(命令扩展点)、MenuContribution(菜单扩展点) 类型的扩展点。CommandContribution 接口定义了 registerCommands 方法,MenuContribution 接口定义了 registerMenus 方法。自定义 Extension 具体实现如下:

import { injectable, inject } from "inversify";
import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry, MessageService } from "@theia/core/lib/common";
import { CommonMenus } from "@theia/core/lib/browser";

export const TheiaHelloWorldExtensionCommand = {
    id: 'TheiaHelloWorldExtension.command',
    label: "Say Hello"
};

@injectable()
export class TheiaHelloWorldExtensionCommandContribution implements CommandContribution {

    constructor(
        @inject(MessageService) private readonly messageService: MessageService,
    ) { }

    registerCommands(registry: CommandRegistry): void {
        registry.registerCommand(TheiaHelloWorldExtensionCommand, {
            execute: () => this.messageService.info('Hello, world!')
        });
    }
}

@injectable()
export class TheiaHelloWorldExtensionMenuContribution implements MenuContribution {

    registerMenus(menus: MenuModelRegistry): void {
        menus.registerMenuAction(CommonMenus.EDIT_FIND, {
            commandId: TheiaHelloWorldExtensionCommand.id,
            label: TheiaHelloWorldExtensionCommand.label
        });
    }
}

CommandContribution 图示如下:

Eclipse Theia 揭秘之拓展机制篇_第3张图片

MenuContribution 图示如下:

Eclipse Theia 揭秘之拓展机制篇_第4张图片

这个 Hello World 例子展示了如何通过 CommandRegistry 的 registerCommand 方法注册一个 Command,同时将通过菜单项关联 Command。我们也可以通过 API 方式调用 Command:

this.commandService.executeCommand(command: string, ...args: any[]): Promise;

Theia Extension 运行流程

上面的 Hello World 例子展示了如何写一个最简单的拓展,下面我们就根据上面的例子说明一下整体的运行机制。这里我们以 Menu 的拓展为例子加以说明,Menu 整体的交互逻辑如下:

Eclipse Theia 揭秘之拓展机制篇_第5张图片

MenuContribution 实现如下:

/**
 * Representation of a menu contribution.
 */
export interface MenuContribution {
    /**
     * Registers menus.
     * @param menus the menu model registry.
     */
    registerMenus(menus: MenuModelRegistry): void;
}

@injectable()
export class MenuModelRegistry {
    protected readonly root = new CompositeMenuNode('');

    constructor(
        @inject(ContributionProvider) @named(MenuContribution)
        protected readonly contributions: ContributionProvider,
        @inject(CommandRegistry) protected readonly commands: CommandRegistry
    ) { }

    onStart(): void {
        for (const contrib of this.contributions.getContributions()) {
            contrib.registerMenus(this);
        }
    }

    registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable {
        const parent = this.findGroup(menuPath);
        const actionNode = new ActionMenuNode(item, this.commands);
        return parent.addNode(actionNode);
    }
  
    ...

    getMenu(menuPath: MenuPath = []): CompositeMenuNode {
        return this.findGroup(menuPath);
    }
}

MenuContribution 是一个接口,MenuModelRegistry onStart 方法调用所有的拓展实例的 registerMenus 方法,从而实现 Menu 的注册。MenuModelRegistry 在 Browser 和 Electron 环境中各自去实现。以 Browser 环境为例,MenuModelRegistry 在 @theia/core 模块的 src/browser/menu/browser-menu-plugin.ts 的 BrowserMainMenuFactory 类中被调用,BrowserMainMenuFactory 注入在 BrowserMenuBarContribution 中,BrowserMenuBarContribution 实现了 FrontendApplicationContribution,从而在 FrontendApplication 启动的过程被挂载。

@injectable()
export class BrowserMenuBarContribution implements FrontendApplicationContribution {

    @inject(ApplicationShell)
    protected readonly shell: ApplicationShell;

    constructor(
        @inject(BrowserMainMenuFactory) protected readonly factory: BrowserMainMenuFactory
    ) { }

    onStart(app: FrontendApplication): void {
        const logo = this.createLogo();
        app.shell.addWidget(logo, { area: 'top' });
        const menu = this.factory.createMenuBar();
        app.shell.addWidget(menu, { area: 'top' });
    }
    ...
}

FrontendApplicationContribution 接口的 onStart 方法,可以在应用的前端启动时增加自定义视图。参考 Menu 的实现,我们通过实现 FrontendApplicationContribution 可以不需要修改 Theia 框架源码很方便的拓展定制 Theia Application 的 UI,而这在 VS Code 中不改源码是没法实现的。

Theia Extension 内部通信

上面我们一个简单的拓展介绍了 Extension 如何注册命令,Extension 根据代码依赖的执行环境不同可以分为 common、browser、node、electron-node、electron-browser、electron-main。根据模块最终运行的环境可以分为:frontend、frontendElectron、backend、backendElectron、electronMain 五类类。代码运行环境差异导致不能简单的调用,在很多业务场景下我们需要实现逻辑交互。从上面的例子知道,Extension 之间可以通过 Command 的方式进行调用,那么 Extension 内部的 Frontend 和 Backend 怎么实现代码调用呢?

Frontend 调用 Backend 模块的方法的步骤为:

  • 1.定义 Backend Service 对外暴露的接口
// common/protocol.ts
export const HELLO_BACKEND_PATH = '/services/helloBackend';
// 变量声明
export const HelloBackendService = Symbol('HelloBackendService');
// 类型声明
export interface HelloBackendService {
    sayHelloTo(name: string): Promise
}

这里需要注意的是 HelloBackendService 在这里两个含义是不一样的,变量声明用于表示 Service 的服务标识符(serviceIdentifier),类型声明是用于表示 Service 定义的接口。

 

  • 2.实现并注册 Backend Service 实例
// node/hello-backend-service.ts
import { injectable } from "inversify";
import { HelloBackendService } from "../common/protocol";

@injectable()
export class HelloBackendServiceImpl implements HelloBackendService {
    sayHelloTo(name: string): Promise {
        return new Promise(resolve => resolve('Hello ' + name));
    }
}

// node/theia-backend-communication-extension-backend-module.ts
bind(HelloBackendService).to(HelloBackendServiceImpl).inSingletonScope()
bind(ConnectionHandler).toDynamicValue(ctx =>
    new JsonRpcConnectionHandler(HELLO_BACKEND_PATH, () => {
        return ctx.container.get(HelloBackendService);
    })
).inSingletonScope();
  • 3.Frontend 需要注册 Backend Service 接口同名的模块
// browser/theia-backend-communication-extension-frontend-module.ts
bind(HelloBackendService).toDynamicValue(ctx => {
    const connection = ctx.container.get(WebSocketConnectionProvider);
    return connection.createProxy(HELLO_BACKEND_PATH);
}).inSingletonScope();
  • 4.前端调用 Backend Service 模块的方法
@inject(HelloBackendService)
private readonly helloBackendService: HelloBackendService;

// 调用 sayHelloTo 方法
this.helloBackendService.sayHelloTo('World').then(r => console.log(r);

electron-browser 调用 electron-main 也是类似的,具体的可以参考:https://www.yuque.com/zhaomenghuan/theia/qalple#FWYNK

Theia Plug-ins

Theia Plug-ins 入门

Theia 和 VS Code 拓展能力区别较大,Theia 通过 Extension 机制提供对应用内部的拓展能力,通过 Plug-ins 机制提供外部的拓展能力;Theia 的 Plug-ins 机制和 VS Code 的拓展机制类似,同时 Theia Plug-ins 对 VS Code 拓展兼容支持。

Plug-ins 相比 Extensions 的优势:

  • 代码隔离:Plug-ins 作为插件在单独进程中运行的代码,它不能阻止 Theia 核心进程;
  • 可以在运行时加载:无需重新编译Theia的完整IDE;
  • 减少编译时间:Theia 应用引入的插件无需进行编译;
  • 独立发布:插件可以打包成一个文件,然后直接加载,无需额外获取 npmjs 等的依赖项;
  • API 方式开发:无需学习 inversify 或其他框架;API 向后兼容,可以轻松进行 Theia 版本升级。

Theia 应用程序由一个内核组成,该内核提供了一组用于特定功能的小部件,命令,处理程序等。Theia 定义了一个运行时 API,允许插件自定义 IDE 并将其行为添加到应用程序的各个方面。在 Theia 中,插件可以通过名为theia 的对象访问 API,该对象在所有插件中都可用。Theia 可用的 API 使用文档:@theia/plugin,Theia API 兼容 VS Code API,API 覆盖率文档:Compare Theia vs VS Code API。

Theia 在技术架构上分成前端和后端两大部分,对于插件体系也是类似的,分为 Frontend plug-in 和 Backend plug-in。前端插件是工作在 Browser 的 UI线程,因此无法直接打开或写入文件;后端插件的代码在服务器端以专用进程运行,后端插件调用 API 后,将在用户的浏览器/ UI上发送一些操作以注册新命令等。后端插件和 VS Code 的 Extensions 类似。

Theia Plug-ins Hello World

根据 《Authoring Theia Plug-ins》的文档创建插件工程。

npm install -g yo @theia/generator-plugin
mkdir theia-hello-world-plugin
cd theia-hello-world-plugin
yo @theia/plugin

 

未完待续。。。

 

参考

  • KAITIAN IDE 是如何构建扩展能力极强的插件体系的?
  • 开发一个爆款 VSCode 插件

你可能感兴趣的:(Theia,IoT,IoT,Theia)