הניואנסים של מחרוזות קידוד 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
  • ⛳ - 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. לוקחים את המחרוזת הזו ומשתמשים ב-btoa() כדי לקודד אותה ב-base64.

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

זה עובד כי השלב בין 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. אפשר לחשוב על זה כך:

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

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

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

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

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

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

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

כדאי לבדוק אם יש מחרוזות בפורמט תקין

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

תמיכה בדפדפן

  • 111
  • 111
  • 119
  • 16.4

מקור

אפשר להגיע לתוצאה דומה באמצעות הפקודה encodeURIComponent(), שיוצרת הודעת שגיאה URIError אם המחרוזת כוללת רכיב חלופי (Surrogate) בודד.

הפונקציה הבאה משתמשת ב-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;
    }
  }
}

סיכום של כל המידע

עכשיו, כשאתם יודעים איך לטפל גם ב-surrogates של Unicode וגם ב-Surrogates בודדים, תוכלו לחבר את כל מה שצריך כדי ליצור קוד שמטפל בכל בקשות התמיכה וחוסך את הצורך בהחלפה שקטה של הטקסט.

// 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 כך שבמקום להחליף באופן שקט החלפת פוליגונים ל-Polyfill ועוד.

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

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