1. 框架还在跑,支持已经停了:先看清现实
去年接手一个内部管理系统时,我在代码仓库里翻到了angular.min.js的引用,版本号停在 1.8.3,构建脚本里写着"angular": "^1.8.0"。当时第一反应不是"要不要重构",而是一个很实际的问题:这玩意儿现在从哪儿下载?构建机换了一台、CI 镜像重建了一次,npm install拉不下来,整个发布链路就断了。这大概是所有还在维护 AngularJS 老项目的团队都会撞上的第一堵墙——AngularJS 结束官方支持之后,框架代码获取这件事从"敲一行命令"变成了"需要提前规划资产归档"的工程问题;而一旦业务上又冒出必须修的需求,扩展支持就成了绕不开的活儿。
这篇文章想聊的就是这两件事:一是历史代码怎么稳稳拿到手并长期留住,二是当官方不再受理 issue、不再发补丁的时候,团队自己怎么建立一套能撑住的小型维护体系。适合的读者很明确——手上还养着一两个 AngularJS 前端项目、暂时没有重构预算、又必须保证线上能跑的技术负责人和一线开发。这里面有不少坑我自己踩过,包括构建机静默失败、补丁打重了导致内存泄漏、以及一次因为压缩文件版本对不上排查了整整两天的低级事故。
先摆正一个前提:这个框架的许可证是宽松的开源协议,官方停止维护不等于你不能用、不能改、不能分发。它只是从"有人替你把关"变成了"你自己替自己把关"。这两者之间差着一整套流程,而流程的价值远大于代码本身。
1.1 "结束官方支持"具体终结了哪些东西
很多人以为停止支持就是"代码不能下了",其实恰恰相反,代码一直在那儿,只是再也没人去动它了。真正终结的东西可以拆成四层来看,每一层对团队的影响完全不同。
第一层是功能演进停止。不会再有新特性、新指令、新的性能优化,也不会再有对新型浏览器能力的适配。这意味着你后面遇到的任何新问题,都得自己判断是新框架的锅还是浏览器的锅。
第二层是安全补丁停止。这是最要命的一层。官方不再主动审计漏洞,也不再为已发现的漏洞发版。如果哪天某个前端通用攻击手法被披露,而这个框架恰好受影响,不会有人给你一个 1.8.4。这一点必须写进团队的风险台账里,而不是嘴上说说。
第三层是生态兼容性衰减。周边的东西——构建插件、测试运行器、类型定义、UI 组件库——会陆续把最低支持版本抬上去,或者干脆删掉对老框架的适配代码。你今天能装上的某个工具,半年后升级一个主版本就装不上了。
第四层是官方渠道的可见性下降。文档站可能被折叠到归档目录,Issue 区被设为只读,问答社区的活跃度归零。这不影响代码运行,但严重影响新人上手和问题排查效率。
把这四层分清楚,后面的策略才有针对性:第一、四层靠"归档 + 内部文档"解决,第二层靠"自建补丁流程"解决,第三层靠"依赖冻结"解决。三件事的做法完全不同,混在一起谈就会陷入"到底要不要重写"的无休止争论。
1.2 为什么大多数团队短期内还得留着它
我在几个不同规模的团队里都观察到一个共同现象:喊重构的声音很大,真正落地的时间点却一拖再拖。原因不是懒,而是这类老项目的重构收益和风险严重不对称。
一个跑了五六年的后台系统,页面可能有上百个,业务规则藏在各种$scope.$watch和自定义指令里,没有单元测试,需求文档早就过时。这种系统做整体迁移,风险不在于"新技术学不会",而在于行为等价性无法验证。老系统里那些没人记得为什么这么写的边界逻辑,迁移时一旦漏掉一个,就是线上事故。
反过来看,保留它的成本其实可控:代码不动就不出问题,浏览器不瞎升级就不出问题,依赖不瞎升级就不出问题。真正需要投入的,只是把"获取代码"和"打补丁"这两条路径修好,让它变成一个可自持的系统。我的经验是,这套自持机制搭起来大概需要一到两周的人力,之后每年维护成本很低,这笔账比全面重构划算得多。
所以下面的内容都建立在这个判断上:短期不迁移,先把它变成一个自己能管住的资产。
2. 框架代码从哪里获取:四条路径与优先级
代码获取这件事,看起来是一句话的事,实际上不同路径的可靠性、完整性、可审计性差别很大。我按推荐优先级从高到低排了四条,实际项目中建议至少同时保住前两条。
2.1 路径一:包管理器中的历史版本(首选)
公共包管理仓库里的历史版本是永久可下载的,这是最稳、最标准的一条路。框架在这个生态里的包名就是angular,和后来那个同名不同姓的新框架不是一回事,这一点新人特别容易搞混,我见过有人装错了包排查半天。
几个常用操作,直接抄:
# 查这个包有哪些历史版本 npm view angular versions --json # 确认最新(也是最终)版本号 npm view angular dist-tags # 只下载压缩包,不安装,用于归档 npm pack angular@1.8.3 # 精确安装到项目,锁死版本 npm install angular@1.8.3 --save-exactnpm pack拿到的.tgz文件建议直接提交到内部制品库,或者放进仓库的vendor/目录。这个包体很小,几百 KB 级别,存一份成本可以忽略,但关键时刻能救命。
包解开之后,里面的文件结构值得记一下,因为后面打补丁、做按需加载都要用到:
| 文件 | 用途 | 是否常用 |
|---|---|---|
angular.js | 未压缩主文件,含调试信息 | 开发环境 |
angular.min.js | 压缩版主文件 | 生产环境 |
angular.min.js.map | Source Map | 线上排错 |
angular-route.js | 路由模块 | 单页应用必备 |
angular-animate.js | 动画模块 | 按需 |
angular-resource.js | REST 资源封装 | 按需 |
angular-sanitize.js | HTML 净化 | 有富文本就要 |
angular-messages.js | 表单校验消息 | 表单多的话要 |
angular-cookies.js | Cookie 读写封装 | 按需 |
angular-mocks.js | 测试替身 | 只在测试环境 |
注意angular-mocks.js千万别混进生产构建,它体积不小,而且会往全局挂一些调试用的东西。我在一个项目里见过它被打进主包,白白多了几十 KB。
2.2 路径二:代码托管平台的标签与发布归档
如果包管理器哪天出问题(虽然概率很低),第二道保险是代码托管平台上的版本标签和发布归档。这类平台上的仓库通常保留了完整的历史标签,对应的源码压缩包和发布附件也是永久可取的。
我建议的做法不是等出事再去下,而是现在就把完整归档做下来,内容包括三样:源码压缩包、构建产物(就是上一节那些 js 文件)、以及文档站的静态快照。文档站一定要存,因为这个框架的文档是纯前端渲染的单页应用,官方站点以后万一关掉,你连$compile有哪些参数都查不到,只能去翻源码。
归档目录我通常这样组织:
vendor/angularjs-1.8.3/ ├── dist/ # 构建产物,直接用 ├── src/ # 源码,打补丁时用 ├── docs/ # 文档静态快照 ├── types/ # 类型定义(如果项目用 TS) ├── package.tgz # 原始包文件 ├── BUILD-ENV.md # 构建环境说明,见下一节 └── SHA256SUMS # 所有文件的校验和最后那个校验和文件不是形式主义。我有一次在传输过程中碰到过一个文件被截断,min.js少了几百字节,页面加载时报的错完全指向别的地方,查了很久。后来养成习惯,所有归档文件都生成一遍 sha256,加载前或构建时校验一次,几秒钟的事,能省掉一整天的排查。
2.3 路径三:公共 CDN 与本地化落地
很多老项目是在index.html里直接写<script src="...">引 CDN 的。这种做法在生产环境上其实一直有隐患:一旦 CDN 侧调整了文件路径策略,你的页面就直接白屏,而且这种故障往往没有任何预兆。
我的立场很明确:可以在开发和演示环境用 CDN 图省事,生产环境必须本地化。理由有三个——可用性不依赖第三方、构建过程可复现、版本一定对得上。
如果一时改不完,至少要做两件事。第一,给<script>标签加上子资源完整性校验属性,把文件的哈希值写上,这样即使 CDN 上的文件被替换,浏览器也会拒绝加载而不是悄悄执行;第二,在页面上加一个加载失败的回退逻辑,检测全局对象是否存在,不存在就换成本地副本。
<script src="https://公共CDN地址/angular.min.js" integrity="sha384-此处填写真实哈希值" crossorigin="anonymous"></script> <script> if (typeof window.angular === 'undefined') { document.write('<script src="/vendor/angular.min.js"><\/script>'); } </script>这段回退写法有点土,但在老项目里特别管用,改造成本几乎为零。哈希值怎么来?对你本地归档的那份文件跑一遍openssl dgst -sha384 -binary angular.min.js | openssl base64 -A,把结果拼上sha384-前缀就是。
2.4 路径四:内部制品库与构建环境镜像
前面三条都是"拿到文件",这一条是"让它一直能拿到"。团队规模稍微大一点,就应该在内部制品库(私有仓库代理)里给这个包建一个永久保留的策略,禁止任何自动清理规则把它扫掉。
这件事非常容易被忽略。默认的制品库策略通常是"保留最近若干版本"或者"90 天未访问就清理",而一个稳定的老项目可能半年都不装一次依赖,正好落在清理窗口里。等到某天 CI 重建,依赖没了,你才发现。
同样要固化的还有构建环境本身。这是一个特别隐蔽的坑:这个框架当年的源码构建依赖的是很老的工具链,在那个年代的 Node 版本上跑得好好的,换到现在的新版本 Node 上,光是装依赖就会因为各种老插件的编译问题失败一片。等你真的需要从源码重新构建一个补丁版本时,会发现根本构建不出来。
解决办法是把构建环境做成容器镜像,里面固定 Node 版本、固定包管理器版本、固定全局工具版本,然后把这个镜像推到内部镜像仓库长期保存。镜像里顺便把node_modules也打进去,彻底绕开安装环节。这个镜像平时不用,一年可能就用两三次,但每次都能把你从"补丁写完了却打不出包"的绝境里捞出来。
3. 依赖冻结:把"能用"变成"一直能用"
代码拿到手只是第一步。真正让老项目稳定的,是一套把版本、文件、环境全部钉死的冻结策略。这一节讲具体怎么做,都是可以直接照着改的。
3.1 精确版本与锁文件,一个都不能少
package.json里的版本号前面那个^符号,在老项目里是纯风险源。^1.8.0允许装 1.8.x 里的任意版本,理论上无害,但一旦你的私有源上某个版本被替换过、或者某个补丁版本的构建产物有细微差异,问题就会以最难查的方式出现。
正确写法是去掉所有范围符号,全部精确到补丁号:
{ "dependencies": { "angular": "1.8.3", "angular-route": "1.8.3", "angular-sanitize": "1.8.3" } }同时把锁文件提交进仓库,并且在 CI 里用"严格按锁文件安装"的模式,不要让流水线自己去解析版本范围。锁文件里记录了每个包的完整性哈希,这一层校验比版本号可靠得多。
顺带说一件常被忽略的事:同一个项目里所有angular-*模块的版本必须完全一致。主包是 1.8.3,路由模块是 1.7.9,这种混搭在某些场景下不报错,但会在一些边缘行为上产生难以复现的差异,比如路由解析的时序、动画的触发时机。我现在的做法是在构建脚本里加一个检查,不一致就直接让构建失败。
3.2 依赖资产的完整清单
光冻结框架本身不够。老项目的依赖树往往很深,真正让构建挂掉的经常是外围的东西。我一般会整理一张"遗产依赖清单",格式如下,放进仓库根目录,每次动构建相关的东西都对着看一遍。
| 资产类型 | 具体内容 | 冻结方式 | 检查频率 |
|---|---|---|---|
| 框架本体 | 主包及各功能模块 | 精确版本 + 归档 tgz | 每次构建 |
| 类型定义 | 对应的 TypeScript 声明包 | 精确版本 + 归档 | 每次构建 |
| 构建工具 | 打包器、转译器、压缩工具 | 镜像固化 | 每季度 |
| 测试运行器 | 测试框架及其适配层 | 镜像固化 | 每季度 |
| UI 组件库 | 依赖框架的第三方组件 | 精确版本 | 每次构建 |
| 浏览器目标 | 需要支持的浏览器及版本 | 写进文档 | 每半年 |
这张表的价值在于,它把"隐性依赖"变成了"显性资产"。团队里任何人接手,看这张表就知道哪些东西不能随便升级,以及为什么。
3.3 离线可用性的验证方法
冻结做完了不代表真的能离线跑。你验证过一次才有底气。验证方法很简单,但必须定期做:
先把网络出口断掉(或者在容器里禁用外网),然后从零开始走一遍完整的构建流程,包括装依赖、跑测试、出构建产物。整个过程如果不需要访问任何外部地址,说明冻结到位了。
我第一次做这个验证时,发现有三处隐性外网依赖:一个构建插件会去拉浏览器列表数据、一个压缩工具会下载字典文件、还有一个测试工具会去取远程配置。这三处平时完全无感,一旦内网环境变了就会集体失效。后来全部改成内网镜像或者本地固定文件。
建议把"断网构建"做成 CI 里的一条独立流水线,每周跑一次。它不验证业务逻辑,只验证可复现性,挂了就说明资产归档出了缺口。
4. 扩展支持需求怎么处理:先分类,再动手
到这一节才是真正麻烦的部分。官方不再接需求了,但业务还在跑,总会冒出必须改的东西。我的经验是,先别急着写代码,先给需求分类,因为不同类型的需求,处理方式、风险等级、审批流程完全不一样。混着处理一定会出事。
4.1 三类需求与对应的处理策略
我一般把扩展需求分成三类,每类的处理原则如下表:
| 类型 | 典型场景 | 风险等级 | 推荐做法 |
|---|---|---|---|
| 缺陷修复 | 某个指令在特定条件下不工作 | 中 | 运行时补丁优先,改源码兜底 |
| 兼容适配 | 新浏览器报错、新 CSP 策略被拦 | 中高 | 配置层解决,避免改内核 |
| 功能增强 | 业务要求新增交互能力 | 低 | 在业务层实现,不碰框架 |
关键判断标准只有一条:能不能在业务代码里解决。能,就绝对不要动框架。这是我在吃过亏之后定下的铁律。
举个例子。有个项目需要支持更严格的内容安全策略,页面上不允许内联脚本执行。这个框架的表达式编译机制依赖动态构造函数,在严格策略下会直接被浏览器拦住,报的错是"拒绝执行内联脚本"这类看起来很莫名其妙的信息。第一反应是去改框架源码,把表达式编译改成别的实现——这个工作量极大,而且几乎必然会引入新问题。
正确的做法在配置层:框架本身提供了一个兼容严格策略的模式,在启动前告诉它不要用内联方式,改从页面元素的属性里读配置数据。改完之后问题解决,一行代码都没碰框架内部。这就是典型的"兼容适配在配置层解决"。
4.2 分叉管理:怎么改才不失控
确定必须改框架之后,第一件事是建立一条清晰的维护分支,而不是直接在压缩文件上手改。后者听起来荒唐,但我真的见过。
我的做法是在内部代码托管平台上做一份完整分叉,分支结构设计成这样:
upstream分支:只保存原始版本,一个字符都不改,作为对照基线internal/main分支:所有补丁都打在这里- 每个补丁单独一个提交,提交信息里必须写清楚:问题现象、影响范围、修改点、验证方式
- 每次发布打标签,格式建议
internal-1.8.3-01这样,末尾数字是补丁序号
这套规矩看起来有点重,但它的核心价值是可回滚和可审计。半年后有人问"这个文件为什么和原版不一样",你打开提交记录一目了然;哪天某个补丁引发新问题,单独回退那一个提交就行,不用整体重来。
补丁文件本身也要控制粒度。我的原则是最多改一处逻辑,改完立刻跑一遍完整回归。绝不搞"既然都改了不如顺手把这块也优化一下",这种顺手改动是老项目最大的杀手。
4.3 打补丁的三种姿势与选择依据
具体怎么把补丁生效,有三种方式,按侵入程度从低到高排:
第一种,运行时装饰。框架提供了服务装饰机制,可以在不修改源码的前提下替换某个服务的行为。这是最安全的方式,因为它只影响你声明的那个点,而且完全在业务代码里,构建流程一行都不用改。
// 用装饰器包住原有服务,注入额外行为 app.config(['$provide', function ($provide) { $provide.decorator('$log', ['$delegate', function ($delegate) { var originalWarn = $delegate.warn; $delegate.warn = function () { var args = Array.prototype.slice.call(arguments); args[0] = '[内部] ' + args[0]; originalWarn.apply($delegate, args); }; return $delegate; }]); }]);这种写法在修日志、加埋点、加超时控制这类需求上特别合适。判断标准:如果你能在业务代码里写出这段逻辑,就不要改框架文件。
第二种,构建期替换。把框架的某个文件替换成自己构建的版本,或者用打包工具的别名机制指向自己的实现。适合那种必须改内核、但改动点很集中的情况。
这种方式的坑在于构建产物的一致性。源码改了要重新构建,而这个框架的构建工具链非常老,前面说过必须用固定镜像。我第一次自己构建时,输出的压缩文件和官方文件在字节层面差异很大(压缩工具的版本不同),导致 Source Map 对不上,线上报错的堆栈全是乱的。后来做法是:自己构建的版本统一不做压缩,或者压缩后重新生成 Source Map,别拿官方 Map 硬套。
第三种,直接改压缩文件。明确说,除了极端的应急情况(比如线上正在被攻击,需要立刻止血),不要用这种方式。改完之后没有任何可追溯性,下次换人接手时完全看不懂。
5. 自建安全响应:没人发补丁了怎么办
这一节可能是全文最不讨喜但最重要的部分。官方停止发版之后,安全防护的责任完全转移到团队自己身上。指望"我们内网系统没人盯上"是不现实的,攻击行为很多时候是自动化的、无差别的。
5.1 建立一张自己的风险地图
第一步不是写代码,而是把风险点列清楚。我通常从四个维度做盘点:
输入到输出的路径。任何把用户输入直接塞进 HTML 的地方都是高危。这个框架提供了 HTML 净化模块,但这个模块本身也需要正确使用——有些团队为了图方便,直接绕过净化,用最原始的绑定方式插入内容。我在一次代码审计中看到过一个页面,路由参数直接拼进了 HTML,任何能构造链接的人都能注入脚本。
数据合并与对象扩展的路径。老版本框架里有一些深合并工具函数,在处理来自外部的 JSON 时可能存在原型链污染的风险。审计时重点看:有没有把外部数据直接丢进深合并函数。
第三方依赖。这一条最容易被忽略。老项目往往同时引了旧版本的通用库,框架本身没问题,但那些库有已知问题。做法是把所有前端依赖列出来,对照公开的漏洞库逐条核查,该升级的升级,升不动的就在外层加防护。
拼接与求值。框架的表达式求值机制本质上是动态执行,如果某个页面的表达式字符串里混入了用户可控内容,风险等级非常高。审计时搜一遍有没有用变量拼接表达式的写法。
5.2 补丁的验证标准
自己打的补丁,验证标准要比官方发版还严,因为没有第二道把关。我定的最低要求是三条:
第一,有可复现的问题样例。修之前先写一个能稳定复现的最小页面,修完之后这个页面表现正确。没有复现样例的补丁不合并。
第二,有回归清单。改动点所在模块的所有已知用例跑一遍,加上受影响模块的冒烟用例。老项目通常测试覆盖很低,这时候就靠人工整理的冒烟清单,哪怕只有二十条,也比凭感觉强。
第三,有回滚方案。补丁发布前想清楚:如果上线后出问题,多长时间能退回原版?退回后原来的问题会不会重新暴露、能不能接受?这两个问题答不上来就别发。
6. 常见问题与排查技巧实录
下面这些是我和周围同行实际踩过的坑,按"现象—原因—处理"整理成速查表,遇到问题先扫一遍,能省不少时间。
6.1 高频问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 装依赖报 404,包名明明没错 | 私有源未代理该包或代理规则过期 | 检查源配置,改从归档 tgz 装 |
| 页面白屏,控制台无报错 | 压缩文件被截断或版本不一致 | 校验文件哈希,比对版本号 |
| 报"模块未定义" | 主文件引入顺序错误或模块文件缺失 | 检查 script 顺序与模块清单 |
| 页面卡死,风扇狂转 | 监听表达式里做了对象比较或函数调用 | 改为监听基本类型,函数结果缓存 |
| 新浏览器下功能失效 | 严格策略拦截了动态求值 | 启用兼容模式,调整策略配置 |
| 内存持续增长 | 监听未注销、定时器未清理 | 在销毁钩子里统一注销 |
| 测试跑不起来 | 测试环境与运行环境版本不匹配 | 用固定镜像跑测试 |
| 构建耗时突然翻倍 | 压缩工具在新 Node 上降级运行 | 切回固定版本的构建环境 |
表格之外,还有几个不那么"标准化"的经验,我觉得更值钱。
6.2 三条踩出来的实操心得
第一条:不要相信"没改过所以没问题"。老项目最危险的不是改动,而是长期没人碰。我有一次接手一个两年没动过的项目,本地跑得好好的,发到新环境的容器里就白屏。查下来是新基础镜像的默认字符集和之前不一样,某个中文资源文件的解析出了偏差,最终影响到框架的模块注册顺序。这个链条绕得离谱,但根因就是"环境变了,代码没变"。所以每次换环境都要跑一遍完整回归,别假设它是稳定的。
第二条:所有补丁都要写"为什么",不要只写"改了什么"。补丁说明里如果只写"修改了某函数返回值",半年后没人知道能不能删。写成"因为某浏览器在该场景下不触发某事件,导致列表不刷新,故在此处补一次手动刷新"——这样的说明即使代码看不懂,也知道意图和边界,后面的人敢动。
第三条:把"降级"当成一等公民。我现在的习惯是,任何对框架层的改动,都同时准备一个开关,可以一键关掉回到原始行为。开关放在配置里,不需要重新构建。这个习惯救过我一次——某个补丁在小流量环境验证完全正常,全量上线后在一个特定操作序列下引发了死循环,因为开关的存在,五分钟就恢复了,不用紧急回滚版本。开关的成本很低,收益极高。
6.3 迁移这件事,什么时候该认真考虑
最后聊一句迁移的判断标准。我不建议用"框架老了"这种感性理由推动迁移,也不建议用"还能跑就别动"这种鸵鸟心态拖下去。比较务实的信号有这么几个:安全审计出现无法修复的高危项、核心业务要在移动端做重交互、团队里已经没人愿意接手这块代码、以及招聘时这块成为明显阻力。
在信号出现之前,把前面说的资产归档、依赖冻结、补丁流程三件事做扎实,这套系统完全能再稳稳跑好几年。我自己维护过的最久的一个项目,从停止支持到现在又跑了三年多,中间只打过两次补丁,一次是浏览器策略适配,一次是日志埋点需求,都是运行时装饰解决的,框架文件一个字节没动。
真正让人安心的从来不是"官方还在维护",而是"我知道出了问题该去哪儿找、该怎么改、改完怎么退回来"。这套东西建起来之后,框架停不停支持,其实就没那么重要了。