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ư tính năng uống nướcchế độ xem có thể định giá để giúp nhà phát triển cải thiện Các chỉ số quan trọng về trang web và đảm bảo trải nghiệm chất lượng cao cho người dùng cuối. Nghiên cứu về các tính năng bổ sung liên quan đến việc hiển thị phía máy chủ được xây dựng dựa trên chức năng này cũng đang được tiến hành, chẳng hạn như truyền trực tuyến và uống nước 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 các tính năng mới và sắp ra mắt này: thao tác thủ công với cấu trúc DOM cơ bản. Angular yêu cầu cấu trúc của DOM phải 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 cấp nước trên trình duyệt. Việc sử dụng API ElementRef, Renderer2 hoặc DOM để thêm, di chuyển hoặc xoá nút khỏi DOM theo cách thủ công trước khi quá trình hydrat hoá có thể gây ra sự không nhất quán khiến các tính năng này không hoạt động.

Tuy nhiên, không phải mọi thao tác và truy cập DOM theo cách thủ công đều gặp vấn đề và đôi khi việc này là cần thiết. Điều quan trọng để 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 đó hoãn việc sử dụng DOM càng lâu càng tốt. Các hướng dẫn sau giải thích cách bạn có thể thực hiện việc này, cũng như xây dựng các thành phần Angular thực sự phổ biến và phù hợp với tương lai, có thể khai thác 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úng 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 đề mà thao tác DOM theo cách thủ công là tránh hoàn toàn bất cứ khi nào có thể. Angular tích hợp sẵn các API và mẫu có thể thao tác hầu hết các khía cạnh của DOM: bạn nên ưu tiê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, có thể bạn phải 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 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 chỉ cần truy cập 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ủ lưu trữ để khai báo liên kết các giá trị với một biểu thức:

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

Tương tự 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ị khi 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 tệp á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ị này với một tín hiệu như trong ví dụ trước. Bạn có thể làm việc này nếu cần và không cần phải áp dụng các tín hiệu trên toàn bộ cơ sở mã.

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

Bạn sẽ rất muốn sử dụng DOM để truy cập các phần tử thường không 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 thành phần con khác. Tuy nhiên, điều này rất dễ xảy ra lỗi, vi phạm việc đó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 đó sau này.

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í mà 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 hoặc giao diện của thành phần, sau đó đưa ra cách thức an toàn và được ghi lại trong 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 các thuộc tính @Input@Output đơn giản là không đủ.

Trước đây, thường thì bạn nên 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 số phần tử lưu trữ khác, sau đó di chuyển hoặc chiếu nội dung ở đó. Tuy nhiên, vào những ngày này, 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 làm theo các nguyên tắc nêu trên để giảm thiểu việc thao túng và truy cập DOM trực tiếp nhiều nhất có thể, bạn có thể vẫn còn một số nội dung không thể tránh khỏi. Trong những trường hợp như vậy, bạn cần hoãn đăng ký càng lâu càng tốt. Các lệnh gọi lại afterRenderafterNextRender là một phương pháp hiệu quả để 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à gửi chúng vào DOM.

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

Trong một số trường hợp, bạn sẽ có 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 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ần đọc hoặc ghi vào DOM để thực hiện một số bố cục mà các trình duyệt mục tiêu 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, 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ị phase của EarlyRead, Read hoặc Write. Việc đọc bố cục DOM sau khi ghi sẽ buộc trình duyệt 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 bài viết: xử lý bố cục). Do đó, điều quan trọng là bạn phải cẩn thận phân chia logic 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ị một chú giải công cụ có liên quan đến một thành phần khác trên trang sẽ có thể sử dụng hai giai đoạn. Trước tiên, giai đoạn EarlyRead sẽ được dùng để thu nạ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í của 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ể xử lý DOM theo lô một cách hiệu quả trên mọi thành phần khác trong ứng dụng, đảm bảo giảm thiểu tác động đến hiệu suất.

Kết luận

Sắp có nhiều điểm cải tiến mới và thú vị đối với tính năng hiển thị phía máy chủ của Angular, mục tiêu là 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ên 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!