Nuansa string encoding base64 dalam JavaScript

Encoding dan decoding base64 adalah bentuk umum untuk mengubah konten biner agar dapat direpresentasikan sebagai teks yang aman bagi web. URL ini biasa digunakan untuk URL data, seperti gambar inline.

Apa yang terjadi saat Anda menerapkan encoding dan decoding base64 ke string di JavaScript? Postingan ini membahas nuansa dan kesalahan umum yang harus dihindari.

btoa() dan atob()

Fungsi inti untuk mengenkode dan mendekode base64 di JavaScript adalah btoa() dan atob(). btoa() beralih dari string ke string berenkode base64, dan atob() mendekode kembali.

Berikut adalah contoh singkatnya:

// A really plain string that is just code points below 128.
const asciiString = 'hello';

// This will work. It will print:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

Sayangnya, seperti yang dinyatakan dalam dokumen MDN, hal ini hanya berfungsi dengan string yang berisi karakter ASCII, atau karakter yang dapat direpresentasikan oleh satu byte. Dengan kata lain, hal ini tidak akan berfungsi dengan Unicode.

Untuk melihat apa yang terjadi, coba kode berikut:

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will not work. It will print:
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
}

Salah satu emoji dalam string akan menyebabkan error. Mengapa Unicode menyebabkan masalah ini?

Untuk memahami, mari kita mundur selangkah dan memahami {i>string<i}, baik dalam ilmu komputer maupun JavaScript.

String dalam Unicode dan JavaScript

Unicode adalah standar global saat ini untuk encoding karakter, atau praktik pemberian angka ke karakter tertentu sehingga dapat digunakan dalam sistem komputer. Untuk mengetahui Unicode lebih mendalam, buka artikel W3C ini.

Beberapa contoh karakter dalam Unicode dan angka terkaitnya:

  • h - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 dengan pengubah tersembunyi bernomor 65039
  • ⛳ - 9971
  • 🧀 - 129472

Angka yang mewakili setiap karakter disebut "titik kode". Anda dapat menganggap "titik kode" sebagai alamat untuk setiap karakter. Dalam emoji hati merah, sebenarnya ada dua titik kode: satu untuk hati dan satu untuk "memvariasikan" warna dan membuatnya selalu merah.

Unicode memiliki dua cara umum untuk mengambil titik kode ini dan mengubahnya menjadi urutan byte yang dapat ditafsirkan secara konsisten oleh komputer: UTF-8 dan UTF-16.

Tampilan yang disederhanakan adalah sebagai berikut:

  • Di UTF-8, titik kode dapat menggunakan antara satu dan empat byte (8 bit per byte).
  • Dalam UTF-16, titik kode selalu dua byte (16 bit).

Yang penting, JavaScript memproses string sebagai UTF-16. Hal ini akan merusak fungsi seperti btoa(), yang secara efektif beroperasi berdasarkan asumsi bahwa setiap karakter dalam string dipetakan ke satu byte. Hal ini dinyatakan secara eksplisit di MDN:

Metode btoa() membuat string ASCII yang dienkode Base64 dari string biner (yaitu, string yang setiap karakternya diperlakukan sebagai byte data biner).

Sekarang Anda tahu bahwa karakter dalam JavaScript sering kali memerlukan lebih dari satu byte. Bagian berikutnya menunjukkan cara menangani kasus ini untuk encoding dan decoding base64.

btoa() dan atob() dengan Unicode

Seperti yang Anda ketahui sekarang, error yang ditampilkan disebabkan oleh string yang berisi karakter yang berada di luar satu byte dalam UTF-16.

Untungnya, artikel MDN tentang base64 menyertakan beberapa kode contoh yang berguna untuk menyelesaikan "masalah Unicode" ini. Anda dapat mengubah kode ini agar berfungsi dengan contoh sebelumnya:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

Langkah-langkah berikut menjelaskan fungsi kode ini untuk mengenkode string:

  1. Gunakan antarmuka TextEncoder untuk mengambil string JavaScript yang dienkode UTF-16 dan mengonversinya menjadi streaming byte yang dienkode UTF-8 menggunakan TextEncoder.encode().
  2. Tindakan ini akan menampilkan Uint8Array, yang merupakan jenis data yang kurang umum digunakan di JavaScript dan merupakan subclass dari TypedArray.
  3. Ambil Uint8Array tersebut dan berikan ke fungsi bytesToBase64(), yang menggunakan String.fromCodePoint() untuk memperlakukan setiap byte dalam Uint8Array sebagai titik kode dan membuat string darinya, yang menghasilkan string titik kode yang semuanya dapat direpresentasikan sebagai satu byte.
  4. Ambil string tersebut dan gunakan btoa() untuk mengenkodenya dengan base64.

Proses dekode adalah hal yang sama, tetapi dalam arah terbalik.

