Electron杂谈 - 以VS Code为例
node,js以及ts对大多数有学习过前端的同学来说都是基本功,不用多说。但还是需要对VSCode使用相关技术框架有所了解。
VS Code技术框架
Electron
众所周知,VSCode是一款桌面编辑器应用,但是前端单纯用js是做不了桌面应用的,所以采用Electron来构建。Electron是基于 Chromium 和 Node.js,使用 JavaScript, HTML 和 CSS 构建跨平台的桌面应用,它兼容 Mac、Windows 和 Linux,可以构建出三个平台的应用程序。
从实现上来看,Electron = Node.js + Chromium + Native API。
Monaco Editor
微软之前有个项目叫做Monaco Workbench,后来这个项目变成了VSCode,而Monaco Editor就是从这个项目中成长出来的一个web编辑器,他们很大一部分的代码(monaco-editor-core)都是共用的,所以monaco和VSCode在编辑代码,交互以及UI上几乎是一摸一样的,有点不同的是,两者的平台不一样,monaco基于浏览器,而VSCode基于electron,所以功能上VSCode更加健全,并且性能比较强大。
LSP和DAP
Language Server Protocol (语言服务器协议,简称 LSP)是微软于 2016 年提出的一套统一的通讯协议方案。该方案定义了一套编辑器或 IDE 与语言服务器之间使用的协议,该语言服务器提供自动完成、转到定义、查找所有引用等语言功能。
Official page for Language Server Protocol
Official page for Debug Adapter Protocol
Visual Studio Code for the Web
VS Code的多进程架构
架构图
多进程结构
VSCode采用多进程架构,启动后主要由下面几个进程:
- 主进程
- 多个渲染进程包括ActivityBar,SideBar,Panel,Editor等
- 插件宿主进程
- Debug进程
- Search进程
组成。
主进程
主进程是 VSCode 的入口,主要负责管理编辑器生命周期,进程间通信,自动更新,菜单管理等。
我们启动 VSCode 的时候,主进程会首先启动,读取各种配置信息和历史记录,然后将这些信息和主窗口 UI 的 HTML 主文件路径整合成一个 URL,启动一个浏览器窗口来显示编辑器的 UI。主进程会一直关注 UI 进程的状态,当所有 UI 进程被关闭的时候,整个编辑器退出。
此外主进程还会开启一个本地的 Socket,当有新的 VSCode 进程启动的时候,会尝试连接这个 Socket,并将启动的参数信息传递给它,由已经存在的 VSCode 来执行相关的动作,这样能够保证 VSCode 的唯一性,避免出现多开文件夹带来的问题。
渲染进程
渲染界面的,通过IPC和主进程进行交互
插件进程
每一个 UI 窗口会启动一个 NodeJS 子进程作为插件的宿主进程。所有的插件会共同运行在这个进程中。这样设计最主要的目的就是避免复杂的插件系统阻塞 UI 的响应。但是将插件放在一个单独进程也有很明显的缺点,因为是一个单独的进程,而不是 UI 进程,所以没有办法直接访问 DOM 树,想要实时高效的改变 UI 变得很难,在 VSCode 的扩展体系中几乎没有对 UI 进行扩展的 API。
Debug进程
Debugger 插件跟普通的插件有一点区别,它不运行在插件进程中,而是在每次 debug 的时候由UI单独新开一个进程。
搜索进程
搜索是一个十分耗时的任务,VSCode 也使用的单独的进程来实现这个功能,保证主窗口的效率。将耗时的任务分到多个进程中,有效的保证了主进程的响应速度。
VS Code的代码架构
代码全景图
其中VS Code的核心代码位于src的vs目录下,其他的则是一些打包脚本,静态资源文件等。
我们着重观察一下src的代码结构。
├── bootstrap-amd.js # 子进程实际入口
├── bootstrap-fork.js #
├── bootstrap-window.js #
├── bootstrap.js # 子进程环境初始化
├── buildfile.js # 构建config
├── cli.js # CLI入口
├── main.js # 主进程入口
├── paths.js # AppDataPath与DefaultUserDataPath
├── typings
│ └── xxx.d.ts # ts类型声明
└── vs
├── base # 定义基础的工具方法和基础的 DOM UI 控件
│ ├── browser # 基础UI组件,DOM操作、交互事件、DnD等
│ ├── common # diff描述,markdown解析器,worker协议,各种工具函数
│ ├── node # Node工具函数
│ ├── parts # IPC协议(Electron、Node),quickopen、tree组件
│ ├── test # base单测用例
│ └── worker # Worker factory 和 main Worker(运行IDE Core:Monaco)
├── code # VSCode Electron 应用的入口,包括 Electron 的主进程脚本入口
│ ├── electron-browser # 需要 Electron 渲染器处理API的源代码(可以使用 common, browser, node)
│ ├── electron-main # 需要Electron主进程API的源代码(可以使用 common, node)
│ ├── node # 需要Electron主进程API的源代码(可以使用 common, node)
│ ├── test
│ └── code.main.ts
├── editor # Monaco Editor 代码编辑器:其中包含单独打包发布的 Monaco Editor 和只能在 VSCode 的使用的部分
│ ├── browser # 代码编辑器核心
│ ├── common # 代码编辑器核心
│ ├── contrib # vscode 与独立 IDE共享的代码
│ ├── standalone # 独立 IDE 独有的代码
│ ├── test
│ ├── editor.all.ts
│ ├── editor.api.ts
│ ├── editor.main.ts
│ └── editor.worker.ts
├── platform # 依赖注入的实现和 VSCode 使用的基础服务 Services
├── workbench # VSCode 桌面应用程序工作台的实现
├── buildunit.json
├── css.build.ts # 用于插件构建的CSS loader
├── css.ts # CSS loader
├── loader.js # AMD loader(用于异步加载AMD模块,类似于require.js)
├── nls.build.ts # 用于插件构建的 NLS loader
└── nls.ts # NLS(National Language Support)多语言loader
其中vs文件夹下的代码按照功能可以分为
- base: 提供通用服务和构建用户界面
- platform: 注入服务和基础服务代码
- editor: 微软 Monaco 编辑器,也可独立运行使用
- wrokbench: 配合 Monaco 的一些其他功能模块如:浏览器状态栏,菜单栏
按运行环境来看,每个目录组织也十分清晰。
- common: 只使用javascritp api的代码,能在任何环境下运行
- browser: 浏览器api, 如操作dom; 可以调用common
- node: 需要使用node的api,比如文件io操作
- electron-brower: 渲染进程api, 可调用common, brower, node, 依赖electron renderer-process API
-
electron-main: 主进程api, 可调用: common, node 依赖于electron main-process API
- 在 VSCode 代码仓库中,除了上述的src/vs的Core之外,还有一大块即 VSCode 内置的扩展,它们源代码位于extensions内。VSCode 作为代码编辑器,与各种代码编辑的功能如语法高亮、补全提示、验证等都有扩展实现的。所以在 VSCode 的内置扩展内,一大部分都是各种编程语言的支持扩展,如:extensions\html、extensions\javascript、extensions\cpp等等。这也就是我们装完VS Code就能愉快地写前端的原因。
VS Code的事件系统设计
先提一个问题,为什么不直接使用Node中的EventEmitter呢。
Event
想看事件,直接全局搜一下关键字Event。
结合上文对运行环境目录的拆分,这个base/common下的event文件应该就是我们要找的目标了。
/**
* 对于一个事件,一个具有一个或0个形参的函数可以被订阅。
* 事件是订阅者函数本身。
*/
export interface Event<T> {
(listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;
}
// 主要定义了一些接口协议,以及相关方法
// 使用 namespace 的方式将相关内容包裹起来
export namespace Event {
// 来看看里面比较关键的一些方法
// 给定一个事件,返回另一个仅触发一次的事件
export function once<T>(event: Event<T>): Event<T> {}
// 给定一连串的事件处理功能(过滤器,映射等),每个事件和每个侦听器都将调用每个函数
// 对事件链进行快照可以使每个事件每个事件仅被调用一次
// 以此衍生了 map、forEach、filter、any 等方法此处省略
export function snapshot<T>(event: Event<T>): Event<T> {}
// 给事件增加防抖
export function debounce<T>(event: Event<T>, merge: (last: T | undefined, event: T) => T, delay?: number, leading?: boolean, leakWarningThreshold?: number): Event<T>;
// 触发一次的事件,同时包括触发时间
export function stopwatch<T>(event: Event<T>): Event<number> {}
// 仅在 event 元素更改时才触发的事件
export function latch<T>(event: Event<T>): Event<T> {}
// 缓冲提供的事件,直到出现第一个 listener,这时立即触发所有事件,然后从头开始传输事件
export function buffer<T>(event: Event<T>, nextTick = false, _buffer: T[] = []): Event<T> {}
// 可链式处理的事件,支持以下方法
export interface IChainableEvent<T> {
event: Event<T>;
map<O>(fn: (i: T) => O): IChainableEvent<O>;
forEach(fn: (i: T) => void): IChainableEvent<T>;
filter(fn: (e: T) => boolean): IChainableEvent<T>;
filter<R>(fn: (e: T | R) => e is R): IChainableEvent<R>;
reduce<R>(merge: (last: R | undefined, event: T) => R, initial?: R): IChainableEvent<R>;
latch(): IChainableEvent<T>;
debounce(merge: (last: T | undefined, event: T) => T, delay?: number, leading?: boolean, leakWarningThreshold?: number): IChainableEvent<T>;
debounce<R>(merge: (last: R | undefined, event: T) => R, delay?: number, leading?: boolean, leakWarningThreshold?: number): IChainableEvent<R>;
on(listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;
once(listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable;
}
class ChainableEvent<T> implements IChainableEvent<T> {}
// 将事件转为可链式处理的事件
export function chain<T>(event: Event<T>): IChainableEvent<T> {}
// 来自 DOM 事件的事件
export function fromDOMEventEmitter<T>(emitter: DOMEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event<T> {}
// 来自 Promise 的事件
export function fromPromise<T = any>(promise: Promise<T>): Event<undefined> {}
}
我们能看到,Event
中主要是一些对事件的处理和某种类型事件的生成。其中,除了常见的once
和 DOM 事件等兼容,还提供了比较丰富的事件能力:
- 防抖动
- 可链式调用
- 缓存
- Promise 转事件
Emitter
到这里,我们只看到了关于事件的一些功能(参考Event
),而事件的触发和监听又是怎么进行的呢?
// 这是事件发射器的一些生命周期和设置
export interface EmitterOptions {
onFirstListenerAdd?: Function;
onFirstListenerDidAdd?: Function;
onListenerDidAdd?: Function;
onLastListenerRemove?: Function;
leakWarningThreshold?: number;
}
export class Emitter<T> {
// 可传入生命周期方法和设置
constructor(options?: EmitterOptions) {}
// 允许大家订阅此发射器的事件
get event(): Event<T> {
// 此处会根据传入的生命周期相关设置,在对应的场景下调用相关的生命周期方法
}
// 向订阅者触发事件
fire(event: T): void {}
// 清理相关的 listener 和队列等
dispose() {}
}
VS Code中的实践
怎么用呢?
源码里其实给了个示例。
接下来看看实例操作吧。
我们随便全局搜一下关键词 Emitter。
搜出来很多地方都有用,我们来看下dom.ts中是如何使用的。
class FocusTracker extends Disposable implements IFocusTracker {
// 注册一个事件发射器
private readonly _onDidFocus = this._register(new event.Emitter<void>());
// 将该发射器允许大家订阅的事件取出来
public readonly onDidFocus: event.Event<void> = this._onDidFocus.event;
private readonly _onDidBlur = this._register(new event.Emitter<void>());
public readonly onDidBlur: event.Event<void> = this._onDidBlur.event;
private _refreshStateHandler: () => void;
private static hasFocusWithin(element: HTMLElement): boolean {
const shadowRoot = getShadowRoot(element);
const activeElement = (shadowRoot ? shadowRoot.activeElement : document.activeElement);
return isAncestor(activeElement, element);
}
constructor(element: HTMLElement | Window) {
super();
let hasFocus = FocusTracker.hasFocusWithin(<HTMLElement>element);
let loosingFocus = false;
// 当 zoomLevel 有变更时,触发该事件
const onFocus = () => {
loosingFocus = false;
if (!hasFocus) {
hasFocus = true;
this._onDidFocus.fire();
}
};
const onBlur = () => {
if (hasFocus) {
loosingFocus = true;
window.setTimeout(() => {
if (loosingFocus) {
loosingFocus = false;
hasFocus = false;
this._onDidBlur.fire();
}
}, 0);
}
};
this._refreshStateHandler = () => {
const currentNodeHasFocus = FocusTracker.hasFocusWithin(<HTMLElement>element);
if (currentNodeHasFocus !== hasFocus) {
if (hasFocus) {
onBlur();
} else {
onFocus();
}
}
};
this._register(addDisposableListener(element, EventType.FOCUS, onFocus, true));
this._register(addDisposableListener(element, EventType.BLUR, onBlur, true));
this._register(addDisposableListener(element, EventType.FOCUS_IN, () => this._refreshStateHandler()));
this._register(addDisposableListener(element, EventType.FOCUS_OUT, () => this._refreshStateHandler()));
}
refreshState() {
this._refreshStateHandler();
}
}
这里使用了this._register(new Emitter<T>())
这样的方式注册事件发射器。
Dispose : VSCode中的资源管理
上文提到了this._register
,该方法继承自Disposable
。而Disposable
的实现也很简洁:
export interface IDisposable {
dispose(): void;
}
export abstract class Disposable implements IDisposable {
static readonly None = Object.freeze<IDisposable>({ dispose() { } });
// 用一个 Set 来存储注册的事件发射器
protected readonly _store = new DisposableStore();
constructor() {
trackDisposable(this);
setParentOfDisposable(this._store, this);
}
// 处理事件发射器
public dispose(): void {
markAsDisposed(this);
this._store.dispose();
}
// 注册一个事件发射器
protected _register<T extends IDisposable>(o: T): T {
if ((o as unknown as Disposable) === this) {
throw new Error('Cannot register a disposable on itself!');
}
return this._store.add(o);
}
}
也就是说,每个继承Disposable
类都会有管理事件发射器的相关方法,包括添加、销毁处理等。其实我们仔细看看,这个Disposable
并不只是服务于事件发射器,它适用于所有支持dispose()
方法的对象,Dispose 模式主要用来资源管理,资源比如内存被对象占用,则会通过调用方法来释放。
export interface IDisposable {
dispose(): void;
}
export class DisposableStore implements IDisposable {
static DISABLE_DISPOSED_WARNING = false;
private _toDispose = new Set<IDisposable>();
private _isDisposed = false;
constructor() {
trackDisposable(this);
}
/**
* Dispose of all registered disposables and mark this object as disposed.
*
* Any future disposables added to this object will be disposed of on `add`.
*/
public dispose(): void {
if (this._isDisposed) {
return;
}
markAsDisposed(this);
this._isDisposed = true;
this.clear();
}
/**
* Returns `true` if this object has been disposed
*/
public get isDisposed(): boolean {
return this._isDisposed;
}
/**
* Dispose of all registered disposables but do not mark this object as disposed.
*/
public clear(): void {
try {
dispose(this._toDispose.values());
} finally {
this._toDispose.clear();
}
}
public add<T extends IDisposable>(o: T): T {
if (!o) {
return o;
}
if ((o as unknown as DisposableStore) === this) {
throw new Error('Cannot register a disposable on itself!');
}
setParentOfDisposable(o, this);
if (this._isDisposed) {
if (!DisposableStore.DISABLE_DISPOSED_WARNING) {
console.warn(new Error('Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!').stack);
}
} else {
this._toDispose.add(o);
}
return o;
}
}
上面只销毁了事件触发器本身的资源,并没有对订阅函数本身进行销毁。
开发中,如果在某个组件里做了事件订阅这样的操作,当组件销毁的时候是需要取消事件订阅的,否则该订阅内容会在内存中一直存在,除了一些异常问题,还可能引起内存泄露。
在 VS Code 中,注册一个事件发射器、订阅某个事件,都是通过this._register()
这样的方式来实现:
// 1. 注册事件发射器
export class Button extends Disposable {
// 注册一个事件发射器,可使用 this._onDidClick.fire(xxx) 来触发事件
private _onDidClick = this._register(new Emitter<Event>());
get onDidClick(): BaseEvent<Event> { return this._onDidClick.event; }
}
// 2. 订阅某个事件
export class QuickInputController extends Disposable {
// 省略很多其他非关键代码
private getUI() {
const ok = new Button(okContainer);
ok.label = localize('ok', "OK");
// 注册一个 Disposable,用来订阅某个事件
this._register(ok.onDidClick(e => {
this.onDidAcceptEmitter.fire();
}));
}
}
也就是说当某个类被销毁时,会发生以下事情:
- 它所注册的事件发射器会被销毁,而事件发射器中的 Listener、队列等都会被清空。
- 它所订阅的一些事件会被销毁,订阅中的 Listener 同样会被移除。
至于订阅事件的 Listener 是如何被移除的,可参考以下代码:
export class Emitter<T> {
/**
* For the public to allow to subscribe
* to events from this Emitter
*/
get event(): Event<T> {
if (!this._event) {
this._event = (callback: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {
if (!this._listeners) {
this._listeners = new LinkedList();
}
const firstListener = this._listeners.isEmpty();
if (firstListener && this._options?.onFirstListenerAdd) {
this._options.onFirstListenerAdd(this);
}
let removeMonitor: Function | undefined;
let stack: Stacktrace | undefined;
if (this._leakageMon && this._listeners.size >= 30) {
// check and record this emitter for potential leakage
stack = Stacktrace.create();
removeMonitor = this._leakageMon.check(stack, this._listeners.size + 1);
}
if (_enableDisposeWithListenerWarning) {
stack = stack ?? Stacktrace.create();
}
const listener = new Listener(callback, thisArgs, stack);
const removeListener = this._listeners.push(listener);
if (firstListener && this._options?.onFirstListenerDidAdd) {
this._options.onFirstListenerDidAdd(this);
}
if (this._options?.onListenerDidAdd) {
this._options.onListenerDidAdd(this, callback, thisArgs);
}
const result = listener.subscription.set(() => {
removeMonitor?.();
if (!this._disposed) {
removeListener();
if (this._options && this._options.onLastListenerRemove) {
const hasListeners = (this._listeners && !this._listeners.isEmpty());
if (!hasListeners) {
this._options.onLastListenerRemove(this);
}
}
}
});
if (disposables instanceof DisposableStore) {
disposables.add(result);
} else if (Array.isArray(disposables)) {
disposables.push(result);
}
return result;
};
}
return this._event;
}
}
小结
根据上文的阐述,VS Code 中事件相关的管理的设计也都呈现出来了,包括:
- 提供标准化的
Event
和Emitter
能力
- 通过注册
Emitter
,并对外提供类似生命周期的方法onXxxxx
的方式,来进行事件的订阅和监听
- 通过提供通用类
Disposable
,统一管理相关资源的注册和销毁
- 通过使用同样的方式
this._register()
注册事件和订阅事件,将事件相关资源的处理统一挂载到dispose()
方法中
为什么不使用EventEmitter呢?
首先Electron同时存在浏览器环境和Node环境,使用EventEmitter当然可以在Node使用,但在浏览器端呢?再者,EventEmitter需要手动释放订阅函数等,所以手动自己实现一套事件系统更能符合工具的需求。
VS Code的通信机制
通信机制会有如下内容需要考虑:
- 协议设计-Protocol
- 通信频道-Channel
- 频道管理模块-ChannelServer,ChannelClient
- 连接-Connection,具体实现:IPC Server 和IPC Client
这里埋一个问题,为什么Connection里
让我们先梳理一下基本流程吧。
暂时无法在飞书文档外展示此内容
- IPC Client发送连接消息,并存储对应ChannelClient以及ChannelServer(其实就是一个connection,客户端里是一一对应的,不需要存多组)
- IPC Server收到连接消息,存储对应ChannelClient,接着将ChannelClient以及ChannelServer作为一个connection放入对应集合中(因为往往一个服务端对应多个客户端,所以connection有多个)。
- connection建立的时候,会将通用服务注册给connection的ChannelServer,表示此连接可默认使用的服务。
- 每一个connection中,会存在一个ChannelServer,用于管理此连接已订阅的ServerChannel,以及处理IPCClient对ServerChannel的需求。
用个简单的例子来说明一下上面的流程吧。
首先假设,我们有一个支付宝全家桶服务商(IPCServer)公众号,提供了电缴费查询服务、水缴费服务、气缴费服务、社保查询服务。
- 我们关注了服务商(IPCClient发送连接消息)。
- 服务商的粉丝列表里,多一个用户(存储对应ChannelClient)。
- 在服务列表里,发现了:电缴费查询服务、水缴费服务、气缴费服务服务。(服务注册)
- 打开电缴费查询服务,发起了电费查询请求,并返回查询结果。(处理服务需求)
基本原理
主进程和渲染进程的通信基础还是 Electron 的webContents.send
、ipcRender.send
、ipcMain.on
。
Protocol
IPC 通信中,协议是最基础的。
作为通信能力,最基本的协议范围包括发送和接收消息:
export interface IMessagePassingProtocol {
send(buffer: VSBuffer): void;
onMessage: Event<VSBuffer>;
/**
* Wait for the write buffer (if applicable) to become empty.
*/
drain?(): Promise<void>;
}
export interface Sender {
send(channel: string, msg: unknown): void;
}
至于具体协议内容,可能包括连接、断开、发送等:
/**
* The Electron `Protocol` leverages Electron style IPC communication (`ipcRenderer`, `ipcMain`)
* for the implementation of the `IMessagePassingProtocol`. That style of API requires a channel
* name for sending data.
*/
export class Protocol implements IMessagePassingProtocol {
constructor(private sender: Sender, readonly onMessage: Event<VSBuffer>) { }
send(message: VSBuffer): void {
try {
this.sender.send('vscode:message', message.buffer);
} catch (e) {
// systems are going down
}
}
disconnect(): void {
this.sender.send('vscode:disconnect', null);
}
}
Channel
作为一个频道而言,它会有两个功能,一个是执行call
,一个是监听listen
。
/**
* An `IChannel` is an abstraction over a collection of commands.
* You can `call` several commands on a channel, each taking at
* most one single argument. A `call` always returns a promise
* with at most one single return value.
*/
export interface IChannel {
call<T>(command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(event: string, arg?: any): Event<T>;
}
/**
* An `IServerChannel` is the counter part to `IChannel`,
* on the server-side. You should implement this interface
* if you'd like to handle remote promises or events.
*/
export interface IServerChannel<TContext = string> {
call<T>(ctx: TContext, command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(ctx: TContext, event: string, arg?: any): Event<T>;
}
ChannelClient & ChannelServer
一般来说,客户端和服务端的区分主要是:发起连接的一端为客户端,被连接的一端为服务端。在 VSCode 中,主进程是服务端,提供各种频道和服务供订阅;渲染进程是客户端,收听服务端提供的各种频道/服务,也可以给服务端发送一些消息(接入、订阅/收听、离开等)。
不管是客户端和服务端,它们都会需要发送和接收消息的能力,才能进行正常的通信。
在 VSCode 中,客户端包括ChannelClient
和IPCClient
,ChannelClient
只处理最基础的频道相关的功能,包括:
- 获得频道
getChannel
。
- 发送频道请求
sendRequest
。
- 接收请求结果,并处理
onResponse/onBuffer
。
// 客户端
export class ChannelClient implements IChannelClient, IDisposable {
getChannel<T extends IChannel>(channelName: string): T {
const that = this;
return {
call(command: string, arg?: any, cancellationToken?: CancellationToken) {
return that.requestPromise(channelName, command, arg, cancellationToken);
},
listen(event: string, arg: any) {
return that.requestEvent(channelName, event, arg);
}
} as T;
}
private requestPromise(channelName: string, name: string, arg?: any, cancellationToken = CancellationToken.None): Promise<any> {}
private requestEvent(channelName: string, name: string, arg?: any): Event<any> {}
private sendRequest(request: IRawRequest): void {}
private send(header: any, body: any = undefined): void {}
private sendBuffer(message: VSBuffer): void {}
private onBuffer(message: VSBuffer): void {}
private onResponse(response: IRawResponse): void {}
private whenInitialized(): Promise<void> {}
dispose(): void {}
}
/**
* An `IChannelClient` has access to a collection of channels. You
* are able to get those channels, given their channel name.
*/
export interface IChannelClient {
getChannel<T extends IChannel>(channelName: string): T;
}
同样的,服务端包括ChannelServer
和IPCServer
,ChannelServer
也只处理与频道直接相关的功能,包括:
- 注册频道
registerChannel
。
- 监听客户端消息
onRawMessage/onPromise/onEventListen
。
- 处理客户端消息并返回请求结果
sendResponse
。
// 服务端
export class ChannelServer<TContext = string> implements IChannelServer<TContext>, IDisposable {
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channels.set(channelName, channel);
}
private sendResponse(response: IRawResponse): void {}
private send(header: any, body: any = undefined): void {}
private sendBuffer(message: VSBuffer): void {}
private onRawMessage(message: VSBuffer): void {}
private onPromise(request: IRawPromiseRequest): void {}
private onEventListen(request: IRawEventListenRequest): void {}
private disposeActiveRequest(request: IRawRequest): void {}
private collectPendingRequest(request: IRawPromiseRequest | IRawEventListenRequest): void {}
public dispose(): void {}
}
/**
* An `IChannelServer` hosts a collection of channels. You are
* able to register channels onto it, provided a channel name.
*/
export interface IChannelServer<TContext = string> {
registerChannel(channelName: string, channel: IServerChannel<TContext>): void;
}
/**
* An `IServerChannel` is the counter part to `IChannel`,
* on the server-side. You should implement this interface
* if you'd like to handle remote promises or events.
*/
export interface IServerChannel<TContext = string> {
call<T>(ctx: TContext, command: string, arg?: any, cancellationToken?: CancellationToken): Promise<T>;
listen<T>(ctx: TContext, event: string, arg?: any): Event<T>;
}
\
Connection的具体实现
现在有了频道直接相关的客户端部分ChannelClient
和服务端部分ChannelServer
,但是它们之间需要连接起来才能进行通信。一个连接(Connection
)由ChannelClient
和ChannelServer
组成。
interface Connection<TContext> extends Client<TContext> {
readonly channelServer: ChannelServer<TContext>; // 服务端
readonly channelClient: ChannelClient; // 客户端
}
而连接的建立,则由IPCServer
和IPCClient
负责。其中:
IPCClient
基于ChannelClient
,负责简单的客户端到服务端一对一连接
IPCServer
基于channelServer
,负责服务端到客户端的连接,由于一个服务端可提供多个服务,因此会有多个连接
// 客户端
export class IPCClient<TContext = string> implements IChannelClient, IChannelServer<TContext>, IDisposable {
private channelClient: ChannelClient;
private channelServer: ChannelServer<TContext>;
getChannel<T extends IChannel>(channelName: string): T {
return this.channelClient.getChannel(channelName) as T;
}
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channelServer.registerChannel(channelName, channel);
}
}
// 由于服务端有多个服务,因此可能存在多个连接
export class IPCServer<TContext = string> implements IChannelServer<TContext>, IRoutingChannelClient<TContext>, IConnectionHub<TContext>, IDisposable {
private channels = new Map<string, IServerChannel<TContext>>();
private _connections = new Set<Connection<TContext>>();
// 获取连接信息
get connections(): Connection<TContext>[] {}
/**
* 从远程客户端获取频道。
* 通过路由器后,可以指定它要呼叫和监听/从哪个客户端。
* 否则,当在没有路由器的情况下进行呼叫时,将选择一个随机客户端,而在没有路由器的情况下进行侦听时,将监听每个客户端。
*/
getChannel<T extends IChannel>(channelName: string, router: IClientRouter<TContext>): T;
getChannel<T extends IChannel>(channelName: string, clientFilter: (client: Client<TContext>) => boolean): T;
getChannel<T extends IChannel>(channelName: string, routerOrClientFilter: IClientRouter<TContext> | ((client: Client<TContext>) => boolean)): T {}
// 注册频道
registerChannel(channelName: string, channel: IServerChannel<TContext>): void {
this.channels.set(channelName, channel);
// 添加到连接中
this._connections.forEach(connection => {
connection.channelServer.registerChannel(channelName, channel);
});
}
}
VS Code的优化手段
CovalenceConf 2019: Visual Studio Code – The First Second
CovalenceConf 是一个以 Electron 构建桌面软件为主题的技术会议,这也是 VS Code 团队为数不多的对外分享之一。
衡量性能的一些指标
developer.mozilla.org/zh-CN/docs/…
在这之前我们需要明确几个首屏启动性能相关的概念,这里列举的并不是全部,有兴趣的可以自行在 Web.Dev 查找其他指标。
我们不一定关注以上所有的指标,但有几个对用户体感差异较为明显的指标可以重点关注一下,例如 LCP 、 FID 以及 TTI。
\
还有另一项指标 FMP (First Meaningful Paint 首次有效渲染时间) 不是很推荐,因为它无法直观的识别页面的主体内容是否加载完成,例如某些网站会在有意义的内容渲染前展示一个全屏的 Loading 动画,这对用户来讲显然是没有任何意义的,而相比之下 LCP 更为纯粹,它只看页面主体内容、图像是否加载完成。
这与 VS Code 的原则不谋而合,对于文本编辑器来说,性能好坏最直接的问题就是从点开图标到我可以输入文本需要多久? VS Code 的答案是 1 秒 (热启动在 500 毫秒左右)。
所以第一步永远是测量,不管是 console.time 还是新的 Performance API,在关键的节点添加这些性能标记,通过大量的数据收集可以得到一个真实的性能指标。VS Code 选择了 Performance API ,这样更方便汇总上报数据。运行 Startup Performance 命令可以看到这些性能指标的耗时 (总耗时2s+, 实际上 TTI 是 977ms)。
\
数据收集除了能看到当前真实的性能指标,更能帮助我们发现耗时花在了哪些地方。要做到这一点,需要找到这些关键节点。VS Code 是基于 Electron ,除了常规的页面渲染之外,还有一包括等待 Electron App Ready、创建窗口、LoadURL 等耗时,这部分的性能有专业的团队来保障(Electron、V8),不需要关心太多。所以重点需要关心的是 UI 部分的呈现及可交互时间。
VS Code关于启动性能优化的一些做法
-
性能优化基本的法则
- 测量,测量,还是测量,并基于此建立一个基准线 (VS Code 使用 Performance API,并对整个启动过程中的关键节点打点)
- 建立监控,针对每个版本的性能变化快速做出优化措施
- 使用硬件较为落后的一台 ThinkPad 做测试,确保它能在1.8秒内启动 VS Code
- 不要过多的专注于 Electron、V8 这些底层依赖,因为有一群聪明的人在不断的优化它们,专注于加载代码以及运行程序。
-
确保代码尽可能快的加载
- 使用 Rollup、Webpack 等构建工具将代码打包成单文件,这可以节省约 400ms
- 压缩代码,可以节省约 100ms
- 使用 V8 Cached Data 将一些模块代码编译成字节码文件(一种中间态),这可以节省约 400ms, VS Code 自己使用 AMD Loader 实现了这个缓存,也可以直接用 v8-compile-cache 这个包。
-
生命周期阶段(Lifecycle Phases),分先后顺序来做应该做的事?不要一股脑全部执行
- 梳理清楚所有关于启动阶段事情的优先级
- 保证资源管理器和编辑器初始化,然后再做其他不是非常重要的事
-
requestIdleCallback, 将不那么重要的工作放在浏览器空闲时间执行
-
通过一些小技巧使得界面「体感上」较快
- 切换编辑器时,使用 MouseDown 来替代 MouseUp / Click 事件,先确保 Tab 很快的切换
- 打开耗时较大的文件时,首先将面包屑、状态栏等其他 UI 部分渲染出来,使得用户感觉 UI 反应很快
- 重复以上步骤
老调重弹:软件开发没有银弹
VS Code 是少有的核心功能完全使用 Web 技术构建的桌面编辑器,在这之前是 Atom,但师出同门(Electron) 的 Atom 最为人诟病的就是其性能问题。VS Code 自诞生那天起,保证性能优先就是最重要的一条准则,诚然相比老牌的 Sublime Text,VS Code 性能表现并不能称得上优秀,但相比之下已经完全是可以接受的水平了。
整场分享看下来,实际上并没有听到什么软件优化的黑魔法。
总结下来,基本有两点原则是VS Code团队是一直在践行的
- 划分优先级,确保高优先级任务的渲染速度(比如永远确保文件树和编辑器最快渲染出来,并且光标第一时间在编辑器内跳动(这意味着用户可以开始编辑文件了)
- 做好性能监控,每个都收集尽可能多的数据,不断对短板性能进行优化。
性能优化是一个长期的过程,并不是某个时间段集中精力优化一波就高枕无忧了,你可以在 VS Code 的 issue 列表里找到一系列标签为 perf 和 startup-perf 相关的 issue,并且这些 issue 都有人长期跟踪解决的。
说到底,性能还是需要反复地不断进行优化,并没有优化的黑魔法。
\
参考以下文章
转载自:https://juejin.cn/post/7146459053068124190