深入理解Electron(二)VSCode架构探索
背景
说起建立在Electron之上最出色且复杂的应用,那就不得不提到VSCode。VSCode 是一个基于 Electron 构建的跨平台代码编辑器,对于想要深入了解它的启动过程的开发者来说,这是一个非常有价值的学习过程。通过了解 VSCode 的启动过程,我们可以更好地理解 Electron 应用程序的启动流程,并学习如何在应用程序中加载、初始化和管理大量的代码模块和资源。此外,VSCode 启动过程中会涉及到各种与系统环境、用户配置和扩展安装有关的因素,深入研究这些方面可以让我们更好地优化应用程序的性能和用户体验。
在了解启动过程之前,我们先得对VSCode整个源码的架构有个大致的了解。
VSCode源码架构概述
vscode
整体源码结构还是比较清晰的,重要目录如下:
extensions/
:包含默认安装的vscode扩展。product.json
:描述vscode产品的配置文件。src/
:包含vscode的源码,是最核心的目录。base/
:提供通用的工具函数和用户界面构建块,可在其他层次中使用。platform/
: 提供依赖注入支持和VSCode的基础服务,这些服务在多个层次之间共享,例如工作台和代码。不应包含编辑器或工作台特定的服务或代码。workbench/
:托管 "Monaco" 编辑器、笔记本和自定义编辑器,并提供面板框架,如资源管理器、状态栏或菜单栏,利用 Electron 实现 VS Code 桌面应用程序和浏览器 API 提供 VS Code Web 版本。code/
:VSCode桌面应用程序的入口点,负责将所有内容拼合在一起,包括Electron主文件、共享进程和CLI。server/
:远程开发的服务器应用程序的入口点,负责处理远程开发的相关服务。
它们之间的依赖关系如下图:
以上是VS Code源码的核心组织架构。通过 code
和 server
作为入口,workbench
作为主框架,而 editor
、platform
和 base
则成为这个庞大应用的基石。
由于VSCode本身同时提供面向浏览器的版本和客户端的版本,所以在二级目录下整体的目录结构又是跟所支持的目标平台环境有关:
common
:只需要基本的JavaScript API,可以运行在所有其他目标环境中。browser
:需要Web API,例如DOM。可能使用common
中的代码。node
:需要Node.js API。可能使用common
中的代码。electron-sandbox
:需要浏览器API,如访问DOM,以及与Electron主进程通信的一小部分API(来自src/vs/base/parts/sandbox/electron-sandbox/globals.ts
)。可能使用common
、browser
和electron-sandbox
中的代码。electron-main
:需要Electron主进程API。可能使用common
和node
中的代码。
入口文件解析
VSCode作为Electron入口文件在src/main.js
中,这个文件主要做了以下几件事情:
初始化应用程序:在引入了 electron 模块后,通过 electron.app 模块中的 ready 事件监听函数,在 Electron 框架准备好后,执行初始化应用程序的函数。初始化应用程序的过程包括:
启用 ASAR 支持。 设置用户数据路径。 注册自定义协议。 设置本地化环境等。
function startup(codeCachePath, nlsConfig) {
nlsConfig._languagePackSupport = true;
process.env['VSCODE_NLS_CONFIG'] = JSON.stringify(nlsConfig);
process.env['VSCODE_CODE_CACHE_PATH'] = codeCachePath || '';
// Load main in AMD
perf.mark('code/willLoadMainBundle');
require('./bootstrap-amd').load('vs/code/electron-main/main', () => {
perf.mark('code/didLoadMainBundle');
});
}
这段代码就是vscode的核心启动函数,主要是通过AMD的方式加载electron-main/main
这个入口。为什么要采用AMD呢?这可能跟VSCode的历史背景有关,也是目前VSCode的一个核心技术债,下面我们来详细说一说:
关于VSCode中的AMD加载
VS Code使用AMD(Asynchronous Module Definition)来加载JavaScript模块。AMD是一种异步的模块定义规范,它支持在浏览器环境下异步加载JavaScript模块,从而避免了页面加载时长时间等待所有模块都被下载和执行的情况。
我们知道,VSCode最早期是先有浏览器版本的,而在ES Module出现之前,浏览器最受欢迎的模块加载方案就是AMD。我们稍微回顾一下模块化的历史发展(为了简化起见,就不提sea了):
IIFE模式(立即执行函数表达式):在ES5时代,JavaScript没有官方的模块化机制,因此开发者通常使用IIFE来实现模块化。IIFE可以将一段代码封装到一个匿名函数中,避免全局变量的污染,从而实现基本的模块化。 CommonJS规范:CommonJS是一个JavaScript模块化规范,主要用于服务器端,Node.js就是使用了CommonJS规范的模块化机制。CommonJS使用require()函数来引入模块,使用module.exports来导出模块。 AMD规范:AMD(Asynchronous Module Definition)是一个在浏览器端使用的JavaScript模块化规范,它的主要特点是异步加载模块,这样可以提高页面加载速度。AMD使用require.js库来实现模块化。 ES6模块化:ES6是JavaScript的下一代标准,它在语言层面上提供了模块化机制。ES6模块化使用import语句来引入模块,使用export语句来导出模块。ES6模块化的好处是可以静态分析模块的依赖关系,从而实现更好的优化。
其实VSCode使用AMD,除了有浏览器这一层的原因外,AMD本身可以带来一些特性:
AMD本身提供了define和require的模块化机制,可以将所有依赖bundle在一起,类似于Webpack,这样做能够很显著的提高性能(其实文件磁盘IO的开销在客户端应用当中并不小)。 AMD相比CommonJS来说,是支持异步加载的,这样在加载的时候可以先不阻塞主线程。
VSCode在Node环境中如何实现AMD的,让我们稍微深入分析一下(vscode专门实现了一个vscode-loader的仓库,用来实现CMD,做到了多端的统一模块化):
我们看一下_createAndEvalScript
的实现:
private _createAndEvalScript(moduleManager: IModuleManager, contents: string, options: INodeVMScriptOptions, callback: () => void, errorback: (err: any) => void): INodeVMScript {
const recorder = moduleManager.getRecorder();
recorder.record(LoaderEventType.NodeBeginEvaluatingScript, options.filename);
const script = new this._vm.Script(contents, options);
const ret = script.runInThisContext(options);
const globalDefineFunc = moduleManager.getGlobalAMDDefineFunc();
let receivedDefineCall = false;
const localDefineFunc: IDefineFunc = <any>function () {
receivedDefineCall = true;
return globalDefineFunc.apply(null, arguments);
};
localDefineFunc.amd = globalDefineFunc.amd;
ret.call(global, moduleManager.getGlobalAMDRequireFunc(), localDefineFunc, options.filename, this._path.dirname(options.filename));
recorder.record(LoaderEventType.NodeEndEvaluatingScript, options.filename);
if (receivedDefineCall) {
callback();
} else {
errorback(new Error(`Didn't receive define call in ${options.filename}!`));
}
return script;
}
这里面核心就是创建了一个_vm.Script,并且调用runInThisContext
执行,在调用vm时,其实就是在V8上创建了字节码,接下来供V8进行解析执行。
如果说这个步骤跟原生的require类似的话,那接下来对于file的处理应该就是最大的不同了:
private _readSourceAndCachedData(sourcePath: string, cachedDataPath: string | undefined, recorder: ILoaderEventRecorder, callback: (err?: any, source?: string, cachedData?: Buffer, hashData?: Buffer) => any): void {
if (!cachedDataPath) {
// no cached data case
this._fs.readFile(sourcePath, { encoding: 'utf8' }, callback);
} else {
// cached data case: read both files in parallel
let source: string | undefined = undefined;
let cachedData: Buffer | undefined = undefined;
let hashData: Buffer | undefined = undefined;
let steps = 2;
const step = (err?: any) => {
if (err) {
callback(err);
} else if (--steps === 0) {
callback(undefined, source, cachedData, hashData);
}
}
this._fs.readFile(sourcePath, { encoding: 'utf8' }, (err: any, data: string) => {
source = data;
step(err);
});
this._fs.readFile(cachedDataPath, (err: any, data: Buffer) => {
if (!err && data && data.length > 0) {
hashData = data.slice(0, 16);
cachedData = data.slice(16);
recorder.record(LoaderEventType.CachedDataFound, cachedDataPath);
} else {
recorder.record(LoaderEventType.CachedDataMissed, cachedDataPath);
}
step(); // ignored: cached data is optional
});
}
}
这里的核心点在于fs.readFile,这是个异步方法,而require内部用的是readFileSync,这也是AMD能实现异步加载的核心。另一部分是关于v8 cacheData的处理,这一点和require其实是类似的。
目前,VSCode已经有将AMD迁移到ESM的计划,可以参考:https://github.com/microsoft/vscode/issues/160416
VSCode的依赖注入机制
private async startup(): Promise<void> {
// Set the error handler early enough so that we are not getting the
// default electron error dialog popping up
setUnexpectedErrorHandler(err => console.error(err));
// Create services
const [instantiationService, instanceEnvironment, environmentMainService, configurationService, stateMainService, bufferLogService, productService, userDataProfilesMainService] = this.createServices();
try {
// Init services
try {
await this.initServices(environmentMainService, userDataProfilesMainService, configurationService, stateMainService, productService);
} catch (error) {
// Show a dialog for errors that can be resolved by the user
this.handleStartupDataDirError(environmentMainService, productService, error);
throw error;
}
// Startup
await instantiationService.invokeFunction(async accessor => {
const logService = accessor.get(ILogService);
const lifecycleMainService = accessor.get(ILifecycleMainService);
const fileService = accessor.get(IFileService);
const loggerService = accessor.get(ILoggerService);
// Create the main IPC server by trying to be the server
// If this throws an error it means we are not the first
// instance of VS Code running and so we would quit.
const mainProcessNodeIpcServer = await this.claimInstance(logService, environmentMainService, lifecycleMainService, instantiationService, productService, true);
// Write a lockfile to indicate an instance is running
// (https://github.com/microsoft/vscode/issues/127861#issuecomment-877417451)
FSPromises.writeFile(environmentMainService.mainLockfile, String(process.pid)).catch(err => {
logService.warn(`app#startup(): Error writing main lockfile: ${err.stack}`);
});
// Delay creation of spdlog for perf reasons (https://github.com/microsoft/vscode/issues/72906)
bufferLogService.logger = loggerService.createLogger(URI.file(join(environmentMainService.logsPath, 'main.log')), { id: 'mainLog', name: localize('mainLog', "Main") });
// Lifecycle
once(lifecycleMainService.onWillShutdown)(evt => {
fileService.dispose();
configurationService.dispose();
evt.join('instanceLockfile', FSPromises.unlink(environmentMainService.mainLockfile).catch(() => { /* ignored */ }));
});
return instantiationService.createInstance(CodeApplication, mainProcessNodeIpcServer, instanceEnvironment).startup();
});
} catch (error) {
instantiationService.invokeFunction(this.quit, error);
}
}
以上就是VSCode启动代码(位于src/vs/code/electron-main/main.ts),它主要做了以下几件事情:
创建各种服务:首先,VS Code创建一些服务实例来管理不同的任务和功能。通过尽可能延迟创建和初始化这些服务,VS Code可以减少启动时间。在这个过程中,VS Code还尽可能地减少I/O操作,例如,它会延迟创建日志服务,以避免不必要的写入到磁盘。 初始化服务:一旦创建了所有服务实例,VS Code会初始化这些服务以确保它们可以正确地工作。在这个过程中,VS Code会尝试读取用户设置和状态,但是它会尽可能地将这些操作延迟到需要时再进行。 启动:一旦所有服务都初始化完成,VS Code会开始启动主应用程序。在这个过程中,VS Code会尽可能地减少I/O操作,并使用缓存来避免不必要的模块加载和初始化。例如,在启动过程中,VS Code会尝试延迟创建IPC服务器,以避免不必要的通信开销。 生命周期管理:VS Code会在启动后立即处理生命周期管理。例如,它会立即创建一个IPC服务器,并写入一个锁文件,以指示当前有一个实例正在运行。这些任务是在启动过程中进行的,以确保它们尽可能地快速完成。
整个流程中,其实最重要复杂的就是启动VSCode的依赖注入,它的核心实现位于vs/platform/instantiation:
这个组件的核心概念是 ServiceCollection
,它是一组可被实例化的服务的集合。在启动 VS Code 时,VS Code 的主进程会创建一个 ServiceCollection
对象,然后注册需要被实例化的服务。
不同于Invesify等依赖注入库,VSCode的所有依赖实例都需要提前显示注册到ServiceCollection
中,最初的依赖可以在createServices
中找到:
private createServices(): [IInstantiationService, IProcessEnvironment, IEnvironmentMainService, ConfigurationService, StateService, BufferLogger, IProductService, UserDataProfilesMainService] {
const services = new ServiceCollection();
const disposables = new DisposableStore();
process.once('exit', () => disposables.dispose());
// Product
const productService = { _serviceBrand: undefined, ...product };
services.set(IProductService, productService);
// Environment
const environmentMainService = new EnvironmentMainService(this.resolveArgs(), productService);
const instanceEnvironment = this.patchEnvironment(environmentMainService); // Patch `process.env` with the instance's environment
services.set(IEnvironmentMainService, environmentMainService);
// Logger
const loggerService = new LoggerMainService(getLogLevel(environmentMainService));
services.set(ILoggerMainService, loggerService);
// ...
return [new InstantiationService(services, true), instanceEnvironment, environmentMainService, configurationService, stateService, bufferLogger, productService, userDataProfilesMainService];
}
可以看到,serviceCollection
的key是一个Identifier
,这实际上是一个装饰器,所有的Identifier
都是通过createDecorator
来创建的:
export function createDecorator<T>(serviceId: string): ServiceIdentifier<T> {
if (_util.serviceIds.has(serviceId)) {
return _util.serviceIds.get(serviceId)!;
}
const id = <any>function (target: Function, key: string, index: number): any {
if (arguments.length !== 3) {
throw new Error('@IServiceName-decorator can only be used to decorate a parameter');
}
storeServiceDependency(id, target, index);
};
id.toString = () => serviceId;
_util.serviceIds.set(serviceId, id);
return id;
}
在使用这个装饰器修饰成员变量的时候,会执行到storeServiceDependency
,将当前的信息存储在类上面,这样在后续进行依赖分析的时候,就可以读取到这些依赖相关的信息。
在VSCode的编码实现中,我们会发现实现一个服务所需要的通用步骤:
定义服务接口:首先需要定义服务接口,该接口定义了服务的 API,即服务能够提供哪些功能。接口通常放在 vs/platform
文件夹下的一个子文件夹中,比如vs/platform/telemetry/common/telemetry
。定义与服务器同名的Identifier,比如 export const IProductService = createDecorator<IProductService>('productService');
。注册服务:其次需要在应用程序的入口处,即 vs/code/electron-main/main.ts
中注册服务,并将其添加到容器中(以Identifier为key,实例或者SyncDescriptor实例作为value)。
这里提到了SyncDescriptor
,其实就是一个包装类,当我们不通过new
来手动创建实例时,就调用这个包装类交给serviceCollection
,由依赖注入机制帮助我们去实例化相关的依赖:
export class SyncDescriptor<T> {
readonly ctor: any;
readonly staticArguments: any[];
readonly supportsDelayedInstantiation: boolean;
constructor(ctor: new (...args: any[]) => T, staticArguments: any[] = [], supportsDelayedInstantiation: boolean = false) {
this.ctor = ctor;
this.staticArguments = staticArguments;
this.supportsDelayedInstantiation = supportsDelayedInstantiation;
}
}
它的注册方式如下:
services.set(IRequestService, new SyncDescriptor(RequestMainService, undefined, true));
另外,在前面提到vscode本身是支持多目标环境的,所以platform
目录下的所有服务,都是分为Node
、Electron-main
、common
、browser
去实现的,它们之间有基本的继承关系,下图表示了三个最基础服务实现的继承关系:
classDiagram
class IProductService {
<<interface>>
_serviceBrand: undefined
}
class IProductConfiguration {
<<interface>>
version: string;
date?: string;
quality?: string;
commit?: string;
nameShort: string;
nameLong: string;
...
}
IProductConfiguration <|-- IProductService : extends
IProductService <|.. ProductService : implements
class IEnvironmentService {
<<interface>>
_serviceBrand: undefined;
stateResource: URI;
userRoamingDateHome: URI;
untitledWorkspaceHome: URI;
userDataSyncHome: URI;
...
}
class INativeEnvironmentService {
<<interface>>
args: NativeParsedArgs;
appRoot: string;
machineSettingsResource: URI;
extensionsPath: string;
...
}
class IEnvironmentMainService {
<<interface>>
cachedLanguagesPath: string;
backupHome: string;
codeCachePath: string;
useCodeCache: boolean;
mainIPCHandle: string;
...
}
class AbstractNativeEnvironmentService {
<<abstract>>
}
IEnvironmentService <|-- INativeEnvironmentService : extends
INativeEnvironmentService <|-- IEnvironmentMainService : extends
INativeEnvironmentService <|.. AbstractNativeEnvironmentService : implements
AbstractNativeEnvironmentService <|-- NativeEnvironmentService : extends
NativeEnvironmentService <|-- EnvironmentMainService : extends
IEnvironmentMainService <|.. EnvironmentMainService : implements
EnvironmentMainService --> ProductService
class ILoggerService {
<<interface>>
_serviceBrand: undefined;
createLogger(resource: URI, options?: ILoggerOptions): ILogger;
getLogger(resource: URI): ILogger;
onDidChangeLogLevel: Event<LogLevel>;
setLogLevel(level: LogLevel): void;
setLogLevel(resource: URI, level: LogLevel): void;
getLogLevel(resource?: URI): LogLevel;
onDidChangeVisibility: Event<[URI, boolean]>;
setVisibility(resource: URI, visible: boolean): void;
onDidChangeLoggers: Event<DidChangeLoggersEvent>;
registerLogger(resource: ILoggerResource): void;
deregisterLogger(resource: URI): void;
getRegisteredLoggers(): Iterable<ILoggerResource>;
getRegisteredLogger(resource: URI): ILoggerResource;
}
class ILoggerMainService {
<<interface>>
getOnDidChangeLogLevelEvent(windowId: number): Event<LogLevel>;
getOnDidChangeVisibilityEvent(windowId: number): Event<[URI, boolean]>;
getOnDidChangeLoggersEvent(windowId: number): Event<DidchangeLoggersEvent>;
createLogger(resource: URI, options?: ILoggerOptions, windowId?: number): ILogger;
registerLogger(resource: ILoggerResource, windowId?: number): void;
getRegisteredLoggers(windowId?: number): ILoggerResource[];
deregisterLoggers(windowId: number): void;
}
ILoggerService <|.. AbstractLoggerService : implements
AbstractLoggerService <|-- LoggerService : extends
LoggerService <|-- LoggerMainService : extends
ILoggerService <|.. AbstractLoggerService : implements
ILoggerMainService <|.. LoggerMainService : implements
ILoggerService <|-- ILoggerMainService : extends
最后,在经过instantiationService
启动创建实例后,所有的依赖都会按照一定的顺序进行实例化,在VSCode的实现中,实际上是通过一个图的遍历算法来实现的:
private _createAndCacheServiceInstance<T>(id: ServiceIdentifier<T>, desc: SyncDescriptor<T>, _trace: Trace): T {
type Triple = { id: ServiceIdentifier<any>; desc: SyncDescriptor<any>; _trace: Trace };
const graph = new Graph<Triple>(data => data.id.toString());
let cycleCount = 0;
const stack = [{ id, desc, _trace }];
while (stack.length) {
const item = stack.pop()!;
graph.lookupOrInsertNode(item);
// a weak but working heuristic for cycle checks
if (cycleCount++ > 1000) {
throw new CyclicDependencyError(graph);
}
// check all dependencies for existence and if they need to be created first
for (const dependency of _util.getServiceDependencies(item.desc.ctor)) {
const instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id);
if (!instanceOrDesc) {
this._throwIfStrict(`[createInstance] ${id} depends on ${dependency.id} which is NOT registered.`, true);
}
// take note of all service dependencies
this._globalGraph?.insertEdge(String(item.id), String(dependency.id));
if (instanceOrDesc instanceof SyncDescriptor) {
const d = { id: dependency.id, desc: instanceOrDesc, _trace: item._trace.branch(dependency.id, true) };
graph.insertEdge(item, d);
stack.push(d);
}
}
}
while (true) {
const roots = graph.roots();
// if there is no more roots but still
// nodes in the graph we have a cycle
if (roots.length === 0) {
if (!graph.isEmpty()) {
throw new CyclicDependencyError(graph);
}
break;
}
for (const { data } of roots) {
// Repeat the check for this still being a service sync descriptor. That's because
// instantiating a dependency might have side-effect and recursively trigger instantiation
// so that some dependencies are now fullfilled already.
const instanceOrDesc = this._getServiceInstanceOrDescriptor(data.id);
if (instanceOrDesc instanceof SyncDescriptor) {
// create instance and overwrite the service collections
const instance = this._createServiceInstanceWithOwner(data.id, data.desc.ctor, data.desc.staticArguments, data.desc.supportsDelayedInstantiation, data._trace);
this._setServiceInstance(data.id, instance);
}
graph.removeNode(data);
}
}
return <T>this._getServiceInstanceOrDescriptor(id);
}
这个算法通过两层遍历,一层遍历生成一个有向图,第二层遍历为图的每个节点进行实例化的操作。使用图的数据结构可以清晰地看到依赖关系链路,并且能够快速识别出循环依赖问题。
最后,在使用层,依赖注入就变得十分简单:
class Client {
constructor(
@IModelService modelService: IModelService
) {
// use services
}
}
CodeApplication的启动
在经过main
的一系列依赖注入初始化后,最终启动的是CodeApplication
这个入口,它所依赖的服务都是在main
入口提前实例化好的:
constructor(
private readonly mainProcessNodeIpcServer: NodeIPCServer,
private readonly userEnv: IProcessEnvironment,
@IInstantiationService private readonly mainInstantiationService: IInstantiationService,
@ILogService private readonly logService: ILogService,
@IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService,
@ILifecycleMainService private readonly lifecycleMainService: ILifecycleMainService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IStateService private readonly stateService: IStateService,
@IFileService private readonly fileService: IFileService,
@IProductService private readonly productService: IProductService,
@IUserDataProfilesMainService private readonly userDataProfilesMainService: IUserDataProfilesMainService,
) {
super();
this.configureSession();
this.registerListeners();
}
这里是每个参数的作用:
mainProcessNodeIpcServer
: Node.js的IPC服务器,用于与主进程进行通信。userEnv
: 用户环境,包括所有环境变量和命令行参数。mainInstantiationService
: 用于创建VSCode的实例化服务,可以通过它来创建和管理服务和对象。logService
: 用于记录日志和诊断信息的服务。environmentMainService
: 提供了许多与环境相关的功能,例如查找应用程序路径,读取配置文件等等。lifecycleMainService
: 提供了应用程序生命周期的管理和控制功能。configurationService
: 提供了应用程序配置的管理和控制功能。stateService
: 提供了应用程序状态的管理和控制功能。fileService
: 提供了与文件和文件系统相关的功能。productService
: 提供了与产品信息相关的功能,例如产品名称、版本号等等。userDataProfilesMainService
: 提供了与用户数据配置文件相关的功能,例如创建、删除、查找配置文件等等。
这里的configureSession
和registerListeners
用于应用启动前的初始化:
configureSession
设置一些 Electron 的 session 相关配置,包括一些webview访问的拦截安全设置,vscode自身支持的protocal访问等等。registerListeners
注册了一系列主进程的事件监听,以及一些提早注册的IPC事件。
最终,整个应用的加载流程是调用setup
方法启动的:
async startup(): Promise<void> {
this.logService.debug('Starting VS Code');
this.logService.debug(`from: ${this.environmentMainService.appRoot}`);
this.logService.debug('args:', this.environmentMainService.args);
// Make sure we associate the program with the app user model id
// This will help Windows to associate the running program with
// any shortcut that is pinned to the taskbar and prevent showing
// two icons in the taskbar for the same app.
const win32AppUserModelId = this.productService.win32AppUserModelId;
if (isWindows && win32AppUserModelId) {
app.setAppUserModelId(win32AppUserModelId);
}
// Fix native tabs on macOS 10.13
// macOS enables a compatibility patch for any bundle ID beginning with
// "com.microsoft.", which breaks native tabs for VS Code when using this
// identifier (from the official build).
// Explicitly opt out of the patch here before creating any windows.
// See: https://github.com/microsoft/vscode/issues/35361#issuecomment-399794085
try {
if (isMacintosh && this.configurationService.getValue('window.nativeTabs') === true && !systemPreferences.getUserDefault('NSUseImprovedLayoutPass', 'boolean')) {
systemPreferences.setUserDefault('NSUseImprovedLayoutPass', 'boolean', true as any);
}
} catch (error) {
this.logService.error(error);
}
// Main process server (electron IPC based)
const mainProcessElectronServer = new ElectronIPCServer();
this.lifecycleMainService.onWillShutdown(e => {
if (e.reason === ShutdownReason.KILL) {
// When we go down abnormally, make sure to free up
// any IPC we accept from other windows to reduce
// the chance of doing work after we go down. Kill
// is special in that it does not orderly shutdown
// windows.
mainProcessElectronServer.dispose();
}
});
// Resolve unique machine ID
this.logService.trace('Resolving machine identifier...');
const machineId = await resolveMachineId(this.stateService);
this.logService.trace(`Resolved machine identifier: ${machineId}`);
// Shared process
const { sharedProcess, sharedProcessReady, sharedProcessClient } = this.setupSharedProcess(machineId);
// Services
const appInstantiationService = await this.initServices(machineId, sharedProcess, sharedProcessReady);
// Auth Handler
this._register(appInstantiationService.createInstance(ProxyAuthHandler));
// Transient profiles handler
this._register(appInstantiationService.createInstance(UserDataProfilesHandler));
// Init Channels
appInstantiationService.invokeFunction(accessor => this.initChannels(accessor, mainProcessElectronServer, sharedProcessClient));
// Setup Protocol URL Handlers
const initialProtocolUrls = appInstantiationService.invokeFunction(accessor => this.setupProtocolUrlHandlers(accessor, mainProcessElectronServer));
// Signal phase: ready - before opening first window
this.lifecycleMainService.phase = LifecycleMainPhase.Ready;
// Open Windows
await appInstantiationService.invokeFunction(accessor => this.openFirstWindow(accessor, initialProtocolUrls));
// Signal phase: after window open
this.lifecycleMainService.phase = LifecycleMainPhase.AfterWindowOpen;
// Post Open Windows Tasks
appInstantiationService.invokeFunction(accessor => this.afterWindowOpen(accessor, sharedProcess));
// Set lifecycle phase to `Eventually` after a short delay and when idle (min 2.5sec, max 5sec)
const eventuallyPhaseScheduler = this._register(new RunOnceScheduler(() => {
this._register(runWhenIdle(() => this.lifecycleMainService.phase = LifecycleMainPhase.Eventually, 2500));
}, 2500));
eventuallyPhaseScheduler.schedule();
}
setup
主要做了以下几件事情:
记录应用程序的启动参数和应用程序根路径等信息。 创建 Electron IPC 服务器。 创建共享进程,并等待共享进程准备就绪。 初始化服务。 注册代理认证和用户数据配置处理程序。 初始化通信通道。 设置协议 URL 处理程序。 打开第一个窗口。 执行打开窗口后的任务。 最后,等待一段时间,将应用程序的生命周期状态设置为 Eventually
。
这里包含了VSCode启动的关键步骤,其中IPC
和Channel
、共享进程、窗口机制,都是非常重要的服务,限于篇幅,我们在后面的系列中进行展开分析。
最后来看看打开窗口的实现:
private async openFirstWindow(accessor: ServicesAccessor, initialProtocolUrls: IInitialProtocolUrls | undefined): Promise<ICodeWindow[]> {
const windowsMainService = this.windowsMainService = accessor.get(IWindowsMainService);
const context = isLaunchedFromCli(process.env) ? OpenContext.CLI : OpenContext.DESKTOP;
const args = this.environmentMainService.args;
// First check for windows from protocol links to open
if (initialProtocolUrls) {
// Openables can open as windows directly
if (initialProtocolUrls.openables.length > 0) {
return windowsMainService.open({
context,
cli: args,
urisToOpen: initialProtocolUrls.openables,
gotoLineMode: true,
initialStartup: true
// remoteAuthority: will be determined based on openables
});
}
// Protocol links with `windowId=_blank` on startup
// should be handled in a special way:
// We take the first one of these and open an empty
// window for it. This ensures we are not restoring
// all windows of the previous session.
// If there are any more URLs like these, they will
// be handled from the URL listeners installed later.
if (initialProtocolUrls.urls.length > 0) {
for (const protocolUrl of initialProtocolUrls.urls) {
const params = new URLSearchParams(protocolUrl.uri.query);
if (params.get('windowId') === '_blank') {
// It is important here that we remove `windowId=_blank` from
// this URL because here we open an empty window for it.
params.delete('windowId');
protocolUrl.originalUrl = protocolUrl.uri.toString(true);
protocolUrl.uri = protocolUrl.uri.with({ query: params.toString() });
return windowsMainService.open({
context,
cli: args,
forceNewWindow: true,
forceEmpty: true,
gotoLineMode: true,
initialStartup: true
// remoteAuthority: will be determined based on openables
});
}
}
}
}
const macOpenFiles: string[] = (<any>global).macOpenFiles;
const hasCliArgs = args._.length;
const hasFolderURIs = !!args['folder-uri'];
const hasFileURIs = !!args['file-uri'];
const noRecentEntry = args['skip-add-to-recently-opened'] === true;
const waitMarkerFileURI = args.wait && args.waitMarkerFilePath ? URI.file(args.waitMarkerFilePath) : undefined;
const remoteAuthority = args.remote || undefined;
const forceProfile = args.profile;
const forceTempProfile = args['profile-temp'];
// Started without file/folder arguments
if (!hasCliArgs && !hasFolderURIs && !hasFileURIs) {
// Force new window
if (args['new-window'] || forceProfile || forceTempProfile) {
return windowsMainService.open({
context,
cli: args,
forceNewWindow: true,
forceEmpty: true,
noRecentEntry,
waitMarkerFileURI,
initialStartup: true,
remoteAuthority,
forceProfile,
forceTempProfile
});
}
// mac: open-file event received on startup
if (macOpenFiles.length) {
return windowsMainService.open({
context: OpenContext.DOCK,
cli: args,
urisToOpen: macOpenFiles.map(path => (hasWorkspaceFileExtension(path) ? { workspaceUri: URI.file(path) } : { fileUri: URI.file(path) })),
noRecentEntry,
waitMarkerFileURI,
initialStartup: true,
// remoteAuthority: will be determined based on macOpenFiles
});
}
}
// default: read paths from cli
return windowsMainService.open({
context,
cli: args,
forceNewWindow: args['new-window'] || (!hasCliArgs && args['unity-launch']),
diffMode: args.diff,
mergeMode: args.merge,
noRecentEntry,
waitMarkerFileURI,
gotoLineMode: args.goto,
initialStartup: true,
remoteAuthority,
forceProfile,
forceTempProfile
});
}
其实这里是根据vscode
传入不同的协议参数而打开不同状态的窗口,vscode
本身是支持很多协议参数的,比如说空窗口、文件夹、文件、合并窗口等。至此,我们的启动流程就结束了(暂时不深入workbench相关的分析)。
VSCode启动过程中的性能优化
在vscode中,所有被perf.mark
标记的打点可以通过一个task来查看:
执行这个task可以得到一份性能测试报告:
System Info
Code: 1.75.1 (441438abd1ac652551dbe4d408dfcec8a499b8bf) OS: win32(10.0.22621) CPUs: Apple Silicon(4 x 3200) Memory(System): 7.99 GB(4.74GB free) Memory(Process): 234.50 MB working set(182.02MB private, 2.90MB shared) VM(likelihood): 100% Initial Startup: true Has 1 other windows Screen Reader Active: false Empty Workspace: false
Performance Marks
What | Duration | Process | Info |
---|---|---|---|
start => app.isReady | 139 | [main] | initial startup: true |
nls:start => nls:end | 92 | [main] | initial startup: true |
require(main.bundle.js) | 132 | [main] | initial startup: true |
start crash reporter | 29 | [main] | initial startup: true |
serve main IPC handle | 2 | [main] | initial startup: true |
create window | 53 | [main] | initial startup: true, state: 0ms, widget: 53ms, show: 0ms |
app.isReady => window.loadUrl() | 235 | [main] | initial startup: true |
window.loadUrl() => begin to require(workbench.desktop.main.js) | 285 | [main->renderer] | NewWindow |
require(workbench.desktop.main.js) | 244 | [renderer] | cached data: YES, node_modules took 0ms |
wait for window config | 1 | [renderer] | - |
init storage (global & workspace) | 17 | [renderer] | - |
init workspace service | 46 | [renderer] | - |
register extensions & spawn extension host | 939 | [renderer] | - |
restore viewlet | 45 | [renderer] | workbench.view.explorer |
restore panel | 36 | [renderer] | terminal |
restore & resolve visible editors | 293 | [renderer] | 1: workbench.editors.files.fileEditorInput |
overall workbench load | 563 | [renderer] | - |
workbench ready | 1528 | [main->renderer] | - |
renderer ready | 874 | [renderer] | - |
shared process connection ready | 1038 | [renderer->sharedprocess] | - |
extensions registered | 2311 | [renderer] | - |
这个时间的计算方法,我们可以在workbench/contrib/performance/browser/previewEditor
和workbench/services/timer/browser/timerService
中看到:
private async _computeStartupMetrics(): Promise<IStartupMetrics> {
const initialStartup = this._isInitialStartup();
let startMark: string;
if (isWeb) {
startMark = 'code/timeOrigin';
} else {
startMark = initialStartup ? 'code/didStartMain' : 'code/willOpenNewWindow';
}
const activeViewlet = this._paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar);
const activePanel = this._paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel);
const info: Writeable<IStartupMetrics> = {
version: 2,
ellapsed: this._marks.getDuration(startMark, 'code/didStartWorkbench'),
// reflections
isLatestVersion: Boolean(await this._updateService.isLatestVersion()),
didUseCachedData: this._didUseCachedData(),
windowKind: this._lifecycleService.startupKind,
windowCount: await this._getWindowCount(),
viewletId: activeViewlet?.getId(),
editorIds: this._editorService.visibleEditors.map(input => input.typeId),
panelId: activePanel ? activePanel.getId() : undefined,
// timers
timers: {
ellapsedAppReady: initialStartup ? this._marks.getDuration('code/didStartMain', 'code/mainAppReady') : undefined,
ellapsedNlsGeneration: initialStartup ? this._marks.getDuration('code/willGenerateNls', 'code/didGenerateNls') : undefined,
ellapsedLoadMainBundle: initialStartup ? this._marks.getDuration('code/willLoadMainBundle', 'code/didLoadMainBundle') : undefined,
ellapsedCrashReporter: initialStartup ? this._marks.getDuration('code/willStartCrashReporter', 'code/didStartCrashReporter') : undefined,
ellapsedMainServer: initialStartup ? this._marks.getDuration('code/willStartMainServer', 'code/didStartMainServer') : undefined,
ellapsedWindowCreate: initialStartup ? this._marks.getDuration('code/willCreateCodeWindow', 'code/didCreateCodeWindow') : undefined,
ellapsedWindowRestoreState: initialStartup ? this._marks.getDuration('code/willRestoreCodeWindowState', 'code/didRestoreCodeWindowState') : undefined,
ellapsedBrowserWindowCreate: initialStartup ? this._marks.getDuration('code/willCreateCodeBrowserWindow', 'code/didCreateCodeBrowserWindow') : undefined,
ellapsedWindowMaximize: initialStartup ? this._marks.getDuration('code/willMaximizeCodeWindow', 'code/didMaximizeCodeWindow') : undefined,
ellapsedWindowLoad: initialStartup ? this._marks.getDuration('code/mainAppReady', 'code/willOpenNewWindow') : undefined,
ellapsedWindowLoadToRequire: this._marks.getDuration('code/willOpenNewWindow', 'code/willLoadWorkbenchMain'),
ellapsedRequire: this._marks.getDuration('code/willLoadWorkbenchMain', 'code/didLoadWorkbenchMain'),
ellapsedWaitForWindowConfig: this._marks.getDuration('code/willWaitForWindowConfig', 'code/didWaitForWindowConfig'),
ellapsedStorageInit: this._marks.getDuration('code/willInitStorage', 'code/didInitStorage'),
ellapsedSharedProcesConnected: this._marks.getDuration('code/willConnectSharedProcess', 'code/didConnectSharedProcess'),
ellapsedWorkspaceServiceInit: this._marks.getDuration('code/willInitWorkspaceService', 'code/didInitWorkspaceService'),
ellapsedRequiredUserDataInit: this._marks.getDuration('code/willInitRequiredUserData', 'code/didInitRequiredUserData'),
ellapsedOtherUserDataInit: this._marks.getDuration('code/willInitOtherUserData', 'code/didInitOtherUserData'),
ellapsedExtensions: this._marks.getDuration('code/willLoadExtensions', 'code/didLoadExtensions'),
ellapsedEditorRestore: this._marks.getDuration('code/willRestoreEditors', 'code/didRestoreEditors'),
ellapsedViewletRestore: this._marks.getDuration('code/willRestoreViewlet', 'code/didRestoreViewlet'),
ellapsedPanelRestore: this._marks.getDuration('code/willRestorePanel', 'code/didRestorePanel'),
ellapsedWorkbench: this._marks.getDuration('code/willStartWorkbench', 'code/didStartWorkbench'),
ellapsedExtensionsReady: this._marks.getDuration(startMark, 'code/didLoadExtensions'),
ellapsedRenderer: this._marks.getDuration('code/didStartRenderer', 'code/didStartWorkbench')
},
// system info
platform: undefined,
release: undefined,
arch: undefined,
totalmem: undefined,
freemem: undefined,
meminfo: undefined,
cpus: undefined,
loadavg: undefined,
isVMLikelyhood: undefined,
initialStartup,
hasAccessibilitySupport: this._accessibilityService.isScreenReaderOptimized(),
emptyWorkbench: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY
};
await this._extendStartupInfo(info);
return info;
}
上面的数据是我分配了4核8G的Windows虚拟机的数据,可以看到整个workbench的ready花了1528ms的时间,在这个配置的Windows机器上,可以说vscode已经在启动性能上做的十分出色了。
VSCode启动流程做了哪些性能优化
减少IO操作
在启动过程中,如果进行大量的磁盘读写,会严重影响到启动性能。因此VSCode启用了BufferLog
的机制,把日志缓存在内存中的一个 Buffer 中,等到 Buffer 被写满了,或者过了一定时间间隔,才会将缓存中的日志写入到磁盘中的文件中。这种机制可以减少频繁的磁盘写入,提高性能。
在主进程的入口中,我们会发现它创建了这个实例:
// Logger
const loggerService = new LoggerMainService(getLogLevel(environmentMainService));
services.set(ILoggerMainService, loggerService);
// Log: We need to buffer the spdlog logs until we are sure
// we are the only instance running, otherwise we'll have concurrent
// log file access on Windows (https://github.com/microsoft/vscode/issues/41218)
const bufferLogger = new BufferLogger(loggerService.getLogLevel());
const logService = disposables.add(new LogService(bufferLogger, [new ConsoleMainLogger(loggerService.getLogLevel())]));
services.set(ILogService, logService);
延迟加载与Idle Until Urgent
Idle Until Urgent这个概念源自于https://philipwalton.com/articles/idle-until-urgent/这篇文章,作者比较深入地分析了启动时加载性能和延迟加载策略的探讨,全部同步执行以及全部延迟加载都是在走极端,最好的方式是在将一些可被延迟的任务放在idle周期中去处理,在使用时能够尽可能快地拿到。
以下是VScode的一个实现:
export class IdleValue<T> {
private readonly _executor: () => void;
private readonly _handle: IDisposable;
private _didRun: boolean = false;
private _value?: T;
private _error: unknown;
constructor(executor: () => T) {
this._executor = () => {
try {
this._value = executor();
} catch (err) {
this._error = err;
} finally {
this._didRun = true;
}
};
this._handle = runWhenIdle(() => this._executor());
}
dispose(): void {
this._handle.dispose();
}
get value(): T {
if (!this._didRun) {
this._handle.dispose();
this._executor();
}
if (this._error) {
throw this._error;
}
return this._value!;
}
get isInitialized(): boolean {
return this._didRun;
}
}
这里的runWhenIdle
实际上是requestIdleCallback
的polyfill,如果不支持requestIdleCallback
,会退化为setTimeout0
,而setTimeout0
也是一种Polyfill,在支持postMessage
的情况下会使用message
回调以获得在nextTick的尽快执行,这些细节在这里就不进一步展开了。
在VSCode中,依赖注入的延迟机制正是通过IdleValue来实现的,整个实例对象其实是一个Proxy,可以理解为getter调用时和IdleValue启动时的竞速,如果在Idle周期内能够初始化,则可以拿到实例对象,否则就在getter时机再去初始化,这样能够保证主线程的最大性能,将时间分片交给更需要的任务来做。
打包与压缩
之前详细分析了VSCode的AMD加载机制,实际上VSCode通过gulp将源代码进行了bundle和压缩,在主进程加载的时候避免了磁盘的多次读写,其实小文件磁盘读写会带来比较大的性能损耗。
这个实践可以在VSCode这次Talk中看到:https://www.youtube.com/watch?v=r0OeHRUCCb4:
可以看到在Bundle和Minify之后,性能提速了近400ms,效果显著。
小结
本文通过对VSCode源码架构的梳理,重点探索了VSCode的启动流程,深入介绍了VSCode启动时所使用的技术和优化。
在VSCode的启动流程中,通过合理的使用依赖注入的Lazy Load和懒解析,大大缩短了启动时间。同时,VSCode还采用了ThrottledDelayer等技术,实现了Idle Until Urgent的特性,提高了用户体验。
除此之外,VSCode还通过BufferLogger等技术减少了IO操作,提升了启动速度和性能表现。在日志系统方面,VSCode使用ILoggerMainService和ILogService来进行日志记录,分别负责记录和输出日志信息,提高了系统的可维护性和可扩展性。
总的来说,VSCode的启动流程还是十分庞大和复杂的,虽然有着自己的技术债,但在measure和细节的优化方面,vscode有很多值得我们学习的地方。
借用vscode分享的三句话来完成本文的小结,所谓的性能优化,无非就是在重复这样的过程: