關於JS裡的字元表情亂碼

一秋知叶發表於2024-08-16

背景

1、業務背景

公司在處理業務時,需要使用socket傳輸字串內容,在A處輸入,在B處顯示。但反饋說輸入表情符號經過傳輸後,ios會變成問號,PC會亂碼。如下情況:

2、表情亂碼

表情符號亂碼的原因通常與 UTF-8 編碼的處理不當有關。表情符號屬於 Unicode 中的高碼點字元,需要使用 4 個位元組來表示。如果在處理這些高碼點字元時出現問題,就會導致表情符號亂碼。

關於Unicode編碼

Unicode 是一種字元編碼標準,旨在為世界上所有的文字和符號提供唯一的編碼。它的目標是支援全球所有書寫系統,涵蓋從古代文字到現代符號的廣泛字符集。以下是關於 Unicode 編碼的一些關鍵點:

基本概念

  1. 字元:表示一個書寫符號,例如字母、數字、標點符號等。
  2. 碼點:每個字元在 Unicode 標準中的唯一編號,通常表示為 U+XXXX,其中 XXXX 是四位十六進位制數。
  3. 編碼形式:將碼點轉換為位元組序列的方式。常見的 Unicode 編碼形式包括 UTF-8、UTF-16 和 UTF-32。

Unicode 平面

Unicode 將字元劃分為多個平面,每個平面包含 65,536 個碼點(從 U+0000 到 U+FFFF)。主要平面包括:

  1. 基本多文種平面(BMP):從 U+0000 到 U+FFFF,包含大多數常用的字元。
  2. 補充多文種平面(SMP):從 U+10000 到 U+1FFFF,包含古代文字、音樂符號等。
  3. 補充表意文字平面(SIP):從 U+20000 到 U+2FFFF,主要用於中日韓表意文字。
  4. 補充專用區(SSP):從 U+E0000 到 U+EFFFF,用於私人使用的字元。

BMP 是 Unicode 的第一個平面,包含了從 U+0000 到 U+FFFF 的字元編碼。它涵蓋了大多數常用的字元,包括:

  • 拉丁字母
  • 希臘字母
  • 西裡爾字母
  • 漢字(中日韓統一表意文字)
  • 阿拉伯字母
  • 以及其他常用的符號和標點符號

BMP 的設計初衷是為了滿足大多數現代文書處理的需求,因此大部分常用的字元都被安排在這個平面內。

高碼點字元是指 Unicode 編碼中使用較高碼點(code point)表示的字元。Unicode 碼點的範圍是從 U+0000 到 U+10FFFF。

表情符號(emoji):如 😀 (U+1F600)
古代文字:如 𐎀 (U+10380,烏加里特字母)
特殊符號:如 𝄞 (U+1D11E,音樂符號)

常見的 Unicode 編碼形式

  1. UTF-8

    • 可變長度編碼,使用 1 到 4 個位元組表示一個字元。
    • 向後相容 ASCII(即 U+0000 到 U+007F 的字元使用單位元組編碼)。
    • 廣泛用於網路傳輸和檔案儲存。
  2. UTF-16

    • 可變長度編碼,使用 2 或 4 個位元組表示一個字元。
    • BMP 內的字元使用 2 位元組編碼,超出 BMP 的字元使用 4 位元組編碼(稱為代理對)。
  3. UTF-32

    • 固定長度編碼,使用 4 個位元組表示一個字元。
    • 簡單但佔用更多儲存空間,主要用於記憶體中字元處理。

原因分析

各個端都無法顯示,且自身重新整理後也無法顯示,應該是在資料傳輸層面出現的問題。

Socket傳輸我們使用的是二進位制的形式傳送的,透過ArrayBuffer把資料傳送給服務端。而在生成ArrayBuffer的時候,使用的是一種自己轉換的形式,程式碼如下:

/**
		 * <p>將 UTF-8 字串寫入位元組流。類似於 writeUTF() 方法,但 writeUTFBytes() 不使用 16 位長度的字為字串新增字首。</p>
		 * <p>對應的讀取方法為: getUTFBytes 。</p>
		 * @param value 要寫入的字串。
		 */
		public function writeUTFBytes(value:String):void {
			// utf8-decode
			value = value + "";
			for (var i:int = 0, sz:int = value.length; i < sz; i++) {
				var c:int = value.charCodeAt(i);
				
				if (c <= 0x7F) {
					writeByte(c);
				} else if (c <= 0x7FF) {
					//最佳化為直接寫入多個位元組,而不必重複呼叫writeByte,免去額外的呼叫和邏輯開銷。
					_ensureWrite(this._pos_ + 2);
					this._u8d_.set([0xC0 | (c >> 6), 0x80 | (c & 0x3F)], _pos_);
					this._pos_ += 2;
				} else if (c <= 0xFFFF) {
					_ensureWrite(this._pos_ + 3);
					this._u8d_.set([0xE0 | (c >> 12), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F)], _pos_);
					this._pos_ += 3;
				} else {
					_ensureWrite(this._pos_ + 4);
					this._u8d_.set([0xF0 | (c >> 18), 0x80 | ((c >> 12) & 0x3F), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F)], _pos_);
					this._pos_ += 4;
				}
			}
		}

