Angular的变更检测到底有多蠢?信号出来后我终于找到了真相

🔑 关键词:Angular,信号,变更检测,Zone.js,前端框架对比

📖 摘要:这篇文章从实际项目重构出发,对比了Angular传统变更检测和新的信号机制,指出Zone.js的过时设计以及信号带来的架构变革。不吹不黑,说说我踩过的坑和真实感受。

先说结论:Angular的变更检测在信号出来之前,基本就是一门玄学。你用Zone.js打补丁,它监听所有异步操作,然后跑一遍全组件树。我记着在Angular 16里,一个页面上挂几个实时数据流,加上表格里几百行的单元格,setInterval一更新,亲测肉眼可见的卡顿。Chrome Performance面板里全是changeDetection的调用,你根本不知道哪个组件触发的。后来我学会了手动把ChangeDetectionStrategy.OnPush加上,又得时刻盯着immutable更新,生怕哪次不小心改了对象属性,视图就不动了。这简直比写React的useMemo还累,至少React还给你个警告,Angular是直接装瞎。

图片

然后Angular 17把信号作为稳定功能推出来的时候,我第一反应是:这不就是Redux的useSelector山寨版么?直到我真把一个实时行情组件重构完,才意识到自己错得离谱。信号不是用来替代组件的响应式状态,它是在zone之外模拟了React的fiber调度。你想想,一个信号写到组件模板里,它自己就建立了一棵依赖图,只有读到信号的那个地方才会在信号变化时重新渲染。我那个表格原本要跑200多毫秒的脏检查,现在只需要更新变了的那几个单元格。数据从WebSocket推过来,每秒更新20多次,CPU占用从百分之三十几掉到百分之七八。说实话,当时我有点激动,差点以为Angular换了个魂。

图片

但你要觉得信号是银弹,那就天真了。信号只能把视图层的变更检测做细,搞不定副作用。比如你的组件里头有订阅,有setTimeout,还有手动操作DOM的指令,这些该乱还是乱。而且信号本身的学习曲线很搞笑:官方文档告诉你,computed会惰性求值,effect会在异步时机跑,然后你兴冲冲写了个effect去同步localStorage,结果发现它跑了不止一次,因为effect里读其他信号会重新触发。去Stack Overflow一搜,一堆人问“为什么我的effect重复执行”,答案是你得自己写flush逻辑,或者用untracked。这设计是不是有点反人类?React的严格模式至少只是开发环境抽风,Angular的effect是生产环境给你表演。

图片

更关键的是,信号并没有彻底让Zone.js退休。虽然Angular 18你可以在vite配置里把zones.js去掉,开启experimental zoneless,但默认还是带着那套zone猴子补丁。我的实际体验是:去掉zone后,原来一些第三方库(尤其是那些直接在原生事件里调回调的)全都失灵了,因为你没告诉Angular“现在要检查”,它就不更新。然后你被迫在每个事件回调外面包一个markDirty。这跟手动调detectChanges有什么区别?所以我觉得Angular团队弄得挺拧巴的:又想拥抱细粒度响应式,又舍不得老一套,就给你两套并行。你项目一旦大了,一半组件用信号,另一半还在用BehaviorSubject加async管道,那调试起来真是酸爽。

图片

说实话,我现在反而有点明白为什么周围人宁愿用React都不用Angular了。Angular本身就是个框架,不该指望它像React那样自由。但问题在于,Angular的每次进步都像是在给同一个房子加楼层,地基却没变。信号其实是个很好的地基,但Angular还舍不得拆掉旧楼。我的观点是:如果你是新项目,直接上Angular 18并强制开启zoneless,所有状态都走信号,只用subscribe接受外部事件然后塞进信号,这样其实能彻底摆脱脏检查的阴影。但如果你维护老代码,那就别指望信号救命了,该用OnPush还得用,该手动ref.detectChanges()的手千万别停。这或许就是Angular的宿命:永远给你选择,但选择多了,比没选择更痛苦。

图片