VS Code源码简析

一.Electron基本结构

VS Code作为Electron的成功案例,一头扎进源码之前,有必要简单梳理下Electron的基本结构

从实现上来看:

Electron = Node.js + Chromium + Native API

也就是说Electron拥有Node运行环境,依靠Chromium提供基于Web技术(HTML、CSS、JS)的界面交互支持,另外还具有一些平台特性,比如桌面通知

从API设计上来看,Electron App一般都有1个Main Process和多个Renderer Process:

  • main process:主进程环境下可以访问Node及Native API

  • renderer process:渲染器进程环境下可以访问Browser API和Node API及一部分Native API

API设计如此,那么Electron App的项目结构也至少包括这两部分内容

主进程

相当于后台服务,常用于:

  • 多窗体管理(创建/切换)

  • 应用生命周期管理

  • 作为进程通信基站(IPC Server)

  • 自动更新

  • 工具条菜单栏注册

渲染器进程

界面交互相关的,具体的业务功能,都由renderer进程来做,3个基本原则:

  • 尽量用renderer干活,包括网络请求

  • 太耗时的用Worker拆出去

  • 需要跨renderer共享的用子进程拆出去,交由main管理

You can use all packages that work with Node.js in the main process as well as in the renderer process if you have webPreferences.nodeIntegration set to true in the BrowserWindow options. This is the default.

It’s actually recommended to do as much as possible in the renderer process.

P.S.关于main与renderer分工的讨论,请查看What is the best way to make Http requests using Electron?

二.vscode源码结构

以下内容参考源码版本为v1.19.3

目录结构

├── build       # gulp编译构建脚本
├── extensions  # 内置插件
├── gulpfile.js # gulp task
├── i18n        # 国际化翻译包
├── out         # 编译输出目录
├── product.json  # App meta信息
├── resources     # 平台相关静态资源,图标等
├── scripts       # 工具脚本,开发/测试
├── src           # 源码目录
└── test          # 测试套件

src下的结构如下:

├── bootstrap-amd.js  # 子进程实际入口
├── bootstrap.js  # 子进程环境初始化
├── buildfile.js  # 构建config
├── cli.js        # CLI入口
├── main.js       # 主进程入口
├── paths.js      # AppDataPath与DefaultUserDataPath
├── typings
│   └── xxx.d.ts  # ts类型声明
└── vs
    ├── base      # 通用工具/协议和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)
    ├── buildunit.json
    ├── code        # VS Code主窗体相关
    ├── css.build.js  # 用于插件构建的CSS loader
    ├── css.js        # CSS loader
    ├── editor        # 对接IDE Core(读取编辑/交互状态),提供命令、上下文菜单、hover、snippet等支持
    ├── loader.js     # AMD loader(用于异步加载AMD模块,类似于require.js)
    ├── nls.build.js  # 用于插件构建的NLS loader
    ├── nls.js        # NLS(National Language Support)多语言loader
    ├── platform      # 支持注入服务和平台相关基础服务(文件、剪切板、窗体、状态栏)
    └── workbench     # 协调editor并给viewlets提供框架,比如目录查看器、状态栏等,全局搜索,集成Git、Debug

其中最关键的部分(业务相关的)是:

  • src/vs/code:主窗体、工具栏菜单创建

  • src/vs/editor:代码编辑器,IDE核心相关

  • src/vs/workbench:UI布局,功能服务对接

P.S.IDE Core可独立使用,叫Monaco

每层按目标执行环境细分组织:

三.启动流程

启动流程相关文件递进关系如下:

功能入口
src/main.js
  src/vs/code/electron-main/main.ts
    src/vs/code/electron-main/app.ts
      src/vs/code/electron-main/windows.ts
        src/vs/code/electron-main/window.ts
