当迁移框架的代价大到无法承受时,不妨换个思路------不迁移代码,迁移开发体验。
一、背景:一个"不敢动"的老项目
我们团队维护着一个庞大的 AngularJS 1.x 前端项目,而且业务还在持续增长,新功能不断在加。
升级框架?我们认真评估过------迁移到 React 或 Angular 2+,无论哪条路,代价都大到难以接受:几十万行的模板代码要重写、业务逻辑要重新梳理、回归测试的工作量巨大,更关键的是,业务不会因为你要重构就停下来等你。
所以我们选择了另一条路:不迁移框架,而是让在老框架上的开发体验尽可能现代化。
第一步是引入 TypeScript。这一步效果立竿见影------类型系统带来的重构信心和代码可维护性提升是巨大的。
但还有一个痛点没有解决:HTML 模板里的开发体验依然原始 。在 .html 文件中写 AngularJS 表达式,没有自动补全、没有类型提示、没有跳转定义,写错了属性名只能等运行时才发现。相比之下,现代框架(React JSX、Vue SFC、Angular 2+ 模板)的 IDE 支持已经非常成熟了。
于是我决定写一个 VS Code 插件:ng-helper,为 AngularJS 1.x 补上这块缺失的拼图。
二、它能做什么?
先看效果,再聊技术。这个插件目前已经覆盖了日常开发中最高频的场景:
数据绑定的智能提示
这是最核心的能力。在 HTML 模板的 {{ }} 表达式和指令属性中,你可以获得:
- 自动补全 :输入
ctrl.后,自动列出控制器上的所有属性和方法,带有完整的类型信息 - 悬停类型提示:鼠标悬停在表达式上,显示其 TypeScript 类型
- 跳转到定义:Ctrl+Click 直接跳到 TypeScript 中对应的属性或方法定义
- 函数签名提示:调用方法时显示参数列表和类型
组件与指令
自定义 component 和 directive 的标签名、属性名都有自动补全和悬停提示,点击可以跳转到定义处。补全时还会自动插入必填属性。
模板表达式诊断
在 HTML 中写错了 AngularJS 表达式?不用等到运行时了------插件会实时标红并给出错误信息。
更多实用功能
-
Filter 的补全、提示和跳转:
-
ng-*内置指令补全: -
templateUrl一键跳转到 HTML 文件: -
通过 Controller/Service 名称跳转到实现文件:
-
搜索 component/directive 在哪些地方被使用:
-
依赖注入匹配校验:
-
inline-html 语法高亮:
三、技术挑战:在无类型的 HTML 和有类型的 TypeScript 之间架桥
功能看起来很自然,但背后的技术问题并不简单。核心挑战是:AngularJS 的 HTML 模板是纯字符串,没有任何类型信息,而我们的业务逻辑已经用 TypeScript 写了。如何把两者连接起来?
我并不是一开始就想好了所有方案,而是一步步被真实需求推着往前走的。下面按实际开发历程来讲。
第一步:先解决类型从哪来的问题
最初的目标很简单:在 HTML 模板的 {{ ctrl.userName }} 上提供自动补全和类型提示。表达式本身就是合法的 JS 属性访问,不需要特殊解析------但关键问题是,TypeScript 的类型信息怎么拿到?
这里有一个方案选型的思考过程。
方案一:自己启动一个 TypeScript 编译器
最直觉的想法是在扩展中直接调用 ts.createProgram(),自己建一个 TypeScript 程序实例来做类型分析。但很快就会发现几个严重问题:
- 内存翻倍:VS Code 已经通过内置的 tsserver 为项目维护了一整套 AST 和类型信息。再起一个等于把整个项目的类型图在内存里复制一份,对于大型项目这是不可接受的。
- 编辑不同步 :用户在编辑器里改了代码还没保存,
createProgram()只能读磁盘上的旧文件。tsserver 维护了内存中的编辑缓冲区,能实时反映用户的修改。 - 重复造轮子 :
tsconfig.json的解析、项目引用的处理、文件监听和增量编译......这些 tsserver 都已经做好了,自己搞一套成本很高。
方案二:利用 VS Code 已有的 API
VS Code 提供了 vscode.executeCompletionItemProvider、vscode.executeHoverProvider 等命令,可以请求内置 TypeScript 扩展返回补全和悬停信息。但问题是:
- 这些 API 只对
.ts/.js文件生效,对.html文件不会返回 TypeScript 的类型信息。 - 即使通过虚拟文档等技巧绕过文件类型限制,返回的也是展示层的数据(字符串形式的标签、文档),而不是结构化的类型对象。我需要的是
ts.Type,需要能调用.getApparentProperties()(获取所有属性)、.getCallSignatures()(获取函数签名)、.getNumberIndexType()(获取数组元素类型)等方法来逐层深入。
方案三(最终选择):写一个 TypeScript Server Plugin,"住进" tsserver 里面
TypeScript 提供了一个官方机制:TypeScript Server Plugin。通过这个机制,我可以把自己的代码注入到 tsserver 进程中运行,直接访问它内部的 Program 和 TypeChecker 对象------不需要额外的内存,天然与用户编辑同步。
// TypeScript Server Plugin 的入口
function init(modules: { typescript: typeof import('typescript') }) {
return {
create(info: ts.server.PluginCreateInfo) {
// info.project['program'] 就是 tsserver 当前维护的 Program
// 通过它可以拿到 TypeChecker,进行任意类型查询
}
};
}
这是三个方案中唯一能同时满足"零额外内存"、"实时同步编辑"、"完整类型 API 访问"的方案。代价是------它运行在 tsserver 进程中,而我的 VS Code 扩展运行在扩展宿主进程中,两者之间没有直接的调用接口。
第二步:搭建跨进程通信的桥梁
选择了 TypeScript Server Plugin 方案后,新的难题来了:扩展和插件运行在两个完全隔离的进程中,怎么让它们对话?
这个问题困扰了我相当长时间。我研究了各种可能的方案,也看了其他插件是怎么做的:
方案一:走 tsserver 的标准协议? 行不通。VS Code 严格管控了与 tsserver 之间的通信------标准协议中没有为插件预留自定义请求/响应的通道,多余的数据无法添加和返回。官方提供了一个 configurePlugin API,但它是严格单向的:只能从扩展向插件传配置,插件无法返回任何数据。
方案二:参考 Volar(Vue 语言工具)的 Request Forwarding? Volar 借助 VS Code 内部的 typescript.tsserverRequest 命令,通过 Language Server 作为中间层转发请求。方案很优雅,但它依赖一个独立的 LSP 服务器层,对我的场景来说太重了。
方案三:在 TS Plugin 中启动一个 HTTP Server。 研究了日本开发者做的 ts-type-expand 等项目后,我发现这条路最简单、限制也最少------插件在 tsserver 进程内启动一个 HTTP 服务,扩展通过 configurePlugin 把端口号传过去,然后作为 HTTP Client 发请求获取类型数据。
早期版本就是用的这个方案:TS Plugin 是 Server,VS Code 扩展是 Client。 简单直接,很快就跑起来了。
但随着使用,一个问题开始频繁出现:tsserver 会因为各种原因重启 (tsconfig.json 变更、TypeScript 版本切换、内存压力等),每次重启都意味着插件进程被销毁,HTTP Server 随之消失,扩展侧的连接断掉。虽然可以加重试逻辑,但扩展侧并不知道 tsserver 何时重启完成,轮询检测既浪费又不可靠。
于是我想:能不能把 Server 和 Client 的角色反过来?
如果扩展侧是 Server,它的生命周期是稳定的(只要 VS Code 窗口在就不会消失)。TS Plugin 重启后,主动作为 Client 重连上来------这个方向的重连逻辑就简单多了,因为 Plugin 加载时一定会触发 onConfigurationChanged,在这个回调里发起连接就行。
同时,角色反转后,HTTP 的请求-响应模式就不太合适了(Server 在扩展侧,但发起"请求"的也是扩展侧)。WebSocket 的全双工通信天然适合这种场景------连接建立后,双方可以自由收发消息,不再受"谁是请求方"的约束。
最终的架构演化成了这样:
VS Code 扩展
│ Node.js IPC (process.send)
▼
RPC 服务进程(扩展 fork 出的子进程,WebSocket Server)
│ WebSocket
▼
TypeScript 插件(运行在 tsserver 中,WebSocket Client)
中间多出一个 fork 的子进程,是因为 WebSocket Server 需要一个稳定运行的宿主。扩展通过 configurePlugin 把端口号传给插件,插件加载后主动连上来。tsserver 重启?没关系,插件重新加载后会自动重连,整个过程对用户无感。
到这一步,通信通道打通了。但具体怎么从 TypeScript 的类型系统里"挖"出类型信息呢?
以 ctrl.user.name 这个表达式为例。Plugin 收到请求后做了两件事:
第一件:找到 ctrl 对应的控制器类型。 Plugin 启动时会扫描项目中所有源文件,找到 .component('myComp', { controller: MyController, controllerAs: 'ctrl' }) 这样的注册语句并缓存起来。当请求到来时,通过 controllerAs 的值('ctrl')匹配到对应组件,再用 TypeChecker 从 controller: MyController 这个 AST 节点上取出类型。这里有个小坑:getTypeAtLocation() 返回的是 typeof MyController(构造函数类型),而不是实例类型,需要再通过 getDeclaredTypeOfSymbol() 转换成实例类型。
第二件:沿着属性链逐层查询类型。 把表达式字符串 ctrl.user.name 用 ts.createSourceFile() 解析成一棵 AST(纯内存操作,不涉及磁盘),然后按 AST 结构逐层向下:
ctrl → 直接使用控制器实例类型(rootType)
ctrl.user → rootType.getProperty("user") → 得到 User 类型
ctrl.user.name → User类型.getProperty("name") → 得到 string 类型
每一步都调用 TypeChecker 的 getTypeOfSymbolAtLocation() 获取真实类型,支持泛型推导、联合类型、函数返回值等所有 TypeScript 类型系统能力。
这个"临时 AST 定结构,真实类型图查类型"的两步法,就是整个插件类型解析的核心。早期版本的补全、悬停、跳转定义就是这样实现的。
第三步:被迫手写解析器------当 AngularJS 语法超出 JavaScript 的边界
随着功能往深处走,我开始遇到麻烦。
前面那套"把表达式直接交给 TypeScript 解析"的流程能跑起来,是因为 ctrl.userName 这类表达式本身就是合法的 JavaScript。但当我尝试支持 ng-repeat 和 Filter 时,这条路走不通了------AngularJS 模板中的表达式不是标准 JavaScript,而是 AngularJS 自己的表达式语言。它长得像 JS,但有关键差异:
|是 Filter(管道)操作符,不是位或:items | orderBy:'name'ng-repeat="item in items track by item.id"有自己独特的语法- 没有
var、let、function等声明语句 - 某些 JS 关键字(如
for、return)在这里是合法的标识符
这些语法直接丢给 ts.createSourceFile() 会解析出错。而我又不能放弃已有的类型查询流程------那是整个插件的核心能力。
所以解析器的定位很明确:它不是要替代 TypeScript 做类型分析,而是作为一个"预处理层",把 AngularJS 的特殊语法解析理解后,从中提取出合法的 JavaScript 表达式,再交给已有的流程去查类型。 比如 items | orderBy:'name' 经过解析器处理后,插件知道这是一个 Filter 表达式,真正需要查类型的部分是 items;ng-repeat="item in ctrl.users track by item.id" 被解析后,插件能提取出 ctrl.users 作为集合表达式,item 作为迭代变量。
参考 AngularJS 源码中 $parse 的实现,我手写了一个完整的词法分析器和语法分析器 (ng-parser),有一些有趣的技术细节:
- 完整实现了 AngularJS 的表达式文法(定义在一份 CFG 语法规则中)
- 支持 Filter 表达式、ng-repeat、ng-controller 三种不同的解析入口
- 产出带有完整位置信息的类型化 AST,支持 Visitor 模式遍历
- 对
ng-repeat中的as和track by子句使用了正则预扫描 + 扫描范围收缩的技巧来避免歧义------先用正则找到as或track by的位置,然后把扫描器的结束位置设置在那里,这样表达式解析器就不会"越界"
第四步:巧妙的变量替换------让 TypeScript 理解 ng-repeat 的作用域
有了解析器之后,还有一个问题。考虑这样一段模板:
<div ng-controller="UserCtrl as ctrl">
<div ng-repeat="item in ctrl.users">
{{ item.name }}
</div>
</div>
当用户在 item.name 上悬停时,我们需要知道 item 的类型。但 item 是 ng-repeat 在运行时创建的作用域变量,TypeScript 并不认识它。
解决方案是一层变量替换 :把 item 替换成 ctrl.users[0]。这样 item.name 就变成了 ctrl.users[0].name------一个 TypeScript 完全可以解析和推导类型的合法表达式。
类似地,ng-repeat 的特殊变量也有对应的处理:
$index→ 直接标记为number类型$first、$last、$even、$odd→ 直接标记为boolean类型
当用户在 HTML 中触发补全时,完整的流程是:
- 扩展侧用 ng-parser 解析表达式,识别出光标位置的语义上下文
- 对 ng-repeat 等作用域变量进行替换,生成合法的 TypeScript 表达式
- 通过 RPC 发送给 tsserver 中的 TypeScript Plugin
- Plugin 用 TypeChecker 逐层解析类型(属性访问 → 函数调用 → 数组索引......)
- 结果原路返回,展示给用户
这就是整个插件的核心链路:解析 → 替换 → 查询 → 展示。 每一层解决一个特定的问题,最终让 HTML 模板中的 AngularJS 表达式获得了与 .ts 文件中同等质量的类型信息。
四、从 0 到 1.0:一年的迭代
回顾这个项目的 Changelog,从 2024 年 7 月的 v0.0.5 到 2025 年 7 月的 v1.0.0,经历了 18 个版本。功能是逐步叠加的:
| 阶段 | 版本 | 核心里程碑 |
|---|---|---|
| 起步 | v0.0.5 ~ v0.1.0 | 组件名补全、数据绑定的补全和类型提示 |
| 成长 | v0.2.0 ~ v0.5.0 | 跳转到定义、directive 支持、语法高亮 |
| 成熟 | v0.6.0 ~ v0.8.0 | 依赖注入校验、ng-repeat 支持、Filter 支持 |
| 完善 | v0.9.0 ~ v1.0.0 | 表达式诊断、函数签名提示 |
每个版本都是由真实的开发痛点驱动的。比如 ng-repeat 的支持,是因为团队日常大量使用列表渲染,没有 item 的类型提示严重影响效率。又比如依赖注入校验,是因为 AngularJS 的 DI 是基于字符串匹配的,参数顺序写错是非常隐蔽的 Bug。
五、AI 视角:如果今天重新来过
这个插件的开发始于 2024 年中,当时 AI 编程助手还没有今天这么强大。如果今天重新来过,有一些环节 AI 可以显著加速:
解析器开发:手写词法分析器和语法分析器是这个项目中最耗时的部分之一。如果有 AI 辅助,可以先描述语法规则,让 AI 生成初版解析器代码,再手动调优和补全边界情况,开发周期能大幅缩短。
测试用例生成:解析器需要大量的测试用例覆盖各种边界情况。AI 非常擅长根据语法规则生成多样化的测试输入,包括各种合法和非法的表达式组合。
HTML 模板分析:光标位置分析(判断光标在标签名上、属性名上、属性值上、模板表达式里......)涉及大量的条件分支和边界判断,这类"规则明确但情况繁多"的代码正是 AI 的强项。
但有些地方 AI 帮不了太多:整体架构设计(三层 RPC、变量替换策略)、TypeScript 内部 API 的摸索(很多没有文档,需要读源码)、以及那些只有在真实大型项目中才会暴露的 Edge Case。这些仍然需要开发者对问题域的深入理解。
我的体会是:AI 是优秀的"代码执行者",但架构和设计上的创造性思考,仍然是人的核心价值。
六、写在最后
面对遗留系统,开发者常常陷入两个极端:要么"忍着用",要么"推倒重来"。但其实还有第三条路------用工具化的思维,让当下变得更好。
一个 VS Code 插件不会让 AngularJS 变成 React,但它能让每天在这个代码库中工作的开发者少一些心智负担、多一些开发效率。有时候,解决问题的最好方式不是消灭问题本身,而是改善与问题共处的方式。
如果你也在维护类似的老项目,希望这篇文章能给你一些启发------不一定是去写一个 VS Code 插件,而是:面对不可改变的约束时,找到自己能改变的那个切入点。
ng-helper 是一个开源项目,欢迎体验和反馈:GitHub | VS Code Marketplace