Angular 可延迟视图 `@defer` 完整指南:按需加载、触发器、prefetch 与工程化最佳实践
2026/9/7 2:59:33 网站建设 项目流程

Angular 可延迟视图@defer完整指南:按需加载、触发器、prefetch 与工程化最佳实践

【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular

@defer(Deferrable Views)是 Angular 模板编译器内建的控制流语法块,它把不参与首屏渲染的组件、指令、管道及其样式拆分成独立的 JS chunk,按需动态加载,从而显著缩小首屏 bundle 体积、加快初始加载,并改善以 Largest Contentful Paint(LCP)与 Time to First Byte(TTFB)为代表的核心 Web 指标(Core Web Vitals,CWV)。本文以 官方文档 为骨架,结合本仓库的运行时实现(packages/core/src/defer/*)与测试用例,系统讲解@defer的依赖拆分规则、四个状态子块、on/when触发器、prefetch预取、测试方法,以及与 NgModule、HMR、SSR/SSG 的协作方式和生产级最佳实践。

为什么需要@defer:从代码拆分到 CWV 优化

任何 Web 应用的初始加载耗时都与「首屏实际需要执行的 JavaScript 总量」直接相关。如果一个页面中存在大量组件——尤其是首屏并不可见的图表、编辑器、数据表格——而它们全部被打进同一个 bundle,浏览器就必须在渲染关键内容之前先下载、解析并执行这些无关代码。

@defer的解法是声明式的:只需在模板中把暂时不需要的部分包进@defer块,Angular 编译器就会自动完成剩余工作:

@defer { <large-component /> }

当构建工具对模板进行编译时,位于@defer块内的组件、指令与管道代码会被拆分到独立的 JavaScript 文件中。@defer块内的主内容在整个模板其余部分渲染完成之后,按需被加载并显示。开发者无需手工管理动态import()ngComponentOutlet或路由级懒加载,代码拆分的决策下沉到单个模板片段级别。

除延迟代码本身外,@defer还提供了一套完整的触发器(triggers)预取(prefetch)选项以及用于占位(placeholder)、加载(loading)、错误(error)状态的子块,让「何时加载、加载期间显示什么、失败后怎么办」都由模板声明表达。

哪些依赖可以被延迟?

Angular 对可延迟内容有明确边界。进入@defer块的可延迟对象包括:

  • 组件(Components)
  • 指令(Directives)
  • 管道(Pipes)
  • 这些组件关联的组件级 CSS 样式

但要让一个依赖真正被延迟,它必须同时满足两个条件:

  1. 必须是 Standalone 的。非 standalone 依赖无法被延迟,即使写在@defer块内也会被急切加载。
  2. 在同一个文件中,不能被@defer块之外的代码引用。如果该依赖在@defer块外部被使用,或者通过ViewChild等查询被引用,它会被急切地打进主 bundle。

有趣的是,位于@defer块中的组件/指令/管道的**传递依赖(transitive dependencies)**并不强制要求 standalone——它们仍然可以声明在某个NgModule中并照常参与延迟加载。

从编译与运行机理看,Angular 编译器会为@defer块中的每个组件、指令和管道生成对应的动态 import 语句 与 interfaces.ts)。

管理延迟加载的四个阶段:@defer与三个子块

一个完整的延迟加载过程会经历状态转换:占位 → 加载 → 主内容(或错误)@defer通过以下子块让你对每个阶段都有完全的控制权。

@defer:主块

主块定义了被延迟加载的内容区间。初始渲染时它不显示任何东西,直到指定触发器发生或when条件成立后才加载并渲染。

@defer { <large-component /> }

默认情况下,@defer会在浏览器进入idle状态后触发(见下文 idle 触发器小节)。

@placeholder:占位内容

默认(且未写@placeholder)情况下,触发器生效之前 defer 块不会渲染任何内容。@placeholder用于声明「触发器发生之前显示什么」:

@defer { <large-component /> } @placeholder { <p>Placeholder content</p> }

占位块并非必需,但某些触发器依赖它:比如viewportinteractionhover在没有显式给出 template reference variable(模板引用变量) 时,需要@placeholder作为被观察的触发元素。这时的占位块必须有单一根元素

占位内容可以是普通 HTML,也可以是组件、指令、管道。请务必牢记:占位块的依赖是急切加载的,因此占位内容应该保持轻量。加载完成后,Angular 会用主内容替换占位内容。

@placeholder支持一个可选参数minimum,指定占位内容渲染之后至少要展示多久:

@defer { <large-component /> } @placeholder (minimum 500ms) { <p>Placeholder content</p> }

minimum以毫秒(ms)或秒(s)为单位。它用于防止「依赖很快就取回、占位内容一闪而过」造成的视觉闪烁。运行时实现中,该时长对应 interfaces.ts 内LDeferBlockDetails的一个独立槽位STATE_IS_FROZEN_UNTIL——它记录「当前状态最早可切换到下一状态的时间戳」,minimum到期前状态被冻结。

@loading:加载内容

当延迟依赖开始加载时,@loading块的内容会替换掉占位内容。它同样是可选块:

@defer { <large-component /> } @loading { <img alt="loading..." src="loading.gif" /> } @placeholder { <p>Placeholder content</p> }

@loading的依赖与@placeholder一样是急切加载的。它接受两个可选参数,用来避免依赖快速取回时的闪烁:

  • minimum:该加载模板最少显示多久;
  • after:加载开始后等待多久,才显示加载模板。
@defer { <large-component /> } @loading (after 100ms; minimum 1s) { <img alt="loading..." src="loading.gif" /> }

两个参数都使用mss计时,且两者的计时器都从加载被触发的那一刻立即开始。若加载耗时短于after,加载模板根本不会闪现;若加载超过after,则显示加载模板并至少持续minimum时长。实现细节上,等待显示@loading的定时器会生成一个清理函数,存储在LDeferBlockDetailsLOADING_AFTER_CLEANUP_FN槽位中(见 interfaces.ts),以便在状态提前转换时取消 pending 的定时任务。相关行为由 defer_spec.ts 中should support minimum and after conditions等用例覆盖。

@error:加载失败的错误态

如果延迟依赖加载失败,@error块的内容会被展示。与@placeholder@loading一样,其依赖也是急切加载的:

@defer { <large-component /> } @error { <p>Failed to load large component.</p> }

从运行时角度看,四种可见状态在源码中被建模为DeferBlockState枚举:PlaceholderLoadingCompleteError(见 interfaces.ts),另外还有一个内部状态Initial(值为 -1,代表「什么都没渲染」)。

用触发器精确控制加载时机:onwhen

当一个@defer块被触发时,占位内容被替换为懒加载出来的主内容。多个事件触发器可以用分号;分隔同时声明,它们之间是OR关系(任一满足即触发)。

触发器分为两大类:on(基于事件/条件的声明式触发)与when(基于表达式真值)。对应地,运行时通过DeferBlockTrigger枚举区分Idle / Immediate / Viewport / Interaction / Hover / Timer / When / Never各类触发器(见 interfaces.ts)。

on触发器一览

触发器触发时机说明
idle浏览器空闲时基于requestIdleCallback,支持可选超时参数;这是@defer默认行为
viewport指定内容进入视口时基于 Intersection Observer API
interaction用户与指定元素交互时监听clickkeydown事件
hover鼠标悬停在指定区域时监听mouseover/focusin等事件
immediate非延迟内容渲染完成后立即加载
timer指定时长之后需要以ms/s给出时长
idle

idle触发器在浏览器基于requestIdleCallback进入空闲状态后加载内容,这也是@defer块的默认触发行为。你可以可选地传入毫秒级超时,它会作为参数传给requestIdleCallback:如果浏览器迟迟没有调度该回调,那么工作最迟会在超时时间到达后执行。

<!-- 默认行为即等价于 on idle --> @defer { <large-cmp /> } @placeholder { <div>Large component placeholder</div> } <!-- 带 500ms 超时 --> @defer (on idle(500)) { <large-cmp /> }

on timer之类带时长的参数以及idle的 deadline 都会经由本仓库的运行时实现解析与调度。

定制idle行为:IdleServiceprovideIdleServiceWith

Angular 通过抽象接口IdleService将「空闲调度」能力注入化,默认实现为RequestIdleCallbackService(见 idle_service.ts)。该默认实现优先使用浏览器原生的requestIdleCallback/cancelIdleCallback;在二者不可用的环境(例如 Node.js 与 Safari)中,会优雅降级为setTimeout/clearTimeoutshim,其中浏览器 API 被包进函数,以便测试环境 mock。

IdleService接口契约(idle_service.ts)包含两个方法:

  • requestOnIdle(callback, options): number:在应用或浏览器空闲时调度callback,返回可用于取消的 id;
  • cancelOnIdle(id): void:按 id 取消已调度但尚未执行的回调。

provideIdleServiceWith是一个EnvironmentProviders工厂函数(idle_service.ts):它接受一个InjectionToken<IdleService>或抽象类型(AbstractType<IdleService>),并以useExisting方式覆盖内部IDLE_SERVICEtoken。因此传入的提供者必须能从根注入器注入,且注入值须实现IdleService接口。

@Injectable() class CustomIdleService implements IdleService { requestOnIdle( callback: (deadline?: IdleDeadline) => void, options?: IdleRequestOptions, ) { // 在这里实现自定义的空闲调度逻辑。 } cancelOnIdle(id: number) { // 在这里实现自定义的空闲取消逻辑。 } } bootstrapApplication(App, { providers: [provideIdleServiceWith(CustomIdleService)], });
viewport

viewport触发器使用 Intersection Observer API 监测指定内容进入视口。被观察的内容可以是@placeholder内容(默认行为,此时占位块需有单一根元素),也可以是同一个模板中的显式模板引用变量:

@defer (on viewport) { <large-cmp /> } @placeholder { <div>Large component placeholder</div> }

也可以显式指定被观察进入视口的元素(模板引用变量),把它作为参数传给触发器:

<div #greeting>Hello!</div> @defer (on viewport(greeting)) { <greetings-cmp /> }

若想自定义IntersectionObserver配置,viewport支持传入对象字面量,该字面量支持IntersectionObserver第二个参数除root之外的全部属性。使用对象字面量记法时,必须通过trigger属性传入触发器引用(不写trigger时,占位块作为隐式观察目标):

<div #greeting>Hello!</div> <!-- 带选项和显式 trigger --> @defer (on viewport({trigger: greeting, rootMargin: '100px', threshold: 0.5})) { <greetings-cmp /> } <!-- 带选项、隐式 trigger(观察占位块) --> @defer (on viewport({rootMargin: '100px', threshold: 0.5})) { <greetings-cmp /> } @placeholder { <div>Implied trigger</div> }

从实现看,dom_triggers.ts 中registerDomTrigger借助afterEveryRender进行轮询,直到解析出触发器所在的视图(LView)与 DOM 元素后才真正注册监听;IntersectionObserver被创建在 Angular zone 之外(ngZone.runOutsideAngular),而回调会重新跳回 zone 内执行,避免不必要的变更检测。若视图在触发器解析前被销毁,轮询会自行终止,并注册相应的清理函数以防止事件泄漏。

interaction

用户通过clickkeydown事件与指定元素交互时触发。默认以占位块作为交互元素(同样要求单一根元素):

@defer (on interaction) { <large-cmp /> } @placeholder { <div>Large component placeholder</div> }

也支持显式传入模板引用变量:

<div #greeting>Hello!</div> @defer (on interaction(greeting)) { <greetings-cmp /> }

在 primitives/defer/src/triggers.ts 中,交互事件被定义为interactionEventNames = ['click', 'keydown'],事件监听采用了事件委托的思路:统一注册监听后,在事件发生时判断目标元素是否位于触发元素内部,从而减少为每个元素单独绑定监听的开销。

hover

鼠标悬停到指定区域时触发。默认以占位块作为悬停区域,也可显式指定模板引用变量:

@defer (on hover) { <large-cmp /> } @placeholder { <div>Large component placeholder</div> }
<div #greeting>Hello!</div> @defer (on hover(greeting)) { <greetings-cmp /> }

本仓库源码(triggers.ts)中 hover 事件集定义为hoverEventNames = ['mouseenter', 'mouseover', 'focusin']——除了鼠标进入/悬停事件外还包含focusin,以保证键盘等非指针交互方式同样能够触发。

immediate

非延迟内容全部渲染完成后立即加载:

@defer (on immediate) { <large-cmp /> } @placeholder { <div>Large component placeholder</div> }
timer

在指定时长后加载,时长必须以毫秒(ms)或秒(s)为单位:

@defer (on timer(500ms)) { <large-cmp /> } @placeholder { <div>Large component placeholder</div> }

值得关注的是timer的底层调度器设计。若模板中存在大量带timer的 defer 块(例如for循环内部生成的块),为每个块单独调用一次setTimeout显然不经济。为此运行时提供了TimerScheduler(见 timer_scheduler.ts):它把多个定时回调合并在一个setTimeout中调度,以约一帧(16ms,按 60fps 折算)为粒度进行批处理与重排;定时器创建在 zone 之外(runOutsideAngular),到期后再ngZone.run回到 zone 内统一派发回调,从而把变更检测的触发次数降到最低。

when:条件表达式触发器

when接受一个自定义条件表达式,表达式为真值(truthy)时加载延迟内容:

@defer (when condition) { <large-cmp /> } @placeholder { <div>Large component placeholder</div> }

它是一次性操作:一旦条件变为真、块被渲染,即使之后条件变回假,@defer块也不会退回占位状态。除模板字面量外,when的条件中还可以使用管道(例如@defer (when isVisible | test),见 defer_spec.ts 中相关用例)。

prefetch提前获取资源

除「何时展示延迟内容」之外,还可以指定一个prefetch 触发器:它负责在延迟内容真正展示之前,提前把@defer块关联的 JavaScript 加载进缓存。这为更精细的性能策略留出空间——例如在用户还未看到或交互 defer 块、但即将与之交互时就开始预取,让资源在用户真正需要时更快可用。

prefetch 触发器的写法和主触发器几乎一致,只需加上prefetch关键字前缀;主触发器与 prefetch 触发器之间同样用分号;分隔。下面示例中,浏览器空闲时开始预取,而块内容要等用户与占位块交互后才渲染:

@defer (on interaction; prefetch on idle) { <large-cmp /> } @placeholder { <div>Large component placeholder</div> } <!-- 带 500ms 空闲超时的预取 --> @defer (on interaction; prefetch on idle(500)) { <large-cmp /> }

从运行时角度看,预取与主触发各自维护独立的清理函数列表(TRIGGER_CLEANUP_FNSPREFETCH_TRIGGER_CLEANUP_FNS,见 interfaces.ts),保证状态到达后对应的事件监听与定时器能被正确释放。

在测试中逐步驱动@defer状态

Angular 提供了专门的 TestBed API 来测试 defer 块。默认情况下,测试环境中的 defer 块会像真实浏览器里那样一路走完(Playthrough 模式);如果想要手动控制每个状态的切换,可以把行为切换为Manual模式,由测试代码逐步驱动。

DeferBlockBehavior枚举(见 interfaces.ts)定义了两个值:

  • Manual:手动触发模式,测试代码全权控制 defer 块何时渲染、渲染哪个状态;
  • Playthrough:播放模式,行为与真实浏览器一致,是测试环境的默认值。

测试流程为:用fixture.getDeferBlocks()拿到所有 defer 块 fixture,再对某个块调用render(DeferBlockState.X)渲染指定状态并断言 DOM:

it('should render a defer block in different states', async () => { // 将 defer 块行为配置为 Manual(初始为 "paused"),以便手动控制。 TestBed.configureTestingModule({deferBlockBehavior: DeferBlockBehavior.Manual}); @Component({ // ... template: ` @defer { <large-component /> } @placeholder { Placeholder } @loading { Loading... } `, }) class ExampleA {} // 创建组件 fixture。 const componentFixture = TestBed.createComponent(ExampleA); // 获取所有 defer 块 fixture 并取第一个。 const deferBlockFixture = (await componentFixture.getDeferBlocks())[0]; // 默认渲染占位状态。 expect(componentFixture.nativeElement.innerHTML).toContain('Placeholder'); // 渲染加载状态并校验输出。 await deferBlockFixture.render(DeferBlockState.Loading); expect(componentFixture.nativeElement.innerHTML).toContain('Loading'); // 渲染最终状态并校验输出。 await deferBlockFixture.render(DeferBlockState.Complete); expect(componentFixture.nativeElement.innerHTML).toContain('large works!'); });

DeferBlockState提供Placeholder / Loading / Complete / Error四个可见状态(与真实运行时枚举一致)。仓库内 defer_spec.ts 覆盖了大量状态迁移场景,例如should transition between placeholder, loading and loaded statesminimum and after参数、OnPush 组件嵌套 defer 块等,可作为编写自有测试时的参照。

兼容性边界:NgModule、HMR 与 SSR/SSG

NgModule的协作

@defer同时兼容 standalone 与基于 NgModule 的组件/指令/管道——但只有 standalone 的组件、指令和管道可以被延迟。基于 NgModule 的依赖不会被延迟,而是被打进急切加载的 bundle 中。

与热模块替换(HMR)的冲突

当 HMR 生效时,所有@defer块的 chunk 都会被急切地拉取,覆盖掉任何已配置的触发器。想要恢复标准的触发器行为,需要禁用 HMR——即以--no-hmr标志启动 dev server。仓库内 HMR 相关测试也验证了「存在 defer 块时会产生 'eagerly loaded deps' 提示、不存在 defer 块时不产生该提示」的行为差异(见 defer_spec.ts 中with HMR分组)。

SSR / SSG 下的默认行为与增量水合

默认情况下,在服务端渲染(SSR 或 SSG)时,defer 块总是渲染其@placeholder(未写占位块则什么都不渲染),触发器在服务端不会被调用。在客户端,占位块内容被水合(hydration),随后触发器被激活并加载真实内容。

若希望服务端也渲染 defer 块的主内容(SSR 与 SSG 均可),可以启用 Incremental Hydration(增量水合)特性,并为相应块配置hydrate触发器。

构建产物排查:barrel 文件会破坏懒加载 chunk

如果你用了@defer却在构建产物里看不到独立的懒加载 chunk,请先检查延迟组件的导入方式。通过barrel 文件index.ts)导入是常见元凶——打包器把 barrel 视为单一模块并保留其全部导出,于是无论是否使用@defer,组件都会被留在主 bundle 中:

// index.ts export {HeavyComponent} from './heavy.component'; export {OtherComponent} from './other.component';
// parent.component.ts import {HeavyComponent} from './index'; // 把 OtherComponent 也一并引入 @Component({ imports: [HeavyComponent], template: `@defer { <heavy-component /> }`, }) export class ParentComponent {}

修复方式很直接——从组件自己的文件路径直接导入:

import {HeavyComponent} from './heavy.component';

这样打包器就能把它拆成独立 chunk,并在触发器发生时按需懒加载。

工程实践与最佳实践

避免嵌套@defer产生级联加载

当存在嵌套@defer块时,应让它们使用不同的触发器,否则内外块可能同时加载,产生级联请求(cascading requests),反而拖累页面加载性能。

避免布局偏移(CLS)

尽量不要延迟那些在首屏视口内可见的组件——否则可能导致累计布局偏移(Cumulative Layout Shift,CLS)指标恶化。如果确有必要,则应避开immediatetimerviewport以及自定义的when这类会在初始页面渲染期间就加载内容的触发器,同时为可能改变布局的区域预留固定尺寸的占位空间。

保持可访问性(a11y)

使用@defer时要考虑对使用屏幕阅读器等辅助技术的用户的影响:当屏幕阅读器聚焦到延迟区域时,它最初只会读到占位或加载内容,而在延迟内容真正加载完成后,可能并不会主动播报变化。

要让内容变化能被屏幕阅读器及时播报,可以把@defer块包在一个带live region(活动区域)的元素中:

<div aria-live="polite" aria-atomic="true"> @defer (on timer(2000)) { <user-profile [user]="currentUser" /> } @placeholder { Loading user profile... } @loading { Please wait... } @error { Failed to load profile } </div>

这样当 defer 块在 占位 → 加载 → 内容/错误 之间切换时,变化会被自动播报给用户。

总结与源码指引

@defer把「代码分割 + 按需加载」从路由/模块级下沉到了任意模板片段级,配合@placeholder@loading@error状态块与on/when/prefetch触发器,几乎可以覆盖所有「非首屏关键内容」的加载策略需求。其要点可以浓缩为:

  • 只有standalone且在块外无引用的依赖会被真正拆分;
  • 6 种on事件触发器 +when条件触发器 +prefetch预取,共同构成加载时机控制矩阵;
  • 默认idle触发,且空闲调度可通过provideIdleServiceWith注入自定义IdleService完全替换;
  • 测试中用DeferBlockBehavior.ManualgetDeferBlocks()逐状态驱动断言;
  • 注意与 NgModule、HMR、SSR/SSG、barrel 文件之间的兼容边界。

若想深入运行时原理,建议从这些源码继续阅读:

  • 触发器与状态模型:interfaces.ts(DeferBlockStateDeferBlockTriggerLDeferBlockDetails内部槽位)
  • 空闲服务与注入:idle_service.ts(IdleServiceprovideIdleServiceWith
  • DOM 触发器注册与 zone 处理:dom_triggers.ts(registerDomTriggergetTriggerLView
  • 批量定时调度:timer_scheduler.ts
  • 原始事件定义:primitives/defer/src/triggers.ts
  • 端到端行为测试:defer_spec.ts

【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询