vscode作为微软推出的现代编辑器已经在github上 开源 了。用过vscode的人都纷纷表示速度极快,秒杀同为使用 electron 架构的 atom 。这次我们从源码级别来剖析为何vscode快速,高效。
electron electron 是基于 node.js 和 chromium 的跨平台桌面应用开发框架。使用 javascipt , html , css 真正将 node.js 带到了前端。 electron 通过 browserwindow 可以创建一个本地窗口,并加载一个 html 文档, browserwindow 中的内容就是一个浏览器窗口,不仅能创建 dom 元素,同时能使用任意的node模块,并且还可以通过 ipc 与主进程通讯。
多进程 每一个 electron 应用都对应一个主进程(main process), 主进程通过 browserwindow 创建的每个本地窗口对应一个渲染进程(renderer process)。
主进程 vscode的主进程主要负责创建窗口和菜单,生命周期管理,自动更新等与系统相关的功能。
渲染进程 绝大多数代码都是运行在渲染进程中的,渲染进程负责界面的显示,响应用户操作。前面说到在浏览器中也可以使用node模块,渲染进程还通过node创建了一个插件子进程,负责插件的初始化。另外渲染进程还可以创建worker执行一些复杂的计算,比如markdown的解析。
插件进程 每一个渲染进程同时也对应一个插件进程,插件运行在单独的进程不会对渲染进程造成影响,这也是vscode比atom要快的原因。atom中插件是直接运行在渲染进程中的,所以当插件很多的时候会卡。同时又由于vscode的插件运行在一个普通的node进程中,所以对ui的操作能力是比较弱的,这点不及atom。
vscode loader vscode loader 是类似于 requirejs 的一个异步加载模块( amd )。所有的typescript源码都被编译成了使用amd规范的js文件,使用时通过这个loader加载。
虽然主进程(node进程)是使用commonjs规范的,但是在浏览器中的代码加载是异步的,所以使用amd是没有争议的。在vscode中的一些核心代码,基本库都是用typescript编写的,也会被编译成amd规范的js,这些基本代码也会被主进程用到,所以主进程里面也用到了这个loader。同理,插件进程和worker都会使用这个loader加载代码。
vscode loader不仅实现了类似requirejs的 模块加载 功能,还附带几个插件可以加载css( css.js )和 文档 ,以及实现 多语言 。
项目结构 vscode的主要目录结构如下:
├── build // gulp打包编译相关脚本├── node_modules // 依赖模块├── src // 源代码和素材(ts,js,css,svg,html等)│ ├── typings // 常用模块定义│ ├── vs│ │ ├── base // 核心模块,常用库和基本组件│ │ ├── editor // 编辑器模块│ │ ├── languages // 默认编辑器语言支持│ │ ├── platform // 核心功能接口定义和基本实现│ │ ├── workbench // 业务逻辑功能实现│ │ ├── loader.js // vscode loader│ │ └── vscode.d.ts // 插件api定义│ └── main.js // 主进程入口├── gulpfile.js // gulp打包编译入口├── product.json // 产品描述文件└── package.json
base base包封装了大量api,实现常用功能。在vscode中目录结构都是都是按照browser,common,node,electron的方式划分的。
browser 实现浏览器相关的功能。 common 实现不依赖node模块的基本功能。 node 实现需要node模块支持的功能,比如文件操作。 electron 实现需要electron api的功能,比如ipc通讯。 browser
browser中实现了一个简单的ui库,包括 button , checkbox , list , scrollbar 等常用组件。并且封装了一套类似jquery的dom操作api(参见 dom.ts 和 builder.ts )。
common
common包中封装了大量实用工具类。如
arrays.ts , strings.ts , objects.ts 封装了一套类似underscore的api。 uri.ts 和 paths.ts 实现了路径解析功能。 winjs.base.js 实现了一个功能强大的promise。 另外还有很多其他的工具类,每一个模块的耦合度都很低,基本都可以单独拿出来用,学习起来也和容易。这里就不一一介绍了。
node
node包中封装了一些node实现的功能。如
extfs.ts 和 pfs.ts 封装了文件操作相关的api。 request.ts 封装了网络请求的api,能方便的发送网络请求,加载json,下载文件。 service.cp.ts 和 service.net.ts 封装了socket和进程通讯的api。 zip.ts 封装了解压缩文件的操作 parts
这个包额外定义了一些复杂的ui组件,tree 和 quickopen。
editor and language 本篇主要了解vscode基本框架的结构,这两包作为编辑器功能的主要实现,这里面的逻辑太复杂就不细说了。
platform and workbench vscode中基本所有的具体功能实现代码都在这两包中。platform主要定义了一些服务的接口和简单实现,workbench则实现了这些接口,并且创建了一个工作台,构建了一个完整界面结构。
下面从程序入口开始,从源码一步一步来看vscode是怎样运行起来的。
启动主进程 eletron通过 package.json 中的 main字段 来定义应用入口。 main.js 是vscode的入口。
初始化loader 这个模块是一个壳,主要解析多语言配置,然后初始化loader,通过loader加载 main.ts 。
// load our code once readyapp.once('ready', function () { var nlsconfig = getnlsconfiguration(); process.env['vscode_nls_config'] = json.stringify(nlsconfig); require('./bootstrap-amd').bootstrap('vs/workbench/electron-main/main');});
这里的 bootstrap-amd.js 负责创建一个loader,实现异步加载。
loader.config({ baseurl: urifrompath(path.join(__dirname)), catcherror: true, noderequire: require, nodemain: __filename, 'vs/nls': nlsconfig});......exports.bootstrap = function (entrypoint) { if (!entrypoint) { return; } loader([entrypoint], function () { }, function (err) { console.error(err); });};
解析命令行参数 在 main.ts 中依赖一个 env 的模块
import env = require('vs/workbench/electron-main/env');
该模块负责命令行参数的解析,以及读取 package.json 和 product.json 保存软件的一些基本信息,主要变量如下:
// 是否是发行版export const isbuilt = !process.env.vscode_dev;// 应用程序根目录export const approot = path.dirname(uri.parse(require.tourl('')).fspath);// 产品配置export const product: iproductconfiguration = productcontents;// 程序版本export const version = app.getversion();// 命令行参数export const cliargs = parsecli();// 数据文件目录export const apphome = app.getpath('userdata');// setting文件路径export const appsettingspath = path.join(appsettingshome, 'settings.json');// keybindings文件路径export const appkeybindingspath = path.join(appsettingshome, 'keybindings.json');// 用户插件目录export const userextensionshome = cliargs.extensionshomepath || path.join(userhome, 'extensions');
初始化管理器 在main.ts的main方法中,初始化了主进程中的各个管理器
// lifecyclelifecycle.manager.ready();// load settingssettings.manager.loadsync();// propagate to clientswindows.manager.ready(userenv);// install menumenu.manager.ready();.....// setup auto updateupdatemanager.initialize();
lifecycle 负责管理软件的生命周期,派发 onbeforequit 等事件。 setting 负责管理用户设置和快捷键绑定的读取和存储。 windows 负责窗口的创建和管理,非常核心的一个模块。 menus 负责菜单栏的创建。 update-manager 自动更新功能。 以上管理器中大量使用了eletron的 ipc 模块发送接收渲染进程的消息,来实现主进程和渲染进程的交互。
打开第一个窗口 在main.ts的main方法的最后
// open our first windowif (env.cliargs.opennewwindow && env.cliargs.patharguments.length === 0) { windows.manager.open({ cli: env.cliargs, forcenewwindow: true, forceempty: true }); // new window if -n was used without paths} else if (global.macopenfiles && global.macopenfiles.length && (!env.cliargs.patharguments || !env.cliargs.patharguments.length)) { windows.manager.open({ cli: env.cliargs, pathstoopen: global.macopenfiles }); // mac: open-file event received on startup} else { windows.manager.open({ cli: env.cliargs, forcenewwindow: env.cliargs.opennewwindow, diffmode: env.cliargs.diffmode }); // default: read paths from cli}
调用了 windows 模块的 open 方法打开了第一个窗口。这里调用了 env.cliargs 获取命令行参数传递给 open 方法来实现不同的打开方式。
在 open 方法中创建一个了 vscodewindow 实例,并且通过 toconfiguration 方法创建了一个 iwindowconfiguration 的对象。
iwindowconfiguration 中定义了大量的 env 中的信息,包括环境变量,命令行参数,软件信息等。在之后 iwindowconfiguration 会作为参数传递给 vscodewindow 的 load 方法。
vscodewindow 包装了一个 browserwindow 对象。 load 方法调用 geturl 加载了一个的 html文件 。
private geturl(config: iwindowconfiguration): string { let url = require.tourl('vs/workbench/electron-browser/index.html'); // config url += '?config=' + encodeuricomponent(json.stringify(config)); return url;}
可以看到 iwindowconfiguration 被序列化成字符串作为参数传递给了 index.html 。由于在浏览器进程要获取主进程中 env 模块的数据比较复杂(需要使用 ipc 通讯)。所以这里直接将一些基本信息打包成config传递给了浏览器进程。这时浏览器窗口才正式打开并初始化。
启动浏览器 初始化loader 浏览器的入口在 index.html 中。与主进程类似这里也对loader进行了初始化并加载浏览器主模块 main 。主要代码如下:
// 解析config参数var args = parseurlqueryargs();var configuration = json.parse(args['config']);......// loader的加载根目录var rooturl = urifrompath(configuration.approot) + '/out';// 加载loadercreatescript(rooturl + '/vs/loader.js', function() { // 多语言配置 var nlsconfig; try { var config = process.env['vscode_nls_config']; if (config) { nlsconfig = json.parse(config); } } catch (e) { } if (!nlsconfig) { nlsconfig = { availablelanguages: {} }; } // 配置loader require.config({ baseurl: rooturl, 'vs/nls': nlsconfig, recordstats: configuration.enableperformance }); ...... require([ // 项目正式发布后大多数的js都被合并进了workbench.main.js中 'vs/workbench/workbench.main', 'vs/nls!vs/workbench/workbench.main', 'vs/css!vs/workbench/workbench.main' ], function() { timers.afterload = new date(); // 浏览器主模块 var main = require('vs/workbench/electron-browser/main'); // config作为参数,调用startup启动主模块 main.startup(configuration, globalsettings).then(function() { mainstarted = true; }, function(error) { onerror(error, enabledevelopertools) }); });});
初始化工作台 在 main 模块的 startup 方法中进一步加工 config ,并创建了一个 workspace 。
export function startup(environment: imainenvironment, globalsettings: iglobalsettings): winjs.tpromise { // 将主进程中的环境变量合并到浏览器进程 assign(process.env, environment.userenv); // shell configuration let shellconfiguration: iconfiguration = { env: environment }; ...... let shelloptions: ioptions = { ...... }; ...... // open workbench return openworkbench(getworkspace(environment), shellconfiguration, shelloptions);}function getworkspace(environment: imainenvironment): iworkspace { if (!environment.workspacepath) { return null; } ...... let workspace: iworkspace = { 'resource': workspaceresource, 'id': platform.islinux ? realworkspacepath : realworkspacepath.tolowercase(), 'name': foldername, 'uid': platform.islinux ? folderstat.ino : folderstat.birthtime.gettime(), 'mtime': folderstat.mtime.gettime() }; return workspace;}
这里的 environment 就是上文的 config 。 iworkspace 记录了当前打开的文件夹路径等信息(当打开单文件时 iworkspace 不存在)。
function openworkbench(workspace: iworkspace, configuration: iconfiguration, options: ioptions): winjs.tpromise { let eventservice = new eventservice(); let contextservice = new workspacecontextservice(eventservice, workspace, configuration, options); let configurationservice = new configurationservice(contextservice, eventservice); return configurationservice.initialize().then(() => { ...... let shell = new workbenchshell(document.body, workspace, { configurationservice, eventservice, contextservice }, configuration, options); shell.open(); ...... });}
在 openworkbench 创建了三个基本服务(service),并将 config , workspace 等参数传给 workbenchshell 。 workbenchshell 获取html文档的 body 节点准备创建界面。
初始化服务 workbenchshell 主要负责初始化各服务(service),并创建了一个 workbench 完成界面的初始化工作。
常用的service比如
istorageservice 浏览器进程数据的持久化存储与读取。 iworkspacecontextservice 获取工作空间数据和config等基本配置信息。 ikeybindingservice 管理快捷键相关的注册。 ifileservice 封装了文件操作的相关api,并实现 filewatcher 功能。 iextensionservice 管理插件的初始化,加载和交互。 iinstantiationservice 负责创建实例对象,这个service比较重要,下面单独说明。 initinstantiationservice 方法中创建了各个服务,并返回 iinstantiationservice 。
iinstantiationservice 在vscode中随处可见 iinstantiationservice 的应用。以 closewindowaction 为例
export class closewindowaction extends action { public static id = 'workbench.action.closewindow'; public static label = nls.localize('closewindow', close window); constructor(id: string, label: string, @iwindowservice private windowservice: iwindowservice) { super(closewindowaction.id, label); } public run(): tpromise { this.windowservice.getwindow().close(); return tpromise.as(true); }}
在构造函数( constructor )中,后面的参数写法比较特殊
@iwindowservice private windowservice: iwindowservice
使用了 @iwindowservice 这种decorate语法。当要创建 closewindowaction 这个实例时,可以使用 iinstantiationservice 只需要传入前两个参数,在 iinstantiationservice 中能获取所有的其他服务对象, windowservice 这个参数由 iinstantiationservice 传入。
this.instantiationservice.createinstance(closewindowaction, closewindowaction.id, closewindowaction.label);
创建workbench workbenchshell 的 createcontents 方法还创建了一个 workbench 负责整个界面的创建。
private createcontents(parent: builder): builder { ...... // instantiation service with services let instantiationservice = this.initinstantiationservice(); ...... // workbench this.workbench = new workbench(workbenchcontainer.gethtmlelement(), this.workspace, this.configuration, this.options, instantiationservice); this.workbench.startup({ onservicescreated: () => { this.initextensionsystem(); }, onworkbenchstarted: () => { this.onworkbenchstarted(); } }); ......}
workbench 是 ipartservice 的具体实现。vscode由多个part组成。
activitybar 是最左边(也可以设置到右边)的选项卡。目前有 explore , search , git , debug 这4个选项卡。 sidebar 是activitybar选中的内容。 editor 是最主要的编辑器部分。 statusbar 是下方的状态栏。 panel 是状态栏上方的面板选项卡,目前主要有 output , debug , errorlist 等几个面板。 quickopen 是悬浮在中上方的弹出界面,常用的命令面板(f1)就是一个 quickopen widget 。
下面的代码展示了各个part的创建,并添加到显示列表。
private renderworkbench(): void { ...... // create parts this.createactivitybarpart(); this.createsidebarpart(); this.createeditorpart(); this.createpanelpart(); this.createstatusbarpart(); // create quickopen this.createquickopen(); // add workbench to dom this.workbenchcontainer.build(this.container);}
扩展点的注册和实现 vscode中几乎每个部分都是可扩展的。例如最常见的有快捷键命令的注册,编辑器类型的扩展,扩展输出面板channel。下面以 viewletregistry 为例,分析 activitybar 和 sidebar 上面的 explore 文件浏览器是如何显示的。
contribution 通常情况下以 .contribution 结尾的模块,都用作扩展点的注册。由于一般情况下这些模块不会被其他模块依赖,所以要提供一个入口来加载这些模块,这个入口就是 workbench.main 。
其中 explore 文件浏览器的注册是在 files.contribution 中定义的。
// register viewlet(registry.as(viewletextensions.viewlets)).registerviewlet(new viewletdescriptor( 'vs/workbench/parts/files/browser/explorerviewlet', 'explorerviewlet', viewlet_id, nls.localize('explore', explorer), 'explore', 0));(registry.as(viewletextensions.viewlets)).setdefaultviewletid(viewlet_id);
explorerviewlet 模块是 explore 的界面显示入口。
registry platform 中定义了 iregistry 接口及实现。
export interface iregistry { /** * adds the extension functions and properties defined by data to the * platform. the provided id must be unique. * @param id a unique identifier * @param data a contribution */ add(id: string, data: any): void; /** * returns true iff there is an extension with the provided id. * @param id an extension idenifier */ knows(id: string): boolean; /** * returns the extension functions and properties defined by the specified key or null. * @param id an extension idenifier */ as(id: string): any; as(id: string): t;}
add 添加一个注册点, as 方法获取一个注册点对象。
viewlet 模块添加了 viewletregistry 。
registry.add(extensions.viewlets, new viewletregistry());
之后可以通过 registry.as(extensions.viewlets) 获取 viewletregistry 注册不同的 viewlet 。
实现注册功能 所有的注册信息储存在 viewletregistry 中,使用时通过 getviewlet 或者 getviewlets 方法获取。 activitybarpart 实现了注册点的读取,并填充 actionbar ,显示出来。
private createviewletswitcher(div: builder): void { // viewlet switcher is on top this.viewletswitcherbar = new actionbar(div, { ....... }); this.viewletswitcherbar.getcontainer().addclass('position-top'); // build viewlet actions in correct order let activeviewlet = this.viewletservice.getactiveviewlet(); let registry = (registry.as(viewletextensions.viewlets)); let viewletactions: action[] = registry.getviewlets() // 获取注册的viewlets .sort((v1: viewletdescriptor, v2: viewletdescriptor) => v1.order - v2.order) .map((viewlet: viewletdescriptor) => { let action = this.instantiationservice.createinstance(viewletactivityaction, viewlet.id + '.activity-bar-action', viewlet); ...... return action; }); // add to viewlet switcher this.viewletswitcherbar.push(viewletactions, { label: true, icon: true });}
类似的这种扩展点还有很多,如:
iworkbenchactionregistry 注册一个action和快捷键,并出现在命令面板中。 ieditorregistry 注册一种编辑器。 iconfigurationregistry 注册设置项。 iactionbarregistry 注册右键菜单。 这种通过注册扩展点的架构方式,使得vscode整体变得很容易扩展。
vscode pk atom vscode整体架构给人一种很清晰明了的感觉。多进程从主进程到浏览器,从浏览器到插件系统,服务驱动,可扩展的结构。
另外无论是ui组件还是工具和加载器都是自身实现的,没有借助第三方模块,使得耦合性和性能都得到了很好的保障。这也是vscode速度比atom快的原因。
尽管扩展vscode自身是很容易的,但是目前vscode开放的插件接口还是极其有限。由于为了保证渲染进程的安全和速度,插件是一个单独的node进程,插件进程无法创建ui,这一点使得vscode的插件开放没有atom灵活,很多需要借助ui的插件功能也无法实现。
结语 微软大法好