使用 Angular SSR 安全地存取 DOM

傑拉德摩納哥
Gerald 摩納哥

過去一年來,Angular 累積了許多新功能,例如飲水可延遲檢視畫面,協助開發人員改進網站體驗核心指標,確保使用者享有良好體驗。此外,我們也正在研究其他利用這項功能打造出的伺服器端算繪相關功能,例如串流和部分水分。

不過,有一個模式可能會導致您的應用程式或程式庫無法充分利用所有新功能和即將推出的功能:手動操控基礎 DOM 結構。Angular 需要從伺服器將元件序列化到 DOM 的結構保持一致,直到在瀏覽器中填滿元件為止。在飲水前使用 ElementRefRenderer2 或 DOM API 手動新增、移動或移除 DOM 中的節點,可能會產生不一致的問題,導致這些功能無法運作。

不過,並非所有手動操作的 DOM 操作及存取都有問題,有時確實是必要步驟。安全使用 DOM 的關鍵在於盡量減少您對 API 的需求,然後儘可能延遲使用。下列指南將說明如何達成這個目標,並建構真正能因應未來需求的通用 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 會在需要時自動新增或移除值。

在某些情況下,您必須動態計算「鍵」。您也可以繫結至傳回一組值或對應值的信號或函式:

@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 接受 phase 的值 EarlyReadReadWrite。如果在編寫 DOM 版面配置後讀取 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 伺服器端算繪功能推出許多全新且令人期待的改良功能,讓您可以更輕鬆地為使用者提供絕佳體驗。希望以上提示能協助您在應用程式和程式庫中充分運用這些功能!