使用 Angular SSR 安全地访问 DOM

Gerald Monaco
Gerald Monaco

在过去一年中,Angular 获得了许多新功能(例如 hydration可延期视图),以帮助开发者改进 Core Web Vitals 并确保其最终用户获得出色的体验。我们还在对基于此功能的其他服务器端渲染相关功能进行调查,例如流式传输和部分水合。

遗憾的是,有一种模式可能会阻止您的应用或库充分利用所有这些新功能和即将推出的功能:手动处理底层 DOM 结构。从服务器序列化组件到浏览器水化期间,Angular 要求 DOM 的结构必须保持一致。在水合之前,使用 ElementRefRenderer2 或 DOM API 在 DOM 中手动添加、移动或移除节点,可能会导致不一致的情况,使这些功能无法正常运行。

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

避免手动 DOM 操作

毫无疑问,要避免手动 DOM 操作所导致的问题,最好的办法就是尽可能完全避免这种问题。Angular 具有内置 API 和模式,可以操纵 DOM 的大部分方面:您应该优先使用这些 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 操作和访问后,您可能还会遇到不可避免的遗留问题。在这种情况下,请务必尽可能延后该数据。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 服务器端渲染进行许多激动人心的新改进,这些改进旨在让您更轻松地为用户提供出色的体验。我们希望前面的提示能帮助您在自己的应用和库中充分利用这些技巧!