ความแตกต่างของสตริงการเข้ารหัส 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 และตัวเลขที่เกี่ยวข้องมีดังนี้

  • สูง - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 พร้อมตัวปรับแต่งที่ซ่อนอยู่ซึ่งมีตัวเลขเป็น 65039
  • ⛳ - 9,971
  • 🧀 - 129,472

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

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

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

  • สำหรับ UTF-8 จุดโค้ดสามารถใช้ระหว่าง 1 ถึง 4 ไบต์ (8 บิตต่อไบต์)
  • ใน UTF-16 จุดโค้ดจะเป็น 2 ไบต์ (16 บิต) เสมอ

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

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

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

btoa() และ atob() พร้อม Unicode

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

โชคดีที่บทความ MN เกี่ยวกับ base64 มีตัวอย่างโค้ดที่เป็นประโยชน์สำหรับการแก้ปัญหา "Unicode ปัญหาเกี่ยวกับ 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}]`);

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

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

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

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

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

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

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

ความสามารถนี้ท้าทายมากใน JavaScript เพราะ API บางตัวทำงานแม้ว่าจะมีตัวแทนเพียงรายเดียวที่ยังทำงานแทนในขณะที่ตัวอื่นๆ ทำงานล้มเหลว

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

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

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

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

ตรวจสอบสตริงที่มีรูปแบบถูกต้อง

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

การสนับสนุนเบราว์เซอร์

  • 111
  • 111
  • 119
  • 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 แล้ว โพสต์นี้ยกตัวอย่างว่าเหตุใดการประมวลผลข้อความอย่างรอบคอบจึงสำคัญอย่างยิ่ง โดยเฉพาะอย่างยิ่งเมื่อข้อมูลข้อความมาจากแหล่งที่มาที่ผู้ใช้สร้างขึ้นหรือแหล่งข้อมูลภายนอก