Unicode & Character Encoding — i18n

Unicode & Character Encoding Sebelum Unicode, setiap bahasa punya encoding sendiri (ASCII, ISO-8859-1, Shift-JIS, dll). Ini menyebabkan "mojibake" — teks yang…

Unicode memberi tiap karakter dari semua aksara di dunia satu nomor, disebut code point. Encoding adalah cara menulis nomor itu sebagai byte, dan di web hampir selalu UTF-8. Bayangin Bu Sri mencatat pesanan di buku tamu dengan kotak-kotak kecil. Tiap huruf punya nomor di satu daftar besar (Unicode). Ke buku, nomornya ditulis dengan sandi hemat: huruf Latin cukup 1 kotak, huruf Arab 2 kotak, huruf Jepang 3 kotak, emoji 4 kotak (UTF-8). Kalau orang lain membaca buku itu dengan sandi yang berbeda, yang keluar huruf aneh. Itu mojibake.

🧪 Tebakan cepat: Tamu dari Jepang mengetik 予約 ("pesan", dua huruf) di kolom catatan. Berapa '予約'.length di JavaScript, dan berapa byte yang dikirim ke server: 2 dan 2, 2 dan 6, atau 6 dan 6? Jawabannya ada di bagian "Tiga cara menghitung panjang teks".

Code point: nomor untuk tiap karakter

Dulu tiap bahasa punya encoding sendiri. ASCII cuma punya 128 karakter (huruf Inggris, angka, tanda baca, nomor 0 sampai 127). Untuk bahasa Jepang ada Shift_JIS, untuk bahasa Arab windows-1256, untuk Eropa Barat windows-1252. Satu file gak bisa memuat bahasa Jepang dan Arab sekaligus.

Unicode menyatukannya: satu daftar untuk semua aksara. Unicode 17.0 (2025) berisi 159.801 karakter. Code point ditulis U+ lalu nomornya dalam heksadesimal:

Karakter Code point Keterangan
R U+0052 Huruf Latin, sama dengan nomor ASCII-nya
予 U+4E88 Huruf Jepang (kanji)
ح U+062D Huruf Arab ha
👨 U+1F468 Emoji laki-laki
(gak kelihatan) U+200D Zero width joiner (ZWJ), perekat antar emoji
'予'.codePointAt(0).toString(16);   // "4e88"
String.fromCodePoint(0x062d);        // "ح"

UTF-8: dari nomor ke byte

UTF-8 menulis tiap code point sebagai 1 sampai 4 byte. Makin besar nomornya, makin banyak byte-nya:

Rentang code point Byte Contoh
U+0000 sampai U+007F 1 Huruf Latin, angka (persis sama dengan ASCII)
U+0080 sampai U+07FF 2 Huruf Arab, é, spasi tak terputus U+00A0
U+0800 sampai U+FFFF 3 Huruf Jepang dan sebagian besar huruf Tionghoa
U+10000 ke atas 4 Emoji

Kami mengukurnya dengan TextEncoder, yang mengubah teks jadi byte UTF-8:

Teks Byte UTF-8 (heksadesimal) Jumlah
Rp 700.000 (diketik) 52 70 20 37 30 30 2E 30 30 30 10
Rp 700.000 dari Intl.NumberFormat 52 70 C2 A0 37 30 30 2E 30 30 30 11
予約 E4 BA 88 E7 B4 84 6
حجز D8 AD D8 AC D8 B2 6
👨‍👩‍👧 F0 9F 91 A8 E2 80 8D F0 9F 91 A9 E2 80 8D F0 9F 91 A7 18

Tiga cara menghitung panjang teks

String JavaScript disimpan sebagai UTF-16: tiap code point jadi satu atau dua code unit 16-bit. Code point di atas U+FFFF (emoji) butuh dua code unit, disebut surrogate pair. Jadi ada tiga hitungan yang berbeda, plus byte UTF-8:

Teks .length (code unit UTF-16) [...s].length (code point) Intl.Segmenter (yang terlihat) Byte UTF-8
Rp 700.000 10 10 10 10
予約 2 2 2 6
حجز 3 3 3 6
👨‍👩‍👧 8 5 1 18

