背景
有很多优秀的代码编辑器,具有自动重构选中代码的功能,VSCode 也能执行这样的操作。
我们用 VSCode 打开一个 .ts 文件,
const f = x => {
x, y
};
鼠标选中函数体 x, y,按快捷键 ⌘ + .,就会弹出下图这样的选择框,

我们选第二个 Extract to function in global scope,选中的代码就会被提取到一个全局函数中,
const f = x => {
newFunction(x);
};
function newFunction(x: any) {
x, y;
}
VSCode 到底是怎么做到的呢?
简单说,它是通过 Language Server Protocol 调用了 tsserver。
由 tsserver 返回了重构后的结果。
本文我们先来研究这条链路是怎么跑通的,下一篇文章再来探讨 tsserver 的业务逻辑。
1. 调试 vscode 和 tsserver
为了看清整条链路,我们需要同时对 vscode 和 tsserver 进行调试。
1.1 源码准备
vscode 源码,
我们选用了当前 release 最新的版本 v1.45.1。
$ git clone https://github.com/microsoft/vscode.git
$ cd vscode
$ git checkout 1.45.1
为了描述方便,我们将这里的 vscode 目录,记为 {VSCodeRoot}。
tsserver 源码在 typescript 中,当前已经更新到 v3.9.3 了,
但为了保持与前几篇文章一致,我们仍然使用 v3.7.3。
$ git clone https://github.com/microsoft/TypeScript.git
$ cd TypeScript
$ git checkout v3.7.3
为了描述方便,我们将这里的 TypeScript 目录,记为 {TypeScriptRoot}。
1.2 编译
$ cd {VSCodeRoot}
$ yarn
$ yarn compile
有些 node 版本中 yarn 会失败,我本机的 node 版本是 v10.17.0。
yarn 版本是 1.22.4。
$ cd {TypeScriptRoot}
$ npm i
$ node node_modules/.bin/gulp LKG
gulp LKG,会将 src/ 中的源码编译到 built/local/ 文件夹中。
1.3 一些必要的软链接
为了能让 vscode 源码调用我们下载的 typescript 源码,需要添加一些软链接。
# 进入 vscode 源码根目录
$ cd {VSCodeRoot}
# 内置插件依赖的 typescript 目录改个名,不用这个目录了
$ mv extensions/node_modules/typescript extensions/node_modules/_typescript
# 软链到 typescript 源码根目录
$ ln -s {TypeScriptRoot} extensions/node_modules/typescript
vscode 源码中的内置插件,依赖的 TypeScript 默认位于 {VSCodeRoot}/extensions/node_modules 中。
为了对 TypeScript(tsserver)源码进行调试(固定 v3.7.3 版本,且用上 source map),
这里创建了一个软链接,让 vscode 直接依赖我们之前下载的 TypeScript 源码。
# 进入 typescript 源码根目录
$ cd {TypeScriptRoot}
# lib/ 目录改个名,不用这个目录了
$ mv lib _lib
# 将 lib/ 软链到 typescript 构建产物 built/local/ 目录
$ ln -s built/local lib
vscode 启动 tsserver 时,硬编码了 tsserver 的路径(即,lib 这个名字不能修改),
而这个路径下的 tsserver.js 是没有 source map 的。
为了能调试源码,我们将原来的 lib/ 目录删掉,并建立软链接,指向 TypeScript 项目编译产物目录 built/local/
1.4 调试配置
打开 vscode 源码根目录 {VSCodeRoot} 中的 .vscode/launch.json,
添加这样一个调试配置,名字记为 Debug TypeScript Extension。
{
...,
"configurations": [
{
"type": "extensionHost",
"request": "launch",
"name": "Debug TypeScript Extension",
"runtimeExecutable": "${execPath}",
"args": [
"${workspaceFolder}",
"--extensionDevelopmentPath=${workspaceFolder}/extensions/typescript-language-features",
],
"outFiles": [
"${workspaceFolder}/extensions/typescript-language-features/out/**/*.js"
],
"env": {
"TSS_DEBUG": "9003",
}
},
...,
]
}
env.TSS_DEBUG 设置为了 9003,这是 vscode 内置 TypeScript 插件(typescript-language-features)启动 tsserver 的调试端口号。
位于 extensions/typescript-language-features/src/tsServer/spawner.ts#L98。
const childProcess = electron.fork(version.tsServerPath, args, this.getForkOptions(kind, configuration));
getForkOptions(kind: ServerKind, configuration: TypeScriptServiceConfiguration) {
const debugPort = TypeScriptServerSpawner.getDebugPort(kind);
const tsServerForkOptions: electron.ForkOptions = {
execArgv: [
...(debugPort ? [`--inspect=${debugPort}`] : []),
...(configuration.maxTsServerMemory ? [`--max-old-space-size=${configuration.maxTsServerMemory}`] : [])
]
};
return tsServerForkOptions;
}
注意这里用了 --inspect 而不是 --inspect-brk,
这说明 tsserver 启动后并不会停在第一行等待 attach,而是直接继续运行。
源码中支持 --inspect-brk 的 commit 已经 merge 到 master 了。
只是在写这篇文章的时候,还没有 release,
下一个 release 应该就可以使用 env.TSS_DEBUG_BRK 来配置 --inspect-brk 形式的调试端口号了。
typescript 源码目录 {TypeScript} 下是没有 .vscode/launch.json 的,
我们新建这样一个文件(或点击菜单:Run - Add configuration 也行),并添加如下配置,
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "attach to tsserver",
"port": 9003,
"skipFiles": [
"<node_internals>/**"
]
}
]
}
注意到这里的 port 端口号,与 vscode 那边的 env.TSS_DEBUG 应保持一致。
1.5 启动调试
用 VSCode 打开 vscode 源码目录 {VSCodeRoot},
提前在 extensions/typescript-language-features/src/extension.ts#L27,
typescript 插件(typescript-language-features)的激活函数 activate 中第一行打个断点。
在调试面板中选择刚才创建的配置 Debug TypeScript Extension,按 F5 启动调试。
它会打开一个新的 VSCode 窗口,名为 [Extension Development Host]。
我们在这个窗口中打开一个 .ts 文件,以激活 typescript 插件(typescript-language-features)。

