我的第一个VScode插件:ChEER

前言

记录我开发 VSCode 插件 ChEER 的心路历程和一点思考(顺便测试ChEER)。

ChEER 是什么以及它是怎么来的

正如 ChEER 的 Readme 文档中所述,ChEER 启发自我最喜欢的 Obsidian 插件:Yaozhuwa/easy-typing-obsidian: This is a plugin of obsidian for users writing in an easy way.。它是在征得原作者的同意后(尽管 easy-typing 似乎是 MIT 协议),我独立开发出来的。

ChEER 这个名字是由 DeepSeek 想出来的,它的含义是 “Chinese Editing Enhancement & Refinement”,即“中文编辑增强与调优”。在反映插件功能之余,拟合的单词 cheer 含义也很好:我希望能传达一种愉悦积极的情感,使用它,在 VS Code 上写作变成了一件更开心的事。

我很喜欢的音乐人 Porter Robinson 有一首 《Cheerleader》,这也是促使我选择这个名字而非其它名字的原因。

目前来说, ChEER 还谈不上有什么独占的功能,我希望并致力于的是将 Easy-typing 上的功能尽可能快地移植到 VS Code 中来。

ChEER 开发的经历流水账

开发 ChEER 的想法其实很早就有了,但是由于各种原因,开发工作在写下这篇文章的几周前才正式开始。

比较幸运的是,在很早之前我就搭建起来了一个最简单的 Hello World 插件,然后就吧这事儿彻底搁置直到最近。所以这次开发我主要就是参考了两个项目,一个是前面提到的 easy-typing,一个是formulahendry/vscode-auto-close-tag。前者提供了功能上的灵感,后者则帮助我迈出了写有文字编辑功能的插件的第一步。当然,毕竟对 Typescript 和 VSCode API 不熟,我还大量参考了 AI 的方案,我主要使用了 ChatGPT-4o 和 DeepSeek R1,也用了一小会 Qwen3。

实际写出一个基本框架的时间并不长。具体的时间虽然并不可考,但是应该也就一天或两天的时间就实现基本的成对匹配和删除。这个时候,成对输入的具体逻是监听onDidChangeTextDocument事件,如果在事先指定的 Map 中找到对应的键,就取出其值用 repalce()替换掉,再将光标重新指定成左右符号的中间位置即可。删除功能就更简单了,遍历所有的规则检查是否匹配,如果是就调用 delete 函数就可以。

不过,上面的代码逻辑还是太粗糙了。首先,在处理符号包裹时(即选中一段文字,按下可以左右包裹的按键,那么将用对应符号包裹选中文字),光标需要计算到正确的位置上。而考虑到多光标的场景,我们就必须要累计一个偏移量。而且,我们还必须考虑到每个光标处由于并非都是替换状态、并不都在一行等情况,既需要计算字符偏移量,也需要处理行偏移量。为了更好的处理这种情况,我们需要定义一个 iEdit 结构。在逻辑中首先根据输入情况生成一个 iEdit 数组,然后在统一处理。这样代码可扩展性也更好:如果我们需要添加新功能,只需要在不同的功能函数之间依次传递 iEdit 就可以了。其二,我们还必须考虑到原生成对符号输入的影响。举个简单的例子,<>符号对是 VSCode 原生支持成对输入的,并且在传入的 event.contentChanges 数组中,它被分成了两个部分。这就意味着我们需要做额外处理,不能看到<就直接替换为成对符号。当然,我们也不能直接只处理第一个符号或直接忽视这个符号对。我们仍然希望提供良好的多光标编辑体验,并且给予用户添加<> 的自由:毕竟,VSCode原生的成对符号体验并不总是生效。

