使用 Angular SSR 安全地访问 DOM

杰拉尔德·摩纳哥
Gerald Monaco

在过去的一年里,Angular 获得了许多新功能,例如 hydration可延迟视图,旨在帮助开发者改进核心网页指标并确保为最终用户提供出色的体验。此外,我们还在研究基于此功能构建的其他与服务器端渲染相关的功能,例如流式传输和部分水合。

不幸的是,有一种模式可能会阻止您的应用程序或库充分利用所有这些新功能和即将推出的功能:手动处理底层 DOM 结构。Angular 要求 DOM 的结构从服务器序列化组件开始,一直到浏览器进行水化 (hydration) 为止。在 hydration 之前使用 ElementRefRenderer2 或 DOM API 在 DOM 中手动添加、移动或移除节点可能会导致不一致,进而导致这些功能无法正常运行。

不过,并不是所有对 DOM 的手动操作和访问都会出现问题,有时还是有必要这样做。安全使用 DOM 的关键在于,尽可能减少对 DOM 的需求,然后尽可能延后其使用时间。以下指南介绍了如何实现此目标,以及如何构建真正通用且面向未来的 Angular 组件,以便充分利用 Angular 的所有新功能和即将推出的功能。

避免手动 DOM 操纵

毫无疑问,为避免手动 DOM 操纵所导致的问题,最好的方式就是尽可能地避免这种情况。Angular 具有内置 API 和模式,可以操纵 DOM 的大多数方面:您应该首选使用它们,而不是直接访问 DOM。

更改组件自身的 DOM 元素

在编写组件或指令时,您可能需要修改主元素(即与组件或指令的选择器匹配的 DOM 元素),例如添加类、样式或属性,而不是定位或引入封装容器元素。我们倾向于使用 ElementRef 更改底层 DOM 元素。相反,您应使用主机绑定,以声明方式将值绑定到表达式:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true'
  },
})
export class MyComponent {
  /* ... */
}

HTML 中的数据绑定一样,您还可以绑定到属性和样式,以及将 'true' 更改为其他表达式,以便 Angular 根据需要自动添加或移除相应值。

在某些情况下,需要动态计算 key。您还可以绑定到一个返回一组值或映射值的信号或函数:

@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 元素执行 mutate 操作

人们很想尝试使用 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 操作和访问后,还可能会有一些不可避免的其余问题。在这种情况下,请务必尽可能推迟应用。afterRenderafterNextRender 回调是实现此目的的好方法,因为它们仅在 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 处于一致的状态。afterRenderafterNextRender 接受 phaseEarlyReadReadWrite。如果在编写 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 服务器端渲染做出了许多令人兴奋的新改进,目的是让您更轻松地为用户提供出色的体验。我们希望上述提示能够帮助您在自己的应用和库中充分利用它们!