Hal ini berfungsi karena langkah antara Uint8Array dan string menjamin bahwa meskipun string dalam JavaScript direpresentasikan sebagai UTF-16, encoding dua byte, titik kode yang direpresentasikan oleh setiap dua byte selalu kurang dari 128.

Kode ini berfungsi dengan baik dalam sebagian besar situasi, tetapi akan gagal secara diam-diam dalam situasi lainnya.

Kasus kegagalan senyap

Gunakan kode yang sama, tetapi dengan string yang berbeda:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is invalid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
// '\uDE75' is code unit that is one half of a surrogate pair.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

Jika Anda mengambil karakter terakhir tersebut setelah mendekode (�) dan memeriksa nilai heksadesimalnya, Anda akan menemukan bahwa nilainya adalah \uFFFD, bukan \uDE75 yang asli. Fungsi ini tidak gagal atau menampilkan error, tetapi data input dan output telah berubah secara diam-diam. Mengapa?

String bervariasi menurut JavaScript API

Seperti yang dijelaskan sebelumnya, JavaScript memproses string sebagai UTF-16. Namun, string UTF-16 memiliki properti unik.

Ambil contoh emoji keju. Emoji (BOLDCS) memiliki titik kode Unicode 129472. Sayangnya, nilai maksimum untuk angka 16-bit adalah 65535. Jadi, bagaimana UTF-16 mewakili angka yang jauh lebih tinggi ini?

UTF-16 memiliki konsep yang disebut pasangan surrogate. Anda dapat menganggapnya seperti ini:

  • Angka pertama dalam pasangan menentukan "buku" mana yang akan ditelusuri. Hal ini disebut "surogat".
  • Angka kedua dalam pasangan tersebut adalah entri dalam "buku".

Seperti yang mungkin Anda bayangkan, terkadang dapat menimbulkan masalah jika hanya ada angka yang mewakili buku, tetapi bukan entri sebenarnya dalam buku tersebut. Dalam UTF-16, hal ini dikenal sebagai surrogate tunggal.

Hal ini sangat menantang di JavaScript, karena beberapa API berfungsi meskipun memiliki surrogate tunggal sementara yang lainnya gagal.

Dalam hal ini, Anda menggunakan TextDecoder saat mendekode kembali dari base64. Secara khusus, default untuk TextDecoder menentukan hal berikut:

Nilai defaultnya adalah false, yang berarti bahwa dekoder mengganti data yang salah format dengan karakter pengganti.

Karakter � yang Anda amati sebelumnya, yang direpresentasikan sebagai \uFFFD dalam hex, adalah karakter pengganti tersebut. Di UTF-16, string dengan surrogate tunggal dianggap "berubah" atau "tidak diformat dengan baik".

Ada berbagai standar web (contohnya 1, 2, 3, 4) yang secara tepat menentukan kapan string yang salah format memengaruhi perilaku API, tetapi yang paling penting adalah TextDecoder adalah salah satu API tersebut. Sebaiknya pastikan string diformat dengan benar sebelum melakukan pemrosesan teks.

Memeriksa string yang terbentuk dengan baik

Browser terbaru kini memiliki fungsi untuk tujuan ini: isWellFormed().

Dukungan Browser

  • Chrome: 111.
  • Edge: 111.
  • Firefox: 119.
  • Safari: 16.4.

Sumber

Anda dapat mencapai hasil yang serupa dengan menggunakan encodeURIComponent(), yang menampilkan error URIError jika string berisi satu pengganti.

Fungsi berikut menggunakan isWellFormed() jika tersedia dan encodeURIComponent() jika tidak. Kode serupa dapat digunakan untuk membuat polyfill untuk isWellFormed().

// Quick polyfill since older browsers do not support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

Menyatukan semuanya

Setelah mengetahui cara menangani Unicode dan surrogate tunggal, Anda dapat menggabungkan semuanya untuk membuat kode yang menangani semua kasus dan melakukannya tanpa penggantian teks senyap.

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Quick polyfill since Firefox and Opera do not yet support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

if (isWellFormed(validUTF16String)) {
  // This will work. It will print:
  // Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // This will work. It will print:
  // Decoded string: [hello⛳❤️🧀]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // Not reached in this example.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // Not reached in this example.
} else {
  // This is not a well-formed string, so we handle that case.
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

Ada banyak pengoptimalan yang dapat dilakukan pada kode ini, seperti melakukan generalisasi menjadi polyfill, mengubah parameter TextDecoder untuk ditampilkan, bukan mengganti pengganti tunggal secara diam-diam, dan banyak lagi.

Dengan pengetahuan dan kode ini, Anda juga dapat membuat keputusan eksplisit tentang cara menangani string yang salah format, seperti menolak data atau mengaktifkan penggantian data secara eksplisit, atau mungkin menampilkan error untuk analisis di lain waktu.

Selain menjadi contoh yang berharga untuk encoding dan decoding base64, postingan ini memberikan contoh mengapa pemrosesan teks yang cermat sangat penting, terutama jika data teks berasal dari sumber yang dibuat pengguna atau eksternal.