先记住这个答案
在 Angular 中,Signals 通过依赖追踪将依赖视图标记为脏,但实际 DOM 更新仍由变更检测执行。OnPush 策略下,信号变化会标记所有读取该信号的视图,只有当这些视图被标记后才会在变更检测中被检查,不会引发全局扫描。若组件使用 Default 策略,信号本身不触发检测,检查范围由异步事件决定,信号优化收益有限。协同体现为:信号负责响应式状态传播,变更检测负责按需渲染。
- 信号触发脏标记,非直接更新DOM
- OnPush下信号仅将依赖它的视图纳入检查
- Default策略信号变化不缩小检查范围
信号依赖追踪与变更检测触发
每个组件模板在渲染时读取信号,会建立依赖关系。当信号写入新值,框架会标记所有依赖该信号的视图。这一标记动作不依赖组件策略,任何读取了该信号的视图都会被标记,但检查范围受策略影响。OnPush 组件被标记后,仅当变更检测执行到该组件时,才重算模板绑定,且会跳过未标记的祖先。
Angular 的变更检测是自上而下的树遍历。在默认启用 zone 的典型应用中,Default 策略的组件通常会在每次异步事件后执行全树检查,信号更新本身不会改变这一扫描范围,因此性能收益有限。OnPush 则尊重标记,只检查被标记的组件及其必要子树。信号提供精细化的脏标记,但无法绕过策略本身。
大量列表项中的信号更新
假设一个列表组件有1000个子项,每个子项为OnPush组件,内部使用信号显示文本。当用户点击某一行,该行信号更新。由于该子项被标记,变更检测会检查该子项及其可能受影响的子树,但父组件和兄弟列表项通常不会被整体检查。在理想场景下,检查范围可以从全列表缩小到单行,从而带来可观的性能提升。
若将列表组件也设为OnPush,但信号在列表组件内部更新,则列表组件本身会被标记。其子项是否被检查,取决于它们是否读取了该信号或输入引用发生了变化。若子项没有依赖该信号,则不会单独被检查,除非父组件模板更新导致子项输入变化。此时若要优化,应让信号在叶子组件中更新,或使用不可变数据配合OnPush。
场景失效与策略切换
当组件使用Default策略,且应用未启用zone-less模式,若信号在异步事件(如DOM事件)中更新,zone会触发全树变更检测,即使信号只在组件内更新,也无法避免全局扫描。Signal本身只改标记,不会直接触发检测。因此纯信号优化在OnPush或zone-less下更明显。
若在OnPush组件中调用markForCheck或使用async pipe,会扩大检查范围。async pipe标记整个组件,而信号只标记依赖它的视图。混合使用可能导致不必要的检查。可行策略是统一用信号,避免组件内直接操作变化检测,让框架统一调度。注意在 effect 中写入信号默认会抛错,如需写入应使用 allowSignalWrites 并明确其副作用。
容易答错的地方
- 信号自动更新DOM
- 错误认为写入信号后模板立即刷新。实际信号只通知框架,DOM更新在变更检测周期内批量进行。例如设置信号后同步读取DOM仍为旧值,需等待变更检测周期执行或显式调用detectChanges。
- OnPush下信号能跳过变更检测
- OnPush仅减少检查范围,但信号变化仍需要触发检查机制。如果组件被脱离变更检测器或使用noop,信号不会导致视图更新。必须确保组件仍被框架检测,或使用effect手动渲染。
面试官还会怎么问?
Signals和RxJS在变更检测上有什么本质区别?
Signals是同步依赖追踪,写入时立即标记;RxJS是异步流,常配合async pipe标记。Signal不需要订阅管理,而RxJS需处理取消。二者都可触发OnPush检测,但Signal更轻量,无Subscription开销。
在OnPush组件中使用信号,但父组件是Default,性能会好吗?
不一定。父组件Default策略下若信号在父组件中写入,会全树检查。若信号在子OnPush组件中写入,则子组件被标记,父Default也可能因事件触发全树检查。因此需配合zone-less或使用独立事件绑定。
如何检测信号是否真的减少了变更检测次数?
可在组件ngDoCheck中添加计数,对比使用前后次数。若信号在OnPush叶子组件更新,父组件ngDoCheck不应调用。若仍调用,说明有其他触发源,如zone捕获了异步事件。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。