减少工作量提升Angular性能:超越OnPush和trackBy的十种技巧
利用@defer、增量式水合、resource()、计算信号、渲染阶段以及Web Workers,削减现代Angular中多余的网络请求、响应式操作、DOM操作和CPU负载。
来源: How to Make Angular Extremely Fast: 10 Performance Tricks Most Developers Miss
Angular 应用运行缓慢的原因很少是其运行时机制的问题。更常见的情况是,应用会下载、初始化、重新计算并渲染那些用户并未要求的内容。诸如 OnPush、懒加载路由、trackBy、AOT 编译、生产环境构建以及减少 HTTP 请求等常见解决方案依然有效,但最新版本的 Angular 能让开发者实现更精细的控制:决定代码何时下载、组件何时具备交互性、请求何时被取消、何种状态会导致数据失效、DOM 操作在何时执行,以及繁重计算在哪个线程中进行。本指南将介绍基于这种控制能力的十种技术,讲解如何将它们组合使用,以及在确定实际耗时点后应以何种顺序应用这些技术。
其核心理念很简单:不要试图让 Angular 在不必要的操作上更快,而应设法避免这些操作的发生。
Angular 应用程序的运行开销所在
在研究各个 API 之前,先了解每个 Angular 应用程序会消耗的三种资源以及通常导致这些资源被大量使用的因素会很有帮助。
Angular Application
│
┌────────────────┼────────────────┐
│ │ │
Network CPU DOM
│ │ │
JS downloads Change detection Layout
API requests Computation Paint
Dependencies Parsing Reflow
│ │ │
└────────────────┼────────────────┘
│
User Experience
这些开销是相互关联的。如果首次加载页面时就下载了 5 MB 的 JavaScript,那么即便调整变更检测机制也收效甚微;而如果每次交互都会触发耗时的计算,那么使用较小的代码包也无法带来好处。应追求的目标是:减少下载量、减少初始化工作、减少计算量、减少渲染内容,并降低对主线程的占用。以下的十种技术分别针对上述目标中的一个或多个方面。
1. 让 @defer 真正生成独立的代码块
@defer 块是减少启动时所需 JavaScript 量的最简单方法之一。想象一个包含图表、分析数据、报告、地图、视频播放器以及管理界面的控制面板。这些元素中很少有需要在页面打开时就立即显示的,但普通模板却会急于加载所有内容:
<app-heavy-chart />
<app-map />
<app-report />
将体积较大的组件包裹在 @defer 块中,可以告诉 Angular 仅在触发条件满足时才去获取该组件的代码,此处即是指当占位符滚动到可视区域时:
@defer (on viewport) {
<app-heavy-chart />
}
避免将延迟加载的组件放入桶式导入中
这里有一个需要注意的细节:@defer 只能用于分离那些无法通过常规导入获取的代码。如果宿主组件是通过桶式文件来导入该延迟加载组件的,那么该桶式文件会将其它所有导出项及其依赖项一同纳入同一个代码结构中。因此,不应从 index 文件进行导入:
import { HeavyComponent } from './index';
从该组件所在的文件中导入它:
import { HeavyComponent } from './heavy.component';
目标是尽可能让每个延迟执行块背后的依赖关系保持隔离状态。需要避免的第一种结构如下所示:
BAD
其结构为通过单个“管道”将所有组件与控制面板相连:
Dashboard
│
└── index.ts
├── Chart
├── Map
├── Reports
├── Video
└── Analytics
理想的结构如下所示:
GOOD
这样,复杂的图表就可以直接被导入,从而拥有独立的代码块:
Dashboard
│
└── HeavyChart
│
└── heavy-chart.chunk.js
“管道式导入”是导致 @defer 块无法缩小初始代码包大小的常见隐蔽原因之一。确认是否成功的最简单方法是在添加该块后检查构建输出中是否有新的懒加载代码块出现。
其好处是呈指数级增长的:较小的初始数据包意味着需要下载、解析、编译和执行的量更少,而所有这些节省都在 Angular 渲染任何内容之前实现。
2. 仅先加载用户所需的资源
在服务器端渲染中,HTML 到达后会出现第二个瓶颈。服务器可以快速生成标记,但浏览器随后必须让整个页面具备交互性,这就需要下载并运行页面上每个组件的代码。以典型的控制面板布局为例:
Dashboard
│
├── Header
├── Navigation
├── KPI Cards
├── Huge Chart
├── Map
├── Analytics Table
├── Video Player
└── Recommendations
并非所有这些部分都需要立即响应输入。传统的全量加载方式会将页面视为一个整体:
SSR HTML
↓
Hydrate EVERYTHING
↓
Interactive
渐进式加载允许将这一过程拆分成多个阶段,这样页面可以立即显示,同时各部分会逐步具备交互功能:
SSR HTML
↓
Show page immediately
↓
Hydrate only what is needed
↓
Hydrate other sections later
它通过相同的 @defer 块来实现,只不过增加了水合触发机制。在下面的示例中,服务器仍然会渲染图表的 HTML,客户端会在浏览器空闲时对其进行水合处理,而在纯客户端渲染模式下,则会在用户交互时才加载该模块:
@defer (on interaction; hydrate on idle) {
<app-heavy-chart />
}
这种架构上的优势在于,渲染 HTML 与使其具备交互性不再是一个非此即彼的单一步骤。必须在水合配置中启用渐进式水合功能;请查阅对应 Angular 版本的 SSR 指南,了解具体的提供程序选项。
3. 使用 resource() 取消过时的请求
边输入边搜索是典型的网络资源浪费场景。当用户输入内容时,请求会依次经过每一个中间状态:
a
an
ang
angu
angul
angula
angular
一种简单的实现方式是为每个输入值都发起一次请求:
Request 1 → "a"
Request 2 → "an"
Request 3 → "ang"
Request 4 → "angu"
Request 5 → "angul"
Request 6 → "angular"
只有最后一个响应才重要,其他五个都是多余的。现代 Angular 提供了 resource() 和 httpResource() 用于响应式异步加载。首先创建一个存储查询条件的信号:
const query = signal('');
接着声明一个资源,其 params 函数用于读取该信号,而 loader 则负责执行请求操作。在下面的示例中,loader 被写在 params 结束大括号的同一行;实际上它们是选项对象中的两个独立属性:
const results = resource({
params: () => ({
q: query()
}), loader: ({ params, abortSignal }) =>
fetch(
`/api/search?q=${params.q}`,
{ signal: abortSignal }
).then(res => res.json())
});
当加载仍在运行时参数发生变化,Angular 会中止之前的加载任务。这之所以可行,是因为 loader 会将传入的信号传递给 fetch,这一点需要特别注意:
{ signal: abortSignal }
如果不这样做,旧的请求会一直运行到完成。在实际代码中值得做两项改进:在将查询串插入URL之前先用encodeURIComponent对其进行编码,同时要记住取消操作并非防抖机制。每次按键仍然会触发一个请求随后又被中止,因此如果还想避免发送这些请求,就需要将资源与防抖后的查询信号结合使用。最快的请求就是那种在变得无关紧要后立即被终止的请求,这种模式在搜索、自动补全、过滤、实时仪表板、快速变化的ID以及一系列依赖调用场景中都能发挥作用。
4. 保持信号简洁且专注
信号虽然高效,但仍然有可能被错误地使用从而丧失这种效率。一种常见的错误做法是让一个信号承载整个页面的状态:
const pageState = signal({
user: {},
filters: {},
notifications: [],
products: [],
statistics: {},
settings: {}
});
如果只有过滤器发生变化,所有使用pageState的组件都会收到通知,因为整个信号的数值都被替换了。将独立的状态部分拆分为不同的信号可以缩小影响范围。第一个信号变为:
const user = signal(...);
其余的也遵循相同的模式(此处展示为在同一行中连续输出;实际代码中则每行一个):
const filters = signal(...);const notifications = signal(...);const products = signal(...);const statistics = signal(...);const settings = signal(...);
现在,每个使用组件仅依赖于其实际读取的状态。电子表格是一个很好的类比模型。当某个单元格发生变化时:
A1 changes
无需重新计算整个表格,只需重新计算依赖该单元格的那一部分即可:
A1
↓
Dependent cells
↓
Only affected UI
大型对象信号会导致那些只关心其部分内容的消费者失效;而小型信号则能实现更精确的无效化范围控制。不必考虑所有状态是否都能放入一个信号中,而应思考当某个特定值发生变化时究竟哪些部分需要响应。如果确实需要复合对象,应使用computed()根据小型信号来生成它,而非将其作为唯一真实的数据源存储。
5. 将耗时的计算移出模板
在模板中调用函数是造成重复工作的最简单方式之一:
<div>
{{ calculateTotal(items()) }}
</div>
这看起来似乎无害,但Angular会在每次检查视图时重新评估该表达式,因此即使items没有变化,耗时的calculateTotal()函数仍会不断被执行。解决方法是使用computed()信号。首先声明原始状态:
const items = signal<Item[]>([]);
从中计算出总数:
const total = computed(() =>
items().reduce(
(sum, item) => sum + item.price,
0
)
);
并在模板中读取该计算得到的信号:
<div>
{{ total() }}
</div>
computed()会缓存其计算结果,只有当它所读取的信号发生变化时才会重新计算,而非在每次模板评估时都计算。正因如此,它非常适合用于过滤、排序、聚合、统计、仪表板数据以及任何其他派生出的UI状态。基于搜索词信号的过滤后产品列表也采用相同的结构:
const searchTerm = signal('');
以及一个同时依赖于产品和搜索词的两个参数的派生列表:
const filteredProducts = computed(() =>
products().filter(product =>
product.name.includes(searchTerm())
)
);
这取代了诸如以下的模板调用,将相关计算工作交由响应式图处理:
{{ filterProducts(products(), searchTerm()) }}
需要注意一点:includes 是区分大小写的,因此实际搜索通常会先对两个字符串进行规范化处理。可以把 computed() 看作是用于存储派生状态的缓存,并能正确地使这些状态失效。
6. 懒加载重型服务,而不仅仅是组件
懒加载通常在两个层面进行讨论:
Lazy route
Lazy component
不过,服务本身也可能包含庞大的依赖关系树。Markdown 预览服务就是一个很好的例子:
MarkdownService
│
├── marked
├── highlight.js
├── syntax definitions
└── other dependencies
如果该服务仅在用户点击“预览 Markdown”按钮时才被使用,那就没有必要将解析器、语法高亮工具及其相关规则一起打包在初始版本中。正确的做法是在需要时通过动态导入的方式来加载该服务。从概念上讲,这是通过使用 injectAsync() 辅助函数来实现的:
const markdownService = injectAsync(
() =>
import('../markdown.service')
.then(m => m.MarkdownService)
);
该组件随后会在需要它的处理函数中等待对应的服务:
async preview(content: string) {
const service = await this.markdownService();
并利用该服务来渲染内容:
this.html = service.render(content);
}
应将injectAsync()视为一种设计模式而非必然存在的核心API:需确认所使用的Angular版本或相关库是否提供此功能。在处理函数中使用普通的await import('../markdown.service')后再通过注入器获取该类,也能达到相同效果。无论哪种方式,服务及其复杂的依赖项都会被打包到独立的代码块中。总体原则是应在这些依赖真正需要使用时再加载它们,而不仅仅是在路由边界处。
7. 将DOM的读取和写入操作视为高成本API
有时性能瓶颈并非JavaScript本身,而是浏览器的布局引擎。这种情况会在代码同时进行测量操作和数据修改时出现:
read DOM
write DOM
read DOM
write DOM
read DOM
write DOM
每次写入之后的读取都可能迫使浏览器同步重新计算布局,这种现象被称为布局抖动。Angular为此提供了与渲染相关的钩子函数。第一个是:
afterNextRender()
第二个支持明确阶段划分的钩子函数是:
afterRenderEffect()
使用afterRenderEffect()时,可将测量操作放在earlyRead阶段:
afterRenderEffect({
earlyRead: () => {
return element.getBoundingClientRect();
},
而修改操作则放在write阶段,该阶段会接收读取操作返回的值(此片段属于同一TypeScript调用的其余部分,而非CSS代码):
write: height => {
element.style.height = `${height}px`;
}
});
Angular会先执行所有组件的读取阶段,然后再执行写入阶段,因此浏览器会将这些操作视为按此顺序分组处理:
READ
↓
READ
↓
READ
↓
WRITE
↓
WRITE
↓
WRITE
而不是不断交替执行:
READ → WRITE → READ → WRITE
这种基于阶段的协调在图表、地图、动画、大小调整和滚动处理、第三方UI库以及复杂仪表板中尤为重要,因为在这些场景下,每次渲染后都有许多组件会操作DOM。
8. 通过事件与运行合并实现批量变更检测
在基于区域的应用程序中,对于逻辑上属于同一用户操作的情况,可能会有多个触发器接连触发:
click
↓
event bubbles
↓
parent handler
↓
ngZone.run()
↓
another update
如果不进行合并,每个步骤都可以自行安排一次变更检测。当在provideZoneChangeDetection中同时启用这两个选项时,Angular可以将它们合并起来:
bootstrapApplication(AppComponent, {
providers: [
provideZoneChangeDetection({
eventCoalescing: true,
runCoalescing: true
})
]
});
无需为每个事件都执行一次检测:
Event A → Change Detection
Event B → Change Detection
Event C → Change Detection
重叠的任务会被合并为一次检测即可:
Event A ─┐
Event B ─┼──→ One Change Detection
Event C ─┘
在具有大量事件处理程序、繁忙的 WebSocket 流、频繁调用的 ngZone.run() 方法或高度交互式的控制台的应用程序中,这一功能尤为有用。需注意,这些选项适用于基于 Zone.js 的变更检测机制;较新版本的 Angular 更倾向于无区域变更检测,此时调度已由信号和显式通知驱动,因此在使用这些选项之前,请先确认您的应用程序所采用的检测模式。
9. 查找那些并非有意包含的依赖项
一些最严重的性能问题并非源于 Angular 本身,而是导入机制的问题。您可能只需要从某个库中获取一个函数,但您的导入结构却会引入更多不必要的内容:
Application
│
└── Library
│
├── Feature A
├── Feature B
├── Feature C
├── Feature D
└── Feature E
而实际上您仅使用了:
Feature A
正因如此,代码包分析应是每项性能检测的必备环节。请生成生产环境构建的统计信息:
ng build --configuration=production --stats-json
接着检查输出结果,看看哪些文件和库占据了最大的体积。像 source-map-explorer 这样的工具是基于源映射工作的,因此需要在你要分析的构建过程中启用它们;统计文件格式取决于项目所使用的构建工具,需确认该工具支持哪种格式。关键问题不在于应用是否运行缓慢,而在于你实际发送给了浏览器的内容是什么。一旦明确了这一点,大多数优化措施就会变得显而易见。为避免后续出现功能退化,可考虑在 CI 中设置性能预算以限制代码包大小。
10. 将繁重的计算任务从主线程中移出
有时框架本身没有问题,问题出在算法上。假设某个控制面板接收到了如此规模的数据集:
100,000 records
并在浏览器中对其执行统计分析、图像处理、几何运算、大规模变换、复杂解析或聚合操作。在主线程上,这些操作会与其他所有任务竞争资源:
Main Thread
│
├── Angular
├── Rendering
├── User input
├── Animations
└── 100K-record calculation
其结果是性能问题:输入响应迟缓、动画卡顿以及渲染延迟。Web Worker可将计算任务转移到单独的线程中,仅返回结果:
Main Thread Worker
│ │
│──── large dataset ─────>│
│ │
│ Calculate
│ Transform
│ Analyze
│ │
│<──── result ─────────────│
│
Render UI
Angular CLI可以生成Worker模板并配置相关设置:
ng generate web-worker app
组件则通过相对于模块的URL来创建该Worker,以便打包工具能够找到并构建它:
const worker =
new Worker(
new URL('./app.worker', import.meta.url)
);
Web Worker并不会降低Angular本身的运行成本。它的作用是防止昂贵的应用逻辑阻塞页面渲染和用户交互。只有当计算任务确实非常繁重时才使用Web Worker,因为线程间传递的数据会通过结构化克隆进行复制,而这种数据传输本身也需要消耗资源。如需从框架中立的角度了解这一决策,可参阅何时将CPU任务移至Web Worker中。
在同一个控制面板中结合运用这些技术
这些并非相互独立的技巧,而是相辅相成的。未经优化的分析控制面板会一次性处理所有任务:
Browser
│
├── Download huge JS bundle
├── Initialize everything
├── Hydrate everything
├── Start every API request
├── Recalculate large state
├── Execute expensive template functions
├── Perform DOM reads/writes
└── Calculate large datasets on main thread
应用了这些技术后,每一层仅执行当前阶段所需的操作:
Browser
│
├── Smaller initial bundle
│ └── @defer
│
├── Partial interactivity
│ └── Incremental hydration
│
├── Only latest API request
│ └── resource/httpResource
│
├── Narrow reactive updates
│ └── Fine-grained signals
│
├── Cached derived values
│ └── computed()
│
├── Heavy services loaded later
│ └── injectAsync()
│
├── Coordinated DOM work
│ └── afterRenderEffect()
│
├── Fewer redundant CD cycles
│ └── Event coalescing
│
├── Smaller dependencies
│ └── Bundle analysis
│
└── Heavy computation elsewhere
└── Web Worker
该应用并非运行速度更快,而是需要处理的工作量大幅减少。
简洁的性能优化公式
可以用一系列减少项来概括这种优化方法:
Performance
=
Less JavaScript
+
Less Network Work
+
Less Reactive Work
+
Less DOM Work
+
Less Main-Thread Work
更简短地说:高效的 Angular 意味着减少工作量、延迟执行任务,并且只在必要时才进行处理。
实用的优化顺序
不要一次性应用所有优化措施。首先进行测量,然后按照大致以下的顺序逐步实施优化,因为早期的步骤往往对应用启动速度的影响最大。
首先,减少初始 JavaScript 代码量:
@defer
injectAsync()
bundle analysis
其次,减少初始数据加载的工作量:
Incremental Hydration
第三,削减不必要的网络请求:
resource()
httpResource()
第四,缩小响应式更新的范围:
Fine-grained signals
computed()
第五,降低渲染开销:
afterRenderEffect()
afterNextRender()
第六,移除冗余的变更检测功能:
eventCoalescing
runCoalescing
第七,消除CPU瓶颈:
Web Workers
优化前先进行测量
最重要的原则是避免盲目优化。到处都应用这种做法并非有效的策略:
OnPush
在不知道瓶颈所在的情况下引入这些措施同样不可行:
memoization
workers
RxJS operators
defer blocks
使用Chrome DevTools、Lighthouse、Angular DevTools、生产环境代码包分析工具以及浏览器的性能面板来进行性能分析。然后提出核心问题:
What is actually slow?
并将答案归类到特定类别中:
Network?
JavaScript?
Change detection?
Rendering?
Layout?
CPU?
只有确定了哪种问题最为突出后,才应选择相应的优化技术。每次做出更改后都需再次进行测量,以确认确实取得了改进。
从更快的变更检测到更少的工作量
早期的 Angular 性能优化建议大多围绕一个问题:
"How can I make change detection faster?"
现代 Angular 则提倡一个更有用的问题:
"Why am I running this work at all?"
第二个问题适用于所有层面。为何现在就要下载这个组件?为何现在就要加载这部分内容?既然用户已经更改了查询条件,为何还要等待 HTTP 请求完成?为何要因为一个字段就使整个状态对象失效?为何要重新计算那些输入并未改变的值?为何要在渲染完成之前就操作 DOM?为何要在 UI 线程上执行繁重的计算?你发现的每一个不必要的操作都是一种优化机会。
核心要点
这十种技术针对不同的层面,每一种都能消除特定类型的浪费:
- 通过直接导入配合
@defer可以减少初始 JavaScript 代码量。 - 增量式加载可以降低最初的加载工作量。
resource()结合中止信号可移除过时的网络操作。computed()可避免重复进行值计算。afterRenderEffect()机制可避免布局频繁重排。最快的Angular应用并非是执行JavaScript速度最快的应用,而是那些最少执行不必要的JavaScript代码的应用。整个策略可以用一句话概括:
MAKE ANGULAR FAST
│
┌──────────┴──────────┐
│ │
DO LESS DO LATER
│ │
┌─────┼─────┐ ┌─────┼─────┐
│ │ │ │ │ │
Less Less Less Defer Lazy Hydrate
JS CPU DOM code data later
│ │ │ │ │ │
└─────┴─────┴─────────┴─────┴─────┘
│
▼
FASTER EXPERIENCE
测量、找出浪费的运算,将其移除,然后再进行测量。