这是vscode架构的第六篇,基于mini-vscode
前言
上一篇文章我们聊了 VSCode 的插件系统:插件是怎么被加载、怎么注册命令、怎么和主线程通信的。插件系统解决了“能力怎么扩展”的问题,而语言服务就是插件系统里最重要的一类能力。
当我们写代码时,能看到语法高亮、错误提示、代码补全,甚至可以跳转到定义,这些能力并不是凭空出现的。它们背后有 Monaco、Provider、LSP、Language Server 等一整套机制。
这一篇就顺着 mini-vscode 的实现,来看看 VSCode 里的语言服务到底是怎么工作的,以及 LSP 在其中到底扮演了什么角色
代码跳转和代码诊断是什么意思?
在阅读代码的时候,我们需要查看某个对象的具体定义,就会按住 ctrl + 鼠标左键,然后编辑器就会跳转到定义的位置,这个位置可能是当前的文件,也可能是另一个文件。这就是代码的跳转
在打开文件的时候,vscode 会对代码做语法诊断、类型检查等等,如果不对,就会在错误的地方,画上红色波浪线,或者在 output 中输出对应的错误。这就是代码诊断。
这些语言能力是编辑器内置的,还是插件提供的?
语言能力分成编辑器基础能力和worker / LSP 提供的更强能力。而编辑器基础能力分成两层,一层是通用的底层能力,一层是内置的能提供较强语言能力的 worker
在通用的底层语言能力,是编辑器本身就会支持的,这个不需要插件额外提供,比如:
- 语法高亮
- 括号匹配
- 代码折叠
- 注释快捷键
- 多光标
- 基础的的 tokenization
这些能力更多的是来自编辑器,所以在渲染 monaco 的时候,需要设置 language,然后就能看见 monaco 出了渲染不同语言,同时提供基础的编辑体验。
第二层 Monaco 自身内置了一些 worker,这些 worker 能够提供一些更强的语言能力。比如 jsonWorker、cssWorker、htmlWorker、tsWorker、editorWorker
tsWorker 提供了补全、Hover、跳转定义、引用、诊断、格式化等能力;jsonWorker 提供了语法诊断、schema 补全、格式化等能力、htmlWorker 提供了标签不全、Hover、格式化等能力
你可能会问了,为什么 Monaco 要自己做这么多?不是交给插件就好了吗?
我是这么理解的,Monaco 不仅仅会在 VSCode 场景下使用,还会在其他的 Microsoft 的云产品中使用,甚至是 IE 的开发者工具中,这些场景是没有什么 lsp 的,vscode 开发团队希望在这些场景中,可以提供一些基本语言能力。
而且 Monaco 被独立出来成为了一个 npm 三方库,那方便开发者可以开箱即用,所以就提供这些现成的 worker,用以提供基本的编程体验。
在完整工作区场景下,LSP / tsserver 这类独立语言服务通常更强,因为它可以长期维护项目状态,读取 tsconfig、node_modules、跨文件依赖。
Monaco 提供了编辑器基础能力,以及 json/css/html/ts 等 worker;但 VS Code 级别的项目语言能力通常不会直接依赖 Monaco worker。
在真正的 VS Code 里,TS/JS 语言能力主要来自内置 TypeScript 扩展启动的 tsserver,而不是 Monaco tsWorker;也不是严格意义上的 LSP。
为什么 VS Code 要引入 LSP,而不是每种语言写一套专用逻辑?
VSCode 要成为通用的语言编辑器,为所有语言编写兼容代码是不现实的,唯一可行的办法是公开一个标准协议(LSP),让第三方各自的 language server 对接到 LSP 上就可以,这样 vscode 不关心每种语言的内部的语法,语义等信息,就可以实现代码跳转以及代码诊断
LSP 里的 client 和 server 分别是谁?
在 mini-vscode 中,专门写一个插件通过 ts-lsp,vscode 与该插件的通信链条是这样的:
vscode monaco <--RPC Server--> ts-lsp <--LSP JSON-RPC--> ts-language-server
其中 ts-lsp 插件,就是 LSP 的 client,ts-language-server就是 LSP 的 server。monaco 与 插件通信是 RPC(上篇文章有讲过);LSP 的 client 和 server 之间通信是 LSP 协议
LSP 的 client 和 server 是怎么通信的?
client 采用一问一答的方式,数据结构采用 JSON-RPC 的方式通信。通过 id 标识请求,
LSP server 采用的是懒加载的方式,等有需求来了,就会初始化
注册 provider
const server = createTypescriptLspServer(onNotification, onServerRequest)
context.subscriptions.push(
vscode.languages.registerDefinitionProvider(['typescript', 'typescriptreact'], {
async provideDefinition(document, position) {
// 获取lsp server的连接
const c = server.getConnection() || (await server.ensureConnection(findRoot(document.uri.path)))
}
})
)
在 vscode 调用provideDefinition时候,才会真正初始化
启动连接
const c = server.getConnection() || (await server.ensureConnection(findRoot(document.uri.path)))
刚开始server.getConnection()的返回值是空的,逻辑会走到server.ensureConnection。
const cp = require('child_process')
const path = require('path')
const { createConnection } = require('./lspProtocol')
const { pathToUri } = require('./lspUtils')
function createTypescriptLspServer(onNotification, onServerRequest) {
let conn = null
let starting = null
function ensureConnection(rootPath) {
if (conn) return Promise.resolve(conn)
if (starting) return starting
const cli = require.resolve('typescript-language-server/lib/cli.mjs')
console.log('[ts-lsp] spawning server:', cli, 'root=', rootPath)
const child = cp.spawn(process.execPath, [cli, '--stdio'], {
// 让 Electron 二进制以 Node 模式运行子进程(VSCode 同款手法)
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['pipe', 'pipe', 'inherit']
})
child.on('exit', code => {
console.log('[ts-lsp] server exited', code)
conn = null
starting = null
})
const c = createConnection(child, onNotification, onServerRequest)
starting = c
.request('initialize', {
processId: process.pid,
rootUri: pathToUri(rootPath),
workspaceFolders: [{ uri: pathToUri(rootPath), name: path.basename(rootPath) }],
capabilities: {
textDocument: {
synchronization: { dynamicRegistration: false },
definition: {},
publishDiagnostics: {}
}
}
})
.then(() => {
c.notify('initialized', {})
conn = c
starting = null
console.log('[ts-lsp] initialized')
return c
})
return starting
}
return {
ensureConnection,
getConnection() {
return conn
}
}
}
module.exports = { createTypescriptLspServer }
为了方便理解,我就把所有的代码都放出来了。直接看ensureConnection的逻辑
- 如果有连接,就返回 conn
- 如果没有,就看是否是正在连接的。因为在连接建立起来之前,可能会有两个连续的请求打过来
- 建立连接就是以 node 程序运行子进程的方式,执行一个 js 文件。
createConnection对子进程的发送消息和接送消息做了一层封装。暴露出来两个方法request、notify。conn 的就是这个东西
可以看到,在建立成功之后,就向 server 发送了两个消息,一个是initialize,在初始化完成后,再发送了个initialized
握手
其实握手就是初始化。没啥好讲的,可以接着这个机会讲讲 JSON-RPC 收发信息是什么样的
he/** mini JSON-RPC over stdio(LSP 分帧) */
function createConnection(child, onNotification, onServerRequest) {
let buffer = Buffer.alloc(0)
let contentLength = -1
const pending = new Map()
let nextId = 1
function send(obj) {
const json = JSON.stringify(obj)
child.stdin.write('Content-Length: ' + Buffer.byteLength(json, 'utf8') + '\r\n\r\n' + json)
}
function dispatch(msg) {
if (msg.id !== undefined && msg.method !== undefined) {
// 服务器 → 客户端 请求:必须回应,否则服务器可能卡住
Promise.resolve(onServerRequest(msg.method, msg.params)).then(result =>
send({ jsonrpc: '2.0', id: msg.id, result: result === undefined ? null : result })
)
} else if (msg.id !== undefined) {
const p = pending.get(msg.id)
pending.delete(msg.id)
if (p) {
if (msg.error) p.reject(new Error(msg.error.message || 'LSP error'))
else p.resolve(msg.result)
}
} else if (msg.method) {
onNotification(msg.method, msg.params)
}
}
child.stdout.on('data', chunk => {
buffer = Buffer.concat([buffer, chunk])
for (;;) {
if (contentLength < 0) {
const headerEnd = buffer.indexOf('\r\n\r\n')
if (headerEnd < 0) break
const header = buffer.slice(0, headerEnd).toString('ascii')
const m = /Content-Length:\s*(\d+)/i.exec(header)
contentLength = m ? parseInt(m[1], 10) : 0
buffer = buffer.slice(headerEnd + 4)
}
if (buffer.length < contentLength) break
const body = buffer.slice(0, contentLength).toString('utf8')
buffer = buffer.slice(contentLength)
contentLength = -1
let msg
try {
msg = JSON.parse(body)
} catch {
continue
}
dispatch(msg)
}
})
return {
request(method, params) {
const id = nextId++
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject })
send({ jsonrpc: '2.0', id, method, params })
})
},
notify(method, params) {
send({ jsonrpc: '2.0', method, params })
}
}
}
module.exports = { createConnection }
request和notify都是发送消息,只不过 request 是需要回复的,所以将请求 id 放进了 pending 中存起来。而 notify 是不需要的,直接发出去就完事
发送消息都会经过send函数,将参数 string 化,然后拼接成Content-Length: ${length}/r/n/r/n${jsonData}
这个数据格式和 DAP RPC 传输的格式是一模一样的,感兴趣的话,可以翻看我其他的文章,有对 DAP 做详细讲解。
DAP 是代码调试的时候,底层的 debug server 和 debug adapter 通信的一种协议。vscode 对调试做的架构设计和 lsp 的设计几乎是一样的,将 vscode 和语言调试服务用一套标准协议连接起来,为的就是 vscode 可以调试任意语言程序
接收数据会经过child.stdout.on('data')的回调函数,将接收的字符串转成一个完整的数据对象,然后才会交给dispatch做具体的消息类型判断。
处理字符串的过程,因为底层是 TCP 通信,并不会保证上层消息的完整性,只保证数据的无误。所以可能会出现半包和粘包的问题,这里就是出处理这个问题的。
具体的处理细节,在讲 DAP 的文章提到过,感兴趣可以看看
初始化发送了很多信息过去:
c.request('initialize', {
processId: process.pid,
rootUri: pathToUri(rootPath),
workspaceFolders: [{ uri: pathToUri(rootPath), name: path.basename(rootPath) }],
capabilities: {
textDocument: {
synchronization: { dynamicRegistration: false },
definition: {},
publishDiagnostics: {}
}
}
})
.then(() => {
c.notify('initialized', {})
conn = c
starting = null
console.log('[ts-lsp] initialized')
return c
})
这里告诉 lsp server:
- 我是客户端
- 我的工作区 root 是哪里
- 我支持文档同步
- 我需要
definition - 我能接收
diagnostics
启动完成后,发送initialized 通知,这样就算 LSP Server 启动完成了。
总结
这一篇我们从代码跳转和代码诊断开始,拆开了 VSCode 语言服务背后的几层结构。
Monaco 本身提供了基础编辑能力,也内置了一些 worker,可以提供轻量级语言能力。但在完整的编辑器场景里,仅靠 Monaco worker 还不够,因为真正的项目级语言理解需要读取 tsconfig、分析跨文件依赖、维护项目状态。
所以 VSCode 引入了 Provider 和 LSP。Provider 是编辑器暴露给插件的能力接口,比如“给我当前位置的定义”“给我这个文件的诊断信息”;LSP 则把这些语言能力进一步标准化,让插件可以作为 client,去连接真正的 language server。
最终可以把这套链路理解成:
- Monaco 负责编辑器显示
- Provider 负责承接语言能力
- LSP Client 负责协议转发
- Language Server 负责真正理解代码
这样 VSCode 不需要为每种语言都写一套专用逻辑,只要提供统一的接入方式,就可以让不同语言通过各自的语言服务器接进来。这就是 LSP 最核心的价值。