Các sắc thái của chuỗi mã hoá base64 trong JavaScript

Mã hoá và giải mã base64 là một hình thức phổ biến để chuyển đổi nội dung nhị phân thành văn bản an toàn cho web. Phương thức này thường được dùng cho URL dữ liệu, chẳng hạn như hình ảnh cùng dòng.

Điều gì sẽ xảy ra khi bạn áp dụng phương thức mã hoá và giải mã base64 cho các chuỗi trong JavaScript? Bài đăng này sẽ khám phá những sắc thái và những sai lầm phổ biến cần tránh.

Các hàm cốt lõi để mã hoá và giải mã base64 trong JavaScript là btoa()atob(). btoa() chuyển từ một chuỗi sang một chuỗi được mã hoá base64 và atob() giải mã lại.

Sau đây là ví dụ nhanh:

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

Thật không may, như các tài liệu MDN đã lưu ý, tính năng này chỉ hoạt động với các chuỗi chứa ký tự ASCII hoặc các ký tự có thể được biểu thị bằng một byte duy nhất. Nói cách khác, phương thức này sẽ không hoạt động với Unicode.

Để xem điều gì sẽ xảy ra, hãy thử mã sau:

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

Bất cứ biểu tượng cảm xúc nào trong chuỗi đều sẽ gây ra lỗi. Tại sao Unicode lại gây ra vấn đề này?

Để hiểu rõ, hãy quay lại và tìm hiểu về chuỗi, cả trong khoa học máy tính và JavaScript.

Chuỗi trong Unicode và JavaScript

Unicode là tiêu chuẩn toàn cầu hiện tại cho việc mã hoá ký tự hoặc cách gán số cho một ký tự cụ thể để có thể sử dụng trong các hệ thống máy tính. Để tìm hiểu sâu hơn về Unicode, hãy truy cập vào bài viết này của W3C.

Một số ví dụ về ký tự trong Unicode và số tương ứng:

  • h – 104
  • ñ – 241
  • ❤ – 2764
  • ❤️ – 2764 có phím bổ trợ ẩn được đánh số 65039
  • ⛳ – 9971
  • 🧀 – 129472

Các số đại diện cho mỗi ký tự được gọi là "điểm mã". Bạn có thể coi "điểm mã" là địa chỉ của từng ký tự. Trong biểu tượng trái tim màu đỏ, thực tế có hai điểm mã: một điểm mã cho trái tim và một điểm mã để "thay đổi" màu sắc và luôn làm cho trái tim có màu đỏ.

Unicode có hai cách phổ biến để lấy các điểm mã này và biến chúng thành các trình tự byte mà máy tính có thể diễn giải một cách nhất quán: UTF-8 và UTF-16.

Dưới đây là một cách xem đơn giản hoá:

  • Trong UTF-8, một điểm mã có thể sử dụng từ 1 đến 4 byte (8 bit mỗi byte).
  • Trong UTF-16, một điểm mã luôn là hai byte (16 bit).

Quan trọng là JavaScript xử lý các chuỗi dưới dạng UTF-16. Điều này sẽ làm hỏng các hàm như btoa(), hoạt động hiệu quả dựa trên giả định rằng mỗi ký tự trong chuỗi ánh xạ đến một byte. Điều này được nêu rõ trên MDN:

Phương thức btoa() tạo một chuỗi ASCII được mã hoá Base64 từ một chuỗi nhị phân (tức là một chuỗi trong đó mỗi ký tự trong chuỗi được coi là một byte dữ liệu nhị phân).

Giờ bạn đã biết rằng các ký tự trong JavaScript thường yêu cầu nhiều byte, nên phần tiếp theo sẽ minh hoạ cách xử lý trường hợp này khi mã hoá và giải mã base64.

btoa() và atob() với Unicode

Như bạn đã biết, lỗi này xảy ra là do chuỗi của chúng ta chứa các ký tự nằm ngoài một byte trong UTF-16.

May mắn là bài viết của MDN về base64 có một số mã mẫu hữu ích để giải quyết "vấn đề Unicode" này. Bạn có thể sửa đổi mã này để hoạt động với ví dụ trước:

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

Các bước sau đây giải thích chức năng của mã này trong việc mã hoá chuỗi:

  1. Sử dụng giao diện TextEncoder để lấy chuỗi JavaScript được mã hoá UTF-16 và chuyển đổi chuỗi đó thành luồng các byte được mã hoá UTF-8 bằng TextEncoder.encode().
  2. Thao tác này sẽ trả về một Uint8Array. Đây là một loại dữ liệu ít được sử dụng trong JavaScript và là một lớp con của TypedArray.
  3. Lấy Uint8Array đó và cung cấp cho hàm bytesToBase64(). Hàm này sử dụng String.fromCodePoint() để coi mỗi byte trong Uint8Array là một điểm mã và tạo một chuỗi từ điểm mã đó, dẫn đến một chuỗi các điểm mã đều có thể được biểu thị dưới dạng một byte.
  4. Lấy chuỗi đó và sử dụng btoa() để mã hoá base64.

Quá trình giải mã cũng tương tự như vậy, nhưng theo hướng ngược lại.

