D
P
0

JavaScript

SplitText Kustom Menampilkan Teks `&` Mentah di Layar? Kamu Membaca `innerHTML`

12 Juli 2026·3 menit baca
SplitText Kustom Menampilkan Teks `&` Mentah di Layar? Kamu Membaca `innerHTML`

Saya bikin implementasi SplitText sendiri untuk sebuah situs klien, karena butuh animasi teks per-kata dengan GSAP tanpa menambah plugin berbayar. Semuanya jalan mulus sampai QA mengirim satu screenshot: sebuah heading yang di sumbernya berbunyi & tržišnu percepciju malah tampil di layar sebagai &amp; tržišnu percepciju. Karakter &-nya berubah jadi entity HTML mentah, terlihat oleh pengunjung. Dan ada satu bug tambahan: heading yang punya <br> untuk memaksa baris kedua malah kolaps jadi satu baris yang membungkus sendiri.

Refleks pertama saya salah arah. Saya kira ini masalah encoding dari CMS, jadi saya cek data mentahnya. Bersih. Di database dan di respons server, karakternya adalah & biasa, satu ampersand normal. Baru setelah render SplitText jalan, ampersand itu berubah jadi &amp;. Artinya bukan datanya yang rusak. Kode saya sendiri yang menciptakan entity itu dari ampersand yang tadinya baik-baik saja.

Kenapa ini terjadi

Saya buka lagi routine split saya, dan langsung ketahuan pelakunya. Untuk memecah heading jadi kata, saya membaca markup elemen lewat getter innerHTML, lalu menjalankan regex di string itu untuk membuang tag dan mengambil teksnya. Kelihatannya masuk akal. Masalahnya, getter innerHTML bukan cuma menyalin apa yang kamu ketik, dia meng-encode ulang teks agar jadi HTML yang valid. Jadi sebuah & di dalam DOM balik ke saya sebagai string &amp;. Regex saya cuma melihat karakter, tidak tahu bahwa &amp; itu satu entity. Jadi dia menyalin lima karakter itu apa adanya ke output, dan browser menampilkannya persis begitu.

<br> juga korban dari pendekatan yang sama. Karena saya membaca innerHTML sebagai string, <br> cuma jadi token teks yang di-strip oleh regex saya, bukan sebuah pemisah baris. Jadi struktur baris yang disengaja di heading itu hilang.

Dan ada satu jebakan ketiga yang lebih halus. Ada bug terpisah yang mengosongkan elemen sebelum saya sempat menelusurinya, jadi begitu jalur "kata" berjalan, elemennya sudah kosong dan semua segmen hilang. Tiga masalah menumpuk jadi satu, tapi akarnya sama: saya memperlakukan DOM sebagai string HTML, padahal seharusnya saya menelusurinya sebagai pohon node.

Perbaikannya

Solusinya adalah berhenti membaca innerHTML sama sekali, dan menelusuri childNodes yang hidup langsung dari DOM, SEBELUM mengosongkan elemennya. Node teks memberi saya node.textContent, yang sudah di-decode oleh browser, jadi & tetap &. Untuk elemen, saya cek tagName: kalau BR, saya tutup segmen saat ini dan mulai baris baru; kalau bukan, saya rekursi ke dalamnya.

class CustomSplitText {
  split() {
    const segments = [];
    let buffer = "";
 
    const walk = (node) => {
      for (const child of node.childNodes) {
        if (child.nodeType === Node.TEXT_NODE) {
          buffer += child.textContent;
        } else if (child.nodeType === Node.ELEMENT_NODE) {
          if (child.tagName === "BR") {
            segments.push(buffer);
            buffer = "";
          } else {
            walk(child);
          }
        }
      }
    };
 
    walk(this.element);
    segments.push(buffer);
 
    this.renderWords(segments);
  }
}

Perhatikan urutannya: saya menelusuri this.element.childNodes DULU, mengumpulkan semua segmen, baru setelah itu me-render ulang isinya. Karena textContent sudah didecode browser, tidak ada lagi &amp; yang muncul. Karena saya menangani BR sebagai batas segmen sungguhan, bukan token string, struktur baris multi-line kembali utuh.

Untuk bug ketiga, kuncinya adalah urutan clear. Pengosongan elemen HARUS tetap berada di dalam cabang pemrosesan karakter, bukan di puncak split(). Kalau saya mengosongkan elemen di awal, jalur penelusuran kata berjalan di atas elemen yang sudah kosong dan semua segmen lenyap. Dengan menahan clear di cabang chars, penelusuran kata selalu berjalan di atas DOM yang masih utuh.

splitChars() {
  const chars = this.collectChars();
  this.element.innerHTML = ""; // clear HANYA di sini, setelah membaca
  this.renderChars(chars);
}

Pelajaran

Getter innerHTML itu serializer, bukan cermin. Dia mengembalikan HTML yang di-encode ulang, jadi begitu kamu menjalankan operasi string di atasnya, entity seperti &amp;, &lt;, &gt; akan bocor ke output sebagai teks literal. Kalau yang kamu butuhkan adalah teks sungguhan yang dilihat pengguna, baca textContent dari node, atau lebih baik lagi, telusuri childNodes sebagai pohon: node teks memberi teks yang sudah di-decode, dan elemen seperti <br> bisa kamu tangani secara semantik alih-alih sebagai token string. Dan kalau kamu perlu mengosongkan sebuah elemen sebagai bagian dari transformasi, baca dia dulu sampai tuntas, lalu baru kosongkan, jangan pernah terbalik. Sejak saya berhenti mem-parse HTML dengan regex dan mulai menelusuri DOM, ampersand-nya kembali jadi ampersand.