UI入口
src/vs/workbench/electron-browser/bootstrap/index.html
  src/vs/workbench/electron-browser/bootstrap/index.js
    src/vs/workbench/workbench.main js index文件
      src/vs/workbench/electron-browser/main.ts
        src/vs/workbench/electron-browser/shell.ts 界面与功能服务的接入点
          src/vs/workbench/electron-browser/workbench.ts 创建界面
            src/vs/workbench/browser/layout.ts 布局计算,绝对定位

Electron CLI启动应用

启动步骤:

# 编译构建(ts转换,打包)
npm run compile
# 通过Electron启动应用
./scripts/code.sh

code.sh的作用类似于Electron Demo中常见的:

"name": "electron-quick-start",
"version": "1.0.0",
"description": "A minimal Electron application",
"main": "main.js",
"scripts": {
  "start": "electron ."
}

主要部分如下:

# Configuration
export NODE_ENV=development
export VSCODE_DEV=1
export VSCODE_CLI=1
export ELECTRON_ENABLE_LOGGING=1
export ELECTRON_ENABLE_STACK_DUMPING=1

# Launch Code
exec "$CODE" . "$@"

配置dev环境变量,最后通过exec执行:

./.build/electron/Code - OSS.app/Contents/MacOS/Electron .

Electron CLI会把pkg.main作为入口文件去加载执行:

"name": "code-oss-dev",
"version": "1.19.3",
"distro": "2751aca3e43316e3418502935939817889deb719",
"author": {
  "name": "Microsoft Corporation"
},
"main": "./out/main"

即转到入口文件out/main.js,对应源码是src/main.js,重要部分如下:

// src/main.js
app.once('ready', function () {
  perf.mark('main:appReady');
  global.perfAppReady = Date.now();
  var nlsConfig = getNLSConfiguration();
  process.env['VSCODE_NLS_CONFIG'] = JSON.stringify(nlsConfig);

  nodeCachedDataDir.then(function () {
    require('./bootstrap-amd').bootstrap('vs/code/electron-main/main');
  }, console.error);
});

cacheData目录准备好后,通过AMD loader加载main process入口文件out/vs/code/electron-main/main.js,进入main process初始化流程

Main Process初始化

main process入口文件对应源码src/vs/code/electron-main/main.js的主要部分如下:

// Startup
    return instantiationService.invokeFunction(a => createPaths(a.get(IEnvironmentService)))
        .then(() => instantiationService.invokeFunction(setupIPC))
        .then(mainIpcServer => instantiationService.createInstance(CodeApplication, mainIpcServer, instanceEnv).startup());

其中CodeApplication来自vs/code/electron-main/app.ts,启动流程相关部分如下:

// Open Windows
appInstantiationService.invokeFunction(accessor => this.openFirstWindow(accessor));
// Post Open Windows Tasks
appInstantiationService.invokeFunction(accessor => this.afterWindowOpen(accessor));

openFirstWindow()主要内容如下:

this.windowsMainService = accessor.get(IWindowsMainService);

// Open our first window
    const args = this.environmentService.args;
    const context = !!process.env['VSCODE_CLI'] ? OpenContext.CLI : OpenContext.DESKTOP;
    if (args['new-window'] && args._.length === 0) {
        this.windowsMainService.open({ context, cli: args, forceNewWindow: true, forceEmpty: true, initialStartup: true }); // new window if "-n" was used without paths
    } else if (global.macOpenFiles && global.macOpenFiles.length && (!args._ || !args._.length)) {
        this.windowsMainService.open({ context: OpenContext.DOCK, cli: args, pathsToOpen: global.macOpenFiles, initialStartup: true }); // mac: open-file event received on startup
    } else {
        this.windowsMainService.open({ context, cli: args, forceNewWindow: args['new-window'] || (!args._.length && args['unity-launch']), diffMode: args.diff, initialStartup: true }); // default: read paths from cli
    }

