JavaScript 字串操作:Unicode 與 Emoji 處理
簡介
在日常的 Web 開發或 Node.js 應用中,字串是最常見的資料類型之一。隨著行動裝置與社群平台的普及,使用者輸入的文字不再只局限於 ASCII 或單一語系,Unicode(統一碼)與 emoji(表情符號)已成為不可或缺的元素。
如果對 Unicode 的特性缺乏了解,往往會在字串長度計算、切割、正則表達式或儲存時遭遇意外錯誤,甚至導致 UI 顯示錯亂。本文將從基礎概念說起,示範在 JavaScript 中正確且安全地處理 Unicode 與 emoji,協助初學者與中階開發者寫出更健全的程式碼。
核心概念
1️⃣ 什麼是 Unicode?
Unicode 是一套為全球所有文字、符號、表情等分配唯一編號(code point)的標準。每個字符都有一個十六進位的碼點,例如:
| 字符 | 代表的 Unicode 碼點 |
|---|---|
| A | U+0041 |
| 中 | U+4E2D |
| 🌈 | U+1F308 |
JavaScript 內部使用 UTF‑16 編碼,每個 16 位元(2 byte)單位稱為 code unit。對於大多數 BMP(Basic Multilingual Plane,U+0000~U+FFFF)內的字符,一個 code unit 即能完整表示;但對於超出 BMP 的字符(如大多數 emoji),會以 兩個 code unit(稱為 surrogate pair)儲存。
⚠️ 重點:在 JavaScript 中,
String.length回傳的是 code unit 數量,而非真正的字符(code point)數量。
2️⃣ 為什麼普通的字串操作會失效?
以下示例說明常見的錯誤情況:
const s = '🌈'; // U+1F308,使用 surrogate pair
console.log(s.length); // 2 ← 不是 1
console.log(s[0]); // � ← 只取到高位代理 (high surrogate)
如果直接使用 slice、substring 或 charAt,很可能會切斷半個 surrogate pair,產生無效的 Unicode 序列,導致瀏覽器顯示「�」或拋出錯誤。
3️⃣ 正確取得 Unicode length(code point 數)
ES6 引入了 Array.from 與 擴充運算子(spread operator),可將字串自動展開為「真實字符」的陣列,從而正確取得長度:
const emoji = '👩💻'; // 👩 + ZWJ + 💻,實際上是一個「複合 emoji」
const chars = Array.from(emoji); // → ['👩', '', '💻']
console.log(chars.length); // 3(每個 code point)
如果想要把 整個顯示單位(包括 ZWJ 連結的複合 emoji)視為一個字元,可使用 Intl.Segmenter(Node 12+、瀏覽器支援較新):
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
const graphemes = [...seg.segment(emoji)];
console.log(graphemes.length); // 1,視為單一「圖素」(grapheme cluster)
4️⃣ 安全的字串切割與遍歷
下面提供三個實務範例,示範如何在保留 Unicode 正確性的前提下進行切割、遍歷與搜尋。
範例 1:安全切割(取前 n 個顯示單位)
/**
* 取出字串的前 n 個 grapheme cluster(顯示單位)
* @param {string} str 來源字串
* @param {number} n 取前 n 個顯示單位
* @returns {string}
*/
function sliceGrapheme(str, n) {
const seg = new Intl.Segmenter('zh-Hant', { granularity: 'grapheme' });
const iterator = seg.segment(str)[Symbol.iterator]();
let result = '';
let count = 0;
for (let { value, done } = iterator.next(); !done && count < n; { value, done } = iterator.next()) {
result += value;
count++;
}
return result;
}
console.log(sliceGrapheme('Hello 🌏👩💻世界', 7)); // "Hello 🌏👩💻"
說明:Intl.Segmenter 會把「👩💻」視為單一顯示單位,避免切斷半個 emoji。
範例 2:遍歷每個真實字符(code point)
const text = '𠜎abc🌟';
for (const ch of text) {
console.log(ch, ch.codePointAt(0).toString(16));
}
// 輸出:
// 𠜎 20b9e
// a 61
// b 62
// c 63
// 🌟 1f31f
使用 for...of(或 Array.from)會自動以 code point 為單位迭代,對於 surrogate pair 不會產生半字的情況。
範例 3:正則表達式匹配 emoji
ES2022 引入了 Unicode property escapes(\p{...}),可直接匹配 Emoji、中文、拉丁字母等屬性:
const msg = '今天心情很好 😊,一起加油! 🚀';
const emojiPattern = /\p{Emoji_Presentation}/gu; // 只抓出「呈現為 emoji」的字符
const emojis = msg.match(emojiPattern);
console.log(emojis); // ["😊", "🚀"]
注意:正則必須加上 u(Unicode)旗標,否則會把 surrogate pair 當成兩個獨立字符。
5️⃣ 字串與儲存(UTF‑8、UTF‑16)
在伺服器端(Node.js)或資料庫中,通常會使用 UTF‑8 編碼儲存文字。JavaScript 的 Buffer 物件可以在 UTF‑8 與 UTF‑16 之間轉換:
const str = '💡✨';
const utf8 = Buffer.from(str, 'utf8'); // 轉成 UTF‑8 位元組
console.log(utf8); // <Buffer f0 9f 92 a1 e2 9c a8>
const back = utf8.toString('utf8');
console.log(back === str); // true
確保在 API 交互、檔案寫入 時使用正確的編碼,可避免「亂碼」或資料遺失。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
String.length 直接使用 |
只算 code unit,對 emoji 會得到 2 或更多 | 使用 Array.from(str).length 或 Intl.Segmenter |
正則未加 u flag |
會把 surrogate pair 拆開,匹配失敗或產生錯誤 | 加上 /u,或使用 Unicode property escape |
切割時使用 slice、substring |
可能切斷 surrogate pair,產生非法字符 | 使用 sliceGrapheme(基於 Intl.Segmenter) |
| 直接比較字串長度判斷「字數」 | 中文、emoji 會被計算成多個單位 | 先正規化為 grapheme,再比較 |
| 輸入/輸出編碼不一致 | 在 Node.js 與資料庫間傳遞時產生亂碼 | 明確指定編碼(utf8)並使用 Buffer 轉換 |
最佳實踐
- 總是使用 Unicode‑aware 方法:
for...of、Array.from、Intl.Segmenter。 - 正則表達式加上
uflag,必要時使用\p{...}。 - 在 UI 中顯示 emoji 前,先正規化(NFC)以避免不同平台呈現差異。
- 儲存與傳輸時統一使用 UTF‑8,避免跨平台編碼衝突。
- 測試包含多語系與 emoji 的字串,尤其是長度、切割與搜尋的單元測試。
實際應用場景
| 場景 | 為何需要 Unicode/emoji 處理 | 可能的實作方式 |
|---|---|---|
| 社群平台貼文編輯器 | 使用者會貼上大量 emoji、ZWNJ/ZWJ 連結的複合表情 | 使用 Intl.Segmenter 計算貼文字數上限,確保不會因切斷半個 emoji 而產生錯誤 |
| 聊天機器人 (Chatbot) | 需要辨識使用者輸入的情緒 emoji,做出回應 | 正則 \p{Emoji} 搭配 matchAll 取得所有表情,計算情緒分數 |
| 多語系搜尋引擎 | 文字搜尋需支援中文、日文、表情符號等 | 建立倒排索引時,使用 grapheme 單位切詞,避免把 emoji 與相鄰文字混在一起 |
| 檔案上傳與儲存 | 檔名可能包含 Unicode 字元或 emoji | 在 Node.js 中使用 fs 時,以 UTF‑8 編碼寫入,並使用 path.normalize 處理路徑 |
| 國際化 UI (i18n) | 不同語系的文字長度差異大,emoji 亦佔位 | 使用 Intl.Segmenter 動態計算字串長度,調整 UI 元素寬度或字體大小 |
總結
Unicode 與 emoji 已成為現代 Web 與應用程式不可或缺的文字元素。
- JavaScript 內部使用 UTF‑16,
String.length並不等於實際字符數。 - 透過
Array.from、for...of、Intl.Segmenter,我們可以安全地遍歷、切割與計算字串長度。 - 正則表達式必須加上
uflag,並善用 Unicode property escapes 來匹配 Emoji。 - 在儲存、傳輸或跨平台顯示時,統一使用 UTF‑8 編碼,並做好正規化。
掌握這些概念與實作技巧後,你就能在開發聊天應用、社群平台、國際化網站等場景時,避免因 Unicode 處理不當而產生的「亂碼」或「字元斷裂」問題,寫出更健全、使用者友善的程式碼。祝你在 JavaScript 的字串世界裡玩得開心、寫得順利!