Angular 20 makes Zoneless officially stable, but a real migration isn't as simple as changing one line of config. This article documents the complete process of migrating a medium-sized Angular project (~80 components) from zone.js to Zoneless, sharing the pitfalls we encountered and their solutions.
Migration Prep: Detecting zone.js Dependencies in Your Code
Before removing zone.js, understand which parts of your project implicitly rely on it:
# 安装 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"
}
}
Common patterns of implicit zone.js dependency:
// ❌ 模式 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);
}
}
// ❌ 模式 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: [] });
}
Phased Migration Strategy
Phase 1 (1-2 weeks): Fully switch to OnPush + eliminate zone dependencies
① Add changeDetection: ChangeDetectionStrategy.OnPush to all components
② Convert regular properties to signal()
③ Wrap Observables with toSignal()
④ Replace manual unsubscribe with takeUntilDestroyed()
Phase 2 (1 week): Parallel test Zoneless mode
① Create a separate Zoneless build for the dev environment
② Run E2E tests to find test cases that fail due to zone.js dependency
③ Fix all issues
Phase 3 (Go Live): Production switch
① Add provideZonelessChangeDetection() to production config
② Remove zone.js from polyfills
③ Monitor Sentry/error logs for 3 days
Third-Party Library Compatibility Issues
// 问题:某些第三方库(如老版 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 Configuration in Tests
// 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");
});
});
Migration Results
Actual metrics after migrating our project to Zoneless:
Bundle size: reduced by 13 KB (gzip); first-paint improved ~50ms on slow networks
Change detection cycles: reduced by ~60% (no global zone interception)
LCP improvement: average 120ms (less JS execution time)
Memory usage: reduced ~8% (no zone.js patch overhead)
Conclusion
The core of Zoneless migration is "signal-ifying all mutable state." This is already the recommended best practice in Angular 17+, so if your new project uses Signal APIs from the start, migrating to Zoneless is virtually zero-cost. The difficulty lies in legacy projects—code that heavily uses subscribe + direct assignment needs a systematic overhaul. A phased migration (OnPush → Zoneless) is lower-risk than a single cutover.