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 để được biểu thị dưới dạng văn bản an toàn cho web. Tham số 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ì 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 khám phá những sắc thái và sai lầm phổ biến cần tránh.

btoa() và atob()

Các hàm chính để mã hoá và giải mã base64 trong JavaScript là btoa()atob(). btoa() đi từ một chuỗi đến chuỗi được mã hoá base64 và atob() giải mã trở lại.

Sau đây là một 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}]`);

Rất tiếc, 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. Nói cách khác, thuộc tính 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 kỳ biểu tượng cảm xúc nào trong chuỗi đều gây ra lỗi. Tại sao Unicode lại gây ra sự cố này??

Để hiểu rõ hơn, hãy cùng nhìn lại các 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 dành cho việc mã hoá ký tự, hoặc phương pháp chỉ định một 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 kỹ hơn về Unicode, hãy xem bài viết này về W3C.

Một số ví dụ về các ký tự trong Unicode và số liên quan:

  • giờ – 104
  • ñ – 241
  • ❤ – 2764
  • ❤️ - 2764 với đối tượng sửa đổi ẩn số 65039
  • ⛳ – 9971
  • 🧀 – 129472

Những số đại diện cho từng ký tự được gọi là "điểm mã". Bạn có thể xem "điểm mã" là địa chỉ cho từng ký tự. Trong biểu tượng cảm xúc trái tim màu đỏ, thực sự có 2 điểm mã: một điểm là trái tim và điểm còn lại để "thay đổi" màu sắc và làm cho nó luôn 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 chuỗi 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.

Một thành phần hiển thị được đơn giản hoá quá mức là:

  • 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à 2 byte (16 bit).

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

Phương thức btoa() sẽ 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 mà trong đó mỗi ký tự trong chuỗi được coi là một byte dữ liệu nhị phân).

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

btoa() và atob() bằng Unicode

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

May mắn là bài viết MDN về Base64 có chứa một số mã mẫu hữu ích để giải quyết "vấn đề về Unicode" này. Bạn có thể sửa đổi mã này để phù hợp 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 để mã hoá chuỗi:

  1. Sử dụng giao diện TextEncoder để lấy chuỗi JavaScript đã mã hoá UTF-16 rồi chuyển đổi chuỗi đó thành luồng byte được mã hoá UTF-8 bằng TextEncoder.encode().
  2. Thao tác này sẽ trả về Uint8Array, một loại dữ liệu ít được dùng trong JavaScript và là 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ừ đó, 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ã tương tự như nhau, nhưng ngược lại.

Cách này hiệu quả vì bước giữa Uint8Array và một chuỗi đảm bảo rằng trong khi chuỗi trong JavaScript được biểu thị dưới dạng mã hoá 2 byte UTF-16, đ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 hoạt động trong những trường hợp khác.

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

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. Quá trình này sẽ không thất bại hoặc báo lỗi, nhưng dữ liệu đầu vào và đầu ra đã tự động thay đổi. Tại sao?

Các chuỗi 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 duy nhất.

Hãy lấy biểu tượng cảm xúc phô mai làm ví dụ. Biểu tượng cảm xúc (🧀) có điểm mã Unicode là 129472. Thật không may, giá trị tối đa của số 16 bit là 65535! Vậy làm thế nào để UTF-16 đại diện cho con số cao hơn nhiều này?

UTF-16 có một khái niệm được gọi là cặp thay thế. Bạn có thể hình dung như sau:

  • Số đầu tiên trong cặp chỉ định "sách" cần tìm kiếm. Phương thức này được gọi là "giá trị thay thế".
  • Số thứ hai trong cặp là mục nhập trong "sách".

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

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

Trong trường hợp này, bạn sẽ sử dụng TextDecoder khi giải mã trở lại từ base64. Cụ thể, mặc định của TextDecoder sẽ chỉ định những thông tin sau:

Giá trị này mặc định là false, tức 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ị dưới dạng \uFFFD trong hệ thập lục phân, là ký tự thay thế đó. Trong UTF-16, các chuỗi có giá trị thay thế đơn lẻ được coi là "không đúng định dạng" hoặc "không được định dạng đúng".

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 không đúng sẽ ả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 các chuỗi được định dạng đúng cách trước khi xử lý văn bản.

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

Các trình duyệt rất gần đây hiện đã có hàm cho mục đích này: isWellFormed().

Hỗ trợ trình duyệt

  • 111
  • 111
  • 119
  • 16,4

Nguồn

Bạn có thể đạt được kết quả tương tự bằng cách sử dụng encodeURIComponent(). Tính năng này sẽ gửi ra lỗi URIError nếu chuỗi chứa một giá trị thay thế duy nhất.

Hàm sau sẽ sử dụng isWellFormed() nếu có và encodeURIComponent() nếu không có. Bạn có thể dùng mã tương tự để tạo 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, khi đã biết cách xử lý cả giá trị thay thế Unicode và giá trị thay thế đơn lẻ, bạn có thể kết hợp mọi thứ lại với nhau để tạo mã xử lý mọi trường hợp mà không cần thay thế văn bản ngầ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á cho mã này, chẳng hạn như tổng quát thành một polyfill, thay đổi các tham số TextDecoder để gửi thay vì tự động thay thế các giá trị thay thế đơ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 không đúng định dạng, chẳng hạn như từ chối dữ liệu hoặc cho phép thay thế dữ liệu một cách rõ ràng hoặc có thể báo lỗi để phân tích sau.

Ngoài việc là một ví dụ có giá trị về việc mã hoá và giải mã base64, bài đăng này còn cung cấp một ví dụ về lý do 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.