VS Code 之所以是最流行的开发者工具,与其强大的插件生态是分不开的,VS Code 生态内有各种增强功能的 VS Code Extensions,Theia 在 VS Code 拓展机制上又进一步设计,增加基于 Extension 和
Plug-ins 两种不同的拓展方法,本文将对 Theia 插件拓展机制进行详细说明,在理解 Theia 的拓展机制之前,会先介绍 VS Code 拓展的一些基础作为铺垫,从而更容易理解 Theia 为啥有更强大的拓展能力。
Visual Studio Code 在设计上充分考虑了可扩展性,从 UI 到编辑体验,几乎可以通过扩展 API 对 VS Code 的每个部分进行自定义和增强。实际上,VS Code 的许多核心功能都是作为扩展构建的,并使用相同的扩展API。
从 VS Code 官网 Extensions Capabilities Overview 文档中我们可以看到,VS Code 拓展的能力建立在Contribution Points和 VS Code API 之上,这些能力分为六大类:通用能力、主题、声明性语言功能、程序语言功能、工作区拓展、调试。
通用功能:可以在任何扩展中使用的核心功能。
主题:主题化控制VS Code的外观,包括编辑器中源代码的颜色和VS Code UI的颜色。
声明性语言功能:声明性语言功能增加了对编程语言的基本文本编辑支持,例如括号匹配,自动缩进和语法突出显示。 这是声明式完成的,无需编写任何代码。
程序语言功能:编程语言功能添加了丰富的编程语言支持,例如悬停,转到定义,诊断错误,IntelliSense 和CodeLens,这些语言功能通过公开 vscode.languages.* API,扩展可以直接使用这些 API,也可以编写语言服务器,并使用 VS Code 语言服务器库适配。
工作区拓展:在 VS Code Workbench 界面中添加自定义组件和视图。
调试:通过编写将 VS Code 的调试 UI 连接到特定调试器或运行时的 Debugger Extensions,支持调试特定的运行时。
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 加载并解析插件时调用。
其中,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 是在 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 中的工作方式。Theia 应用程序由 Extensions 组成。 Extensions 为特定功能提供了一组 Widgets,Commands,Handlers 等。 Theia 本身提供了许多扩展程序,例如 editors,terminals,project等。要创建基于 Theia 的可执行工具或 IDE,需选择和开发需要的拓展。
自定义的 IDE 中的拓展可以是独立方式添加功能,也可以是以松耦合的方式依赖另外一个拓展实现功能,Theia使用 Inversify.js 依赖项注入上下文,通过 DI 容器处理拓展之间的依赖关系。简而言之,这就像一个全局公告板,拓展可以去修改上面的内容。
每个 Extension 都是通过 npm 包的形式提供给 Theia Application 集成。VS Code 中 Contribution Points 比较局限,只能做一些基础的配置及使用特定 API 进行有限操作,而 Theia 中定义了大量的 contribution 接口,通过实现 *Contribution
类型的接口扩展可以为应用增加很多功能。
Theia 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 图示如下:
MenuContribution 图示如下:
这个 Hello World 例子展示了如何通过 CommandRegistry 的 registerCommand 方法注册一个 Command,同时将通过菜单项关联 Command。我们也可以通过 API 方式调用 Command:
this.commandService.executeCommand(command: string, ...args: any[]): Promise;
上面的 Hello World 例子展示了如何写一个最简单的拓展,下面我们就根据上面的例子说明一下整体的运行机制。这里我们以 Menu 的拓展为例子加以说明,Menu 整体的交互逻辑如下:
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 中不改源码是没法实现的。
上面我们一个简单的拓展介绍了 Extension 如何注册命令,Extension 根据代码依赖的执行环境不同可以分为 common、browser、node、electron-node、electron-browser、electron-main。根据模块最终运行的环境可以分为:frontend、frontendElectron、backend、backendElectron、electronMain 五类类。代码运行环境差异导致不能简单的调用,在很多业务场景下我们需要实现逻辑交互。从上面的例子知道,Extension 之间可以通过 Command 的方式进行调用,那么 Extension 内部的 Frontend 和 Backend 怎么实现代码调用呢?
Frontend 调用 Backend 模块的方法的步骤为:
// 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 定义的接口。
// 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();
// 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();
@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 和 VS Code 拓展能力区别较大,Theia 通过 Extension 机制提供对应用内部的拓展能力,通过 Plug-ins 机制提供外部的拓展能力;Theia 的 Plug-ins 机制和 VS Code 的拓展机制类似,同时 Theia Plug-ins 对 VS Code 拓展兼容支持。
Plug-ins 相比 Extensions 的优势:
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 类似。
根据 《Authoring Theia Plug-ins》的文档创建插件工程。
npm install -g yo @theia/generator-plugin
mkdir theia-hello-world-plugin
cd theia-hello-world-plugin
yo @theia/plugin
未完待续。。。