ความแตกต่างของสตริงการเข้ารหัส base64 ใน JavaScript

การเข้ารหัสและการถอดรหัส base64 เป็นรูปแบบทั่วไปของการเปลี่ยนรูปแบบเนื้อหาไบนารีให้แสดงเป็นข้อความที่แสดงผลได้บนเว็บ ซึ่งมักใช้กับ URL ข้อมูล เช่น รูปภาพในบรรทัด

จะเกิดอะไรขึ้นเมื่อคุณใช้การเข้ารหัสและถอดรหัส Base64 กับสตริงใน JavaScript โพสต์นี้จะอธิบายถึงความแตกต่างและข้อผิดพลาดที่พบบ่อยที่ควรหลีกเลี่ยง

btoa() และ atob()

ฟังก์ชันหลักสําหรับการเข้ารหัสและถอดรหัส Base64 ใน JavaScript คือ btoa() และ atob() btoa() เปลี่ยนจากสตริงเป็นสตริงที่เข้ารหัส Base64 และ atob() จะถอดรหัสกลับ

ต่อไปนี้เป็นตัวอย่างคร่าวๆ

// 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}]`);

แต่น่าเสียดายที่ตามที่ระบุไว้ในเอกสารของ MDN การดำเนินการนี้จะใช้ได้กับสตริงที่มีอักขระ ASCII หรืออักขระที่แสดงด้วยไบต์เดี่ยวเท่านั้น กล่าวคือ การดำเนินการนี้จะใช้กับ Unicode ไม่ได้

ลองใช้โค้ดต่อไปนี้เพื่อดูผลลัพธ์

// 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);
}

อีโมจิตัวใดตัวหนึ่งในสตริงจะทำให้เกิดข้อผิดพลาด ทำไม Unicode จึงทำให้เกิดปัญหานี้

ลองย้อนกลับไปทำความเข้าใจสตริง ทั้งในวิทยาการคอมพิวเตอร์และ JavaScript เพื่อทำความเข้าใจ

สตริงใน Unicode และ JavaScript

Unicode เป็นมาตรฐานสากลในปัจจุบันสำหรับการเข้ารหัสอักขระ หรือการกำหนดตัวเลขให้กับอักขระหนึ่งๆ เพื่อให้ใช้ในระบบคอมพิวเตอร์ได้ หากต้องการดูข้อมูลเชิงลึกเพิ่มเติมเกี่ยวกับ Unicode โปรดไปที่บทความนี้ของ W3C

ตัวอย่างอักขระใน Unicode และตัวเลขที่เกี่ยวข้องมีดังนี้

  • h - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 ที่มีตัวแก้ไขที่ซ่อนอยู่ซึ่งมีหมายเลข 65039
  • ⛳ - 9971
  • 🧀 - 129472

ตัวเลขที่แสดงถึงอักขระแต่ละตัวเรียกว่า "จุดโค้ด" คุณอาจมอง "จุดโค้ด" เป็นที่อยู่ของอักขระแต่ละตัว ในอีโมจิหัวใจสีแดงนั้น จริงๆ แล้วมีจุดโค้ด 2 จุด ได้แก่ จุดสำหรับหัวใจ 1 จุด และจุดสำหรับ "เปลี่ยน" สีและทำให้สีแดงเสมอ 1 จุด

Unicode มี 2 วิธีทั่วไปในการนำจุดโค้ดเหล่านี้มาทำให้เป็นลำดับไบต์ที่คอมพิวเตอร์ตีความได้อย่างสม่ำเสมอ นั่นคือ UTF-8 และ UTF-16

มุมมองที่เข้าใจง่ายคือ

  • ใน UTF-8 จุดโค้ดจะใช้ได้ตั้งแต่ 1-4 ไบต์ (8 บิตต่อไบต์)
  • ใน UTF-16 หนึ่ง Code Point จะมีความยาว 2 ไบต์ (16 บิต) เสมอ

สิ่งสำคัญคือ JavaScript จะประมวลผลสตริงเป็น UTF-16 การดำเนินการนี้จะหยุดฟังก์ชันอย่าง btoa() ซึ่งทำงานอย่างมีประสิทธิภาพโดยสมมติว่าอักขระแต่ละตัวในสตริงแมปกับไบต์เดี่ยว ข้อมูลนี้ระบุไว้อย่างชัดเจนใน MDN

เมธอด btoa() จะสร้างสตริง ASCII ที่เข้ารหัส Base64 จากสตริงไบนารี (นั่นคือสตริงที่ระบบจะถือว่าอักขระแต่ละตัวในสตริงเป็นไบต์ของข้อมูลไบนารี)

