Skip to main content

VS Code Source Code Brief Analysis

Free2018-02-24#Node#VSCode源码剖析#VSCode source code analysis#VSCode实现原理#VSCode启动流程#VSCode二次开发

Someone always has to do the same things; fortunately, the giant's shoulders are getting higher and higher

I. Electron Basic Structure

As a successful case of Electron, before diving into VS Code's source code, it's necessary to briefly sort out Electron's basic structure.

From an implementation perspective:

Electron = Node.js + Chromium + Native API

That is, Electron has a Node runtime environment, relies on Chromium to provide interface interaction support based on web technologies (HTML, CSS, JS), and also has some platform features, such as desktop notifications.

From an API design perspective, Electron Apps generally have 1 Main Process and multiple Renderer Processes:

  • main process: Can access Node and Native APIs in the main process environment

  • renderer process: Can access Browser API, Node API, and some Native APIs in the renderer process environment

With such API design, an Electron App's project structure should at least include these two parts.

Main Process

Equivalent to a background service, commonly used for:

  • Multi-window management (create/switch)

  • Application lifecycle management

  • As an inter-process communication base station (IPC Server)

  • Automatic updates

  • Toolbar menu bar registration

Renderer Process

Interface interaction related, specific business functions, are all done by the renderer process, with 3 basic principles:

  • Try to use renderer for work, including network requests

  • Use Worker to offload time-consuming tasks

  • Use subprocesses for sharing across renderers, managed by 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. For discussion about the division of labor between main and renderer, please see What is the best way to make Http requests using Electron?

II. vscode Source Code Structure

The following content references source code version v1.19.3

Directory Structure

├── build       # gulp compilation build scripts
├── extensions  # Built-in extensions
├── gulpfile.js # gulp task
├── i18n        # Internationalization translation packages
├── out         # Compilation output directory
├── product.json  # App meta information
├── resources     # Platform-related static resources, icons, etc.
├── scripts       # Utility scripts, development/testing
├── src           # Source code directory
└── test          # Test suites

The structure under src is as follows:

├── bootstrap-amd.js  # Subprocess actual entry
├── bootstrap.js  # Subprocess environment initialization
├── buildfile.js  # Build config
├── cli.js        # CLI entry
├── main.js       # Main process entry
├── paths.js      # AppDataPath and DefaultUserDataPath
├── typings
│?? └── xxx.d.ts  # ts type declarations
└── vs
    ├── base      # General utilities/protocols and UI library
    │?? ├── browser # Basic UI components, DOM operations, interaction events, DnD, etc.
    │?? ├── common  # diff descriptions, markdown parser, worker protocols, various utility functions
    │?? ├── node    # Node utility functions
    │?? ├── parts   # IPC protocols (Electron, Node), quickopen, tree components
    │?? ├── test    # base unit test cases
    │?? └── worker  # Worker factory and main Worker (runs IDE Core: Monaco)
    ├── buildunit.json
    ├── code        # VS Code main window related
    ├── css.build.js  # CSS loader for plugin builds
    ├── css.js        # CSS loader
    ├── editor        # Connects to IDE Core (reads editor/interaction state), provides commands, context menus, hover, snippet, etc. support
    ├── loader.js     # AMD loader (for asynchronously loading AMD modules, similar to require.js)
    ├── nls.build.js  # NLS loader for plugin builds
    ├── nls.js        # NLS (National Language Support) multi-language loader
    ├── platform      # Supports injecting services and platform-related basic services (files, clipboard, windows, status bar)
    └── workbench     # Coordinates editor and provides framework for viewlets, such as directory viewer, status bar, etc., global search, integrates Git, Debug

The most critical parts (business-related) are:

  • src/vs/code: Main window, toolbar menu creation

  • src/vs/editor: Code editor, IDE core related

  • src/vs/workbench: UI layout, function service docking

P.S. IDE Core can be used independently, called Monaco

Each layer is subdivided and organized by target execution environment:

III. Startup Flow

The progressive relationship of startup flow related files is as follows:

Function Entry
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 Entry
src/vs/workbench/electron-browser/bootstrap/index.html
  src/vs/workbench/electron-browser/bootstrap/index.js
    src/vs/workbench/workbench.main js index file
      src/vs/workbench/electron-browser/main.ts
        src/vs/workbench/electron-browser/shell.ts Interface and function service access point
          src/vs/workbench/electron-browser/workbench.ts Create interface
            src/vs/workbench/browser/layout.ts Layout calculation, absolute positioning

Electron CLI Launches Application

Startup steps:

# Compile build (ts conversion, packaging)
npm run compile
# Launch application through Electron
./scripts/code.sh

The role of code.sh is similar to what's commonly seen in Electron Demos:

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

Main parts are as follows:

# 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" . "$@"

Configure dev environment variables, finally execute through exec:

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

Electron CLI will load and execute pkg.main as the entry file:

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

That is, it turns to the entry file out/main.js, corresponding source code is src/main.js, important parts are as follows:

// 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);
});

After the cacheData directory is ready, load the main process entry file out/vs/code/electron-main/main.js through AMD loader, entering the main process initialization flow.

Main Process Initialization

The main process entry file corresponds to source code src/vs/code/electron-main/main.js, main parts are as follows:

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

Where CodeApplication comes from vs/code/electron-main/app.ts, startup flow related parts are as follows:

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

openFirstWindow() main content is as follows:

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);
}

Note that this.lifecycleService.unload(window, UnloadReason.LOAD) is quite confusing, triggering unload, the reason (UnloadReason) is LOAD, that is, we first new a window, immediately manually call its unload(), then manually call load() to load this window... So, why call unload() first?

