Mengakses DOM dengan aman menggunakan SSR Angular

Gerald Monaco
Gerald Monaco

Selama setahun terakhir, Angular telah mendapatkan banyak fitur baru seperti hidrasi dan tampilan yang dapat ditangguhkan untuk membantu developer meningkatkan Data Web Inti dan memastikan pengalaman yang luar biasa bagi pengguna akhir mereka. Riset terkait fitur terkait rendering sisi server tambahan yang dibuat berdasarkan fungsi ini juga sedang berlangsung, seperti streaming dan hidrasi sebagian.

Sayangnya, ada satu pola yang mungkin mencegah aplikasi atau library Anda memanfaatkan semua fitur baru dan mendatang ini secara maksimal: manipulasi manual struktur DOM yang mendasarinya. Angular mewajibkan struktur DOM tetap konsisten sejak komponen diserialisasi oleh server, hingga di-hydrate di browser. Menggunakan ElementRef, Renderer2, atau DOM API untuk menambahkan, memindahkan, atau menghapus node dari DOM secara manual sebelum hidrasi dapat menimbulkan inkonsistensi yang mencegah fitur ini berfungsi.

Namun, tidak semua manipulasi dan akses DOM manual bermasalah, dan terkadang diperlukan. Kunci untuk menggunakan DOM dengan aman adalah meminimalkan kebutuhan Anda sebanyak mungkin, lalu menunda penggunaannya selama mungkin. Panduan berikut menjelaskan bagaimana Anda dapat melakukannya dan membangun komponen Angular yang benar-benar universal dan tahan masa depan yang dapat memanfaatkan sepenuhnya semua fitur Angular baru dan mendatang.

Menghindari manipulasi DOM manual

Cara terbaik untuk menghindari masalah yang disebabkan oleh manipulasi DOM manual adalah, tidak mengherankan, dengan menghindarinya sama sekali jika memungkinkan. Angular memiliki API dan pola bawaan yang dapat memanipulasi sebagian besar aspek DOM: Anda harus lebih memilih menggunakannya daripada mengakses DOM secara langsung.

Mengubah elemen DOM komponen sendiri

Saat menulis komponen atau perintah, Anda mungkin perlu mengubah elemen host (yaitu, elemen DOM yang cocok dengan pemilih komponen atau perintah) untuk, misalnya, menambahkan class, gaya, atau atribut, bukan menargetkan atau memperkenalkan elemen wrapper. Anda mungkin tergoda untuk langsung menggunakan ElementRef untuk memutasi elemen DOM yang mendasarinya. Sebagai gantinya, Anda harus menggunakan binding host untuk men-bind nilai secara deklaratif ke ekspresi:

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

Sama seperti data binding di HTML, Anda juga dapat, misalnya, mengikat ke atribut dan gaya, serta mengubah 'true' ke ekspresi lain yang akan digunakan Angular untuk otomatis menambahkan atau menghapus nilai sesuai kebutuhan.

Dalam beberapa kasus, kunci harus dihitung secara dinamis. Anda juga dapat mengikat ke sinyal atau fungsi yang menampilkan kumpulan atau peta nilai:

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

Dalam aplikasi yang lebih kompleks, Anda mungkin tergoda untuk menggunakan manipulasi DOM manual untuk menghindari ExpressionChangedAfterItHasBeenCheckedError. Sebagai gantinya, Anda dapat mengikat nilai ke sinyal seperti pada contoh sebelumnya. Hal ini dapat dilakukan sesuai kebutuhan, dan tidak memerlukan penerapan sinyal di seluruh codebase Anda.

Mengubah elemen DOM di luar template

Anda mungkin tergoda untuk mencoba menggunakan DOM guna mengakses elemen yang biasanya tidak dapat diakses, seperti elemen yang termasuk dalam komponen induk atau turunan lainnya. Namun, hal ini rentan terhadap error, melanggar enkapsulasi, dan mempersulit perubahan atau upgrade komponen tersebut di masa mendatang.

Sebagai gantinya, komponen Anda harus menganggap setiap komponen lain sebagai kotak hitam. Luangkan waktu untuk mempertimbangkan kapan dan di mana komponen lain (bahkan dalam aplikasi atau library yang sama) mungkin perlu berinteraksi dengan atau menyesuaikan perilaku atau tampilan komponen Anda, lalu tampilkan cara yang aman dan terdokumentasi untuk melakukannya. Gunakan fitur seperti injeksi dependensi hierarkis untuk menyediakan API ke sub-pohon jika properti @Input dan @Output sederhana tidak memadai.

Secara historis, penerapan fitur seperti dialog modal atau tooltip dengan menambahkan elemen ke akhir <body> atau beberapa elemen host lainnya, lalu memindahkan atau memproyeksikan konten di sana adalah hal yang umum. Namun, saat ini Anda mungkin dapat merender elemen <dialog> sederhana di template:

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

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

Menunda manipulasi DOM manual

Setelah menggunakan panduan sebelumnya untuk meminimalkan manipulasi DOM langsung dan mengakses sebanyak mungkin, Anda mungkin masih memiliki beberapa manipulasi yang tidak dapat dihindari. Dalam kasus semacam itu, penting untuk menundanya selama mungkin. Callback afterRender dan afterNextRender adalah cara yang bagus untuk melakukannya, karena hanya berjalan di browser, setelah Angular memeriksa perubahan apa pun dan melakukan commit ke DOM.

Menjalankan JavaScript khusus browser

Dalam beberapa kasus, Anda akan memiliki library atau API yang hanya berfungsi di browser (misalnya, library diagram, beberapa penggunaan IntersectionObserver, dll.). Daripada memeriksa secara kondisional apakah Anda menjalankan di browser, atau membuat stub perilaku di server, Anda cukup menggunakan afterNextRender:

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

Melakukan tata letak kustom

Terkadang Anda mungkin perlu membaca atau menulis ke DOM untuk melakukan beberapa tata letak yang belum didukung browser target Anda, seperti memosisikan tooltip. afterRender adalah pilihan yang tepat untuk ini, karena Anda dapat yakin bahwa DOM berada dalam status yang konsisten. afterRender dan afterNextRender menerima nilai phase dari EarlyRead, Read, atau Write. Membaca tata letak DOM setelah menulisnya akan memaksa browser menghitung ulang tata letak secara sinkron, yang dapat memengaruhi performa secara serius (lihat: layout thrashing). Oleh karena itu, penting untuk membagi logika Anda dengan cermat ke dalam fase yang benar.

Misalnya, komponen tooltip yang ingin menampilkan tooltip relatif terhadap elemen lain di halaman kemungkinan akan menggunakan dua fase. Fase EarlyRead pertama-tama akan digunakan untuk mendapatkan ukuran dan posisi elemen:

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

Kemudian, fase Write akan menggunakan nilai yang dibaca sebelumnya untuk benar-benar memosisikan ulang tooltip:

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

Dengan membagi logika menjadi fase yang benar, Angular dapat mengelompokkan manipulasi DOM secara efektif di setiap komponen lain dalam aplikasi, sehingga memastikan dampak performa yang minimal.

Kesimpulan

Ada banyak peningkatan baru dan menarik pada rendering sisi server Angular yang akan segera hadir, dengan tujuan mempermudah Anda memberikan pengalaman yang luar biasa bagi pengguna. Kami berharap tips sebelumnya akan bermanfaat dalam membantu Anda memanfaatkannya sepenuhnya di aplikasi dan koleksi Anda.