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
'予約'.lengthdi 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 |
- Teks Latin tetap 1 byte per huruf, jadi file ASCII lama sudah sah sebagai UTF-8. Ini salah satu alasan UTF-8 menang di web.
- UTF-8 bisa menyimpan semua karakter Unicode, dari Latin, Arab, CJK, sampai emoji. ASCII cuma 128 karakter dan gak punya satu pun huruf Jepang. Itu keunggulan utama UTF-8 dibanding ASCII.
- Harga dari
Intl.NumberFormat('id-ID', ...)memakai spasi tak terputus (U+00A0) sesudah "Rp", jadi beratnya 11 byte, bukan 10. Matanya gak bisa membedakan; byte-nya bisa.
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
s.lengthmenghitung code unit UTF-16. Emoji keluarga itu 3 emoji plus 2 ZWJ: 5 code point, tiga di antaranya butuh surrogate pair, jadi 8.[...s]memecah per code point, jadi surrogate pair gak terbelah.Intl.Segmenterdengangranularity: 'grapheme'menghitung yang dilihat orang sebagai satu karakter, disebut grapheme: emoji keluarga itu satu. Hasil tabel ini sama di Chromium 147, Firefox 146, WebKit 26.4, dan Node 22.22.1.TextEncoder().encode(s).lengthmenghitung byte UTF-8: yang dipakai untuk batas ukuran request atau file.
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); // "予約"
- "予約" jadi "予約": 6 byte dibaca sebagai 6 huruf Eropa.
- Harga dari
Intljadi "Rp 700.000": byte C2 A0 dari spasi tak terputus dibaca sebagai "Â" plus spasi. Kalau kamu melihat "Â" nyasar di halaman, hampir pasti ini penyebabnya. - Kebalikannya juga terjadi: byte E9 ("é" di windows-1252) dibaca sebagai UTF-8 jadi
�, karena E9 sendirian bukan UTF-8 yang sah.
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>
<meta charset="utf-8">harus berada di 1.024 byte pertama halaman, karena browser cuma mengintip sejauh itu sebelum mulai membaca.- Header HTTP
Content-Type: text/html; charset=utf-8menang atas<meta>kalau keduanya beda. Laravel mengirimcharset=UTF-8untuk respons HTML secara bawaan. - Di MySQL,
utf8adalah nama lama untukutf8mb3yang maksimal 3 byte per karakter, jadi emoji (4 byte) gak muat. Pakaiutf8mb4; itu bawaan MySQL 8 dan config database Laravel.
⚠️ Jebakan yang sering ketemu
- Membatasi panjang dengan
.length. Emoji keluarga dihitung 8. PakaiIntl.Segmenteruntuk "berapa karakter yang terlihat". - Memotong dengan
.slice. Separuh surrogate pair jadi�di database. - Membandingkan teks tanpa
normalize. "é" NFC dan NFD gak sama dengan===. - Lupa menyatakan UTF-8. Tanpa
<meta charset="utf-8">, header yang benar, danutf8mb4, hasilnya "予約", "Rp 700.000", atau?.
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.