這個函式的具體步驟如下:

  1. 將輸入值轉換為字串

    value = value + "";
    

    這一步確保輸入值被視為字串。

  2. 遍歷字串中的每個字元

    for (var i = 0, sz = value.length; i < sz; i++) {
        var c = value.charCodeAt(i);
        ...
    }
    

    這個迴圈遍歷字串中的每個字元,並使用 charCodeAt 獲取字元的 Unicode 碼點。

  3. 根據字元的 Unicode 碼點範圍確定位元組長度並進行編碼
    根據 Unicode 碼點 c 的值,字元將被編碼為 1、2、3 或 4 個位元組:

    • 1 位元組字元 (U+0000 到 U+007F)

      if (c <= 0x7F) {
          this.writeByte(c);
      }
      

      這個範圍內的字元直接寫為一個位元組。

    • 2 位元組字元 (U+0080 到 U+07FF)

      else if (c <= 0x7FF) {
          this._ensureWrite(this._pos_ + 2);
          this._u8d_.set([0xC0 | (c >> 6), 0x80 | (c & 0x3F)], this._pos_);
          this._pos_ += 2;
      }
      

      這個範圍內的字元被編碼為兩個位元組。

    • 3 位元組字元 (U+0800 到 U+FFFF)

      else if (c <= 0xFFFF) {
          this._ensureWrite(this._pos_ + 3);
          this._u8d_.set([0xE0 | (c >> 12), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F)], this._pos_);
          this._pos_ += 3;
      }
      

      這個範圍內的字元被編碼為三個位元組。

    • 4 位元組字元 (U+10000 到 U+10FFFF)

      else {
          this._ensureWrite(this._pos_ + 4);
          this._u8d_.set([0xF0 | (c >> 18), 0x80 | ((c >> 12) & 0x3F), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F)], this._pos_);
          this._pos_ += 4;
      }
      

      這個範圍內的字元被編碼為四個位元組。

  4. 確保緩衝區有足夠的空間並寫入位元組

    • 函式 this._ensureWrite(this._pos_ + n) 確保緩衝區有足夠的空間寫入 n 個位元組。
    • this._u8d_.set([...], this._pos_) 方法從當前位置 this._pos_ 開始將位元組寫入緩衝區。
    • 寫入後,當前位置 this._pos_ 增加相應的位元組數。

在 JavaScript 中,表情符號的 Unicode 碼點超過了 BMP(基本多文種平面)的範圍,因此需要特別處理這些字元。JavaScript 的 charCodeAt 方法只能返回字元的前兩個位元組,所以對於表情符號這樣的高碼點字元,需要使用 codePointAt 方法來獲取完整的碼點。所以罪魁禍首就是這個charCodeAt了。

let str = "😀";
console.log(str.charCodeAt(0)); // 55357 (高代理)
console.log(str.charCodeAt(1)); // 56832 (低代理)
console.log(str.codePointAt(0)); // 128512 (完整的 Unicode 碼點)

解決方案

解決方案有兩種,一種是改造成使用codePointAt去獲取碼點。另一種則是使用瀏覽器封裝的api處理TextDecoder和TextEncoder。

TextDecoderTextEncoder 是 JavaScript 中用於處理文字編碼和解碼的兩個介面。它們提供了將文字與二進位制資料之間進行相互轉換的功能,支援多種字元編碼。

TextDecoder 用於將二進位制資料(通常是 Uint8ArrayArrayBuffer)解碼為字串。它支援多種字元編碼,如 UTF-8、UTF-16、ISO-8859-1 等。

// 建立一個 TextDecoder 例項
const decoder = new TextDecoder('utf-8');

// 假設我們有一個 Uint8Array
const uint8Array = new Uint8Array([0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd]);

// 將 Uint8Array 解碼為字串
const decodedString = decoder.decode(uint8Array);

console.log(decodedString);  // 輸出: 你好

TextEncoder 用於將字串編碼為二進位制資料(通常是 Uint8Array)。目前,TextEncoder 只支援 UTF-8 編碼。

// 建立一個 TextEncoder 例項
const encoder = new TextEncoder();

// 假設我們有一個字串
const string = '你好';

// 將字串編碼為 Uint8Array
const encodedArray = encoder.encode(string);

console.log(encodedArray);  // 輸出: Uint8Array(6) [228, 189, 160, 229, 165, 189]

TextDecoderTextEncoder 提供了一種簡單且高效的方式來處理文字和二進位制資料之間的轉換,非常適合在需要處理不同字元編碼的應用中使用。

總結

這篇文章主要了解了Unicode編碼以及對字元表情的處理問題。在使用上我們還要注意以下兩個問題:

1、js裡不要使用chatCodeAt處理表情符號,需使用codePointAt
2、更推薦使用TextDecoderTextEncoder處理編碼問題。

本文由mdnice多平臺釋出

相關文章