// Only load when the window has not vetoed this
    this.lifecycleService.unload(window, UnloadReason.LOAD).done(veto => {
  // Load it
  window.load(configuration);
}

注意,this.lifecycleService.unload(window, UnloadReason.LOAD)这句很有迷惑性,触发unload,原因(UnloadReason)是LOAD,也就是说,我们先new了个window,立即手动调用它的unload(),然后再手动调用load()加载这个窗体……那么,为毛要先调用unload()

P.S.这个load()相当关键,后面还会回来

到这里还没有看到入口HTML,而是windowsMainService.open(),追进去(src/vs/code/electron-main/windows.ts):

public open(openConfig: IOpenConfiguration): CodeWindow[] {
  // Open based on config
  const usedWindows = this.doOpen(openConfig, workspacesToOpen, workspacesToRestore, foldersToOpen, foldersToRestore, emptyToRestore, emptyToOpen, filesToOpen, filesToCreate, filesToDiff, filesToWait, foldersToAdd);
}

private doOpen() {
  // Handle empty to open (only if no other window opened)
  if (usedWindows.length === 0) {
    for (let i = 0; i < emptyToOpen; i++) {
      usedWindows.push(this.openInBrowserWindow({
        userEnv: openConfig.userEnv,
        cli: openConfig.cli,
        initialStartup: openConfig.initialStartup,
        forceNewWindow: openFolderInNewWindow
      }));

      openFolderInNewWindow = true; // any other window to open must open in new window then
    }
  }
}

private openInBrowserWindow() {
  window = this.instantiationService.createInstance(CodeWindow, {
            state,
            extensionDevelopmentPath: configuration.extensionDevelopmentPath,
            isExtensionTestHost: !!configuration.extensionTestsPath
        });
}

关键的CodeWindow定义在src/vs/code/electron-main/window.ts,所以初始化过程是多窗体管理类(windows.ts)调用VS Code主窗体(window.ts)。所以open()最终返回了一个CodeWindow实例,简化一下:

// Open our first window
    window = new CodeWindow();

// Only load when the window has not vetoed this
window.load(configuration);

接着看load(),关键部分如下:

public load(config: IWindowConfiguration, isReload?: boolean): void {
  // Load URL
  mark('main:loadWindow');
  this._win.loadURL(this.getUrl(config));
}

private getUrl(windowConfiguration: IWindowConfiguration): string {
  // Config (combination of process.argv and window configuration)
  const config = objects.assign(environment, windowConfiguration);

  return `${require.toUrl('vs/workbench/electron-browser/bootstrap/index.html')}?config=${encodeURIComponent(JSON.stringify(config))}`;
}

HTML出现了,main process的使命完成,renderer process登场

Renderer Process初始化

入口HTMLsrc/vs/workbench/electron-browser/bootstrap/index.html的主要内容如下:

<body class="monaco-shell vs-dark" aria-label="">
      <script src="preload.js"></script>
  </body>

<!-- Startup via index.js -->
  <script src="index.js"></script>

引入了两个JS,preload.js从URL解析出config参数,根据主题配置设置body背景色,index.js含加载逻辑:

function main() {
  const webFrame = require('electron').webFrame;
  // 从URL参数解析出config
  const args = parseURLQueryArgs();
  const configuration = JSON.parse(args['config'] || '{}') || {};

  // 恢复传入的环境变量
  // Correctly inherit the parent's environment
  assign(process.env, configuration.userEnv);
  perf.importEntries(configuration.perfEntries);

  // 恢复NLS多语言配置
  // Get the nls configuration into the process.env as early as possible.
  var nlsConfig = { availableLanguages: {} };
  const config = process.env['VSCODE_NLS_CONFIG'];
  if (config) {
    process.env['VSCODE_NLS_CONFIG'] = config;
    try {
      nlsConfig = JSON.parse(config);
    } catch (e) { /*noop*/ }
  }
  var locale = nlsConfig.availableLanguages['*'] || 'en';
  if (locale === 'zh-tw') {
    locale = 'zh-Hant';
  } else if (locale === 'zh-cn') {
    locale = 'zh-Hans';
  }
  window.document.documentElement.setAttribute('lang', locale);

  // 是否启用DevTools
  const enableDeveloperTools = (process.env['VSCODE_DEV'] || !!configuration.extensionDevelopmentPath) && !configuration.extensionTestsPath;
  const unbind = registerListeners(enableDeveloperTools);

  // 缩放配置
  // disable pinch zoom & apply zoom level early to avoid glitches
  const zoomLevel = configuration.zoomLevel;
  webFrame.setVisualZoomLevelLimits(1, 1);
  if (typeof zoomLevel === 'number' && zoomLevel !== 0) {
    webFrame.setZoomLevel(zoomLevel);
  }

  // 初始化loader
  // Load the loader and start loading the workbench
  const loaderFilename = configuration.appRoot + '/out/vs/loader.js';
  const loaderSource = require('fs').readFileSync(loaderFilename);
  //!!! 换掉node require,并提供define函数
  require('vm').runInThisContext(loaderSource, { filename: loaderFilename });

  window.nodeRequire = require.__$__nodeRequire;

  define('fs', ['original-fs'], function (originalFS) { return originalFS; }); // replace the patched electron fs with the original node fs for all AMD code

  window.MonacoEnvironment = {};
  const onNodeCachedData = window.MonacoEnvironment.onNodeCachedData = [];

  // require配置
  require.config({
    baseUrl: uriFromPath(configuration.appRoot) + '/out',
    'vs/nls': nlsConfig,
    recordStats: !!configuration.performance,
    nodeCachedDataDir: configuration.nodeCachedDataDir,
    onNodeCachedData: function () { onNodeCachedData.push(arguments); },
    nodeModules: [/*BUILD->INSERT_NODE_MODULES*/]
  });
  if (nlsConfig.pseudo) {
    require(['vs/nls'], function (nlsPlugin) {
      nlsPlugin.setPseudoTranslation(nlsConfig.pseudo);
    });
  }

  // 取出性能配置及时间戳
  // Perf Counters
  const timers = window.MonacoEnvironment.timers = {
    isInitialStartup: !!configuration.isInitialStartup,
    hasAccessibilitySupport: !!configuration.accessibilitySupport,
    start: configuration.perfStartTime,
    appReady: configuration.perfAppReady,
    windowLoad: configuration.perfWindowLoadTime,
    beforeLoadWorkbenchMain: Date.now()
  };

  const workbenchMainClock = perf.time('loadWorkbenchMain');
  // 加载功能模块JS
  require([
    'vs/workbench/workbench.main',
    'vs/nls!vs/workbench/workbench.main',
    'vs/css!vs/workbench/workbench.main'
  ], function () {
    workbenchMainClock.stop();
    timers.afterLoadWorkbenchMain = Date.now();

    process.lazyEnv.then(function () {
      perf.mark('main/startup');
      // 加载electron-browser/main,并调用startup()
      require('vs/workbench/electron-browser/main')
        .startup(configuration)
        .done(function () {
          unbind(); // since the workbench is running, unbind our developer related listeners and let the workbench handle them
        }, function (error) {
          onError(error, enableDeveloperTools);
        });
    });
  });
}

其中,loader的实际作用是换掉全局require()并提供define(),如下:

define = function () {
  DefineFunc.apply(null, arguments);
};
AMDLoader.global.require = RequireFunc;
AMDLoader.global.require.__$__nodeRequire = nodeRequire;

P.S.loader是通过runInThisContext()来解释执行的,API文档如下:

vm.runInThisContext() compiles code, runs it within the context of the current global and returns the result. Running code does not have access to local scope, but does have access to the current global object.

在当前global环境执行给定代码并返回结果,与eval()类似,但无法访问非global变量,例如:

let i = 1;
const result = require('vm').runInThisContext(`
  // 篡改require
  global.require = function(...args) {
    console.log.apply(global, ['require called: '].concat(args));
  }
  // 报错,i is not defined
  // i++;
  2;
`);
require('my_module', { opts: 'opts' }); // require called:  my_module Object {opts: "opts"}
require(result); // require called:  2

P.S.注意,在Electron renderer process环境才能得到上面的结果,Node REPL环境(命令行)和模块环境下都不行,因为renderer process里的require === global.require,而其它环境的是Module.prototype._compile()注入的,以局部变量形式(module wrapper参数)存在,无法通过vm.runInThisContext()篡改(当然,可以通过eval来做)

最后走到src/vs/workbench/electron-browser/main.tsstartup()

export function startup(configuration: IWindowConfiguration): TPromise<void> {
  // Open workbench
  return openWorkbench(configuration);
}

function openWorkbench(configuration: IWindowConfiguration): TPromise<void> {
  // ...创建各种service

  return createAndInitializeWorkspaceService(configuration, environmentService).then(workspaceService => {
    return domContentLoaded().then(() => {
      // 初始化各功能区域UI
      // Open Shell
      const shell = new WorkbenchShell(document.body, {
        contextService: workspaceService,
        configurationService: workspaceService,
        environmentService,
        logService,
        timerService,
        storageService
      }, mainServices, configuration);
      shell.open();
    });
  });
}

从创建WorkbenchShell开始正式进入功能区UI布局,UI被称为Shell,算作用来承载功能的容器(“壳”)

UI布局

WorkbenchShell来自src/vs/workbench/electron-browser/shell.ts,其open()方法主要内容如下:

public open(): void {
  // 创建content容器
  // Controls
  this.content = $('.monaco-shell-content').appendTo(this.container).getHTMLElement();

  // 填充内容
  // Create Contents
  this.contentsContainer = this.createContents($(this.content));

  // 计算布局
  // Layout
  this.layout();

  // Listeners
  this.registerListeners();
}

值得注意的是布局计算部分(this.layout()),VS Code没有采用Flex/Grid等强大的CSS布局方式,而是统一用绝对布局 + 计算的方式实现了精确像素布局:

// ref: src/vs/workbench/browser/layout.ts
public layout(options?: ILayoutOptions): void {
  // Workbench
  this.workbenchContainer
          .position(0, 0, 0, 0, 'relative')
          .size(this.workbenchSize.width, this.workbenchSize.height);
  // Title Part
  if (isTitlebarHidden) {
    this.titlebar.getContainer().hide();
  } else {
    this.titlebar.getContainer().show();
  }
  // Editor Part and Panel part
  this.editor.getContainer().size(editorSize.width, editorSize.height);
      this.panel.getContainer().size(panelDimension.width, panelDimension.height);
  // Activity Bar Part
  this.activitybar.getContainer().size(null, activityBarSize.height);
  // Sidebar Part
  this.sidebar.getContainer().size(sidebarSize.width, sidebarSize.height);
  // Statusbar Part
  this.statusbar.getContainer().position(this.workbenchSize.height - this.statusbarHeight);
  // Quick open
  this.quickopen.layout(this.workbenchSize);
  // Sashes
  this.sashXOne.layout();

  // Propagate to Part Layouts
  this.titlebar.layout(new Dimension(this.workbenchSize.width, this.titlebarHeight));
  this.editor.layout(new Dimension(editorSize.width, editorSize.height));
  this.sidebar.layout(sidebarSize);
  this.panel.layout(panelDimension);
  this.activitybar.layout(activityBarSize);
  // Propagate to Context View
  this.contextViewService.layout();
}

做了2件事情:

  • 计算各功能区的定位与尺寸(XXX Part)

  • 各功能区进一步计算内容布局(Propagate to Part Layouts)

P.S.大多数布局计算是通过JS完成的,个别用了calc()

功能服务对接

创建WorkbenchShell时传入的各种service最后用来创建workbench

// ref: src/vs/workbench/electron-browser/shell.ts
private createContents(parent: Builder): Builder {
  // Instantiation service with services
  const [instantiationService, serviceCollection] = this.initServiceCollection(parent.getHTMLElement());

  // 创建workbench
  // Workbench
  this.workbench = instantiationService.createInstance(Workbench, parent.getHTMLElement(), workbenchContainer.getHTMLElement(), this.configuration, serviceCollection, this.lifecycleService);
  try {
    this.workbench.startup().done(startupInfos => this.onWorkbenchStarted(startupInfos, instantiationService));
  } catch (error) {/*...*/}
}

把各部分功能依赖的支撑服务传递给workbench,随后调用startup()

public startup(): TPromise<IWorkbenchStartedInfo> {
  // 创建与UI对接的具体功能service,并添加到serviceCollection
  // Services
  this.initServices();

  // 注入service依赖
  // Contexts
  this.messagesVisibleContext = MessagesVisibleContext.bindTo(this.contextKeyService);
  this.editorsVisibleContext = EditorsVisibleContext.bindTo(this.contextKeyService);
  this.inZenMode = InZenModeContext.bindTo(this.contextKeyService);
  this.sideBarVisibleContext = SidebarVisibleContext.bindTo(this.contextKeyService);
}

要创建的service分为两类,有依赖的和无依赖的:

private initServices(): void {
  // 无依赖,直接new一个添加到collection
  // Services we contribute
  serviceCollection.set(IPartService, this);
  // Clipboard
  serviceCollection.set(IClipboardService, new ClipboardService());

  // 有依赖,借助instantiationService处理依赖,再添加到collection
  // Status bar
  this.statusbarPart = this.instantiationService.createInstance(StatusbarPart, Identifiers.STATUSBAR_PART);
  serviceCollection.set(IStatusbarService, this.statusbarPart);
  // List
  serviceCollection.set(IListService, this.instantiationService.createInstance(ListService));
}

instantiationService.createInstance能够自动处理依赖,很有意思

private _createInstance<T>(desc: SyncDescriptor<T>, args: any[]): T {
  // arguments defined by service decorators
  let serviceDependencies = _util.getServiceDependencies(desc.ctor).sort((a, b) => a.index - b.index);

  // now create the instance
  const argArray = [desc.ctor];
  argArray.push(...staticArgs);
  argArray.push(...serviceArgs);

  return <T>create.apply(null, argArray);
}

能够自动处理依赖的秘密(_util.getServiceDependencies)在这里:

export const DI_DEPENDENCIES = '$di$dependencies';

export function getServiceDependencies(ctor: any): { id: ServiceIdentifier<any>, index: number, optional: boolean }[] {
  return ctor[DI_DEPENDENCIES] || [];
}

Class上挂了个静态变量$di$dependencies,存放依赖关系,例如:

// ref: src/vs/editor/browser/services/codeEditorService.ts
export const ICodeEditorService = createDecorator<ICodeEditorService>('codeEditorService');

createDecorator()是用来声明service依赖的工具函数:

export function createDecorator<T>(serviceId: string): { (...args: any[]): void; type: T; } {
  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, false);
  };

  return id;
}

function storeServiceDependency(id: Function, target: Function, index: number, optional: boolean): void {
  if (target[_util.DI_TARGET] === target) {
    target[_util.DI_DEPENDENCIES].push({ id, index, optional });
  } else {
    target[_util.DI_DEPENDENCIES] = [{ id, index, optional }];
    target[_util.DI_TARGET] = target;
  }
}

至此,整个启动流程都清楚了

  • 先通过Electron CLI加载入口JS

  • 执行入口JS进入main process初始化过程,最后创建BrowserWindow,加载入口HTML

  • 入口HTML加载依赖JS开始renderer process初始化过程,兵分两路:

    • 拼装功能区界面

    • 创建功能区界面对应的功能服务

参考资料

VS Code源码简析》上有2条评论

  1. unsilence

    请教下vscode 里面action 怎么使用,以及机制是怎么样的

    回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code