Nuansa string encoding base64 dalam JavaScript

encoding dan decoding base64 adalah bentuk umum transformasi konten biner agar ditampilkan sebagai teks yang aman untuk web. Atribut ini biasanya digunakan untuk URL data, seperti gambar inline.

Apa yang terjadi jika Anda menerapkan encoding dan decoding base64 ke string di JavaScript? Postingan ini mengeksplorasi perbedaan 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() akan mendekode kembali.

Berikut ini 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 ditunjukkan oleh dokumen MDN, cara ini hanya berfungsi dengan string yang berisi karakter ASCII, atau karakter yang dapat direpresentasikan oleh satu byte. Dengan kata lain, 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 memahaminya, mari 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 penetapan angka ke karakter tertentu sehingga dapat digunakan di sistem komputer. Untuk mempelajari Unicode secara lebih mendalam, buka artikel W3C ini.

Beberapa contoh karakter dalam Unicode dan angka yang terkait:

  • j - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 dengan pengubah tersembunyi bernomor 65039
  • ホ - 9971
  • 🇦 - 129472

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

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

Tampilan yang disederhanakan adalah:

  • Pada 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 beroperasi secara efektif dengan asumsi bahwa setiap karakter dalam string dipetakan ke satu byte. Hal ini dinyatakan secara eksplisit di MDN:

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

Kini 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, error yang ditampilkan disebabkan oleh string berisi karakter yang berada di luar satu byte dalam UTF-16.

Untungnya, artikel MDN tentang base64 menyertakan beberapa kode contoh yang bermanfaat 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 aliran byte berenkode UTF-8 menggunakan TextEncoder.encode().
  2. Tindakan ini menampilkan Uint8Array, yang merupakan jenis data yang jarang 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 mengenkode base64.

Proses decoding adalah hal yang sama, tetapi sebaliknya.

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

Kode ini berfungsi dengan baik dalam sebagian besar situasi, tetapi akan gagal di situasi lain.

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 mengambil karakter terakhir tersebut setelah mendekode ( ) dan memeriksa nilai hex-nya, Anda akan mendapati bahwa karakter tersebut adalah \uFFFD, bukan \uDE75 yang asli. Metode ini tidak menggagalkan 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. Tetapi {i>string<i} UTF-16 memiliki properti yang unik.

Ambil emoji keju sebagai contoh. Emoji (SUPPORTED) memiliki poin kode Unicode dari 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 memahaminya seperti ini:

  • Angka pertama dalam pasangan menentukan "buku" yang akan ditelusuri. Tindakan ini disebut "surrogate".
  • Angka kedua dalam pasangan adalah entri dalam "buku".

Seperti yang dapat Anda bayangkan, terkadang dapat menimbulkan masalah jika hanya memiliki nomor yang mewakili buku, bukan entri sebenarnya dalam buku itu. Pada UTF-16, parameter ini dikenal sebagai surrogate tunggal.

Hal ini sangat menantang dalam JavaScript, karena beberapa API tetap berfungsi meskipun memiliki pengganti tersendiri, sementara yang lainnya gagal.

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

Nilai defaultnya adalah false, yang berarti decoder mengganti data yang rusak dengan karakter pengganti.

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

Ada berbagai standar web (contoh 1, 2, 3, 4) yang secara tepat menentukan kapan string yang salah format memengaruhi perilaku API, tetapi terutama TextDecoder adalah salah satu dari API tersebut. Sebaiknya Anda memastikan bahwa string telah terbentuk dengan baik sebelum melakukan pemrosesan teks.

Memeriksa string yang tersusun dengan baik

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

Dukungan Browser

  • 111
  • 111
  • 119
  • 16,4

Sumber

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

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 menyatukan 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 ke dalam polyfill, mengubah parameter TextDecoder yang akan ditampilkan, bukan mengganti 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 secara eksplisit mengaktifkan penggantian data, atau mungkin menampilkan error untuk analisis nanti.

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