Truy cập vào DOM một cách an toàn bằng Angular SSR

Gerald Monaco
Gerald Monaco

Trong năm qua, Angular đã có thêm nhiều tính năng mới như tái tạothành phần hiển thị có thể trì hoãn để giúp nhà phát triển cải thiện Chỉ số quan trọng chính của trang web và đảm bảo trải nghiệm chất lượng cao cho người dùng cuối. Chúng tôi cũng đang nghiên cứu các tính năng khác liên quan đến hoạt động kết xuất phía máy chủ dựa trên chức năng này, chẳng hạn như truyền trực tuyến và hydrat hoá một phần.

Rất tiếc, có một mẫu có thể ngăn ứng dụng hoặc thư viện của bạn tận dụng tối đa tất cả các tính năng mới và sắp ra mắt này, đó là thao tác thủ công cấu trúc DOM cơ bản. Angular yêu cầu cấu trúc của DOM duy trì nhất quán từ thời điểm một thành phần được máy chủ chuyển đổi tuần tự cho đến khi thành phần đó được xác thực trên trình duyệt. Việc sử dụng các API ElementRef, Renderer2 hoặc DOM để thêm, di chuyển hoặc xoá các nút khỏi DOM theo cách thủ công trước khi quá trình hydrat hoá có thể gây ra những điểm không thống nhất khiến các tính năng này không hoạt động được.

Tuy nhiên, không phải tất cả các thao tác và truy cập DOM thủ công đều có vấn đề và đôi khi là cần thiết. Chìa khoá để sử dụng DOM một cách an toàn là giảm thiểu nhu cầu sử dụng DOM càng nhiều càng tốt, sau đó trì hoãn việc sử dụng DOM càng lâu càng tốt. Các nguyên tắc sau đây giải thích cách bạn có thể thực hiện việc này và xây dựng các thành phần Angular thực sự phổ quát và phù hợp với tương lai, có thể tận dụng tối đa tất cả các tính năng mới và sắp ra mắt của Angular.

Tránh thao tác DOM theo cách thủ công

Không có gì đáng ngạc nhiên khi cách tốt nhất để tránh các vấn đề do thao tác thủ công với DOM gây ra là tránh thao tác thủ công với DOM bất cứ khi nào có thể. Angular có các API và mẫu tích hợp có thể thao túng hầu hết các khía cạnh của DOM: bạn nên sử dụng chúng thay vì truy cập trực tiếp vào DOM.

Thay đổi phần tử DOM riêng của một thành phần

Khi viết một thành phần hoặc lệnh, bạn có thể cần sửa đổi phần tử lưu trữ (tức là phần tử DOM khớp với bộ chọn của thành phần hoặc lệnh) để thêm một lớp, kiểu hoặc thuộc tính, thay vì nhắm mục tiêu hoặc giới thiệu một phần tử trình bao bọc. Bạn có thể chỉ cần sử dụng ElementRef để thay đổi phần tử DOM cơ bản. Thay vào đó, bạn nên sử dụng liên kết máy chủ để liên kết khai báo các giá trị với một biểu thức:

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

Cũng giống như với liên kết dữ liệu trong HTML, bạn cũng có thể liên kết với các thuộc tính và kiểu, đồng thời thay đổi 'true' thành một biểu thức khác mà Angular sẽ sử dụng để tự động thêm hoặc xoá giá trị nếu cần.

Trong một số trường hợp, khoá sẽ cần được tính toán động. Bạn cũng có thể liên kết với một tín hiệu hoặc hàm trả về một tập hợp hoặc ánh xạ các giá trị:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

Trong các ứng dụng phức tạp hơn, bạn có thể muốn thao tác DOM theo cách thủ công để tránh ExpressionChangedAfterItHasBeenCheckedError. Thay vào đó, bạn có thể liên kết giá trị với một tín hiệu như trong ví dụ trước. Bạn có thể thực hiện việc này khi cần và không cần áp dụng tín hiệu trên toàn bộ cơ sở mã.

Biến đổi các phần tử DOM bên ngoài mẫu

Bạn sẽ muốn sử dụng DOM để truy cập vào các phần tử thường không thể truy cập được, chẳng hạn như các phần tử thuộc về các thành phần mẹ hoặc con khác. Tuy nhiên, điều này dễ gặp lỗi, vi phạm đóng gói và gây khó khăn cho việc thay đổi hoặc nâng cấp các thành phần đó trong tương lai.

