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
的模块会返回一个定义好的对象。
1. `// VS Code 插件进程源码`
2. `function defineAPI(factory: IExtensionApiFactory, extensionPaths: TernarySearchTree<IExtensionDescription>, extensionRegistry: ExtensionDescriptionRegistry): void {`
4. `// each extension is meant to get its own api implementation`
5. `const extApiImpl = new Map<string, typeof vscode>();`
6. `let defaultApiImpl: typeof vscode;`
8. `// 已被全局劫持过的 require`
9. `const node_module = <any>require.__$__nodeRequire('module');`
10. `const original = node_module._load;`
11. `// 重写 Module.prototype._load 方法`
12. `node_module._load = function load(request: string, parent: any, isMain: any) {`
13. `// 模块名不是 vscode 调用原方法返回模块`
14. `if (request !== 'vscode') {`
15. `return original.apply(this, arguments);`
16. `}`
18. `// 这里会为每一个插件生成一份独立的 API`
19. `const ext = extensionPaths.findSubstr(URI.file(parent.filename).fsPath);`
20. `if (ext) {`
21. `let apiImpl = extApiImpl.get(ext.id);`
22. `if (!apiImpl) {`
23. `// factory 函数会返回所有 API`
24. `apiImpl = factory(ext, extensionRegistry);`
25. `extApiImpl.set(ext.id, apiImpl);`
26. `}`
27. `return apiImpl;`
28. `}`
29. `/* 省略部分代码 */`
30. `}`
31. `}`
运行时由插件 host 进程加载插件模块,并传入 context
参数:
1. `// 插件进程伪代码`
3. `const extModule = require('path/to/extension.js');`
5. `const extContext = createVSCodeExtensionContext();`
7. `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 的形式传入扩展组件。
1. `// Component`
2. `export const ToolBarComponent = (props) => {`
3. `props.service.alert('hello world.');`
4. `return (`
5. `<button>click me.</button>`
6. `);`
7. `}`
9. `// config`
10. `export default {`
11. `toolBar: {`
12. `position: POSITION.LEFT,`
13. `component: ToolBarComponent,`
14. `}`
15. `}`
扩展插槽可以以 DOM 节点的方式提供,但其 UI 风格较难统一,由于是开发者自行编写的组件,可能由于部门及业务差异,组件样式风格差异较大。另外在组件调用 Node 模块提供的 Services 时,onClick 等事件对象会作为参数传递给对应方法,但由于是 RPC 调用,无法对这一类参数进行序列化,需要将 Services 方法做一次包装,调用方要显式的传递需要的参数过去。
扩展点提供者与消费者
这种方案不直接注入组件,转为由特定的一类插件实现一组插槽,可以自定 API 和参数,普通插件可以作为插槽消费者。例如 ToolBar 部分,A 插件声明为一个插槽容器,提供一组 API,其它组件调用其 API 注册按钮等部件。IDE 需要实现插件之间互相访问的能力,同时对插槽提供者做特定区分。其中普通插件无法对样式做修改,容器插件应该提供一些特定的组件,普通插件需要注册 Command 或者以传入回调函数的方式处理相关的事件。容器插件应该有较大的 UI 自由度,可以提供 browser 模块声明插槽位置,同时应该有内置的如按钮,输入框等基本组件。
容器插件:
1. `// 容器插件 browser`
2. `import * as kaitian from 'kaitian';`
4. `import { ButtonBasic, InputBasic } from './components';`
6. `kaitian.registerSlotContainer({`
7. `position: kaitian.POSITION.LEFT,`
8. `container: Slot,`
9. `components: [`
10. `{`
11. `type: 'input-basic',`
12. `contributions: [`
13. `{`
14. `name: 'onchange',`
15. `type: 'function',`
16. `},`
17. `{`
18. `name: 'icon',`
19. `type: 'string',`
20. `},`
21. `{`
22. `name: 'label',`
23. `type: 'string',`
24. `},`
25. `{`
26. `name: 'position',`
27. `type: 'number',`
28. `}`
29. `],`
30. `component: InputBasic,`
31. `},`
32. `{`
33. `type: 'button-basic',`
34. `contributions: [`
35. `{`
36. `name: 'onclick',`
37. `type: 'function',`
38. `},`
39. `{`
40. `name: 'icon',`
41. `type: 'string',`
42. `},`
43. `{`
44. `name: 'label',`
45. `type: 'string',`
46. `},`
47. `{`
48. `name: 'position',`
49. `type: 'number',`
50. `}`
51. `],`
52. `component: ButtonBasic,`
53. `}`
54. `]`
55. `});`
57. `// 组件容器`
58. `class Slot extends React.Component {`
60. `registerComponents(configs) {`
61. `this.componentMaps = configs;`
62. `}`
64. `componentDidMount() {`
65. `const {kaitianExtendSet} = this.props;`
67. `if (kaitianExtendSet) {`
68. `// 给 node 模块调用`
69. `kaitianExtendSet.set({`
70. `registerComponents: this.registerComponents,`
71. `});`
72. `}`
73. `}`
75. `render(){`
76. `return (`
77. `<div>`
78. `{this.componentMaps.map((com) => {`
79. `const Component = components.get(com.type);`
80. `<Component icon={com.icon} label={com.label} handler={(...args) => props.kaitianExtendService.eventHandler(id, ...args)} />`
81. `})}`
82. `</div>`
83. `);`
84. `}`
85. `}`
87. `// 容器插件 node`
88. `export function activate(context) {`
89. `const { componentProxy, registerExtendModuleService } = context;`
90. `const eventHandlerMap = new Map();`
92. `return {`
93. `registerComponents: (configs) => {`
94. `const serializeConfig = configs.map((config) => {`
95. `if(config.type === 'button-basic') {`
96. `const id = randomId();`
97. `// 事件回调放在 node 层,UI 事件只发 id 和 args到 node 层。`
98. `eventHandlerMap.set(id, config.onclick);`
99. `return {...config, onclick: id};`
100. `}`
101. `// ...`
102. `});`
103. `componentProxy.container.registerComponents(configs);`
104. `},`
105. `eventHandler: (id, ...args) => {`
106. `const handler = eventHandlerMap.get(id);`
107. `handler(...args);`
108. `};`
109. `}`
110. `}`
消费者插件:
1. `// node`
2. `import * as kaitian from 'kaitian';`
4. `function activate(context) {`
5. `const container = kaitian.getSlotContainer('basic-slot-container');`
7. `contaier.activate(context);`
8. `.then((registry) => {`
9. `registry.registerComponents([`
10. `{`
11. `type: 'button-basic',`
12. `onclick: (args) => {`
13. `kaitian.window.showInformationMessage('Hello World');`
14. `// ...`
15. `},`
16. `icon: 'basic btn',`
17. `label: '按钮',`
18. `position: 0,`
19. `}`
20. `]);`
21. `})`
22. `}`
容器插件提供的组件和 API 需要一定的通用性,理想情况下应该是以业务方一个小组为基本单元,编写自有的容器插件,其余业务插件通过调用容器插件注册的方式实现 UI 自定义。实现上这种方案略微复杂,对容器插件开发者和使用者来说都有一定的学习成本,API 设计需要尽可能简洁易懂。
这种方案只需要容器插件提供 UI 层面一些基础的组件模板,消费者插件不需要编写 UI 代码(browser 模块),完全运行在 Node.js 进程中,相比之下安全性和稳定性更高,而且基于容器插件的能力,可以针对各种业务形态定制不同风格的 UI 样式,统一插件的风格。
这两种方案各有优劣,在目前版本的开天 IDE 中都可以实现,一般情况下具有独立功能与 UI 的插件推荐使用第一种方式直接注入组件,而一些需要多个插件组合的复杂场景下,建议使用容器/消费者的方式,减小 UI 部分代码的重复工作量,统一风格。基于容器/消费者的插件 UI 定制方案目前已经在内部场景中上线探索并验证。
未来
工具的价值体现在是否能一定程度上解决问题从而提升工作效率,在这条路上我们才刚刚开始,还有许多未知的挑战在等着我们。我们希望能以 IDE 为基础打通研发体系,实现真正的全链路云端开发。