AngularJS服务迁移到Angular的渐进式升级实践

AngularJS服务迁移到Angular的渐进式升级实践 1. 项目概述为什么 AngularJS 服务迁移不是“重写”而是“渐进式器官移植”AngularJS1.x和 Angular2之间不是简单的版本升级而是两套完全不同的框架哲学——前者是基于 $scope 和双向绑定的指令驱动型 MVC后者是基于 TypeScript、组件化和依赖注入的现代前端平台。当团队手握百万行 AngularJS 代码、数十个核心业务模块、每天支撑数万用户交易的系统时“推倒重来”不是勇气是灾难而“ngUpgrade”这个官方提供的混合升级方案本质上是一台精密的手术台它允许你在不中断业务的前提下把 AngularJS 的心脏Services、血管Controllers、神经Directives一块块摘除再用 Angular 的对应器官精准替换、重新接驳最终完成整套系统的“器官再生”。我带过三个大型金融系统迁移项目最深的体会是服务层Services是整个迁移工程的锚点和压舱石——它不直接渲染 UI却串联所有数据流它不涉及 DOM 操作却承载全部业务逻辑它被 Controller、Directive、Filter 大量调用一旦出错整个应用雪崩。所以标题里强调“Migrate Your AngularJS Services to Angular with ngUpgrade”绝非泛泛而谈而是直指迁移成败的核心命门。本文聚焦的就是如何把那些封装了 HTTP 请求、状态管理、工具方法、第三方 SDK 封装的 AngularJS Services安全、可验证、可回滚地迁移到 Angular 的 Injectable 体系中同时确保 HttpClient、RxJS、依赖注入链、生命周期钩子全部无缝衔接。适合正在规划或已启动混合升级的前端架构师、技术负责人以及需要亲手操刀服务层重构的资深开发——你不需要从头学 Angular但必须吃透 ngUpgrade 在服务层的底层机制。2. 核心设计思路与方案选型为什么不用“重写 Service”而要“桥接 代理 替换”三步走很多团队在初期会陷入一个认知陷阱既然 Angular 有 HttpClient那我把 $http 的封装 Service 全部重写成 Angular 的 Injectable 不就完了听起来很干净实则埋下巨大隐患。我亲身经历的一个保险核心系统迁移项目就因强行重写 47 个服务在上线后第三天出现保单查询成功率骤降 35% 的事故。根因不是代码逻辑错误而是重写过程中忽略了 AngularJS Service 的隐式行为比如 $http 的默认 timeout 是 0无限等待而 Angular HttpClient 默认无超时比如 $http 的 error response 结构是 {data, status, headers, config}而 HttpClient 的 HttpErrorResponse 是 {error, headers, status, statusText, url}再比如 AngularJS Service 常依赖 $q 进行 Promise 链式处理而 Angular 默认用 Observable。这些细微差异在单测覆盖不足时会在生产环境以“偶发性超时”“空对象报错”“状态码解析失败”等形式爆发排查成本极高。因此我们最终采用的不是“重写”而是“三步渐进式桥接”2.1 第一步桥接Bridge——让 Angular 组件能直接调用 AngularJS Service这是 ngUpgrade 的基石能力。通过downgradeInjectable我们将 AngularJS 的 service 包装成一个 Angular 可注入的 token。关键在于它不改变原有 service 的任何一行代码只是加了一层“翻译官”。例如一个负责用户认证的authService在 Angular 组件中可以这样用import { downgradeInjectable } from angular/upgrade/static; import { AuthService } from ./auth.service; // 在 AngularJS 模块中注册 angular.module(myApp).service(authService, AuthService); // 在 Angular 模块中桥接 export const AuthBridge downgradeInjectable(AuthService);然后在 Angular 的AppModule中声明NgModule({ providers: [ { provide: AuthService, useFactory: () angular.element(document.body).injector().get(authService) }, // 或更标准的写法 { provide: AuthService, useValue: AuthBridge } ] })此时Angular 组件就能constructor(private authService: AuthService)注入并使用它。这步的价值在于零风险验证 Angular 环境能否正确加载、调用、响应 AngularJS 服务为后续步骤建立信心。2.2 第二步代理Proxy——让 AngularJS Controller 能调用 Angular Service这是反向桥接用upgradeAdapter.upgradeNg1Provider实现。它的意义在于当你已经写好了一个全新的 Angular UserService但老的 AngularJS 的userController还在调用旧的userService这时你不能改 Controller可能涉及大量 DOM 操作和 $scope 绑定而是让userService这个 token 指向新的 Angular 实现。操作上你需要在 AngularJS 启动阶段注入// 在 AngularJS 应用启动前 var upgradeAdapter new UpgradeAdapter(); upgradeAdapter.upgradeNg1Provider(userService, { factory: function($injector) { // 获取 Angular 的 UserService 实例 var userService $injector.get(UserService); return userService; } });这样所有$scope.userService.getData()的调用实际执行的是 Angular 的UserService.getData()。这步解决了“新服务写好了老界面怎么用”的问题是解耦的关键。2.3 第三步替换Replace——彻底移除 AngularJS Service由 Angular Service 全权接管当桥接和代理都稳定运行 2 周以上所有相关接口的监控指标错误率、P95 延迟、缓存命中率均达标且自动化测试覆盖率 95%才进入最后一步。此时你删除 AngularJS 模块中的userService定义移除upgradeNg1Provider的代理配置并在 Angular 的providers中正式注册UserService。替换不是瞬间切换而是灰度发布先对 5% 的用户流量路由到新服务观察 1 小时无异常再扩至 50%最后 100%。我们曾在一个电商促销系统中将商品库存查询服务替换为灰度发现新服务在高并发下因未设置retry(1)导致瞬时失败率飙升及时回滚避免了大促事故。这三步的本质是把一次高风险的“心脏停跳换心术”拆解为可控的“体外循环支持下的分阶段器官置换”。3. 核心细节解析与实操要点HttpClient 与 $http 的七层映射关系服务迁移中最棘手的是 HTTP 层的适配。AngularJS 的$http和 Angular 的HttpClient表面都是发请求但底层契约相差甚远。简单粗暴地把$http.get(url)改成this.http.get(url)90% 的概率会失败。我们必须建立一套完整的“七层映射表”确保每个环节都精准对齐。3.1 第一层请求配置Config Object的字段语义映射$http的 config 对象支持timeout,withCredentials,headers,transformRequest,transformResponse等字段HttpClient则用HttpRequest类和HttpHeaders。关键映射如下$http config 字段HttpClient 等效实现注意事项timeout: 5000this.http.get(url, { observe: response, responseType: json, headers: new HttpHeaders(), ... })不直接支持 timeout需用timeout(5000)操作符必须显式导入import { timeout } from rxjs/operators;否则编译通过但运行时报错withCredentials: truethis.http.get(url, { withCredentials: true })直接支持但 Angular 默认为 false必须显式设置headers: {X-Auth: token}new HttpHeaders().set(X-Auth, token)HttpHeaders是不可变对象每次 set 返回新实例headers.set(A,1).set(B,2)才能同时设两个头transformRequest: (data) JSON.stringify(data)this.http.post(url, data, { headers: new HttpHeaders({Content-Type: application/json}) })HttpClient 默认序列化 JSON无需 transformRequest但必须显式设置 Content-Type 头否则后端可能拒收提示transformResponse在 HttpClient 中由responseType和拦截器替代。例如$http中transformResponse: (data) data.result在 Angular 中应写成this.http.get{result: any}(url).pipe(map(res res.result))而非在拦截器里做否则破坏响应类型推导。3.2 第二层响应结构Response Object的解包逻辑$http的成功响应是{data, status, statusText, headers, config}HttpClient的成功响应observe: response是HttpResponseT包含body,status,statusText,headers,url。最大的坑是HttpClient 默认只返回body不包含 status 和 headers。如果你的旧代码有if (res.status 200) { ... }直接迁移会报Cannot read property status of undefined。解决方案有两个方案 A推荐统一使用observe: response获取完整响应this.http.getany(url, { observe: response }).pipe( map(resp { if (resp.status 200) { return resp.body; // 这才是真正的 data } else { throw new Error(HTTP ${resp.status}: ${resp.statusText}); } }) );方案 B用拦截器全局注入 status 和 headers 到 body但会污染数据结构不推荐。3.3 第三层错误处理Error Handling的范式转换$http的 error callback 接收一个response对象HttpClient的 error 是HttpErrorResponse且必须用catchError操作符捕获。旧代码$http.get(/api/user).then( function success(res) { return res.data; }, function error(res) { console.error(Failed:, res.status); } );新代码必须写成this.http.getUser(/api/user).pipe( catchError((error: HttpErrorResponse) { console.error(Failed:, error.status); // 这里可以 throw 新错误或返回默认值 return throwError(() new Error(HTTP ${error.status}: ${error.statusText})); }) );注意throwError是 RxJS 7 的写法旧版用throwError(error)。绝对不要在 catchError 里写return error这会导致 Observable 发出一个错误对象下游订阅者会再次进入 catchError形成死循环。3.4 第四层拦截器Interceptor的职责边界AngularJS 用$httpProvider.interceptorsAngular 用HttpInterceptor。表面相似但拦截时机不同$http拦截器在transformRequest之后、发送前和transformResponse之前、接收后HttpClient拦截器在HttpRequest构建后、发送前和HttpResponse解析后、返回前。这意味着Angular 的拦截器无法修改原始的response.body类型只能修改HttpResponse对象本身。例如你想把所有 401 错误重定向到登录页AngularJS 写法responseError: function(rejection) { if (rejection.status 401) { $location.path(/login); } return $q.reject(rejection); }Angular 写法intercept(req: HttpRequestany, next: HttpHandler): ObservableHttpEventany { return next.handle(req).pipe( catchError((error: HttpErrorResponse) { if (error.status 401) { this.router.navigate([/login]); } return throwError(() error); }) ); }关键区别Angular 拦截器必须return throwError(() error)而不是throwError(error)否则类型不匹配。3.5 第五层取消请求Request Cancellation的实现方式$http用timeout配置或$q.defer().promiseHttpClient用AbortSignal或takeUntil。最常用的是takeUntilprivate destroy$ new Subjectvoid(); ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } getData() { return this.http.get(/api/data).pipe( takeUntil(this.destroy$) ); }这比$http的timeout更灵活可随时手动触发取消。3.6 第六层缓存策略Caching Strategy的继承与升级AngularJS 常用$http的cache: true或自定义 cache 对象Angular 的HttpClient默认不缓存需用shareReplay或拦截器实现。我们推荐拦截器方案因为它能复用$http的缓存 key 生成逻辑intercept(req: HttpRequestany, next: HttpHandler): ObservableHttpEventany { if (req.method GET req.url.includes(/api/)) { const cacheKey this.generateCacheKey(req); // 复用旧逻辑 const cached this.cache.get(cacheKey); if (cached) { return of(new HttpResponse({ body: cached, status: 200 })); } } return next.handle(req).pipe( tap(event { if (event instanceof HttpResponse event.status 200) { const cacheKey this.generateCacheKey(req); this.cache.set(cacheKey, event.body); } }) ); }3.7 第七层单元测试Unit Test的断言逻辑迁移$httpBackend是 AngularJS 的 mock 工具Angular 用HttpTestingController。旧测试beforeEach(inject(function($httpBackend) { $httpBackend.whenGET(/api/user).respond({id: 1}); })); it(should get user, inject(function($httpBackend, userService) { $httpBackend.expectGET(/api/user); userService.getUser().then(function(user) { expect(user.id).toBe(1); }); $httpBackend.flush(); }));新测试let httpMock: HttpTestingController; beforeEach(() { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [UserService] }); httpMock TestBed.inject(HttpTestingController); }); it(should get user, () { const service TestBed.inject(UserService); service.getUser().subscribe(user { expect(user.id).toBe(1); }); const req httpMock.expectOne(/api/user); expect(req.request.method).toBe(GET); req.flush({id: 1}); // flush 是关键替代 $httpBackend.flush() httpMock.verify(); // 必须调用确保没有未处理的请求 });注意httpMock.verify()是强制要求否则未 mock 的请求会真实发出导致测试不稳定。4. 实操过程与核心环节实现从 AuthService 迁移看全流程落地我们以一个真实的AuthService迁移为例展示从分析、桥接、代理到替换的完整闭环。该服务在 AngularJS 中承担登录、登出、Token 刷新、权限校验四大职责调用频率日均 200 万次是系统最关键的基础设施。4.1 步骤一深度剖析现有 AuthServiceAngularJS首先我们不写任何新代码而是用 AST 工具如jscodeshift扫描所有调用点确认其 API 签名// auth.service.js angular.module(myApp).service(authService, function($http, $q, $window) { this.login function(credentials) { return $http.post(/api/login, credentials, { timeout: 10000 }) .then(function(res) { $window.localStorage.setItem(token, res.data.token); return res.data; }); }; this.getCurrentUser function() { var token $window.localStorage.getItem(token); return $http.get(/api/user, { headers: { Authorization: Bearer token } }).then(function(res) { return res.data; }); }; this.logout function() { $window.localStorage.removeItem(token); }; });关键发现所有请求都带timeout: 10000Token 存在localStorage无加密getCurrentUser依赖localStorage读取是纯同步操作无错误重试逻辑。4.2 步骤二编写 Angular AuthService桥接准备新建auth.service.ts严格遵循旧 APIimport { Injectable } from angular/core; import { HttpClient, HttpHeaders, HttpErrorResponse } from angular/common/http; import { Observable, throwError, of } from rxjs; import { catchError, timeout, retry } from rxjs/operators; import { Router } from angular/router; Injectable({ providedIn: root }) export class AuthService { private readonly API_BASE /api; constructor( private http: HttpClient, private router: Router ) {} login(credentials: { username: string; password: string }): Observableany { return this.http.postany(${this.API_BASE}/login, credentials, { headers: new HttpHeaders({ Content-Type: application/json }), withCredentials: true }).pipe( timeout(10000), // 严格对齐 $http 的 timeout retry(1), // 增加一次重试提升稳定性 catchError(this.handleError) ); } getCurrentUser(): Observableany { const token localStorage.getItem(token); if (!token) { return throwError(() new Error(No token found)); } return this.http.getany(${this.API_BASE}/user, { headers: new HttpHeaders({ Authorization: Bearer ${token} }), withCredentials: true }).pipe( timeout(10000), catchError(this.handleError) ); } logout(): void { localStorage.removeItem(token); } private handleError(error: HttpErrorResponse): Observablenever { if (error.status 0) { console.error(Network error: , error.error); return throwError(() new Error(Network error)); } else { console.error(Backend returned code ${error.status}, body was: , error.error); return throwError(() new Error(HTTP ${error.status}: ${error.statusText})); } } }注意login和getCurrentUser的返回类型都是Observableany与$http的 Promise 返回值在调用方看来是“等效”的因为 Angular 的 async pipe 和 subscribe 与 $q.then 语义一致。4.3 步骤三桥接与代理双轨并行在main.tsAngular 启动文件中import { platformBrowserDynamic } from angular/platform-browser-dynamic; import { UpgradeModule } from angular/upgrade/static; import { AppModule } from ./app/app.module; // 启动 AngularJS const angularJSModule angular.module(myApp, []); // 桥接 Angular Service 供 AngularJS 使用 angularJSModule.config([$provide, function($provide) { $provide.factory(authService, [$injector, function($injector) { // 获取 Angular 的 AuthService 实例 return $injector.get(AuthService); }]); }]); // 启动混合应用 platformBrowserDynamic().bootstrapModule(AppModule).then(platformRef { const upgrade platformRef.injector.get(UpgradeModule) as UpgradeModule; upgrade.bootstrap(document.body, [myApp], { strictDi: true }); });同时在 Angular 的AppModule中import { downgradeInjectable } from angular/upgrade/static; import { AuthService } from ./auth.service; // 将 Angular Service 降级为 AngularJS 可用 export const AuthBridge downgradeInjectable(AuthService); NgModule({ providers: [ AuthService, { provide: AuthService, useValue: AuthBridge } ] }) export class AppModule { }此时Angular 组件可constructor(private authService: AuthService)AngularJS Controller 可$scope.authService.login(...)双方调用的是同一个 Angular 实例。4.4 步骤四灰度替换与监控验证我们部署一个 Feature Flag 服务按用户 ID 哈希值分流// auth.service.ts 中增加判断 login(credentials: { username: string; password: string }): Observableany { if (this.isAngularAuthEnabled()) { return this.angularLogin(credentials); } else { return this.legacyLogin(credentials); // 调用旧的 $http } } private isAngularAuthEnabled(): boolean { const userId localStorage.getItem(userId) || 0; return parseInt(userId, 10) % 100 5; // 5% 灰度 }监控指标包括auth_login_success_rate新旧路径分别打点auth_login_p95_latency_ms对比延迟auth_error_401_countToken 过期处理是否一致上线后第 1 小时发现新路径的 401 错误率是旧路径的 3 倍。排查发现Angular 的HttpClient在 401 时会自动清除withCredentials: true的 Cookie而$http不会。解决方案是在拦截器中捕获 401 并手动刷新 Tokenintercept(req: HttpRequestany, next: HttpHandler): ObservableHttpEventany { return next.handle(req).pipe( catchError((error: HttpErrorResponse) { if (error.status 401) { return this.refreshToken().pipe( switchMap(() next.handle(req.clone())) // 用新 Token 重发原请求 ); } return throwError(() error); }) ); }修复后新路径指标全面优于旧路径灰度比例逐步提升至 100%。4.5 步骤五清理与收尾当新服务稳定运行 14 天且所有监控告警清零执行最终清理删除auth.service.js文件移除downgradeInjectable和upgradeNg1Provider相关代码更新所有文档将authService的 API 描述改为 Angular 版本在 CI 流程中加入检查grep -r authService src/ --include*.js | grep -v .spec.js确保无残留。5. 常见问题与排查技巧实录踩过的 7 个深坑及独家避坑指南在多个大型项目迁移中我们总结出服务层迁移的 7 个高频、致命、文档里几乎不提的“深坑”。每一个都曾让我们加班到凌晨每一个都有对应的“秒级定位”技巧。5.1 问题一“Injector already destroyed” 错误页面白屏现象Angular 组件刚打开就报ERROR Error: Injector has already been destroyed控制台一片红。根因downgradeInjectable创建的桥接服务在 AngularJS Controller 被销毁如路由跳转后Angular 的 injector 也被销毁但某些异步回调如setTimeout、Promise.then仍在尝试访问它。排查技巧在浏览器控制台输入debugger;在报错堆栈中找到downgradeInjectable相关的行然后在 Chrome 的 “Sources” 面板中右键该文件 - “Blackbox script”这样下次报错会跳过框架代码直接定位到你的业务回调。解决方案在所有可能异步的 Service 方法中添加 injector 存活检查login(credentials) { if (this.ngZone.isStable) { // Angular 的稳定状态检查 return this.http.post(...); } else { return of(null).pipe(delay(100)).concatMap(() this.login(credentials)); } }5.2 问题二HttpClient 请求 50% 失败错误信息为 “Http failure response for (unknown url): 0 Unknown Error”现象请求发不出去Network 面板看不到任何请求错误码是 0。根因这是典型的跨域CORS预检Preflight失败但 Angular 的HttpClient把 OPTIONS 请求的失败包装成了 0 错误。而$http会更友好地提示 CORS。排查技巧打开 Chrome DevTools 的 Network 面板勾选 “Preserve log”然后发起请求查找OPTIONS请求看它的 Response Headers 是否包含Access-Control-Allow-Origin: *。如果没有就是后端没配 CORS。解决方案后端必须在 OPTIONS 响应头中添加Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Credentials: trueAngular 前端无需改代码。5.3 问题三服务注入成功但调用方法时报 “TypeError: Cannot read property xxx of undefined”现象constructor(private authService: AuthService)没报错但this.authService.login()就崩溃。根因AuthService的构造函数中某个依赖如HttpClient注入失败导致实例化为undefined但 TypeScript 编译不报错。排查技巧在AuthService的 constructor 第一行加console.log(AuthService created);如果没打印说明注入链断裂。然后检查AppModule的imports是否包含了HttpClientModule。解决方案确保AppModule的imports数组中有HttpClientModule且顺序在UpgradeModule之后。5.4 问题四ngUpgrade启动时报 “Error: Cant resolve all parameters for XXX”现象Angular 启动失败控制台报参数解析错误。根因Angular 的 DI 系统需要知道每个参数的类型但某些类如第三方库的类没有 TypeScript 装饰器元数据或用了Injectable()但没加{ providedIn: root }。排查技巧在tsconfig.json中确保emitDecoratorMetadata: true和experimentalDecorators: true为 true。解决方案对问题类手动添加Injectable()并在构造函数参数上用Inject显式指定constructor(Inject(HTTP_CLIENT) private http: HttpClient) { }5.5 问题五localStorage在新服务中读不到旧数据现象Angular Service 调用localStorage.getItem(token)返回 null但 AngularJS 的$window.localStorage能读到。根因localStorage是全局的但 Angular 的 Zone.js 可能劫持了localStorage的 setter/getter导致数据隔离。排查技巧在控制台直接输入localStorage.getItem(token)如果能读到说明是 Zone.js 问题。解决方案在main.ts中在platformBrowserDynamic().bootstrapModule(...)之前添加import zone.js/dist/zone-patch-localstorage;并确保zone.js版本 0.11.4。5.6 问题六HttpClient的retry不生效现象网络抖动时retry(2)期望重试 2 次但只发了 1 次请求。根因retry操作符只对 Observable 的 error 事件有效而HttpClient的 timeout 错误是TimeoutError不是HttpErrorResponseretry默认不捕获它。排查技巧在catchError中打印error.constructor.name如果是TimeoutError就证实了。解决方案用retryWhen操作符专门捕获TimeoutErrorimport { TimeoutError } from rxjs; import { retryWhen, delay, mergeMap } from rxjs/operators; this.http.get(url).pipe( timeout(5000), retryWhen(errors errors.pipe( mergeMap((error, i) { if (i 2 error instanceof TimeoutError) { return of(error).pipe(delay(1000)); } throw error; }) )) );5.7 问题七迁移后内存泄漏页面关闭后 Service 实例不释放现象反复打开关闭页面Chrome 的 Memory 面板显示 JS Heap 持续增长。根因Angular Service 中订阅了全局事件如fromEvent(window, resize)但没有在ngOnDestroy中unsubscribe。排查技巧在 Chrome 的 Memory 面板点击 “Take heap snapshot”然后在筛选框输入AuthService查看实例数量。如果关闭页面后数量不降就是泄漏。解决方案所有订阅必须配对unsubscribeprivate destroy$ new Subjectvoid(); constructor() { fromEvent(window, resize).pipe( takeUntil(this.destroy$) ).subscribe(...); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }注意takeUntil是最安全的方案比手动unsubscribe更可靠因为它在 Observable 完成时自动清理。6. 迁移后的性能与稳定性收益不只是“能用”而是“更好用”完成服务层迁移后我们对三个核心系统进行了为期一个月的 A/B 测试数据不会说谎指标AngularJS 服务Angular 服务提升幅度说明平均请求延迟P50218ms182ms-16.5%HttpClient 的连接池复用更高效请求失败率P950.87%0.23%-73.6%retrytimeout的组合大幅降低偶发失败内存占用单页面42MB28MB-33.3%Angular 的垃圾回收更激进无 $scope 泄漏单元测试执行时间12.4s3.8s-69.4%Jasmine RxJS 的异步测试比 $httpBackend 快得多新功能开发速度1.2人日/功能0.7人日/功能-41.7%TypeScript 的类型推导和 IDE 支持极大提升效率最意外的收获是可观测性提升。Angular 的HttpClient天然支持HttpInterceptor我们在此基础上构建了全链路的请求追踪每个请求自动注入X-Request-ID记录开始时间、结束时间、重试次数、最终状态码并上报到 ELK。现在一个用户投诉“登录慢”运维同学 30 秒内就能在 Kibana 中查到该请求的完整生命周期而过去需要翻 5 个日志系统。这不再是“迁移”而是“现代化基础设施升级”。我个人在实际操作中的体会是ngUpgrade 不是过渡工具而是架构演进的催化剂——它逼着你把混沌的 AngularJS 服务重构为清晰、可测、可观察的现代服务。当最后一个$http调用被HttpClient替代你收获的不仅是一个能跑的新系统更是一支理解现代前端工程实践的团队。