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ó được nhiều tính năng mới như lượng nước uốngchế độ xem phản cảm để giúp nhà phát triển cải thiện Chỉ số quan trọng chính của trang web cũng như đả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 bổ sung liên quan đến tính năng kết xuất phía máy chủ được xây dựng dựa trên chức năng này, chẳng hạn như truyền trực tuyến và thay thế một phần dữ liệu.

Thật không may, 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 hướng dẫn sau đây giải thích cách bạn có thể đạt được điều này và xây dựng các thành phần Angular thực sự phổ biến và sẵn sàng cho tương lai. Các thành phần này có thể khai thác tối đa tất cả tính năng mới và sắp ra mắt của Angular.

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

Không có gì bất ngờ, cách tốt nhất để tránh các vấn đề mà thao túng DOM thủ công gây ra là tránh hoàn toàn 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ử máy chủ 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 lớp, kiểu hoặc thuộc tính thay vì nhắm mục tiêu hoặc giới thiệu phần tử trình bao bọc. Bạn muốn chỉ cần tiếp cận 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 {
  /* ... */
}

Giống như với tính năng 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 sẽ muốn tìm cách thao tác DOM 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 mà không cần áp dụng tín hiệu trên toàn bộ cơ sở mã của mình.

Thay đổi các phần tử DOM bên ngoài một 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 phải coi mọi thành phần khác là một hộp đen. 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 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à không đủ.

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

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

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

Trì hoãn thao tác DOM theo cách thủ công

Sau khi sử dụng các nguyên tắc trước đó để hạn chế tối đa việc thao túng và truy cập DOM trực tiếp, bạn có thể vẫn còn một số vấn đề không tránh khỏi. Trong những trường hợp như vậy, bạn cần trì hoãn thời gian đó càng lâu càng tốt. Các lệnh gọi lại afterRenderafterNextRender là một cách hay để làm 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 xem có thay đổi nào không và đưa chúng vào 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 có đang chạy trên trình duyệt hay loại bỏ hành vi trên máy chủ hay không, 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 phải đọ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 trường hợp 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 viết bố cục buộc trình duyệt phải tính toán lại bố cục một cách đồng bộ. Điều này có thể ảnh hưởng nghiêm trọng đến hiệu suất (xem phần đập dừng bố cục). Do đó, điều quan trọng là phải cẩn thận phân chia logic thành các giai đoạn phù hợp.

Ví dụ: một thành phần chú thích muốn hiển thị một chú thích tương ứng với một thành phần khác trên trang thường sẽ trải qua 2 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ự đặt lại vị trí chú thích:

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

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