P.S. This load() is quite critical, we'll come back to it later.

We haven't seen the entry HTML yet, but rather windowsMainService.open(), tracing into it (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
		});
}

The key CodeWindow is defined in src/vs/code/electron-main/window.ts, so the initialization process is the multi-window management class (windows.ts) calling the VS Code main window (window.ts). So open() finally returns a CodeWindow instance, simplified:

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

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

Next, look at load(), key parts are as follows:

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 appears, main process mission complete, renderer process takes the stage.

Renderer Process Initialization

Entry HTML src/vs/workbench/electron-browser/bootstrap/index.html main content is as follows:

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

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

Introduces two JS files, preload.js parses config parameters from URL, sets body background color according to theme configuration, index.js contains loading logic:

function main() {
  const webFrame = require('electron').webFrame;
  // Parse config from URL parameters
  const args = parseURLQueryArgs();
  const configuration = JSON.parse(args['config'] || '{}') || {};

  // Restore passed environment variables
  // Correctly inherit the parent's environment
  assign(process.env, configuration.userEnv);
  perf.importEntries(configuration.perfEntries);

  // Restore NLS multi-language configuration
  // 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);

  // Whether to enable DevTools
  const enableDeveloperTools = (process.env['VSCODE_DEV'] || !!configuration.extensionDevelopmentPath) && !configuration.extensionTestsPath;
  const unbind = registerListeners(enableDeveloperTools);

  // Zoom configuration
  // 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);
  }

  // Initialize loader
  // Load the loader and start loading the workbench
  const loaderFilename = configuration.appRoot + '/out/vs/loader.js';
  const loaderSource = require('fs').readFileSync(loaderFilename);
  //!!! Replace node require, and provide define function
  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 configuration
  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);
    });
  }

  // Extract performance configuration and timestamps
  // 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');
  // Load function module 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');
      // Load electron-browser/main, and call 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);
        });
    });
  });
}

Where, the actual role of loader is to replace the global require() and provide define(), as follows:

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

P.S. The loader is interpreted and executed through runInThisContext(), API documentation is as follows:

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.

Execute given code in the current global environment and return the result, similar to eval(), but cannot access non-global variables, for example:

let i = 1;
const result = require('vm').runInThisContext(`
  // Tamper with require
  global.require = function(...args) {
    console.log.apply(global, ['require called: '].concat(args));
  }
  // Error, 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. Note, you can only get the above results in the Electron renderer process environment, not in Node REPL environment (command line) or module environment, because in renderer process require === global.require, while in other environments it's injected by Module.prototype._compile(), exists as a local variable (module wrapper parameter), cannot be tampered with through vm.runInThisContext() (of course, can be done through eval)

Finally reaches startup() of src/vs/workbench/electron-browser/main.ts:

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

function openWorkbench(configuration: IWindowConfiguration): TPromise<void> {
  // ...create various services

  return createAndInitializeWorkspaceService(configuration, environmentService).then(workspaceService => {
    return domContentLoaded().then(() => {
      // Initialize each function area UI
      // Open Shell
      const shell = new WorkbenchShell(document.body, {
        contextService: workspaceService,
        configurationService: workspaceService,
        environmentService,
        logService,
        timerService,
        storageService
      }, mainServices, configuration);
      shell.open();
    });
  });
}

From creating WorkbenchShell, formally enters the function area UI layout, UI is called Shell, considered as a container ("shell") for carrying functions.

UI Layout

WorkbenchShell comes from src/vs/workbench/electron-browser/shell.ts, its open() method main content is as follows:

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

  // Fill content
  // Create Contents
  this.contentsContainer = this.createContents($(this.content));

  // Calculate layout
  // Layout
  this.layout();

  // Listeners
  this.registerListeners();
}

Worth noting is the layout calculation part (this.layout()), VS Code doesn't use powerful CSS layout methods like Flex/Grid, but uniformly uses absolute layout + calculation to achieve precise pixel layout:

// 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();
}

Does 2 things:

  • Calculate positioning and dimensions of each function area (XXX Part)

  • Each function area further calculates content layout (Propagate to Part Layouts)

P.S. Most layout calculations are done through JS, a few use calc()

Function Service Docking

Various services passed in when creating WorkbenchShell are finally used to create 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());

  // Create 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) {/*...*/}
}

Pass the supporting services that each part of the function depends on to workbench, then call startup():

public startup(): TPromise<IWorkbenchStartedInfo> {
  // Create specific function services that dock with UI, and add to serviceCollection
  // Services
  this.initServices();

  // Inject service dependencies
  // 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);
}

The service to be created is divided into two categories, those with dependencies and those without:

private initServices(): void {
  // No dependencies, directly new one and add to collection
  // Services we contribute
  serviceCollection.set(IPartService, this);
  // Clipboard
  serviceCollection.set(IClipboardService, new ClipboardService());

  // Has dependencies, use instantiationService to handle dependencies, then add to 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 can automatically handle dependencies, very interesting:

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);
}

The secret to automatically handling dependencies (_util.getServiceDependencies) is here:

export const DI_DEPENDENCIES = '$di$dependencies';

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

A static variable $di$dependencies is hung on the Class, storing dependency relationships, for example:

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

createDecorator() is a utility function for declaring service dependencies:

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;
  }
}

至此,the entire startup flow is clear:

  • First load entry JS through Electron CLI

  • Execute entry JS to enter main process initialization process, finally create BrowserWindow, load entry HTML

  • Entry HTML loads dependent JS to start renderer process initialization process, splits into two paths:

    • Assemble function area interface

    • Create function services corresponding to function area interface

References

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment