一.Electron基本结构
VS Code作为Electron的成功案例,一头扎进源码之前,有必要简单梳理下Electron的基本结构
从实现上来看:
代码语言:javascript复制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
目录结构
代码语言:javascript复制├── 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的
三.启动流程
启动流程相关文件递进关系如下:
代码语言:javascript复制功能入口
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/workbench.ts 创建界面
src/vs/workbench/browser/layout.ts 布局计算,绝对定位
Electron CLI启动应用
启动步骤:
代码语言:javascript复制# 编译构建(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 ."
}
主要部分如下:
代码语言:javascript复制# 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布局
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
困惑的答案