ตอนนี้คุณทราบแล้วว่าอักขระใน JavaScript มักต้องใช้มากกว่า 1 ไบต์ ส่วนถัดไปจะแสดงวิธีจัดการกรณีนี้สําหรับการเข้ารหัสและถอดรหัส Base64

btoa() และ atob() ที่มี Unicode

ดังที่คุณทราบแล้ว ข้อผิดพลาดที่เกิดขึ้นเกิดจากสตริงที่มีอักขระที่อยู่นอกไบต์เดียวใน UTF-16

แต่โชคดีที่บทความ MDN เกี่ยวกับ Base64 มีตัวอย่างโค้ดที่เป็นประโยชน์ในการแก้ปัญหา "Unicode" นี้ คุณสามารถแก้ไขโค้ดนี้ให้ทํางานกับตัวอย่างก่อนหน้าได้ดังนี้

// 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}]`);

ขั้นตอนต่อไปนี้จะอธิบายว่าโค้ดนี้มีหน้าที่อะไรในการเข้ารหัสสตริง

  1. ใช้TextEncoder อินเทอร์เฟซเพื่อนำสตริง JavaScript ที่เข้ารหัส UTF-16 และแปลงเป็นสตรีบไบต์ที่เข้ารหัส UTF-8 โดยใช้ TextEncoder.encode()
  2. การดำเนินการนี้จะแสดงผลเป็น Uint8Array ซึ่งเป็นประเภทข้อมูลที่ไม่ค่อยได้ใช้กันมากนักใน JavaScript และเป็นคลาสย่อยของ TypedArray
  3. นำ Uint8Array นั้นไปใส่ในฟังก์ชัน bytesToBase64() ซึ่งใช้ String.fromCodePoint() ในการจัดการไบต์แต่ละไบต์ใน Uint8Array เป็นจุดโค้ด และสร้างสตริงจากจุดโค้ด ซึ่งจะส่งผลให้เกิดสตริงของจุดโค้ดที่สามารถแสดงเป็นไบต์เดี่ยวได้ทั้งหมด
  4. นำสตริงนั้นไปใช้กับ btoa() เพื่อเข้ารหัส Base64

กระบวนการถอดรหัสจะเหมือนกัน แต่ทําในแบบย้อนกลับ

การดำเนินการนี้ได้ผลเนื่องจากขั้นตอนระหว่าง Uint8Array กับสตริงรับประกันว่าในขณะที่สตริงใน JavaScript แสดงเป็นการเข้ารหัส UTF-16 แบบ 2 ไบต์ รหัสจุดที่แต่ละไบต์แสดงจะน้อยกว่า 128 เสมอ

โค้ดนี้ทํางานได้ดีในสถานการณ์ส่วนใหญ่ แต่จะทํางานไม่สําเร็จในบางกรณี

เคสความล้มเหลวเงียบ

ใช้โค้ดเดียวกัน แต่ใช้สตริงอื่น

// 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}]`);

หากนำอักขระสุดท้ายหลังการถอดรหัส (�) มาตรวจสอบค่าฐาน 16 ก็จะพบว่าเป็น \uFFFD ไม่ใช่ \uDE75 เดิม การดำเนินการไม่ล้มเหลวหรือแสดงข้อผิดพลาด แต่ข้อมูลอินพุตและเอาต์พุตมีการเปลี่ยนแปลงโดยอัตโนมัติ เหตุผล

สตริงจะแตกต่างกันไปตาม JavaScript API

JavaScript จะประมวลผลสตริงเป็น UTF-16 ตามที่อธิบายไว้ก่อนหน้านี้ แต่สตริง UTF-16 มีคุณสมบัติที่ไม่ซ้ำกัน

มาดูตัวอย่างอีโมจิชีสกัน อีโมจิ (🧀) มีจุดโค้ด Unicode ของ 129472 ขออภัย ค่าสูงสุดสำหรับหมายเลข 16 บิตคือ 65535 UTF-16 จะแสดงตัวเลขที่สูงกว่านี้ได้อย่างไร

UTF-16 มีแนวคิดที่เรียกว่าคู่ตัวแทน ลองคิดตามตัวอย่างนี้

  • ตัวเลขแรกในคู่หมายถึง "หนังสือ" ที่จะค้นหา ซึ่งเรียกว่า "ตัวแทน"
  • ตัวเลขที่สองในคู่คือรายการใน "หนังสือ"

