Mengakses DOM dengan aman menggunakan SSR Angular

Gerald Monako
Gerald Monako

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 tentang fitur terkait rendering sisi server tambahan yang memanfaatkan fungsi ini juga sedang berlangsung, seperti streaming dan hidrasi sebagian.

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

Namun, tidak semua manipulasi dan akses DOM manual bermasalah, dan terkadang hal ini diperlukan. Kunci untuk menggunakan DOM dengan aman adalah dengan meminimalkan kebutuhan Anda akan DOM sebanyak mungkin, kemudian menunda penggunaannya selama mungkin. Panduan berikut menjelaskan cara mencapainya dan mem-build komponen Angular yang benar-benar universal dan siap menghadapi masa depan yang dapat memanfaatkan sepenuhnya semua fitur baru dan mendatang Angular.

Menghindari manipulasi DOM manual

Cara terbaik untuk menghindari masalah yang disebabkan oleh manipulasi DOM manual adalah, secara tidak mengherankan, menghindarinya sama sekali jika memungkinkan. Angular memiliki API dan pola bawaan yang dapat memanipulasi sebagian besar aspek DOM: Anda sebaiknya 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), misalnya, menambahkan class, gaya, atau atribut, bukan menargetkan atau memperkenalkan elemen wrapper. Anda mungkin ingin menjangkau ElementRef saja untuk mengubah elemen DOM yang mendasarinya. Sebagai gantinya, Anda harus menggunakan binding host untuk mengikat nilai ke ekspresi secara deklaratif:

@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 berbeda yang akan digunakan Angular untuk secara otomatis menambahkan atau menghapus nilai sesuai kebutuhan.

Dalam beberapa kasus, kunci perlu 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()}`];
  });
}

Pada aplikasi yang lebih kompleks, Anda mungkin ingin mencoba manipulasi DOM manual untuk menghindari ExpressionChangedAfterItHasBeenCheckedError. Sebagai gantinya, Anda dapat mengikat nilai ke sinyal seperti dalam 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 ingin mencoba menggunakan DOM untuk mengakses elemen yang biasanya tidak dapat diakses, seperti milik komponen induk atau turunan lainnya. Namun, hal ini rentan terhadap error, melanggar enkapsulasi, dan menyulitkan untuk mengubah atau mengupgrade komponen tersebut di masa mendatang.

Sebagai gantinya, komponen Anda harus menganggap setiap komponen lainnya 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 mengekspos cara yang aman dan didokumentasikan untuk melakukannya. Gunakan fitur seperti injeksi dependensi hierarkis untuk membuat API tersedia ke subhierarki jika properti @Input dan @Output sederhana tidak memadai.

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

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

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

Tunda manipulasi DOM manual

Setelah menggunakan panduan sebelumnya untuk meminimalkan manipulasi dan akses DOM langsung Anda sebanyak mungkin, masih ada sisa yang tidak dapat dihindari. Dalam kasus seperti itu, penting untuk menundanya selama mungkin. Callback afterRender dan afterNextRender adalah cara yang bagus untuk melakukannya, karena hanya berjalan di browser, setelah Angular memeriksa setiap perubahan dan meng-commitnya 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 sedang berjalan di browser, atau menghentikan perilaku di server, Anda dapat 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, seperti memosisikan tooltip. afterRender adalah pilihan yang tepat untuk hal ini, karena Anda dapat memastikan bahwa DOM dalam status konsisten. afterRender dan afterNextRender menerima nilai phase sebesar EarlyRead, Read, atau Write. Membaca tata letak DOM setelah menulis 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 ke dalam fase yang benar secara hati-hati.

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

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

Kemudian, fase Write akan menggunakan nilai yang telah 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 kita ke dalam fase yang benar, Angular mampu secara efektif mengelompokkan manipulasi DOM 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 dalam memberikan pengalaman terbaik bagi pengguna. Semoga tips sebelumnya akan bermanfaat untuk membantu Anda memanfaatkannya secara optimal dalam aplikasi dan library Anda.