הניואנסים של מחרוזות קידוד base64 ב-JavaScript

קידוד ופענוח base64 הם דרך נפוצה להפיכת תוכן בינארי כך שיוצג כטקסט בטוח לשימוש באינטרנט. משתמשים בה בדרך כלל לכתובות URL של נתונים, כמו תמונות בגוף ההודעה.

מה קורה כשמפעילים קידוד ופענוח של base64 למחרוזות ב-JavaScript? בפוסט הזה נסביר על ההבדלים העדינים ועל המלכודות הנפוצות שכדאי להימנע מהן.

הפונקציות הבסיסיות לקידוד ולפענוח ב-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

המספרים שמייצגים כל תו נקראים 'נקודות קוד'. אפשר לחשוב על 'נקודות קוד' ככתובת לכל תו. באמוג'י של הלב האדום יש למעשה שני נקודות קוד: אחת ללב ואחת כדי "לשנות" את הצבע ולגרום לו להיות תמיד אדום.

ב-Unicode יש שתי דרכים נפוצות להפוך את נקודות הקוד האלה לרצפי בייטים שמחשבים יכולים לפרש באופן עקבי: UTF-8 ו-UTF-16.

באופן פשוט, זהו התהליך:

  • ב-UTF-8, נקודת קוד יכולה להשתמש בין בייט אחד לארבעה בייטים (8 ביט לכל בייט).
  • ב-UTF-16, מיקום תו תמיד מורכב משני בייטים (16 ביט).

חשוב לדעת ש-JavaScript מעבד מחרוזות כ-UTF-16. הפעולה הזו מפריעה לפונקציות כמו btoa(), שפועלות על בסיס ההנחה שכל תו במחרוזת ממופה לבייט יחיד. הדבר מצוין במפורש ב-MDN:

השיטה btoa() יוצרת מחרוזת ASCII בקידוד Base64 ממחרוזת בינארית (כלומר, מחרוזת שבה כל תו במחרוזת נחשב כבייט של נתונים בינאריים).

עכשיו ברור לכם שלרוב נדרשים יותר מבייט אחד כדי לייצג תווים ב-JavaScript. בקטע הבא נסביר איך לטפל במקרה כזה בקידוד ובפענוח של 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. לוקחים את המחרוזת הזו ומקודדים אותה ב-base64 באמצעות btoa().

תהליך הפענוח הוא אותו הדבר, רק הפוך.

הסיבה לכך היא שהשלב בין Uint8Array למחרוזת מבטיח שגם אם המחרוזת ב-JavaScript מיוצגת כקידוד UTF-16 של שני בייטים, נקודת הקוד שכל שני הבייטים מייצגים תמיד קטנה מ-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 יש מושג שנקרא זוגות חלופיים. אפשר להתייחס לזה כך:

  • המספר הראשון בזוג מציין באיזה 'ספר' לבצע את החיפוש. הגורם הזה נקרא סורוגט.
  • המספר השני בזוג הוא הרשומה בתוך ה'ספר'.

כפי שאתם יכולים לדמיין, לפעמים יכולה להיות בעיה אם יש רק את המספר שמייצג את הספר, אבל לא את הרשומה בפועל בספר. ב-UTF-16, הקוד הזה נקרא סמל חלופי בודד.

האתגר הזה מאתגר במיוחד ב-JavaScript, כי חלק מממשקי ה-API פועלים גם אם יש מחליפים לבדים, ואחרים נכשלים.

במקרה הזה, משתמשים ב-TextDecoder כשמבצעים פענוח מ-base64. באופן ספציפי, ברירת המחדל של TextDecoder מפרטת את הפרטים הבאים:

ברירת המחדל היא false, כלומר המפענח מחליף נתונים בפורמט שגוי בתו חלופי.

התו ‎� שציינתי קודם, שמיוצג כ-\uFFFD בחזקת 16, הוא התו החלופי הזה. בקידוד UTF-16, מחרוזות עם רכיבי surrogates הבודדים נחשבים כ'פורמט שגוי' או כ'פורמט לא תקין'.

יש כמה תקני אינטרנט (לדוגמה 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, המאמר הזה מספק דוגמה לכך שחשוב במיוחד לעבד טקסט בזהירות, במיוחד כשנתוני הטקסט מגיעים ממקורות חיצוניים או שנוצרו על ידי משתמשים.