ดังที่คุณอาจทราบ บางครั้งการระบุเฉพาะหมายเลขที่แสดงถึงหนังสือ แต่ไม่ได้ระบุรายการจริงในหนังสือนั้นอาจทำให้เกิดปัญหาได้ ใน UTF-16 เรียกกรณีนี้ว่าตัวแทนเดี่ยว

ซึ่งจะทําได้ยากมากใน JavaScript เนื่องจาก API บางรายการทํางานได้แม้จะมีตัวแทนเพียงรายการเดียว แต่บางรายการทํางานไม่ได้

ในกรณีนี้ คุณใช้ TextDecoder เมื่อถอดรหัสจาก base64 โดยเฉพาะอย่างยิ่ง ค่าเริ่มต้นสำหรับ TextDecoder จะระบุข้อมูลต่อไปนี้

โดยค่าเริ่มต้นจะเป็น false ซึ่งหมายความว่าโปรแกรมถอดรหัสจะแทนที่ข้อมูลที่ไม่เป็นรูปแบบด้วยอักขระแทน

อักขระดังกล่าวที่คุณสังเกตเห็นก่อนหน้านี้ซึ่งแสดงเป็น \uFFFD ในเลขฐานสิบหก นั่นก็คืออักขระที่ใช้แทนที่ ใน UTF-16 ระบบจะถือว่าสตริงที่มีตัวแทนตัวเดียว "มีรูปแบบไม่ถูกต้อง" หรือ "มีรูปแบบไม่ถูกต้อง"

มาตรฐานเว็บต่างๆ (ตัวอย่าง 1, 2, 3, 4) จะระบุไว้อย่างชัดเจนว่าสตริงที่มีรูปแบบไม่ถูกต้องจะส่งผลต่อลักษณะการทํางานของ API เมื่อใด แต่ที่น่าสังเกตคือ TextDecoder เป็นหนึ่งใน API เหล่านั้น คุณควรตรวจสอบว่าสตริงอยู่ในรูปแบบที่ถูกต้องก่อนที่จะประมวลผลข้อความ

ตรวจหาสตริงที่มีรูปแบบเหมาะสม

เบราว์เซอร์ล่าสุดมีฟังก์ชันสำหรับวัตถุประสงค์นี้แล้ว ซึ่งก็คือ isWellFormed()

การรองรับเบราว์เซอร์

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

แหล่งที่มา

คุณได้ผลลัพธ์ที่คล้ายกันโดยใช้ encodeURIComponent() ซึ่งจะแสดงURIErrorข้อผิดพลาดหากสตริงมีตัวแทนเพียงรายการเดียว

ฟังก์ชันต่อไปนี้ใช้ isWellFormed() หากมี และ encodeURIComponent() หากไม่มี คุณสามารถใช้โค้ดที่คล้ายกันเพื่อสร้าง polyfill สำหรับ 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;
    }
  }
}

นำข้อมูลทุกอย่างมารวมกัน

ตอนนี้คุณก็รู้วิธีจัดการทั้ง Unicode และตัวแทนแบบโดดๆ แล้ว คุณสามารถรวมทุกอย่างเข้าด้วยกันเพื่อสร้างโค้ดที่จัดการกรณีทั้งหมด โดยไม่ต้องแทนที่ข้อความเงียบ

// 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}]`);
}

มีการเพิ่มประสิทธิภาพหลายอย่างในโค้ดนี้ เช่น การทําให้เป็นแบบทั่วไปเป็น polyfill, การเปลี่ยนพารามิเตอร์ TextDecoder ให้แสดงข้อผิดพลาดแทนการแทนที่ตัวแทนตัวเดียวโดยอัตโนมัติ และอื่นๆ

ความรู้และโค้ดนี้จะช่วยให้คุณตัดสินใจได้อย่างชัดเจนเกี่ยวกับวิธีจัดการสตริงที่มีรูปแบบไม่ถูกต้อง เช่น การปฏิเสธข้อมูลหรือเปิดใช้การเปลี่ยนข้อมูลอย่างชัดเจน หรืออาจแสดงข้อผิดพลาดเพื่อการวิเคราะห์ในภายหลัง

นอกจากจะเป็นตัวอย่างที่มีประโยชน์สำหรับการเข้ารหัสและถอดรหัส base64 แล้ว โพสต์นี้ยังแสดงตัวอย่างว่าเหตุใดการประมวลผลข้อความอย่างละเอียดจึงสำคัญอย่างยิ่ง โดยเฉพาะเมื่อข้อมูลข้อความมาจากแหล่งที่มาภายนอกหรือที่ผู้ใช้สร้างขึ้น