在过去一年中,Angular 新增了许多功能,例如补充和可延迟视图,可帮助开发者改进核心网页指标,并确保为最终用户提供出色的体验。我们还在研究基于此功能的其他服务器端渲染相关功能,例如流式传输和部分注水。
遗憾的是,有一种模式可能会阻止您的应用或库充分利用所有这些新功能和即将推出的功能:手动处理底层 DOM 结构。Angular 要求从服务器序列化组件开始,到组件在浏览器中重新填充为止,DOM 的结构保持一致。如果在补充之前使用 ElementRef
、Renderer2
或 DOM API 从 DOM 手动添加、移动或移除节点,可能会引入不一致性,导致这些功能无法正常运行。
不过,并非所有手动 DOM 操作和访问都会出现问题,有时是必要的。安全地使用 DOM 的关键是尽可能减少对 DOM 的需求,然后尽可能推迟使用 DOM。以下指南介绍了如何实现这一目标,并构建真正通用且面向未来的 Angular 组件,以便充分利用 Angular 的所有新功能和即将推出的功能。
避免手动 DOM 操作
毫不奇怪,避免手动 DOM 操作导致的问题的最佳方法是尽可能完全避免手动操作。Angular 具有可操控 DOM 的大多数方面内置 API 和模式:您应优先使用这些 API 和模式,而不是直接访问 DOM。
修改组件自己的 DOM 元素
编写组件或指令时,您可能需要修改宿主元素(即与组件或指令的选择器匹配的 DOM 元素),例如添加类、样式或属性,而不是定位或引入封装容器元素。开发者会很想直接针对 ElementRef
来改变底层 DOM 元素。相反,您应使用主机绑定以声明方式将值绑定到表达式:
@Component({
selector: 'my-component',
template: `...`,
host: {
'[class.foo]': 'true'
},
})
export class MyComponent {
/* ... */
}
与 HTML 中的数据绑定一样,您还可以绑定到属性和样式,并将 'true'
更改为其他表达式,Angular 将根据需要使用该表达式自动添加或移除值。
在某些情况下,需要动态计算键。您还可以绑定到返回值集或值映射的信号或函数:
@Component({
selector: 'my-component',
template: `...`,
host: {
'[class.foo]': 'true',
'[class]': 'classes()'
},
})
export class MyComponent {
size = signal('large');
classes = computed(() => {
return [`size-${this.size()}`];
});
}
在更复杂的应用中,可能会倾向于执行手动 DOM 操作来避免 ExpressionChangedAfterItHasBeenCheckedError
。相反,您可以将值绑定到信号,如上例所示。您可以根据需要执行此操作,而无需在整个代码库中采用信号。
在模板之外修改 DOM 元素
很容易就想尝试使用 DOM 访问通常无法访问的元素,例如属于其他父级或子级组件的元素。不过,这种做法容易出错、违反封装原则,并且会使日后更改或升级这些组件变得困难。
相反,您的组件应将所有其他组件视为黑盒。请花些时间考虑其他组件(即使是在同一应用或库中)可能需要在何时何地与您的组件的行为或外观进行交互或对其进行自定义,然后公开一种安全且有文档记载的方法来实现此目的。当简单的 @Input
和 @Output
属性不够用时,请使用分层依赖项注入等功能,将 API 提供给子树。
过去,实现模态对话框或提示等功能的常见做法是,在 <body>
或其他某个主元素的末尾添加一个元素,然后将内容移动或投影到该位置。不过,现在您可以改为在模板中呈现一个简单的 <dialog>
元素:
@Component({
selector: 'my-component',
template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
@ViewChild('dialog') dialogRef!: ElementRef;
constructor() {
afterNextRender(() => {
this.dialogRef.nativeElement.showModal();
});
}
}
延迟手动 DOM 操作
在按照上述准则尽可能减少直接 DOM 操作和访问后,您可能仍会不可避免地执行一些操作。在这种情况下,请务必尽可能推迟该操作。afterRender
和 afterNextRender
回调非常适合执行此操作,因为它们仅在 Angular 检查是否有任何更改并将其提交到 DOM 后在浏览器上运行。
运行浏览器专用 JavaScript
在某些情况下,您拥有的库或 API 仅适用于浏览器(例如图表库、IntersectionObserver
的一些用法等)。您可以直接使用 afterNextRender
,而无需有条件地检查您是在浏览器中运行还是在服务器上运行,或在服务器上桩出行为:
@Component({
/* ... */
})
export class MyComponent {
@ViewChild('chart') chartRef: ElementRef;
myChart: MyChart|null = null;
constructor() {
afterNextRender(() => {
this.myChart = new MyChart(this.chartRef.nativeElement);
});
}
}
执行自定义布局
有时,您可能需要读取或写入 DOM,才能执行目标浏览器尚不支持的某种布局,例如定位提示。afterRender
是一个很好的选择,因为您可以确定 DOM 处于一致的状态。afterRender
和 afterNextRender
接受 phase
值 EarlyRead
、Read
或 Write
。在编写 DOM 布局后读取该布局会强制浏览器同步重新计算布局,这会严重影响性能(请参阅:布局抖动)。因此,请务必仔细将逻辑拆分到正确的阶段。
例如,如果某个提示组件想要显示相对于页面上另一个元素的提示,则可能需要使用两个阶段。EarlyRead
阶段首先用于获取元素的大小和位置:
afterRender(() => {
targetRect = targetEl.getBoundingClientRect();
tooltipRect = tooltipEl.getBoundingClientRect();
}, { phase: AfterRenderPhase.EarlyRead },
);
然后,Write
阶段会使用之前读取的值来实际重新定位提示:
afterRender(() => {
tooltipEl.style.setProperty('left', `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`);
tooltipEl.style.setProperty('top', `${targetRect.bottom - 4}px`);
}, { phase: AfterRenderPhase.Write },
);
通过将逻辑拆分为正确的阶段,Angular 能够有效地对应用中的每个其他组件批量执行 DOM 操作,从而最大限度地减少对性能的影响。
总结
我们即将对 Angular 服务器端渲染进行许多激动人心的新改进,这些改进旨在让您更轻松地为用户提供出色的体验。希望上述提示对您有所帮助,让您能够在应用和库中充分利用这些功能!