Angular 可延迟视图(Deferrable Views)实战:@defer 语法、空闲加载机制与懒加载分块原理
Angular 的 deferrable views(可延迟视图)让开发者用 @defer 模板语法把"非首屏必需"的组件延迟下载与渲染,从而缩短首屏时间。本文以官方交互式教程的第一课("What are deferrable views?")为主体,完整讲解如何在模板中包裹一个组件实现延迟加载、默认"浏览器空闲时加载"的行为如何验证,并结合 Angular 仓库源码深入剖析 on idle 触发器的解析规则、IdleScheduler 的批量调度实现,以及空闲回调结束后自动触发变更检测的底层细节。
一、为什么需要 deferrable views
一个完整渲染的 Angular 页面往往包含大量组件、指令与管道。虽然页面的某些部分应当立即展示给用户,但总有一些内容可以稍后再呈现。Angular 的可延迟视图正是为此设计:通过 @defer 语法告诉 Angular——那些不需要立刻显示的部分,其 JavaScript 代码可以推迟下载。
对应仓库中的交互式教程位于 adev/src/content/tutorials/deferrable-views,其示例应用是一个"博文 + 评论区"页面:正文文章需要立即呈现,而评论区组件 article-comments 完全可以晚一些再加载。教程的目标就是学会用 deferrable views 延迟加载组件模板中的某一部分。
二、给模板中的区域添加 @defer 块
教程示例工程的初始版本位于 src/app/app.ts,根组件 App 的模板结构如下:
import {Component} from '@angular/core';
import {ArticleComments} from './article-comments';
@Component({
selector: 'app-root',
template: `
<div>
<h1>How I feel about Angular</h1>
<article>
<p>
Angular is my favorite framework, and this is why. Angular has the coolest deferrable view
feature that makes defer loading content the easiest and most ergonomic it could possibly
be.
</p>
</article>
<article-comments />
</div>
`,
imports: [ArticleComments],
})
export class App {}
其中被延迟加载的目标是 article-comments.ts 中定义的 ArticleComments 组件——一个渲染三条静态评论的轻量组件:
@Component({
selector: 'article-comments',
template: `
<h2>Comments</h2>
<p class="comment">Building for the web is fantastic!</p>
<p class="comment">The new template syntax is great</p>
<p class="comment">I agree with the other comments!</p>
`,
styles: [
`
.comment {
padding: 15px;
margin-left: 30px;
background-color: paleturquoise;
border-radius: 20px;
}
`,
],
})
export class ArticleComments {}
操作步骤
按照教程(README.md)的指引,在 app.ts 中把 article-comments 组件包裹进一个 @defer 块即可实现延迟加载:
@defer {
<article-comments />
}
修改后的完整模板见教程的参考答案 answer/src/app/app.ts:
@Component({
selector: 'app-root',
template: `
<div>
<h1>How I feel about Angular</h1>
<article>
<p>
Angular is my favorite framework, and this is why. ...
</p>
</article>
@defer {
<article-comments />
}
</div>
`,
imports: [ArticleComments],
})
export class App {}
这里有两点值得注意:
@defer是纯模板级语法:无需额外导入任何指令,也不需要在imports中登记任何延迟加载配置——组件本身仍通过imports: [ArticleComments]正常声明,编译工具链会自动为其生成独立的懒加载资源。- 默认行为是"空闲时加载":教程明确指出,
@defer不写任何触发器参数时,会在浏览器空闲时(when the browser is idle)加载article-comments组件。也就是说,正文渲染完成、主线程忙完初始工作后,评论区代码才会在空闲间隙被下载并执行。
三、验证懒加载分块:初始 chunk 与 lazy chunk 分离
教程要求读者在浏览器开发者控制台中验证延迟加载是否生效:可以看到 article-comments-component 对应的 lazy chunk 文件是单独加载的(具体文件名每次构建可能不同)。教程给出的构建产物报告示例如下:
| Initial chunk files | Names | Raw size |
|---|---|---|
| chunk-NNSQHFIE.js | - | 769.00 kB |
| main.js | main | 229.25 kB |
| Lazy chunk files | Names | Raw size |
|---|---|---|
| chunk-T5UYXUSI.js | article-comments-component | 1.84 kB |
这组数据说明了 @defer 的核心收益所在:
- 首屏只下载 Initial chunk:主入口与首屏必需代码进入初始 chunk,被
@defer包裹的组件代码不进初始包; - 被延迟的组件生成独立的 Lazy chunk:
article-comments-component被单独打包为一个约 1.84 kB 的懒加载分块,只有当触发条件满足时才发起请求; - 文件名由构建工具生成:
chunk-NNSQHFIE.js这类带哈希的命名意味着每次构建文件名可能变化,验证时应关注 "Lazy chunk files" 分组与 chunk 名称,而不是具体文件名。
四、源码深挖:触发器是如何被解析的
@defer 块中可写的不只是"什么都不写"。从编译器的触发器解析源码 packages/compiler/src/render3/r3_deferred_triggers.ts 可以看到,Angular 定义的完整触发器集合包括:
on idle(可选毫秒/秒参数,如on idle(2000));on timer(500)(必须带一个时间参数);on interaction/on hover(可选事件选择器参数);on immediate(不允许参数);on viewport(可选引用名或{rootMargin, amount, trigger}形式的选项对象,且root选项不被支持);on never;when 表达式(绑定布尔表达式,动态触发);- 以及可与上述触发器组合使用的
prefetch与hydrate前缀(例如@defer (on viewport prefetch on interaction)或@defer (on viewport prefetch on idle))。
几个对实践有直接影响的事实(均可在 r3_deferred_triggers.ts 中确认):
- 时间参数的格式由正则
^\d+\.?\d*(ms|s)?$约束,解析函数parseDeferredTime会把s单位乘以 1000 统一为毫秒——所以on timer(2s)与on timer(2000)等价,而on timer(2.5ms)也是合法的; - 同名触发器不能重复:
trackTrigger在发现重复(如两个on idle)时直接报错Duplicate "xxx" trigger is not allowed; - 参数个数有严格限制:
timer必须恰好一个参数、immediate不允许参数、hover/interaction/idle最多一个参数。
教程第一课刻意只使用无参数的 @defer 默认行为,正是为了先建立"延迟 = 空闲时自动加载"这一心智模型,后续课程(steps 目录 中的 2-loading-error-placeholder、3-defer-triggers)再逐步引入占位符与显式触发器。
五、源码深挖:on idle 的运行时调度机制
"默认在浏览器空闲时加载"这句话背后的实现位于 packages/core/src/defer/idle_scheduler.ts。核心是一个根级可注入服务 IdleScheduler,以及辅助函数 onIdle(callback, injector, options?)。其设计有四个值得理解的细节:
-
按超时选项分桶(bucketing)批量调度:
IdleScheduler把所有回调按IdleRequestOptions的序列化键(这里就是timeout值)分组成桶,每个桶只发起一次requestIdleCallback。注释中说明了动机——避免 defer 块定义在@for循环里时,每个块都单独调用一次requestIdleCallback,造成调度开销。不同选项的桶互相独立,互不干扰。 -
空闲时间片耗尽后自动续订:桶的回调在每次触发时逐个执行队列中的 defer 块渲染函数,每执行一个就调用一次
this.applicationRef._tick()做变更检测。代码注释特别指出这是"一次优化过的变更检测检查",并且把新建视图的变更检测耗时也计入当前空闲时间片;当deadline.timeRemaining() === 0 && !deadline.didTimeout时立即break,若桶里还有剩余回调则重新requestIdleCallback续订。也就是说,多个延迟块不会在一个空闲时间片里硬跑到底,而是尊重浏览器的空闲预算。 -
回调被包进 NgZone 执行:
requestIdleCallback目前不在 Zone.js 的补丁范围内,因此scheduleBucket中用this.ngZone.run(...)显式把回调拉回 Angular 的 zone,保证 zone-based 应用里变更检测能正常收尾。 -
服务端短路:从 packages/core/src/defer/triggering.ts 的
scheduleDelayedTrigger可以看到,先renderPlaceholder渲染占位内容,随后在不应触发 defer 块的场景(如服务端渲染)直接提前返回,避免不必要的setTimeout/空闲调度延迟服务端序列化——这解释了为什么 SSR 场景下@defer主要产出占位 HTML,真正的 JS 加载推迟到浏览器端。
此外,triggering.ts 中 onIdle 的清理函数(cleanup fn)通过 storeTriggerCleanupFn 存入 defer 块的运行时详情,意味着若父视图在空闲回调触发前被销毁,对应的空闲调度会被取消,不会渲染到已删除的视图上。
六、编译器还会为你检查触发器配置错误
Angular 不只是把触发器翻译成代码,还在类型检查阶段主动拦截明显的配置错误。仓库中的扩展检查 defer_trigger_misconfiguration 会分析 on timer / on idle 与其对应的 prefetch 触发器组合:例如主触发器是 timer(2000) 而 prefetch 是 timer(1000),或者两个无时限 idle 组合等可疑配对,会被静态识别并提示。从源码结构看,该检查通过比较主触发器与 prefetch 触发器的类型与时间参数(isTimerPair、isIdlePair、sameUntimedIdle 等判断)来定位"prefetch 永远无法先于主触发器生效"或"prefetch 配置形同虚设"这类问题。使用 @defer 时建议保持该检查开启,可以在构建期发现触发器时序配置错误。
七、小结与实操要点
回到教程第一课的结论:给模板中某个区域加一个 @defer { ... } 块,就能把该区域内的组件(如 article-comments)从首屏初始包中拆出去,生成独立的 lazy chunk,并在浏览器空闲时自动下载与渲染。结合源码可以看到这一"默认行为"并非空话:
| 关注点 | 行为 | 源码依据 |
|---|---|---|
| 默认触发时机 | 无参数 @defer 等价于空闲触发,先渲染占位,空闲时再加载 JS |
triggering.ts 的 scheduleDelayedTrigger |
| 空闲调度方式 | 按超时选项分桶,一次 requestIdleCallback 服务多个块;时间片耗尽自动续订 |
idle_scheduler.ts |
| 变更检测 | 每个块渲染后执行 applicationRef._tick() 优化变更检测 |
idle_scheduler.ts |
| 触发器合法性 | 时间参数支持 ms/s 单位、同名触发器不可重复、参数个数受限 |
r3_deferred_triggers.ts |
| 配置错误拦截 | 类型检查期检查 timer/idle 与 prefetch 的时序配对 | defer_trigger_misconfiguration |
后续可以沿着同一教程目录继续学习:第 2 步为 @defer 添加 @placeholder / @error 状态,第 3 步系统学习 on viewport、on hover、prefetch 等触发器组合,从而把"延迟加载"从默认的空闲行为扩展为面向交互与滚动位置的精细化调度。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00