Angular:从变更检测到编译时的底层突围

说实话,Angular 这框架在圈内是个争议体。爱它的人吹爆它的工程化;恨它的人嫌弃它笨重。今天我不想站队,只挑两条最核心的底层脉络——变更检测和 Ivy 编译,把它们血淋淋地剖开。顺便,给打算用 Angular 上生产的人备三颗解药。

一、变更检测:默认策略是一根筋,但你能给它装个阀门

写到这一节,我先抛个结论:Angular 的变更检测本质上是一棵被递归遍历的视图节点树。每个组件实例,在编译后都对应着若干节点,节点上绑定了创建/更新指令。默认情况下,只要 Zone.js 捕获到任何异步事件——鼠标点击、网络响应、setInterval——它就会从根组件开始,往下把整棵树跑遍,每个节点执行一遍更新函数。这个复杂度是 O(N),N 就是视图节点的总数。看起来傻,但对框架而言,这是“确定性”的最简单实现。

Angular变更检测视图树遍历机制示意图
Angular变更检测视图树遍历机制示意图

举个例子,你的应用是一个鱼塘,每条鱼都是一个数据源。默认策略就像——只要你往鱼塘里扔一颗石子(任何异步事件),所有水泵都得同时启动,检查有没有鱼被冲上岸。不管那颗石子落在哪个角落。这种“宁可错杀一千,不可放过一个”的方式,在小规模场景下毫无问题。但想象一下,一个后台系统有 300 个组件,每个组件里又有 20 个绑定表达式——那就是 6000 次函数调用。你要是每秒触发 10 次异步操作,那就是 60000 次额外执行。

有人会问,Zone.js 到底干了什么?它本质上是 monkey patch。Angular 启动时,Zone.js 会重写 window 上所有异步 API(setTimeout、addEventListener、XMLHttpRequest…),让它们在回调执行完毕后,再自动触发一个 change detection tick。这就有点“全局代理”的味道。好处是你不用手动去说“数据变了”,糟处是——你没法说“这轮不用检测”。

于是就有了 OnPush。把组件的 changeDetection 属性设为 ChangeDetectionStrategy.OnPush,Angular 就只在两种情况下更新这个组件:输入属性的引用变化(注意,是引用!)或组件自身的事件处理器被调用。这一下就把 O(N) 降到了近似 O(K)——K 是实际需要更新的节点。如果你配合不可变数据来用,那就等于你给鱼塘装了个智能闸门。

但这里有个隐藏的数学陷阱:当子组件是 OnPush 时,它的变化可能不会向上传递。因为父组件的检测到子组件时,子组件没有“脏”标记,就直接跳过了。很多新手就在这儿迷茫:为什么我改了一个父组件传入的对象属性,子组件却不刷新?因为对象引用没变。只有重新赋值一个对象,才会激发更新。想要部分刷新?抱歉,做不到。Angular 的更新粒度是组件级,不是属性级。

二、Ivy:把复杂度还给编译器,让运行时只做哑活

Angular 9 是一个分水岭。之前,Angular 的模板编译器把组件编译成独立的 ngfactory 文件,里面塞满了元数据。浏览器拿到这些 JS 后,还需要通过 Angular 的运行时解释器(JIT)去解析模板的内容。这就像你收到了一份食材清单,还得让厨师现场认菜谱——性能能好吗?

Ivy 彻底改变了这一切。Ivy 在 编译期就把模板翻译成一系列增量 DOM 指令。比如一个简单的插值表达式,编译后就是几行指令调用,直接操作 DOM。代码长得像这样:

function headerTemplate(rf, ctx) {
  if (rf & 1) {
    // 创建元素
    elementStart(0, 'div');
  }
  if (rf & 2) {
    // 更新内容
    text(2, ctx.title);
  }
}

这个编译后的代码是“本地性”的,它只知道自己的那一小块 DOM。更妙的是,Ivy 的指令是内联的,这意味着 tree-shaking 可以精准移除未使用的指令。再加上新的依赖注入编译机制,很多能在编译期解析的依赖都直接被编译成常量,而不是通过反射去查。这带来的直接收益,就是包体积的戏剧性下降。

我们拿自己的项目做过压测。那是一个企业级中后台,包含 120 个路由模块,组件数超过 350。从 Angular 8 升到 Angular 12(Ivy 全面应用),首屏主 bundle 体积由 4.2MB 降到 2.1MB(gzip 前),真实 4G 网络下的全量加载时间由 6.8s 降至 3.1s。在 CI 上,整个项目的生产成本编译时间从 233s 降到 98s。而开发环境下的增量编译,改一行代码的热重载时间,从 5s 左右锐减到 800ms。这不是网上抄的数字,是我们自己抓的日志。

Angular Ivy编译管道与tree-shaking优化流程图
Angular Ivy编译管道与tree-shaking优化流程图