Đây là cách hiệu quả vì bước giữa Uint8Array và một chuỗi đảm bảo rằng mặc dù chuỗi trong JavaScript được biểu thị dưới dạng UTF-16 (mã hoá 2 byte), nhưng điểm mã mà mỗi 2 byte biểu thị luôn nhỏ hơn 128.

Mã này hoạt động tốt trong hầu hết các trường hợp, nhưng sẽ tự động không thành công trong các trường hợp khác.

Trường hợp lỗi thầm lặng

Sử dụng cùng một mã, nhưng với một chuỗi khác:

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

Nếu lấy ký tự cuối cùng đó sau khi giải mã (�) và kiểm tra giá trị hex của ký tự đó, bạn sẽ thấy đó là \uFFFD thay vì \uDE75 ban đầu. API này không gặp lỗi hoặc gửi lỗi, nhưng dữ liệu đầu vào và đầu ra đã tự động thay đổi. Tại sao?

Chuỗi sẽ khác nhau tuỳ theo API JavaScript

Như đã mô tả trước đó, JavaScript xử lý các chuỗi dưới dạng UTF-16. Tuy nhiên, chuỗi UTF-16 có một thuộc tính riêng biệt.

Hãy lấy biểu tượng phô mai làm ví dụ. Biểu tượng cảm xúc (🧀) có điểm mã Unicode là 129472. Rất tiếc, giá trị tối đa cho số 16 bit là 65535! Vậy làm cách nào UTF-16 biểu thị số lượng ký tự lớn hơn nhiều này?

UTF-16 có một khái niệm gọi là cặp ký tự đại diện. Bạn có thể nghĩ theo cách sau:

  • Số đầu tiên trong cặp số chỉ định "sách" cần tìm kiếm. Đây được gọi là "đại diện".
  • Số thứ hai trong cặp là mục nhập trong "sổ".

Như bạn có thể tưởng tượng, đôi khi việc chỉ có số đại diện cho cuốn sách mà không có mục nhập thực tế trong cuốn sách đó có thể gây ra vấn đề. Trong UTF-16, đây được gọi là ký tự đại diện đơn.

Điều này đặc biệt khó khăn trong JavaScript, vì một số API hoạt động mặc dù có các đại diện đơn lẻ, trong khi một số API khác không hoạt động.

Trong trường hợp này, bạn đang sử dụng TextDecoder khi giải mã lại từ base64. Cụ thể, các giá trị mặc định cho TextDecoder chỉ định những nội dung sau:

Giá trị mặc định là false, có nghĩa là bộ giải mã sẽ thay thế dữ liệu không đúng định dạng bằng một ký tự thay thế.

Ký tự � mà bạn đã quan sát trước đó, được biểu thị là \uFFFD theo hệ thập lục phân, là ký tự thay thế đó. Trong UTF-16, các chuỗi có ký tự đại diện đơn lẻ được coi là "bị sai định dạng" hoặc "không được định dạng đúng cách".

Có nhiều tiêu chuẩn web (ví dụ: 1, 2, 3, 4) chỉ định chính xác thời điểm một chuỗi có định dạng không chính xác ảnh hưởng đến hành vi của API, nhưng đáng chú ý là TextDecoder là một trong những API đó. Bạn nên đảm bảo rằng các chuỗi được định dạng đúng trước khi xử lý văn bản.

Kiểm tra các chuỗi được định dạng đúng cách

Các trình duyệt rất mới hiện đã có chức năng cho mục đích này: isWellFormed().

Hỗ trợ trình duyệt

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

Nguồn

Bạn có thể đạt được kết quả tương tự bằng cách sử dụng encodeURIComponent(). Phương thức này sẽ gửi lỗi URIError nếu chuỗi chứa một ký tự đại diện đơn lẻ.

Hàm sau đây sử dụng isWellFormed() nếu có và encodeURIComponent() nếu không có. Bạn có thể sử dụng mã tương tự để tạo một polyfill cho 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;
    }
  }
}

Kết hợp kiến thức đã học

Giờ đây, bạn đã biết cách xử lý cả Unicode và ký tự đại diện đơn, bạn có thể kết hợp mọi thứ để tạo mã xử lý tất cả các trường hợp mà không cần thay thế văn bản thầm.

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

Có nhiều cách tối ưu hoá có thể được thực hiện cho mã này, chẳng hạn như tổng quát hoá thành một polyfill, thay đổi các tham số TextDecoder để gửi thay vì âm thầm thay thế các đại diện đơn lẻ, v.v.

Với kiến thức và mã này, bạn cũng có thể đưa ra quyết định rõ ràng về cách xử lý các chuỗi có định dạng không chính xác, chẳng hạn như từ chối dữ liệu hoặc bật tính năng thay thế dữ liệu một cách rõ ràng, hoặc có thể gửi lỗi để phân tích sau.

Ngoài việc là một ví dụ có giá trị cho quá trình mã hoá và giải mã base64, bài đăng này còn cung cấp một ví dụ về lý do khiến việc xử lý văn bản cẩn thận lại đặc biệt quan trọng, đặc biệt là khi dữ liệu văn bản đến từ các nguồn bên ngoài hoặc do người dùng tạo.