如何实现语言服务插件 - volar 原理解析
写在前面
2021年10月23日,我在「前端早早聊大会」分享了如何开发语言插件的实践,以下是我的分享主题《如何开发语⾔服务插件 - volar 原理解析》
Volar (ˈvōlər)是一款针对 Vue 3 的 VS Code 插件,用于为 的 SFC 提供语言服务(Language Server)。有一些小伙伴可能对「语言服务」这个词比较陌生。我们会选择在 IDE 中写代码,譬如 VSCode 或者 webstorm,而不会选择文本编辑器,很重要的一个原因就是在这些 IDE 中开发会比较「爽」。这个「爽」主要体现在以下几个方面: 代码提示错误诊断语法高亮定义跳转格式化...
项目入口
我们首先要知道 volar 是怎么在 VS Code 里面跑起来的,就是找到 volar 的执行入口。我们发现在 中,有这么几个重要的配置项,构成了 volar 的核心功能,有必要解释下:
:激活事件。在 VS Code 中,插件都是懒加载的,所以我们得告诉 VS Code 啥时候把这个插件跑起来。这里的 表示在 vue 文件打开的时候把插件激活 :配置一门语言,这里指定了这门语言的名称,扩展名,以及配置注释符号(comments),自动闭合的开闭符(autoClosingPairs)等 :为一门语言配置 TextMate 语法,确定了语法和 scopeName,来做语法高亮。 :指定插件执行入口,为
下面的内容主要分为两个部分: 如何实现语法高亮 自动补齐、错误诊断等功能是如何实现的
语法高亮(Syntax Hightlight)
语法高亮是非常基础的功能,决定了在 VS Code 编辑器中不同类型代码的颜色和样式,比如我们发现 JavaScript 中的 const、let 或者 new 等关键字它们是一个颜色,字符串、数字、变量名和注释又是另外一种颜色。
在 VS Code 中实现语法高亮主要分为两步,和把大象放冰箱是一个道理: 分词(Tokenization):文本切分成一个字符串序列
比如 会被分割成 ,分割之后对每个片段会标记具体的 Scope。 主题(Theming):使用主题或用户设置将标记的 Scpoe 映射到对应的颜色和样式
在 VS Code 中,使用 TextMate 语法 将文本分割成一个个字符串。以 为例。VS Code 的内置了 Token 和 Scope 检查器工具,专门用来帮助我们调试语法和语义标记。通过 Command + Shift + P 唤起命令面板,选择
我们在这里看到 HTML 对应的 scope 名称为 ,主题会把颜色和样式映射到 scope 上,这样一来就实现了语法高亮。由于我本地 VS Code 设置的主题为 OneDark-Pro,找到对应的 配置代码。这里可以看到 对应的 为 ,和检查工具显示的一样。
嵌入式语言(Embedded Language)
一个 Vue 的 SFC 通常由多个片段构成,包括 、 和 等,再去实现一遍 TypeScript、Less 的语法高亮好像不太现实。针对这种情况,VS Code 提供了 ,可以在父语言中嵌入其他语言,比如 HTML 中的 JavaScript、CSS 等。配置完了之后就会告诉 VS Code,怎么去处理嵌入式的语言,然后嵌入语言的高亮、注释这些功能都会正常运作。
这里表示触发了 的代码片段,就会有 的代码风格。 详细的配置规则还可以继续 VS Code 的官方语法高亮教程和 TextMate 官网,这里大致理解作用即可,先不展开深究。 语言服务(Language Server) 光有语法高亮还远远不够,不能满足编辑时的一些需求,因此我们还需要一些编程特性的支持。 在 VS Code 中,语言服务有两个部分: 客户端:用 JavaScript / TypeScript 编写的普通 VS Code 插件。 语言服务器:在单独的进程中运行的语言分析工具。
语言服务基本的实现逻辑和我们平时糊页面的交互逻辑类似,所有的动作都需要通过监听一些事件来完成。
我们来看 入口,插件启动时 VS Code 会执行这个入口文件导出的 方法,这里主要做了这么几件事情: 注册一系列方法来监听事件,因为我们需要一些编辑时的交互,开发这一套东西和糊页面的底层逻辑有点像,比如这里点一下,给个响应,那里鼠标悬浮再给个相应。 初始化 LSP Client 对象,最后和 server 端建立连接。
这里的 LSP 其实是 Language Server Protocol 的简写,是由微软定义的一套协议,那为啥要定义这么一套协议呢? 语言服务插件本质上也是一种 VS Code 插件,只不过比较特殊。主要用来为提升编程语言的开发体验和开发效率,比如提供一些自动补齐、错误检查、定义跳转、重命名以及 VS Code 支持的许多其他语言功能。 然而,在 VS Code 中实现对语言特性的支持时,发现了三个常见的问题: 首先,语言服务端大概率是用对应的编程语言实现的,比如 PHP 的服务端就是用 PHP 写的,怎么把 PHP 写的东西跑在 VS Code 里面是个问题。 还有个问题是,语言服务器一般来说是资源密集型的。比如,在写代码的时候为了正确做自动补齐,Language Server 需要解析大量文件,并把它们构建抽象语法树进行执行静态程序分析。这些操作可能比较消耗 CPU 和内存,但是需要保证 VS Code 的性能不受影响。 最后,市面上的代码编辑器很多,对于语言服务的开发者来说,要同时在 VS Code 和 Atom 上实现两边一样的功能也是一件非常痛苦的事情。站在语言工具的角度来看,最好可以一套代码跑在不同的代码编辑上。
就是为了解决这些问题而出现的,标准化了语言工具和代码编辑器之间的通信。 从图中可以看出,遵循了 LSP 协议的插件存在两个部分 客户端。它用来和 VS Code 环境交互,是 VS Code 和语言服务器之间沟通的桥梁。它通过调用 VS Code API,来监听 VS Code 传过来的事件,或者向 VS Code 发送消息。 语言服务器。它是语言特性的核心实现,用来对文本进行词法分析、语法分析、语义诊断等。它在一个单独的进程中运行,不限制于语言。
这样,语言服务器可以用任何语言实现并在运行在自己的进程中来避免性能成本。此外,任何符合规范的 LSP 语言工具都可以与多个支持 LSP 的代码编辑器集成。 总的来说,就是很牛逼。
https://github.com/felixfbecker/php-language-server
我们来看下 VS Code 和 Language Server 日常是怎么沟通。用户打开了一个 vue 文件,VS Code 知道后,会激活 volar 插件的客户端。客户端再通知服务端来做检查,服务端检查完了之后,会把信息返回给 volar 的客户端,最后 VS Code 再把信息展示出来。
再来看另一个场景,用户想 format 一个文件,流程和刚刚说的类似,不同的地方是 volar 会发送一个 format 的请求加上具体的参数到服务端,服务端处理完之后给出响应,这样的流程是不是和 HTTP 非常类似。
从上面两个一来一回的例子,我们总结出完成一次交互,需要三个要素: 事件名称 参数 响应
以 Hover 为例
当鼠标停留在语言元素如函数、变量、符号等 token 时,VS Code 会显示 token 对应描述与帮助信息:
要实现悬停提示功能,首先需要声明插件支持 特性:
之后,需要监听 事件,并在事件回调中返回提示信息:
这样一来,就完成了简单的 hover 实例,本质上就是监听事件 + 返回结果,非常简单。我们来看实际上的请求是啥样的。 当鼠标悬浮在第 2 行 class 附近的某个位置, VS Code 向服务端发送请求。一个简单的 hover 请求需要两个关键的参数: uri:执行 hover 的文件绝对路径 position:执行 hover 的具体位置, 和 都是从 0 开始。
Volar 服务端收到请求处理完了之后,向服务端发送响应。要把 hover 的信息在 VS Code 中展示出来,同样需要两个关键的参数: uri:展示 hover 信息文件的绝对路径 contents:内容,是一个字符串数组 range: 展示 hover 信息的位置,非必需,如果没有返回默认值就是原来 hover 的位置。
Embedded Language Service
前面有提到 vue 里面由多个片段组成,voalr 针对一个 SFC 里的不同片段,会处理成多个 virtual document,再交给不同的 Language service 来处理。比如 TypeScript 部分会交给 来处理,CSS 部分会让 处理。针对 template 的一些语言服务,也是通过 TypeScript 来处理的。例如一个名为 的文件,会被处理成: App.vue.__VLS_script.ts App.vue.__VLS_template.ts App.vue.ts
volar 提供了两个 debug 命令,感兴趣的同学可以自行琢磨下。
由于 TS Server 在运行的时候需要提前收集好文件,所以创建了 的 virtual document。 当收到请求时,volar 会将请求中原本的 position,映射到 virutal document 的位置,然后将映射的 virtual document 位置参数传给 取得 TypeScript 的结果,最后将结果映射回 SFC 中。
总结
这样一来,我们就以 volar 为抓手,结合语法高亮中的分词、主题以及 Embedded Language,由点形成线,最后通过 LSP 的例子,形成了 LSP 客户端到服务端的点线面。