一.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
每層按目標執行環境細分組織:
-
common:可跨環境復用的 -
browser:依賴瀏覽器 API 的,比如 DOM 操作 -
node:依賴 Node API 的 -
electron-browser:依賴 electron renderer-process API 的 -
electron-main:依賴 electron main-process API 的
三.啟動流程
啟動流程相關文件遞進關係如下:
功能入口
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.ts的startup():
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 初始化過程,兵分兩路:
-
拼裝功能區界面
-
創建功能區界面對應的功能服務
-
參考資料
-
vscode 原始碼剖析:總有人要做相同的事情,所幸巨人的肩膀正變得越來越高
-
Hacking Node require:關於
runInThisContext hack require困惑的答案
暫無評論,快來發表你的看法吧