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

发布时间:2026/9/7 2:59:39
Angular 可延迟视图 `@defer` 完整指南:按需加载、触发器、prefetch 与工程化最佳实践 Angular 可延迟视图defer完整指南按需加载、触发器、prefetch 与工程化最佳实践【免费下载链接】angularDeliver web apps with confidence 项目地址: https://gitcode.com/GitHub_Trending/an/angulardeferDeferrable Views是 Angular 模板编译器内建的控制流语法块它把不参与首屏渲染的组件、指令、管道及其样式拆分成独立的 JS chunk按需动态加载从而显著缩小首屏 bundle 体积、加快初始加载并改善以 Largest Contentful PaintLCP与 Time to First ByteTTFB为代表的核心 Web 指标Core Web VitalsCWV。本文以 官方文档 为骨架结合本仓库的运行时实现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 样式但要让一个依赖真正被延迟它必须同时满足两个条件必须是 Standalone 的。非 standalone 依赖无法被延迟即使写在defer块内也会被急切加载。在同一个文件中不能被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 { pPlaceholder content/p }占位块并非必需但某些触发器依赖它比如viewport、interaction、hover在没有显式给出 template reference variable模板引用变量 时需要placeholder作为被观察的触发元素。这时的占位块必须有单一根元素。占位内容可以是普通 HTML也可以是组件、指令、管道。请务必牢记占位块的依赖是急切加载的因此占位内容应该保持轻量。加载完成后Angular 会用主内容替换占位内容。placeholder支持一个可选参数minimum指定占位内容渲染之后至少要展示多久defer { large-component / } placeholder (minimum 500ms) { pPlaceholder content/p }minimum以毫秒ms或秒s为单位。它用于防止「依赖很快就取回、占位内容一闪而过」造成的视觉闪烁。运行时实现中该时长对应 interfaces.ts 内LDeferBlockDetails的一个独立槽位STATE_IS_FROZEN_UNTIL——它记录「当前状态最早可切换到下一状态的时间戳」minimum到期前状态被冻结。loading加载内容当延迟依赖开始加载时loading块的内容会替换掉占位内容。它同样是可选块defer { large-component / } loading { img altloading... srcloading.gif / } placeholder { pPlaceholder content/p }loading的依赖与placeholder一样是急切加载的。它接受两个可选参数用来避免依赖快速取回时的闪烁minimum该加载模板最少显示多久after加载开始后等待多久才显示加载模板。defer { large-component / } loading (after 100ms; minimum 1s) { img altloading... srcloading.gif / }两个参数都使用ms或s计时且两者的计时器都从加载被触发的那一刻立即开始。若加载耗时短于after加载模板根本不会闪现若加载超过after则显示加载模板并至少持续minimum时长。实现细节上等待显示loading的定时器会生成一个清理函数存储在LDeferBlockDetails的LOADING_AFTER_CLEANUP_FN槽位中见 interfaces.ts以便在状态提前转换时取消 pending 的定时任务。相关行为由 defer_spec.ts 中should support minimum and after conditions等用例覆盖。error加载失败的错误态如果延迟依赖加载失败error块的内容会被展示。与placeholder、loading一样其依赖也是急切加载的defer { large-component / } error { pFailed to load large component./p }从运行时角度看四种可见状态在源码中被建模为DeferBlockState枚举Placeholder、Loading、Complete、Error见 interfaces.ts另外还有一个内部状态Initial值为 -1代表「什么都没渲染」。用触发器精确控制加载时机on与when当一个defer块被触发时占位内容被替换为懒加载出来的主内容。多个事件触发器可以用分号;分隔同时声明它们之间是OR关系任一满足即触发。触发器分为两大类on基于事件/条件的声明式触发与when基于表达式真值。对应地运行时通过DeferBlockTrigger枚举区分Idle / Immediate / Viewport / Interaction / Hover / Timer / When / Never各类触发器见 interfaces.ts。on触发器一览触发器触发时机说明idle浏览器空闲时基于requestIdleCallback支持可选超时参数这是defer的默认行为viewport指定内容进入视口时基于 Intersection Observer APIinteraction用户与指定元素交互时监听click与keydown事件hover鼠标悬停在指定区域时监听mouseover/focusin等事件immediate非延迟内容渲染完成后立即加载timer指定时长之后需要以ms/s给出时长idleidle触发器在浏览器基于requestIdleCallback进入空闲状态后加载内容这也是defer块的默认触发行为。你可以可选地传入毫秒级超时它会作为参数传给requestIdleCallback如果浏览器迟迟没有调度该回调那么工作最迟会在超时时间到达后执行。!-- 默认行为即等价于 on idle -- defer { large-cmp / } placeholder { divLarge component placeholder/div } !-- 带 500ms 超时 -- defer (on idle(500)) { large-cmp / }on timer之类带时长的参数以及idle的 deadline 都会经由本仓库的运行时实现解析与调度。定制idle行为IdleService与provideIdleServiceWithAngular 通过抽象接口IdleService将「空闲调度」能力注入化默认实现为RequestIdleCallbackService见 idle_service.ts。该默认实现优先使用浏览器原生的requestIdleCallback/cancelIdleCallback在二者不可用的环境例如 Node.js 与 Safari中会优雅降级为setTimeout/clearTimeoutshim其中浏览器 API 被包进函数以便测试环境 mock。IdleService接口契约idle_service.ts包含两个方法requestOnIdle(callback, options): number在应用或浏览器空闲时调度callback返回可用于取消的 idcancelOnIdle(id): void按 id 取消已调度但尚未执行的回调。provideIdleServiceWith是一个EnvironmentProviders工厂函数idle_service.ts它接受一个InjectionTokenIdleService或抽象类型AbstractTypeIdleService并以useExisting方式覆盖内部IDLE_SERVICEtoken。因此传入的提供者必须能从根注入器注入且注入值须实现IdleService接口。Injectable() class CustomIdleService implements IdleService { requestOnIdle( callback: (deadline?: IdleDeadline) void, options?: IdleRequestOptions, ) { // 在这里实现自定义的空闲调度逻辑。 } cancelOnIdle(id: number) { // 在这里实现自定义的空闲取消逻辑。 } } bootstrapApplication(App, { providers: [provideIdleServiceWith(CustomIdleService)], });viewportviewport触发器使用 Intersection Observer API 监测指定内容进入视口。被观察的内容可以是placeholder内容默认行为此时占位块需有单一根元素也可以是同一个模板中的显式模板引用变量defer (on viewport) { large-cmp / } placeholder { divLarge component placeholder/div }也可以显式指定被观察进入视口的元素模板引用变量把它作为参数传给触发器div #greetingHello!/div defer (on viewport(greeting)) { greetings-cmp / }若想自定义IntersectionObserver配置viewport支持传入对象字面量该字面量支持IntersectionObserver第二个参数除root之外的全部属性。使用对象字面量记法时必须通过trigger属性传入触发器引用不写trigger时占位块作为隐式观察目标div #greetingHello!/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 { divImplied trigger/div }从实现看dom_triggers.ts 中registerDomTrigger借助afterEveryRender进行轮询直到解析出触发器所在的视图LView与 DOM 元素后才真正注册监听IntersectionObserver被创建在 Angular zone 之外ngZone.runOutsideAngular而回调会重新跳回 zone 内执行避免不必要的变更检测。若视图在触发器解析前被销毁轮询会自行终止并注册相应的清理函数以防止事件泄漏。interaction用户通过click或keydown事件与指定元素交互时触发。默认以占位块作为交互元素同样要求单一根元素defer (on interaction) { large-cmp / } placeholder { divLarge component placeholder/div }也支持显式传入模板引用变量div #greetingHello!/div defer (on interaction(greeting)) { greetings-cmp / }在 primitives/defer/src/triggers.ts 中交互事件被定义为interactionEventNames [click, keydown]事件监听采用了事件委托的思路统一注册监听后在事件发生时判断目标元素是否位于触发元素内部从而减少为每个元素单独绑定监听的开销。hover鼠标悬停到指定区域时触发。默认以占位块作为悬停区域也可显式指定模板引用变量defer (on hover) { large-cmp / } placeholder { divLarge component placeholder/div }div #greetingHello!/div defer (on hover(greeting)) { greetings-cmp / }本仓库源码triggers.ts中 hover 事件集定义为hoverEventNames [mouseenter, mouseover, focusin]——除了鼠标进入/悬停事件外还包含focusin以保证键盘等非指针交互方式同样能够触发。immediate非延迟内容全部渲染完成后立即加载defer (on immediate) { large-cmp / } placeholder { divLarge component placeholder/div }timer在指定时长后加载时长必须以毫秒ms或秒s为单位defer (on timer(500ms)) { large-cmp / } placeholder { divLarge 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 { divLarge 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 { divLarge component placeholder/div } !-- 带 500ms 空闲超时的预取 -- defer (on interaction; prefetch on idle(500)) { large-cmp / }从运行时角度看预取与主触发各自维护独立的清理函数列表TRIGGER_CLEANUP_FNS与PREFETCH_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)渲染指定状态并断言 DOMit(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 states、minimum 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 ShiftCLS指标恶化。如果确有必要则应避开immediate、timer、viewport以及自定义的when这类会在初始页面渲染期间就加载内容的触发器同时为可能改变布局的区域预留固定尺寸的占位空间。保持可访问性a11y使用defer时要考虑对使用屏幕阅读器等辅助技术的用户的影响当屏幕阅读器聚焦到延迟区域时它最初只会读到占位或加载内容而在延迟内容真正加载完成后可能并不会主动播报变化。要让内容变化能被屏幕阅读器及时播报可以把defer块包在一个带live region活动区域的元素中div aria-livepolite aria-atomictrue 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.Manual与getDeferBlocks()逐状态驱动断言注意与 NgModule、HMR、SSR/SSG、barrel 文件之间的兼容边界。若想深入运行时原理建议从这些源码继续阅读触发器与状态模型interfaces.tsDeferBlockState、DeferBlockTrigger、LDeferBlockDetails内部槽位空闲服务与注入idle_service.tsIdleService、provideIdleServiceWithDOM 触发器注册与 zone 处理dom_triggers.tsregisterDomTrigger、getTriggerLView批量定时调度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),仅供参考

相关新闻