Thay vào đó, thành phần của bạn nên coi mọi thành phần khác là một hộp đen. Hãy dành thời gian xem xét thời điểm và vị trí các thành phần khác (ngay cả trong cùng một ứng dụng hoặc thư viện) có thể cần tương tác với hoặc tuỳ chỉnh hành vi hay giao diện của thành phần, sau đó đưa ra cách thực hiện an toàn và được lập tài liệu. Hãy sử dụng các tính năng như chèn phần phụ thuộc phân cấp để cung cấp API cho cây con khi thuộc tính @Input@Output đơn giản là chưa đủ.

Trước đây, thường thì các tính năng như hộp thoại phương thức hoặc chú giải công cụ được triển khai bằng cách thêm một phần tử vào cuối <body> hoặc một số phần tử lưu trữ khác, sau đó di chuyển hoặc chiếu nội dung vào đó. Tuy nhiên, ngày nay, bạn có thể hiển thị một phần tử <dialog> đơn giản trong mẫu:

@Component({
  selector: 'my-component',
  template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
  @ViewChild('dialog') dialogRef!: ElementRef;

  constructor() {
    afterNextRender(() => {
      this.dialogRef.nativeElement.showModal();
    });
  }
}

Hoãn thao tác DOM theo cách thủ công

Sau khi áp dụng các nguyên tắc trước đó để giảm thiểu thao tác và quyền truy cập trực tiếp vào DOM nhiều nhất có thể, bạn vẫn có thể phải thực hiện một số thao tác không thể tránh khỏi. Trong những trường hợp như vậy, điều quan trọng là bạn phải trì hoãn việc này càng lâu càng tốt. Lệnh gọi lại afterRenderafterNextRender là một cách tuyệt vời để thực hiện việc này, vì các lệnh gọi lại này chỉ chạy trên trình duyệt, sau khi Angular đã kiểm tra mọi thay đổi và cam kết các thay đổi đó với DOM.

Chạy JavaScript chỉ dành cho trình duyệt

Trong một số trường hợp, bạn sẽ có một thư viện hoặc API chỉ hoạt động trong trình duyệt (ví dụ: thư viện biểu đồ, một số cách sử dụng IntersectionObserver, v.v.). Thay vì kiểm tra có điều kiện xem bạn đang chạy trên trình duyệt hay không hoặc loại bỏ hành vi trên máy chủ, bạn chỉ cần sử dụng afterNextRender:

@Component({
  /* ... */
})
export class MyComponent {
  @ViewChild('chart') chartRef: ElementRef;
  myChart: MyChart|null = null;
  
  constructor() {
    afterNextRender(() => {
      this.myChart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

Thực hiện bố cục tuỳ chỉnh

Đôi khi, bạn có thể cần đọc hoặc ghi vào DOM để thực hiện một số bố cục mà trình duyệt mục tiêu của bạn chưa hỗ trợ, chẳng hạn như định vị chú giải công cụ. afterRender là một lựa chọn tuyệt vời cho việc này, vì bạn có thể chắc chắn rằng DOM đang ở trạng thái nhất quán. afterRenderafterNextRender chấp nhận giá trị phaseEarlyRead, Read hoặc Write. Việc đọc bố cục DOM sau khi ghi buộc trình duyệt phải đồng bộ tính toán lại bố cục, điều này có thể ảnh hưởng nghiêm trọng đến hiệu suất (xem: bố cục bị lỗi). Do đó, điều quan trọng là bạn phải chia logic của mình thành các giai đoạn chính xác.

Ví dụ: một thành phần chú giải công cụ muốn hiển thị chú giải công cụ liên quan đến một phần tử khác trên trang có thể sẽ sử dụng hai giai đoạn. Trước tiên, giai đoạn EarlyRead sẽ được dùng để thu thập kích thước và vị trí của các phần tử:

afterRender(() => {
    targetRect = targetEl.getBoundingClientRect();
    tooltipRect = tooltipEl.getBoundingClientRect();
  }, { phase: AfterRenderPhase.EarlyRead },
);

Sau đó, giai đoạn Write sẽ sử dụng giá trị đã đọc trước đó để thực sự định vị lại chú giải công cụ:

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 },
);

Bằng cách chia logic thành các giai đoạn chính xác, Angular có thể phân lô thao tác DOM trên mọi thành phần khác trong ứng dụng một cách hiệu quả, đảm bảo tác động tối thiểu đến hiệu suất.

Kết luận

Sắp tới, chúng tôi sẽ có nhiều điểm cải tiến mới và thú vị cho tính năng kết xuất phía máy chủ của Angular, nhằm giúp bạn dễ dàng mang đến trải nghiệm tuyệt vời cho người dùng. Chúng tôi hy vọng các mẹo trước đó sẽ hữu ích trong việc giúp bạn khai thác tối đa các tính năng này trong ứng dụng và thư viện của mình!