从原理上讲,Ivy 还引入了局部性。编译器只在模块内部做推导,不依赖全局信息,这能让增量编译并行化。这就是为什么项目越大,Ivy 的编译优势越明显——它不是靠优化单一操作,而是改变了同步方式。

不过话说回来,Ivy 的美学背后也有代价。它让 Angular 的调试变得复杂——你不能再直接搜索模板字符串,因为模板在编译期就被拍扁了。你必须学会看编译后的指令,或者依赖 source map。这也是为什么 Angular 的调试工具一直不好做。但这是工程上的合理取舍:把复杂度前移到编译期,运行时只做哑活——这才是 Angular 想要的。

三、实践落地:三个血泪坑,以及绕坑指南

三、实践落地:三个血泪坑,以及绕坑指南
三、实践落地:三个血泪坑,以及绕坑指南

无论原理聊得多欢,落地才是硬道理。下面三个坑,是我们团队在过去三年里亲身踩出来的。每一个都导致过线上事故或性能危机。

坑 1:Zone.js 的“无差别攻击”让长轮询卡爆

我们有一个监控界面,需要每 5 秒从后端拉取一次设备状态。开发时一切正常,上线后用户反映点击按钮有延迟。我们查了 performance,发现每次轮询响应后都有一次全量变更检测,而这套界面上有十几个图表组件。图表组件内部还有大量 SVG 节点,导致每次 tick 都要执行 20ms 以上的 DOM 更新。连续轮询每秒 0.2 次尚可,但其他用户操作也叠加进来,交互就僵了。

破法:把图表组件全部改成 OnPush,并且让数据流通过 async 管道进入,这样更新范围被限制在数据链路相关的组件中。更深一层,我们用runOutsideAngular包装了轮询定时器,让它在 Angular 的 zone 之外执行,避免触发检测。这样轮询数据推送仍然更新,但不会引发无关组件的检测风暴。注意:如果你用了 NoopZone,就必须手动控制所有检测,这对全局框架来说是重仓,但那种控制才是极致的性能。

坑 2:OnPush 的“冰山效应”——数据更新了,视图冻结

以前我们在一个实时大屏应用上用了 OnPush。接收 WebSocket 推送的组件订阅了服务里的 Observable,然后在回调里直接给一个局部变量赋值,并调用 detectChanges() 刷新自己。起初没问题,直到有一天,推送频率升高后,视图开始闪烁、错乱。原来是在回调里手动触发检测,而异步事件导致多个检测循环交叉执行,甚至和 Angular 自身调度冲突,最终出现脏读。

破法:不要在回调里手动调用 detectChanges。正确的做法是:让模板直接订阅 Observable,用 async 管道。async 管道会自己管理订阅和变更检测,并在 OnPush 下自动调用 markForCheck——它知道什么时候检测是安全的。我们全面改造后,闪烁消失,而且变更检测的次数随之减少。顺带一提,markForCheck 只是向上标记父到根,不会影响兄弟组件的检测,所以粒度是对的。

坑 3:懒加载模块中的重复代码——Ivy 也不是魔法

Ivy 能 tree-shake,但它无法移除那些实际上被引用的重复模块。我们的一个主产品中,有 5 个路由模块都导入了同一个 SharedModule,而这个 SharedModule 里包含一个大型图表指令、一个全局弹窗服务、一个自定义表单控件。构建后发现,主 bundle 里竟然出现了三份图表指令的代码副本。原因是 SharedModule 不是懒加载,它被打进了各个 chunk 里,而 chunk 又同时被多个路由加载……最终就是重复。

破法:首先用 source-map-explorer 或者 webpack-bundle-analyzer 分析哪些模块重复。然后把那些用于列表展示、图表渲染的指令,新建一个 SharedForLazyModule,只在需要它的懒加载模块里单独导入。至于全局弹窗服务,改用依赖注入在 root 提供,不要放在 SharedModule 中。这一顿操作后,我们的 bundle 体积又降了 17%。记住:共享模块不是越庞大越好,越抽象的模块越容易招致重复。

……

写到这里,我猜你已经看明白了:Angular 的坑,几乎都源自于它的“自动化”。默认的全量检测,默认的 Zone 代理,默认的模块共享。这些默认让开发早期顺滑,却让后期性能调优变成一门手艺。你越想掌控它,就越要理解它底层的模型。这就像开车——新手开自动挡最省心,但你要追求极限性能,就得知道换挡逻辑和发动机转速的关系。

Angular 不是完美的。它太容易被骂“笨重”,但当你真正享受系统带来的确定性时,你会发现它的重量,其实是工程化的重量。这重量,是撑起庞大代码库的基石。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:Angular:从变更检测到编译时的底层突围
文章链接:https://lfdjt.com/info_23_12874.html