使用 Angular SSR 安全地存取 DOM

Gerald Monaco
Gerald Monaco

過去一年來,Angular 推出了飲水量可延遲檢視畫面等許多新功能,協助開發人員改善Core Web Vitals,確保使用者能享有絕佳體驗。我們也正在研究其他以這項功能為基礎的伺服器端算繪相關功能,例如串流與部分飲水。

遺憾的是,有一種模式可能會導致應用程式或程式庫無法充分利用所有這些全新和即將推出的功能:手動操控基礎 DOM 結構。Angular 規定,當元件被伺服器序列化時, DOM 的結構必須保持一致,直到瀏覽器為元件供水為止。如果在補充水之前使用 ElementRefRenderer2 或 DOM API 手動新增、移動或移除 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()}`];
  });
}

在較複雜的應用程式中,為避免發生 ExpressionChangedAfterItHasBeenCheckedError 而手動操控 DOM。但可以像上述範例一樣,將值繫結到信號。您可以根據需要,不需要在整個程式碼集中採用信號。

更改範本外的 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 接受 EarlyReadReadWritephase 值。如果在編寫 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 伺服器端的算繪功能現在推出許多令人驚豔的改善項目,希望更輕鬆地為使用者提供優質的服務。我們希望先前的提示能夠幫助您在應用程式和程式庫中,充分利用這些提示!