JavaScript 字串操作 – 字元編碼與字串長度
簡介
在 JavaScript 中,字串是最常見的資料型別之一,幾乎所有的前端與後端程式都會與文字打交道。了解字元編碼的原理,才能正確判斷字串長度、切割子字串、或是安全地處理使用者輸入。
如果只依賴 str.length,在面對 Emoji、中文、或是其他 Unicode 之外的字符時,往往會得到錯誤的結果,進而導致 UI 破版、資料庫儲存錯誤,甚至安全漏洞。
本篇文章將從 UTF‑16 編碼、碼點 (code point)、代理配對 (surrogate pair) 等核心概念說起,說明 JavaScript 為何會出現「字串長度不等於實際字元數」的情況,並提供 實用的程式碼範例、常見陷阱與 最佳實踐,讓你在日常開發中能夠正確且有效率地操作字串。
核心概念
1. JavaScript 內部的字串儲存 – UTF‑16
JavaScript 的字串是以 UTF‑16 為底層編碼方式儲存的。每個 16 位元 (2 個位元組) 的單位稱為 code unit,而一個完整的 Unicode 字元 (code point) 可能由 一個或兩個 code unit 組成。
| 範例 | Unicode 編碼點 | UTF‑16 表示 | 需要的 code unit 數量 |
|---|---|---|---|
A |
U+0041 | 0x0041 | 1 |
中 |
U+4E2D | 0x4E2D | 1 |
😀 |
U+1F600 | 0xD83D 0xDE00 | 2 (代理配對) |
重點:只有基本多語言平面 (BMP) 內的字符 (U+0000 ~ U+FFFF) 佔用一個 code unit;超出 BMP 的字符 (如 Emoji、某些古文字) 會以 代理配對 形式佔用 兩個 code unit。
2. String.length 與實際字元數
String.length 直接回傳 code unit 的數量,因此在含有代理配對的字串上會得到比實際字元多的值。
const str = 'Hello😀世界';
console.log(str.length); // 10
// 解析:H e l l o (5) + 😀 (2) + 世 界 (2) = 9? 其實是 10,因為「😀」佔兩個 code unit
如果要得到真正的字元數(即 Unicode code point 數),可以使用以下方法:
// 方法一:展開運算子 + 陣列長度
const realLength = [...'Hello😀世界'].length; // 9
// 方法二:Array.from
const realLength2 = Array.from('Hello😀世界').length; // 9
技巧:
[...str]與Array.from(str)會自動依 UTF‑16 代理配對 把字串切成真正的「字元」(code point)。
3. 正確切割與遍歷字串
3.1 使用 for...of
for...of 迭代時會以 字元 (code point) 為單位,而非單純的 code unit。
for (const ch of '🦄✨') {
console.log(ch);
}
// 輸出:
// 🦄
// ✨
3.2 String.prototype.slice 與代理配對
slice 接收的參數仍是 code unit 索引,若不小心在代理配對的中間切割,會產生 半個字符,導致顯示異常。
const emoji = '👍';
console.log(emoji.slice(0, 1)); // � (破字)
解法:先把字串展開成陣列,再使用 slice,最後再 join 回字串。
function safeSlice(str, start, end) {
return [...str].slice(start, end).join('');
}
console.log(safeSlice('👍🏻', 0, 1)); // 👍🏻
4. TextEncoder / TextDecoder – 轉換成 UTF‑8
在與後端或檔案系統交互時,往往需要 UTF‑8 編碼的位元組。TextEncoder 會把字串轉成 Uint8Array,而 TextDecoder 則相反。
const encoder = new TextEncoder(); // 預設使用 UTF‑8
const decoder = new TextDecoder('utf-8');
const utf8Bytes = encoder.encode('Hello 🌍');
console.log(utf8Bytes); // Uint8Array([...])
const backToString = decoder.decode(utf8Bytes);
console.log(backToString); // "Hello 🌍"
5. 正規表達式與 Unicode flag (u)
ECMAScript 2015 引入 u flag,使正則表達式支援 Unicode code point,避免因代理配對產生錯誤匹配。
const regex = /\p{Emoji}/u; // 匹配任意 Emoji
console.log(regex.test('😀')); // true
console.log(regex.test('A')); // false
程式碼範例
以下範例展示在日常開發中會用到的 字元編碼與長度 操作技巧,皆附有說明註解。
範例 1 – 計算真實字元長度
/**
* 回傳字串的 Unicode 字元數(不受代理配對影響)
* @param {string} str
* @returns {number}
*/
function getUnicodeLength(str) {
// 展開運算子會自動把代理配對視為單一字元
return [...str].length;
}
console.log(getUnicodeLength('JavaScript')); // 10
console.log(getUnicodeLength('🌟✨🚀')); // 3
console.log(getUnicodeLength('漢字')); // 2
範例 2 – 安全切割含 Emoji 的字串
/**
* 依 Unicode 字元切割字串
* @param {string} str 原始字串
* @param {number} start 起始索引(含)
* @param {number} end 結束索引(不含)
* @returns {string}
*/
function safeSubstring(str, start, end) {
// 先展開成陣列,再切割,最後合併回字串
return [...str].slice(start, end).join('');
}
const text = '🚀 Launch 🚀';
console.log(safeSubstring(text, 0, 2)); // "🚀 "
console.log(safeSubstring(text, 2, 8)); // "Launch"
範例 3 – 逐字元遍歷並統計 Emoji 數量
function countEmojis(str) {
let count = 0;
// 使用 for...of 逐個 Unicode 字元遍歷
for (const ch of str) {
// 判斷是否屬於 Emoji 範圍(簡易寫法)
if (/[\u{1F300}-\u{1FAFF}]/u.test(ch)) {
count++;
}
}
return count;
}
console.log(countEmojis('Hello 😊! 🎉')); // 2
範例 4 – 使用 TextEncoder 計算 UTF‑8 位元組長度
function utf8ByteLength(str) {
const encoder = new TextEncoder(); // UTF‑8
return encoder.encode(str).length;
}
console.log(utf8ByteLength('A')); // 1
console.log(utf8ByteLength('中')); // 3
console.log(utf8ByteLength('😀')); // 4
範例 5 – 正則表達式 u flag 解析 Unicode
// 捕捉所有非 BMP(即需要代理配對的字符)
const nonBMP = /[\u{10000}-\u{10FFFF}]/gu;
const mixed = 'a😀b𠮷c';
console.log(mixed.match(nonBMP)); // ["😀", "𠮷"]
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
直接使用 str.length |
在含有 Emoji、中文或其他非 BMP 字元時會得到錯誤的長度 | 使用 [...str].length 或 Array.from(str).length |
slice、substring 切割代理配對 |
可能產生半個字符,導致顯示「�」或亂碼 | 先展開字串為陣列,再切割,最後 join |
正則表達式未加 u flag |
\w、. 等會把代理配對視為兩個字元,匹配結果不正確 |
加上 u flag,或使用 Unicode Property Escape (\p{...}) |
| 字串與位元組混用 | 把 UTF‑16 長度當作 UTF‑8 位元組長度,會在傳輸或儲存時出錯 | 使用 TextEncoder/TextDecoder 轉換編碼 |
| 字串比較時忽略正規化 | 同一視覺字元可能有不同的 Unicode 正規化形式 (NFC、NFD),比較結果會不相等 |
使用 String.prototype.normalize() 統一正規化形式 |
最佳實踐:
- 統一編碼:在 API、資料庫、檔案 I/O 前,確保所有字串皆已正規化為 NFC(最常用)。
- 使用 Unicode-aware 方法:
for...of、Array.from、[...str]、正則uflag。 - 避免硬編碼索引:若需切割或取子字串,儘量使用 字元索引(透過展開陣列)而非
length。 - 測試多語系與 Emoji:在單元測試中加入包含代理配對的字串,以捕捉潛在錯誤。
實際應用場景
| 場景 | 需求 | 相關技巧 |
|---|---|---|
| 使用者輸入驗證 | 限制字數、檢查是否包含 Emoji | 使用 [...input].length 取得正確字數;正則 u flag 檢測 Emoji |
| UI 顯示截斷 | 顯示省略號 …,不讓文字在中間斷開 |
safeSubstring(str, 0, maxChars) + (str.length > maxChars ? '…' : '') |
| 搜尋與排序 | 依 Unicode 字元排序,確保一致性 | localeCompare 搭配 normalize() |
| 多語系文件上傳 | 計算檔名長度、避免超過系統限制 | utf8ByteLength(filename) 取得實際傳輸位元組長度 |
| 即時聊天系統 | 正確統計訊息長度、限制 Emoji 數量 | countEmojis(message)、getUnicodeLength(message) |
總結
- JavaScript 以 UTF‑16 為底層編碼,
String.length只代表 code unit 數,在遇到代理配對時會產生誤差。 - 想要得到真實的字元數、安全切割或遍歷字串,必須使用 Unicode‑aware 的方法:
[...str]、Array.from、for...of、正則uflag 等。 - 在需要 位元組長度(如網路傳輸、檔案儲存)時,應以
TextEncoder/TextDecoder轉成 UTF‑8,避免直接使用length。 - 常見陷阱包括 代理配對切割、正則未加
u、忽略字串正規化,只要遵循 「先展開再操作」 的原則,就能有效避免。
掌握上述概念與實作技巧,你就能在日常開發中正確、可靠地處理各種文字資料,從簡單的表單驗證到複雜的多語系系統,都能游刃有餘。祝你寫程式寫得 順手又順心!