הניואנסים של מחרוזות קידוד 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

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

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

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

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

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

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

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

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

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

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

יש תקני אינטרנט שונים (דוגמאות: 1,‏ 2,‏ 3,‏ 4) שמציינים בדיוק מתי מחרוזת בפורמט שגוי משפיעה על התנהגות ה-API, אבל TextDecoder הוא אחד מממשקי ה-API האלה. מומלץ לוודא שהמחרוזות תקינות לפני עיבוד הטקסט.

בדיקה של מחרוזות בפורמט תקין

בדפדפנים מהדורות חדשות יש עכשיו פונקציה למטרה הזו: isWellFormed().

תמיכה בדפדפנים

  • Chrome: 111.
  • קצה: 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 וגם ב-sorrogates בודדים, אתם יכולים לאסוף את הכל יחד כדי ליצור קוד שמטפל בכל בקשות התמיכה ועושה זאת ללא החלפת טקסט שקטה.

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