深入 Vscode 源码(3)——构建系统

深入 Vscode 源码(3)——构建系统
gy1 前言
VSCode 能够在各大平台(Windows/Mac/Linux/Web/…)上遍地开花,离不开其强大的跨平台能力。而这个能力不仅依赖于 electron 与 chromium 自身的平台兼容性,还依赖于其复杂的构建系统。
本文旨在深入探索 VSCode 的构建系统,探究其构建过程、依赖管理、工具和配置等。在文末,还会介绍 vscode 实现跨平台构建的具体方案。
2 VSCode 构建系统概述
VSCode 的构建系统并没有使用前端常用的 Webpack、Rollup 等方案,也没有采用开箱即用的脚手架,而是依赖于自己从头搭建的一套工具链,基于 npm
+ gulp
。npm
用于管理项目依赖, gulp
用于自动化构建任务。(几年前 VSCode 使用yarn
管理依赖,现在已经迁移到npm
)
下面我们从文件目录结构的角度来总览 VSCode 的构建系统:
2.1 文件目录结构
与构建相关的源码文件位于 ./build
,与 ./src
目录平级。
1 |
|
构建的主要逻辑存放在 ./build/lib
目录下,
2.2 构建脚本
2.2.1 Package.json
package.json
,位于项目根目录,包含项目元数据、依赖、脚本等。
重点是其 scripts
字段包含一些常用构建命令,比如 compile
、watch
、 npmCheckJs
。我们把 vscode 仓库克隆到本地后,在命令行中运行 npm install && npm run watch
命令即可编译项目,并且会自动启动 watcher 监听文件更改+文件更改后自动增量编译。这对于开发者而言无疑是非常方便的,我也会在后文中详细介绍其实现原理。
2.2.2 gulpfile.js
另一个重要的文件是 gulpfile.js
,也位于项目根目录,是 gulp
任务的入口文件。它引用了 ./build/gulpfile.js
,这个文件定义了一系列的 gulp
任务,用于自动化构建过程。
Gulp 是一个基于 Node.js 的并行任务运行器,常用于前端开发流程的自动化,比如编译、压缩代码、优化图片等重复性任务。其使用 streams 来处理文件。
相比于前端常见的构建系统如 Webpack、Rollup 等,Gulp 是基于任务的,而不像 Webpack 那样基于模块。
./build/gulpfile.js
通过gulp.task
注册以下任务:
任务名 | 作用 |
---|---|
API 提案名称编译和监视 | compileApiProposalNamesTask 编译 API 提案名称,watchApiProposalNamesTask 监视文件变化。 |
SWC 客户端转译 | 清理输出目录 (out ),然后建立 web path,编译客户端代码,并使用 SWC 转译为 ES5 代码。 |
仅转译客户端代码 | 编译客户端代码,但不使用 SWC 转译。 |
快速编译(供开发时使用) | 编译客户端代码,并监视文件变化,以便于开发过程中的即时反馈。 有这三个任务:compile-client,watch-client,watch-client-amd |
AMD 模块格式的监视 | 监视 AMD 模块格式下的代码变化。 |
所有任务 | _compileTask 是一个并行任务,它同时执行多个其他任务,包括类型检查、客户端编译、扩展编译等等。 |
默认任务 | 当直接运行 gulp 时,默认执行 _compileTask 。 |
未完待续… |
从上面的任务列表中不难发现,Vscode 支持使用近年来流行的 SWC(Speedy Web Compiler)来转译代码,相比于传统的 Babel 转译,由 Rust 编写的 SWC 编译速度快的多。
这个脚本最后一行代码值得注意:
1 |
|
鉴于 vscode 构建系统的复杂性,如果吧所有构建逻辑都塞在 gulpfile.js
中,那么该文件会很臃肿,因此 Vscode 使用了 glob
模块来加载当前目录下的所有符合特定模式(gulpfile.*.js)的 Gulp 配置文件,比如客户端的主构建脚本gulpfile.vscode.js
就是通过此方式进行加载的。
2.2.3 其他构建脚本
tsconfig.build.json
: 位于 build
目录下,定义了 TypeScript 编译的配置选项。
1 |
|
tsconfig.json
: 位于 SRC 目录,在 TS 转 JS 的过程中会使用到:
1 |
|
这个文件通过 "outDir": "../out/vs"
指定编译输出目录,"include"
指定编译范围, "./vs/**/*.ts"
基本上包含了所有 TypeScript 源代码。
3 构建过程概览
从源码到安装包,是一个完整的构建过程。下面我们先用一张图来说明 VSCode 的构建过程:
从上图可以看出,VSCode 的构建过程主要分为这几个阶段:编译、优化、压缩、打包、分发。gulpfile.vscode.js
是整个构建过程的主要入口,它把上面提到的每一个阶段都封装成了一个独立的 gulp 任务。
整个过程使用到的一些第三方工具库如下:
首先最重要的就是 TS 语言服务了,编译流程的大部分时间都花在了 TS 语言服务的 AST 解析、类型检查、代码生成等阶段。下面这张图展示了 TS 的架构,TS 语言服务能力正是建立在核心编译器基础之上的:
mangle 库负责遍历 TS Language Service 提供的 AST,进而实现压缩、优化代码。说的详细一点,这就是一个 TS 到 TS 的转换器,其功能是对所有类中的 private 和 protected 字段进行名称混淆(mangling),使用无意义的短字符串将其代替,以减少代码体积。
在经过 mangle 处理后,紧接着就是 PostCSS 的样式处理,进行变量替换、函数调用、条件判断等操作,最终生成目标 CSS 代码。PostCSS 的设计理念类似 webpack,本身并不提供具体的编译功能,而是暴露了一堆 API,进而派生出一系列的 plugin,开发者可以选择安装使用。其架构如下:
VSCode 这里用到了 PostCSS Nesting 插件,让开发者能以嵌套的方式编写 CSS 规则,类似于 Sass 等预处理器的写法,但它是遵循 CSS Nesting 规范的。在优化阶段则是使用了 cssnano 插件来压缩 CSS 代码。
为了压缩 JS 代码,VSCode 使用了 ESBuild。我们知道 Vite 的速度非常快, 正是因为使用了由 Go 编写的 EsBuild, 能够利用原生的性能优势。
4 development build 过程探究
从开发者键入 npm run watch
命令到 development build 完成,这之间发生了什么?本章节试图分析 VSCode 的构建过程,捋清楚构建期间 config 和构建产物的流向,进而理解 vscode 构建系统的工作原理。
先把构建系统的一些核心思想梳理一下:
文件即模块:VSCode 的构建系统是基于文件的,一个文件对应于一个模块。无论是构建过程还是依赖管理过程,都以文件为单位,并且把文件名(包括路径)作为依赖项的唯一标识符。
一切皆流:得益于 gulp,VSCode 的构建系统是流式的,绝大多数输入和输出都是流。可以很好的利用 CPU 资源,并行处理任务,提高构建速度。需注意这里提到的“流”的最小单位是文件。
高可追溯性:构建系统本身支持性能打点统计与进度报告,并有内置的日志系统。并且对 sourceMapping 的支持也很好。
4.1 初始化阶段(创建任务、创建各种流)
当开发者在命令行中键入 npm run watch
命令后,npm
读取 package.json
文件,找到其中的 watch
脚本:
1 |
|
npm-run-all
可以让我们在 Nodejs 命令行以并行或串行的方式运行多个命令,在这里-lp
参数表示并行运行,它会并行运行 watch-client
和 watch-extensions
两个任务。这两个任务的定义依然可以在 package.json
文件中找到:
1 |
|
之所以要把任务一分为二,是因为 vscode 的主逻辑与拓展之间是解耦的,二者需要单独编译,watch-client
任务只编译客户端代码,而 watch-extensions
任务则编译扩展代码。下面我们来看一下watch-client
具体实现。(watch-extensions
的实现异曲同工,读者可以自行探索一下。)
watch-extensions
这一步会首先设置 Nodejs 内存限制 node --max-old-space-size=8192
,设置 V8 引擎的最大老生代空间大小为 8192 MB,即 8 GiB,避免出现构建时期爆内存的尴尬情况。
随后直接调用本地安装的 Gulp CLI 来运行 Gulp 任务 watch-client
,它是这样定义的:
1 |
|
其具体行为:
首先递归清理掉
out
目录(这个就是存放构建结果的目录),确保旧的编译结果不会干扰新的构建。然后 call
buildWebNodePaths
函数在输出目录按照要求生成一个包含版权信息的 js 文件,这个函数用于在构建过程中配置 Web 环境下的 Nodejs 路径。并行执行监视任务,包括:
watchTask('out', false)
开始监视源代码的变化,在检测到变化时自动重新编译。watchApiProposalNamesTask
同时也会监视 API 提案名称的相关文件。
这里承担了编译的重任的自然是 watchTask
,它是这样定义的:
1 |
|
实现编译的关键在于最后一步,watchSrc
是由watch
方法返回的可读Stream
,紧随其后的是流的链式处理管道,依次将监视到的文件变化通过管道传递给 generator.stream
,然后通过 util.incremental
方法进行增量编译,最后将编译结果输出到指定的输出目录 out
。
util.incremental
的目的是为了创建一个新的读写流,该流可以增量的处理数据,并且根据需要可以支持取消功能,且支持状态的防抖,防止频繁的触发操作。其接受IStreamProvider
作为入参,这是一个函数式接口,定义了增量编译的基本操作:
1 |
|
ICancellationToken
,用于判断是否需要取消当前的编译任务,这是一个返回布尔值的无副作用函数,可以通过轮询来实现构建操作的取消。IStreamProvider
接口定义了一个流提供者接口,用于提供一个可读可写的流。
由于util.incremental
仅仅是一个工具函数,用于函数式的处理流数据的增量更新,本身不包含编译逻辑,而且内部实现比较复杂,因此不做过多分析。
4.2 编译过程(调用 tsc)
我们一层一层剥开上面提到的 watchTask
的调用栈:
watchTask
调用createCompile
,后者会在底层继续调用createTypeScriptBuilder
,这是一个工厂函数,其接收配置信息、项目文件路径、编译命令行参数,然后返回一个ITypeScriptBuilder
(顾名思义,他能够对 TypeScript 文件进行编译)。ITypeScriptBuilder
接口定义了两个主要的方法:
file(...)
: 用于添加/更新单个文件到构建系统build(...)
: 开始构建过程,编译所有必要的文件
下面我们按照时间顺序罗列这个 build(...)
的工作步骤:
文件输出 (
emitSoon
) 编译生成目标文件(将 TS 编译为 JS 和.d.ts
声明文件)。此步骤还会处理 source map 文件,以便调试时可以回溯到原始的 TS 源码。语法检查 (
checkSyntaxSoon
) 对每个需要编译的文件进行语法分析,如果发现了语法错误,则立即终止后续的语义检查。语义检查 (
checkSemanticsSoon
) 进行语义分析(检查类型是否正确使用,变量是否已声明等更深层次的问题)。依赖关系管理,追踪模块之间的依赖关系。如果某个文件发生了变化,那么所有依赖该文件的其他文件也会被重新检查+编译。
顺便记录此次构建的时间消耗和内存占用情况,以便优化。
前三个步骤承担了编译的重任,它们直接依赖于 Typescript 官方 API 所提供的语言服务(Language Service),通过该服务获取文件的编译输出、获取语法检查能力等等。
简化版的代码实现如下:
1 |
|
我们逐行分析上述代码:
1 |
|
这里调用了createLanguageService
方法来创建一个语言服务实例。host
参数是一个实现了ts.LanguageServiceHost
接口的对象,提供对文件系统访问的能力以及其他必要的环境信息,以此来桥接构建器与 TypeScript 编译器之间的交互。
而ts.createDocumentRegistry()
则用于管理文档状态,确保在多线程环境中正确处理文档。语言服务可以用来执行诸如查找符号定义、自动完成、重命名符号等操作。
1 |
|
getEmitOutput
是 LanguageService 提供的一个方法,用于获取指定文件名对应的编译结果。此方法返回一个包含多个输出文件的对象(即EmitOutput
),这些输出文件包括编译后的 JS 文件、声明文件(.d.ts
)、map 文件等。fileName
参数指定了要编译的具体文件路径。
1 |
|
最后一部分遍历了由getEmitOutput
返回的outputFiles
数组。每个元素代表一个输出文件的信息。具体而言,Vscode 构建系统在这一步会这么做:对于 .d.ts
文件,生成文件的签名;对于 .js
文件和 .js.map
文件,生成相应的 Vinyl
虚拟文件对象,通过函数式接口 out(file)
将文件推送到流中,以便该文件可以继续沿着流进行处理,直到被写入目的地。
综上,vscode 的构建系统是基于 TypeScript 官方 API 所提供的语言服务(tsc)来实现的,并未采用 babel 之类的工具。
4.2.1 补充内容:依赖管理
上面 build(...)
的工作步骤的第四、五步,即依赖管理,它也是构建器的核心功能之一。在这里稍微用一点篇幅介绍一下依赖管理的原理。
依赖管理的核心思想是:只对依赖链上发生变化的文件进行增量编译,确保当且仅当某个文件的 sha256 签名发生变化时,所有依赖于该文件的其他文件也会被重新检查。
调用 collectDependents
方法来收集依赖于当前文件的所有其他文件,并将它们推入 dependentFiles
数组内,稍后会被重新处理。与前一篇文章提到的 DI 系统类似,文件之间的依赖关系也是通过一个有向无环图来维护。
5 多平台构建原理
5.1 跨平台代码架构
VSCode的跨平台能力并不仅仅由其构建系统所赋予,还得益于其优秀的架构设计。因此我们先来看一下VSCode的核心代码架构。
5.1.1 核心分层架构
VSCode 核心代码被划分为多个层次,此分层架构使得代码可以在不同的 runtime 中尽可能多的得到复用,同时降低治理成本。官方博客对此有提及,在此引用:
- Base 层:提供通用的工具类和用户界面组件,这些组件可以在任何其他层中使用。
- Platform 层:定义服务注入支持和 VS Code 的基础服务,这些服务在多个层之间共享,如
workbench
和code
层。- Editor 层:包含 Monaco 编辑器,该编辑器可以作为一个独立的组件使用。
- Workbench 层:负责管理编辑器、笔记本和自定义编辑器,并提供面板框架(如资源管理器、状态栏、菜单栏等)。
- Code 层:桌面应用的入口点,负责将各个模块整合在一起,包括 Electron 主进程文件、共享进程和 CLI。
- Server 层:远程开发的入口点,支持远程开发场景。
5.1.2 模块化设计
前面提到的分层架构对于 VSCode 这类大型 TS 项目还远远不够,每一层肯定都需要有大量模块化设计,把业务逻辑解耦到不同模块独立维护。每个模块既可以通过依赖注入的方式提供服务,也能够以 standalone 的形态存在,被其他模块所引入。workbench
层通过扩展机制(Contrib)来集成各种功能模块,每个模块通过 .contribution.ts
文件注册到系统中。
5.1.3 【重点】模块的多平台支持
这些模块在编写时,内部逻辑会根据目标环境(common
、browser
、node
、electron-sandbox
等)进行划分,避免在错误的平台上加载不兼容的代码,同时也能做到细粒度的 coverage 控制(tree-shaking)。
目标环境之间详细的区别如下(同样取自官方博客):
- Common:仅依赖基本 JavaScript API 的代码,可以在所有其他目标环境中运行。
- Browser:依赖 Web API(如 DOM)的代码,适用于浏览器环境。
- Node:依赖 Node.js API 的代码,适用于 Node.js 环境。
- Electron-sandbox:依赖浏览器 API 和 Electron 渲染进程 API 的代码,适用于 Electron 渲染进程。
- Electron-utility:依赖 Electron 工具进程 API 的代码,适用于 Electron 工具进程。
- Electron-main:依赖 Electron 主进程 API 的代码,适用于 Electron 主进程。
5.1.4 服务抽象接口与实现分离
如第一篇文章所述,每个服务都有一个 Interface 和多个平台特定的实现。通过依赖注入,模块不需要关心具体的实现细节,只需依赖接口即可。DI 系统和构建系统会根据目标平台注入正确的实现。例如,IFileService
是一个文件操作的接口,而它的实现可以是:
DiskFileSystemProvider
, Electron 桌面端,直接操作本地文件系统RemoteFileSystemProvider
, Web 端,通过远程服务操作文件
1 |
|
5.2 差异化CI
从源码开始构建,打包出最终的安装包,vscode将其分为两大主要步骤:
- 主任务:编译、压缩、合并源码,处理资源文件等。为了降低心智负担,vscode的构建系统确保主任务是平台无关的,各个目标平台使用同一套构建脚本。
- CI:运行在主任务之后,其主要职责:编译native扩展、打包安装包等。这一步与目标平台强相关,因此整个构建流程的差异性就是在CI这一步体现的。
下面代码展示了各平台构建任务是如何生成的:
1 |
|
这里使用了一个数组 BUILD_TARGETS
来定义所有的构建目标,每个目标包含平台、架构和可选的配置项。然后对其进行 forEach
遍历,为每个目标生成对应的构建任务。