Skip to content

Angular 20 Zoneless 迁移实战:从 zone.js 到纯 Signal 驱动

Angular 20 让 Zoneless 正式稳定,但实际迁移并不是改一行配置就完事。本文记录了将一个中型 Angular 项目(约 80 个组件)从 zone.js 迁移到 Zoneless 的完整过程,分享遇到的坑和解决方案。

迁移准备:检测代码中的 zone.js 依赖

在去掉 zone.js 之前,先搞清楚项目中有哪些地方隐式依赖了它:

bash
# 安装 Angular ESLint 的 Zoneless 规则集
npm install --save-dev @angular-eslint/eslint-plugin

# .eslintrc.json 中启用规则
{
  "rules": {
    "@angular-eslint/no-async-lifecycle-method": "warn",
    "@angular-eslint/prefer-on-push-component-change-detection": "warn"
  }
}

常见的 zone.js 隐式依赖模式:

typescript
// ❌ 模式 1:在 setTimeout/setInterval 回调中修改非 Signal 状态
export class BadComponent {
  value = 0; // 普通属性(非 Signal)

  ngOnInit() {
    setTimeout(() => {
      this.value = 42; // zone.js 会拦截 setTimeout,触发变更检测
      // Zoneless 下:不会触发 UI 更新!
    }, 1000);
  }
}

// ✅ 修复:使用 Signal
export class GoodComponent {
  value = signal(0);

  ngOnInit() {
    setTimeout(() => {
      this.value.set(42); // Signal 更新,Zoneless 也能检测到
    }, 1000);
  }
}
typescript
// ❌ 模式 2:直接订阅 RxJS Observable 并修改普通属性
export class BadComponent implements OnDestroy {
  data: User[] = [];
  private sub = this.userService.getUsers().subscribe((users) => {
    this.data = users; // Zoneless 下:不触发更新
  });
}

// ✅ 修复方案 A:使用 Signal + takeUntilDestroyed
export class GoodComponent {
  data = signal<User[]>([]);

  constructor() {
    this.userService
      .getUsers()
      .pipe(takeUntilDestroyed())
      .subscribe((users) => this.data.set(users));
  }
}

// ✅ 修复方案 B:使用 toSignal()
export class BetterComponent {
  data = toSignal(this.userService.getUsers(), { initialValue: [] });
}

分阶段迁移策略

阶段 1(1-2周):全面改用 OnPush + 清除 zone 依赖
  ① 所有组件加上 changeDetection: ChangeDetectionStrategy.OnPush
  ② 将普通属性改为 signal()
  ③ 用 toSignal() 包裹 Observable
  ④ 用 takeUntilDestroyed() 替代手动 unsubscribe

阶段 2(1周):并行测试 Zoneless 模式
  ① 在开发环境单独创建 Zoneless 构建
  ② 运行 E2E 测试,找出因 zone.js 依赖而失败的用例
  ③ 修复所有问题

阶段 3(上线):生产切换
  ① 将 provideZonelessChangeDetection() 加入生产配置
  ② 从 polyfills 中移除 zone.js
  ③ 监控 Sentry/错误日志 3 天

第三方库的兼容性问题

typescript
// 问题:某些第三方库(如老版 Google Maps、Monaco Editor)
// 在 zone.js 不拦截的情况下无法触发变更检测

// 解决方案:在第三方库回调中手动标记
import { ChangeDetectorRef, inject } from '@angular/core';

@Component({ ... })
export class MapComponent {
  private cdr = inject(ChangeDetectorRef);
  private map!: google.maps.Map;

  initMap() {
    this.map = new google.maps.Map(this.mapContainer.nativeElement, options);

    // 第三方回调:手动通知 Angular
    this.map.addListener('click', (event: google.maps.MapMouseEvent) => {
      this.selectedLocation.set(event.latLng);
      // 如果使用 Signal,不需要 markForCheck
      // 如果还有普通属性需要更新:
      this.cdr.markForCheck();
    });
  }
}

测试中的 Zoneless 配置

typescript
// spec 文件中配置 Zoneless 测试
import { provideZonelessChangeDetection } from "@angular/core";
import { TestBed } from "@angular/core/testing";

describe("CounterComponent", () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent],
      providers: [
        provideZonelessChangeDetection(), // 测试也使用 Zoneless
      ],
    }).compileComponents();
  });

  it("should update count", async () => {
    const fixture = TestBed.createComponent(CounterComponent);
    const button = fixture.nativeElement.querySelector("button");

    button.click();
    await fixture.whenStable(); // 等待 Signal 传播

    expect(fixture.nativeElement.querySelector("p").textContent).toContain("1");
  });
});

迁移结果

我们项目迁移到 Zoneless 后的实际数据:

包体积:减少 13KB(gzip),首屏加载提升约 50ms(弱网)
变更检测次数:减少约 60%(无 zone 的全局拦截)
LCP 提升:平均 120ms(减少了 JS 执行时间)
内存占用:减少约 8%(无 zone.js 的 patch 开销)

总结

Zoneless 迁移的核心是"把所有可变状态都 Signal 化"。这本来就是 Angular 17+ 推荐的最佳实践,所以如果你的新项目一开始就用 Signal API,迁移到 Zoneless 几乎是零成本的。难点在于老项目——大量使用 subscribe + 直接赋值的代码需要系统性改造。分阶段迁移(OnPush → Zoneless)比一次性切换风险更低。

MIT Licensed