vscode 那边已激活 typescript 插件(typescript-language-features)之后(按 F5 运行下去),
用 VSCode 打开 typescript 源码目录 {TypeScriptRoot},
直接按 F5,启动调试(因为就一个配置,默认选中了 attach to tsserver)。

看起来好像没有反应,其实已经
attach 到 tsserver 了。上文我们提到了,这是因为当前版本的 vscode(1.45.1)采用了
--inspect 方式启动 tsserver,而不是 --inspect-brk。
2. 业务逻辑
按照上文的介绍,我们已经启动了 vscode 源码仓库中的 typescript 插件(typescript-language-features),
这个插件启动的 tsserver 我们也已经 attach 上了。
下面我们来看一下整体的代码重构逻辑。
2.1 tsserver
先到 typescript 源码仓库 getEditsForRefactor 函数中打个断点,
src/services/refactorProvider.ts#L36
export function getEditsForRefactor(context: RefactorContext, refactorName: string, actionName: string): RefactorEditInfo | undefined {
const refactor = refactors.get(refactorName);
return refactor && refactor.getEditsForAction(context, actionName);
}
然后在 vscode 起来的 [Extension Development Host] 窗口中(已打开了一个 .ts 文件),重复本文开篇背景中介绍的操作步骤。
const f = x => {
x, y
};
选中 x, y,按 ⌘ + .,选择 Extract to function in global scope。
就发现代码跑到了 getEditsForRefactor 函数的断点处了。

这说明重构操作确实用到了 tsserver,跑到了 tsserver 的代码中。
查看调用栈,tsserver 是通过监听 message 的方式,来执行代码重构操作的。

2.2 typescript-language-features
vscode 这边是怎么发送消息的呢?
通过查看 typescript 插件(typescript-language-features)的激活逻辑,或者搜索 getEditsForRefactor 关键字,
我们发现,消息是在 extensions/typescript-language-features/src/features/refactor.ts#L77 execute 函数中发送的,
class ApplyRefactoringCommand implements Command {
public async execute(
...,
): Promise<boolean> {
...,
const response = await this.client.execute('getEditsForRefactor', args, nulToken);
...,
const workspaceEdit = await this.toWorkspaceEdit(response.body);
...,
}
}
execute 函数,先是给 tsserver 发消息,得到了重构结果,
然后再调用 this.toWorkspaceEdit 将改动结果应用到编辑器中。
查看调用栈,可以粗略的识别出这是一个响应前端快捷键,然后再向 tsserver 发送消息的过程。

总结
本文花了较大篇幅介绍 vscode + typescript(tsserver)的联合调试过程。
这个过程看起来很简单,但其实跑通它也花费了不少的精力。
一图胜千言,我们借助软链接,让 vscode 启动了我们 typescript 源码中的 tsserver。

链路通了以后,再研究重构相关的代码逻辑就事半功倍了。
下文开始探讨 tsserver getEditsForRefactor,看它是怎样得到重构结果的。