Jadi '予約'.length adalah 2, dan yang dikirim ke server 6 byte. Itu jawaban tebakannya.

const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}'; // 👨‍👩‍👧
const seg = new Intl.Segmenter('id', { granularity: 'grapheme' });
const bytes = new TextEncoder();

for (const s of ['Rp 700.000', '予約', 'حجز', family]) {
  console.log(s, s.length, [...s].length, [...seg.segment(s)].length, bytes.encode(s).length);
}
// Rp 700.000 10 10 10 10
// 予約 2 2 2 6
// حجز 3 3 3 6
// 👨‍👩‍👧 8 5 1 18

Memotong dengan .slice bisa membelah surrogate pair. 'Mantap👍'.slice(0, 7) menyisakan separuh emoji (code unit \ud83d), dan waktu disimpan sebagai UTF-8 separuh itu berubah jadi U+FFFD, karakter pengganti � (byte EF BF BD).

Normalisasi: é yang kembar

Satu huruf yang terlihat sama bisa ditulis dengan dua cara. "é" bisa satu code point U+00E9 (bentuk NFC), atau "e" plus tanda aksen U+0301 (bentuk NFD).

const a = '\u00E9';        // é, 1 code point
const b = 'e\u0301';       // e plus aksen, 2 code point
a === b;                                   // false
a.length; b.length;                        // 1, 2
a.normalize('NFC') === b.normalize('NFC'); // true

Nama tamu "José" yang disalin dari dua sumber bisa gagal dicocokkan, dan pencarian ikut kacau: 'José'.includes('Jose') memberi false untuk bentuk NFC, tapi true untuk bentuk NFD. Jalankan normalize('NFC') waktu teks masuk (sebelum disimpan atau dibandingkan), supaya semua data memakai bentuk yang sama.

Mojibake: byte benar, cara baca salah

Mojibake terjadi waktu byte UTF-8 dibaca dengan encoding lain. Kami mengubah teks jadi byte UTF-8, lalu membacanya sebagai windows-1252:

const bytes = new TextEncoder().encode('予約');      // E4 BA 88 E7 B4 84
new TextDecoder('windows-1252').decode(bytes);      // "予約"
new TextDecoder('utf-8').decode(bytes);             // "予約"

Kotak kosong (tofu) beda lagi: datanya benar, tapi font di perangkat itu gak punya gambar (glyph) untuk code point tersebut, misalnya emoji baru di HP lama. Tanda tanya ? biasanya berarti teksnya pernah disimpan ke encoding yang gak punya karakter itu, misalnya kolom database yang bukan UTF-8.

Menyatakan UTF-8 di halaman, server, dan database

<!doctype html>
<html lang="id">
<head>
  <meta charset="utf-8">   <!-- baris pertama di <head> -->
  <title>Kamar Teras · Homestay Kabut</title>

⚠️ Jebakan yang sering ketemu

TL;DR: Unicode memberi tiap karakter satu code point (U+4E88 untuk 予); Unicode 17.0 punya 159.801 karakter. UTF-8 menulis code point sebagai 1 sampai 4 byte: Latin 1, Arab 2, Jepang 3, emoji 4. UTF-8 bisa menyimpan semua karakter dunia, sedangkan ASCII cuma 128, dan teks ASCII sudah sah sebagai UTF-8. Di JavaScript, .length menghitung code unit UTF-16, [...s].length code point, Intl.Segmenter yang terlihat, TextEncoder byte: '予約' itu 2, 2, 2, dan 6 byte; emoji keluarga 8, 5, 1, dan 18 byte. Satu huruf bisa NFC atau NFD, jadi normalize('NFC') sebelum membandingkan. Mojibake adalah byte UTF-8 yang dibaca dengan encoding lain ("予約", "RpÂ"); tofu adalah font yang gak punya glyph-nya. Nyatakan UTF-8 di <meta charset>, header Content-Type, dan database utf8mb4.

Yang akan kamu pelajari