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:
-
common: Reusable across environments -
browser: Depends on browser APIs, such as DOM operations -
node: Depends on Node APIs -
electron-browser: Depends on electron renderer-process API -
electron-main: Depends on electron main-process API
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
-
vscode source code analysis: Someone always has to do the same things; fortunately, the giant's shoulders are getting higher and higher
-
Hacking Node require: Answers to confusion about
runInThisContext hack require
No comments yet. Be the first to share your thoughts.