Angular 18 的信号机制到底解决了什么?别再吹 Zone.js 了
先交代背景:我从 Angular 5 时代就开始写项目,到现在差不多五年。以前每次 Angular 版本升级,我都是第一时间冲上去尝鲜的,但这次 Angular 18 的 signal API 我拖了整整三个月才在工作中用上。原因很简单——网上铺天盖地的教程都在讲怎么用 signal()、computed()、effect(),但没人告诉我:用信号之前,我到底哪里痛?
先说一个实际观察。我们有个后台管理系统,表格大概 200 行,每行有 8 个绑定,数据流每秒推送一次。旧版用 Zone.js 默认策略,CPU 在低端笔记本电脑上直接烧到 70% 多。后来我把绑定的数组换成了 signal,又配合 OnPush,CPU 降到 40% 左右。听起来很不错是吧?但问题出在内存上——信号需要为每个引用额外建立依赖图,复杂页面跑半小时后,Chrome DevTools 里看到内存涨了大概 20-30MB。垃圾回收反而更频繁了。所以如果你是抱着“信号一定更快”的心态去改,大概率会失望。
其实信号真正的价值不是“更快”,而是让变更检测的触发变得可预测。Zone.js 的弊病在于它靠猴子补丁拦截所有异步操作(setTimeout、Promise、XHR),然后整个组件树跑一遍 cdRef.detectChanges()。哪怕你只是在一个深层子组件里改了个布尔值,父组件和不相关的兄弟组件也都会被检查。这就像你家里跳闸了,物业不查你屋里的电箱,反而把整栋楼每家每户的电器都重启一遍。
我见过很多团队为了压性能,被迫在组件里手动拆 ChangeDetectorRef、用 RunOutsideAngular、甚至把大列表拆成虚拟滚动。这些都是 Zone.js 逼出来的“补丁式优化”。而信号呢?它把依赖关系做成了细粒度的字节级订阅——只有读取了这个信号的组件才会收到更新通知。理论上确实精准,但实践中你得小心隐式依赖。
举个例子,我一开始把 computed 写得很复杂,里面嵌套了三个信号,结果忘了在模板里正确引用 computed 的顶层,导致 Angular 无法捕获深层信号变化。运行时报错也不会提示你,最后是数据全乱了才靠日志一点点定位。后来查文档才知道:在 computed 里调用另一个信号是支持的,但如果在一个 effect 里同步修改另一个信号值,会直接抛 Error: NG0600 之类的警告。 这种边角问题,旧版 Zone.js 根本不需要考虑。
再说说 Angular 18 新增的 rxInterop 和 takeUntilDestroyed。它们弥补了信号和 RxJS 之间的鸿沟——原来写 BehaviorSubject + async 管道的模式,现在可以直接用 toSignal。但这里有个隐蔽的坑:toSignal 默认会把初始值 undefined 塞进去,如果你直接用这个信号去绑定一个 @Input,控制台会一直刷空值警告。得手动传 initialValue 或者用 require 选项。我看官方文档里写得很清楚,但太多教程压根没提,评论区一堆人说“为什么我的界面闪了一下”。
那到底要不要全面转信号?我的结论是:别把信号当银弹,也不要像网上某些人说 Zone.js 就是垃圾。 如果是中小型项目、异步逻辑少,用 OnPush 配合 async 管道完全能跑得很顺;如果是数据流密集、实时刷新、组件层级深的大项目,信号值得引入。但你要做好心理准备:初期的迁移成本比想象中高,尤其是有很多 @Output 和双向绑定 [(ngModel)] 的地方——信号目前不支持直接双向绑定到自定义组件,你还是得自己写 set() 回调。
最后说点版本细节。Angular 18 里信号已经稳定,但 signal 的调试工具还没跟上。Chrome 的 Angular DevTools 虽然能显示信号值,却不能直观看到依赖关系图。我建议配合 Effect 的调度时机来观察更新顺序,或者干脆给每个信号命名时带上业务前缀(比如 signal__userList),不然几十个信号堆在一起,跟老式 Promise 地狱没区别。当然,如果你已经习惯 Zone.js 的“全量暴力检查”,那真没必要逼自己改。毕竟工具是拿来用的,不是拿来当信仰的。