还有一个最让人头大的问题是和中文输入法之间的搏斗。简单来说,当我们用中文输入的时候,输入的文字会被输入法(input method editor,IME)接管,进入“组词”(Composition)状态,只有选中最后真正要输入的字符,才会真正插入到文本编辑器中。但是这个过程是会触发onDidChangeTextDocument 的!而且,VSCode并没有像 Obsidian一样暴露compositionstartcompositionend事件,这就意味我们必须要找出某种方法分辨处 composition 状态,不然,就有可能误判、干扰 IME 正常工作。下面插一段我的注释笔记:

  • case1: 中文替换:
    • 第一次:selections:被替换的范围,event:被替换的范围
    • 第二次:selections:被替换后光标的位置(新地址的结尾),event:是老地址到新地址范围
    • 第三次:相比于第二次没变化,但是字符已经在第二次调用后插入成功,这次是调用成功导致的
  • case2:英文替换
    • selection:被替换的范围,event:有两个,第一个是后面插入的值的坐标,第二是前面插入的值的坐标
    • 如果不是包裹而是真的替换,那么就只有一个event。event的范围是被替换的范围,也即和selection一致
  • case3:中文插入:每次键入都会发送event,如输入z, k, \s,那么依次会触发三次时间分别是“z”, “zao”, “赵”。
    • 如果是标点符号,则会触发两次,如“《”,“《”。
    • 第一次的event位置是老地址,selection老地址;
    • 第二次selection是新地址, event是老地址到新地址范围,
  • case4:英文插入:只有一次插入,selection和event的范围是一致的

以替换场景(case1、case2)为例,在 case1 的第一次触发时,事实上字符并没有被真正插入到文本中,还处在 composition 阶段。然而,也确实有一些操作发生了,字符已经替换了原来的包裹符号。要解决这个问题,就必须先执行一次undo命令。而要让undo正常工作,首先就必须区分四种case,否则就可能在不合适的时候执行undo,随后还需要区分不同次数,防止多次执行,最后,还要考虑到undo操作本身是异步的……

和 composition 的这场搏斗持续事件非常久,最终敦促我从监听onDidChangeTextDocument事件替换成了劫持type命令。实际上,到这里的时候已经差不多一周了。

type命令相对来说要友好一点,首先,它直接响应每次用户输入,这意味着基本上我们就不用考虑文本长度了,都是 1 个字符(实际上省略号是两个)。其次,由于 type 命令发生在文本变化之前,我们就不用考虑“一次输入发生多次监听事件的区别”或者“由复制粘贴等引起的,不同行值不一样的事件”等等。此外,由于发生在事前,我们也可以直接获取正确的被包裹的字符串坐标,不用担心 undo 和下一次 event 事件之间的次序问题,不用在两个event之间传递数值。包裹选择字符串的逻辑处理起来赏心悦目。

当然,这不意味着 type 就没有自己的坑。type的坑来自于一个我仍然难以理解的现象:由于IME的一些神秘操作,如果直接阻断type命令(即直接返回,不执行),那么IME依然可以将字符插入文本编辑器中,并且会覆盖后面的字符。而如果刚好是在行尾,可能是由于执行顺序的问题,可能即使我们争取计算得出了成对符号,它也可能只能插入左边半个。

解决这个问题,又要请出我们老朋友:undo,首先将IME插入的IME撤回,然后再插入我们计算出的结果。

然后,不出所料的,undo又作妖了: VSCode 会将连续输入的字符视作一体的,再撤销时,它们会被一起撤回。即:

case1

  • 先打一段话,再打一段话
  • 撤销命令
  • 上述两端话均被撤销

case2

  • 先打一段话,移动一下光标,再打一段话
  • 撤销命令
  • 后面一段话被撤销

要解决这个问题,就得想办法断开两段话中间的联系。根据AI 的建议,一开始的方案是,监听到onDidChangeTextDocument事件后,立即插入并删除或撤销一个空字符(Type命令无法检查到输入中文的情况)。这个方案看上去蛮合理的,然而无论怎么试,第二个命令总是不执行(或者可能是执行慢了)。这样一来,尽管看上去解决了问题。但是用户按撤销和删除时,必须要连按两次,先把那个看不见的字符(例如零宽空格)删去。这就让人感觉很不舒服了。

