likes
comments
collection
share

Electron杂谈 - 以VS Code为例

作者站长头像
站长
· 阅读数 25

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更加健全,并且性能比较强大。

Monaco Editor

LSP和DAP

Language Server Protocol (语言服务器协议,简称 LSP)是微软于 2016 年提出的一套统一的通讯协议方案。该方案定义了一套编辑器或 IDE 与语言服务器之间使用的协议,该语言服务器提供自动完成、转到定义、查找所有引用等语言功能。

Electron杂谈 - 以VS Code为例

Official page for Language Server Protocol

Official page for Debug Adapter Protocol

Visual Studio Code for the Web

VS Code的多进程架构

架构图

Electron杂谈 - 以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的代码架构

代码全景图

Electron杂谈 - 以VS Code为例

其中VS Code的核心代码位于src的vs目录下,其他的则是一些打包脚本,静态资源文件等。

我们着重观察一下src的代码结构。

Electron杂谈 - 以VS Code为例

├── 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-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。

Electron杂谈 - 以VS Code为例

结合上文对运行环境目录的拆分,这个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中的实践

怎么用呢?

源码里其实给了个示例。

Electron杂谈 - 以VS Code为例

接下来看看实例操作吧。

我们随便全局搜一下关键词 Emitter。

Electron杂谈 - 以VS Code为例

搜出来很多地方都有用,我们来看下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();
    }));
  }
}

也就是说当某个类被销毁时,会发生以下事情:

  1. 它所注册的事件发射器会被销毁,而事件发射器中的 Listener、队列等都会被清空。
  1. 它所订阅的一些事件会被销毁,订阅中的 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 中事件相关的管理的设计也都呈现出来了,包括:

  • 提供标准化的EventEmitter能力
  • 通过注册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.sendipcRender.sendipcMain.on

Electron杂谈 - 以VS Code为例

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 中,客户端包括ChannelClientIPCClientChannelClient只处理最基础的频道相关的功能,包括:

  1. 获得频道getChannel
  1. 发送频道请求sendRequest
  1. 接收请求结果,并处理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;
}

同样的,服务端包括ChannelServerIPCServerChannelServer也只处理与频道直接相关的功能,包括:

  1. 注册频道registerChannel
  1. 监听客户端消息onRawMessage/onPromise/onEventListen
  1. 处理客户端消息并返回请求结果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)由ChannelClientChannelServer组成。

interface Connection<TContext> extends Client<TContext> {
        readonly channelServer: ChannelServer<TContext>; // 服务端
        readonly channelClient: ChannelClient; // 客户端
}

而连接的建立,则由IPCServerIPCClient负责。其中:

  • 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。

Electron杂谈 - 以VS Code为例

\

还有另一项指标 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)。

\

Electron杂谈 - 以VS Code为例

数据收集除了能看到当前真实的性能指标,更能帮助我们发现耗时花在了哪些地方。要做到这一点,需要找到这些关键节点。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),分先后顺序来做应该做的事?不要一股脑全部执行

    • 梳理清楚所有关于启动阶段事情的优先级
    • 保证资源管理器和编辑器初始化,然后再做其他不是非常重要的事
  • 通过一些小技巧使得界面「体感上」较快

    • 切换编辑器时,使用 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 都有人长期跟踪解决的。

Electron杂谈 - 以VS Code为例

说到底,性能还是需要反复地不断进行优化,并没有优化的黑魔法。

\

参考以下文章

CovalenceConf 2019: Visual Studio Code – The First Second

VSCode 源码解读:事件系统设计 | 被删的前端游乐场 (godbasin.github.io)