拒绝“能跑就行“:为 AngularJS 1.x 老项目注入现代开发体验

当迁移框架的代价大到无法承受时,不妨换个思路------不迁移代码,迁移开发体验。

一、背景:一个"不敢动"的老项目

我们团队维护着一个庞大的 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.executeCompletionItemProvidervscode.executeHoverProvider 等命令,可以请求内置 TypeScript 扩展返回补全和悬停信息。但问题是:

  • 这些 API 只对 .ts/.js 文件生效,对 .html 文件不会返回 TypeScript 的类型信息。
  • 即使通过虚拟文档等技巧绕过文件类型限制,返回的也是展示层的数据(字符串形式的标签、文档),而不是结构化的类型对象。我需要的是 ts.Type,需要能调用 .getApparentProperties()(获取所有属性)、.getCallSignatures()(获取函数签名)、.getNumberIndexType()(获取数组元素类型)等方法来逐层深入。

方案三(最终选择):写一个 TypeScript Server Plugin,"住进" tsserver 里面

TypeScript 提供了一个官方机制:TypeScript Server Plugin。通过这个机制,我可以把自己的代码注入到 tsserver 进程中运行,直接访问它内部的 ProgramTypeChecker 对象------不需要额外的内存,天然与用户编辑同步。

typescript 复制代码
// 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.namets.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" 有自己独特的语法
  • 没有 varletfunction 等声明语句
  • 某些 JS 关键字(如 forreturn)在这里是合法的标识符

这些语法直接丢给 ts.createSourceFile() 会解析出错。而我又不能放弃已有的类型查询流程------那是整个插件的核心能力。

所以解析器的定位很明确:它不是要替代 TypeScript 做类型分析,而是作为一个"预处理层",把 AngularJS 的特殊语法解析理解后,从中提取出合法的 JavaScript 表达式,再交给已有的流程去查类型。 比如 items | orderBy:'name' 经过解析器处理后,插件知道这是一个 Filter 表达式,真正需要查类型的部分是 itemsng-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 中的 astrack by 子句使用了正则预扫描 + 扫描范围收缩的技巧来避免歧义------先用正则找到 astrack by 的位置,然后把扫描器的结束位置设置在那里,这样表达式解析器就不会"越界"

第四步:巧妙的变量替换------让 TypeScript 理解 ng-repeat 的作用域

有了解析器之后,还有一个问题。考虑这样一段模板:

html 复制代码
<div ng-controller="UserCtrl as ctrl">
  <div ng-repeat="item in ctrl.users">
    {{ item.name }}
  </div>
</div>

当用户在 item.name 上悬停时,我们需要知道 item 的类型。但 itemng-repeat 在运行时创建的作用域变量,TypeScript 并不认识它。

解决方案是一层变量替换 :把 item 替换成 ctrl.users[0]。这样 item.name 就变成了 ctrl.users[0].name------一个 TypeScript 完全可以解析和推导类型的合法表达式。

类似地,ng-repeat 的特殊变量也有对应的处理:

  • $index → 直接标记为 number 类型
  • $first$last$even$odd → 直接标记为 boolean 类型

当用户在 HTML 中触发补全时,完整的流程是:

  1. 扩展侧用 ng-parser 解析表达式,识别出光标位置的语义上下文
  2. 对 ng-repeat 等作用域变量进行替换,生成合法的 TypeScript 表达式
  3. 通过 RPC 发送给 tsserver 中的 TypeScript Plugin
  4. Plugin 用 TypeChecker 逐层解析类型(属性访问 → 函数调用 → 数组索引......)
  5. 结果原路返回,展示给用户

这就是整个插件的核心链路:解析 → 替换 → 查询 → 展示。 每一层解决一个特定的问题,最终让 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