最后,也即目前的方案,则是执行一个空操作,什么都没有插入,但是利用标识符,将这个操作与前面的操作放在一个撤销单元里,并且和后面的输入完全隔绝。这样,才达到了防撤销的效果。

感受

这次算是比较完整并且独立地经历了开发一个项目的可用阶段(并非demo)的完整流程。时间比我预想的要慢很多。一个粗略验证想法的demo只需要花一两天,但是 ChEER 的 0.0.1 版本上线我花了两个星期。如今回首,我觉得有以下几个原因是导致时间不及预期的主要原因。

  1. 我对 Typescript 和 VSCode API 并不熟悉。当然,严格来说这个项目也并没有用到多么艰深的知识,了解API是每个Developer早晚必走的一步。但是在没有做到之前,就是会耽误一些时间在“项目之外”,这包括去查文档或者问AI某个特定的语句为什么不合语法,也包括检查某个现象是VSCode的 feature 还是 bug。是否有人已经讨论了这个问题。例如,VSCode 实际上有一个配置文件管理了所有 VSCode 原生的成对输入、成对删除符号。然而一方面对于双字节的成对符号它不是一齐插入一齐删除的,另一方面包裹符号的设置也没有暴露API。我去检查了 VSCode 上的有关讨论才最终确定并不采用这种接近原生的处理方案。这些花费掉的时间并不都能反应到git提交记录上。(跳转一个我参与讨论了的 issues Allow extensions to augment an existing language’s configuration, such as surroundingPairs · Issue #234149 · microsoft/vscode
  2. 没有成熟的项目开发管理方案。由于时间比较闲散自由,实际开发过程是想到什么问题,测出来什么问题就解决什么问题。这也一定程度地降低了开发效率。例如,在使用onDidChangeTextDocument事件监听的解决办法的时候,事实上需要同时考虑四种不同的情况(上面提到了的),如果能互相先在脑中和纸上拟合好一个解决方案,再去动键盘,解决问题的速度可能还要更快一点。现在身边不常被草稿纸,有的时候就只能用“瞪眼法”来思考问题,其实对解决问题的效率还是有影响。当然,具体到这个案例,可能什么时候该放弃,该运用git 灵活的版本管理机制寻找新的方法也是需要学习的一点。这也是一个成熟的项目管理方案应该帮开发者解决的。
  3. 测试的时间。测试实际上远比开发耗时。启动项目->手动调整输入测试文本->发现问题->断点到相应的位置->发现问题原因调整代码,或没发现问题原因,加入测试性语句->再次启动项目。这个反复的循环中,人容易精神疲惫是一方面,搞了半天感觉“刚刚过去的时间毫无意义”是另一方面。这一方面除了提高整体代码能力以外,感觉似乎也没有什么好的解决办法。
  4. 类似上一条,写Prompt和等AI输出的时间,这就无需解释了。
  5. 摸鱼的时间。无论是等AI输出,要做做二游日常,还是要吃饭,还是真的调试了半天准备休息一下,都有可能陷阱信息流里半天才拔出来。更麻烦的是,刷久了就容易中断思绪。这就又需要重新去测试或看代码等等。

这篇文章是在 ChEER 的辅助之下完成的,最后总结一下ChEER 的使用体验吧。两个字:很爽!尽管目前还只有很基础的三个功能。但是它们也是最核心的三个功能。因此已经感受很不错了。当然,我也发现了好几个需要改进的地方了。(0.0.2在路上了)

致谢

大部分之前的博客写到这儿就该硬编也要编几个参考文献了。不过今天这篇文章主要就是写写流水账,所以也就不存在什么参考文献了。硬要写的话,那么就是下面两个开发过程中我主要参考的项目了: