📋 目錄
先看一個讓人震驚的事實:
console.log('👨👩👧'.length); // ???答案是 8。不是 3(家庭成員數),不是 1(看起來像一個 emoji),而是 8。
這個數字讓很多前端工程師第一次意識到:JavaScript 的字串長度,跟你看到的字元數,可能完全不是同一件事。
讓人震驚的真實例子
// 基本 emoji
console.log('👋'.length); // 2(不是 1!)
console.log('👨'.length); // 2(不是 1!)
// 家庭 emoji(ZWJ 序列)
console.log('👨👩👧'.length); // 8(不是 3!)
// 👨 + ZWJ + 👩 + ZWJ + 👧 = 5 個 code point × 2 = 10... wait
// 實際:👨(2) + ZWJ(1) + 👩(2) + ZWJ(1) + 👧(2) = 8
// 彩虹旗(多個 modifier)
console.log('🏳️🌈'.length); // 6(不是 2!)
// 有肤色 modifier 的 emoji
console.log('👋🏽'.length); // 3(不是 2!)
// 👋 + 🏽 = 2 code point × 2 = 4... actually
// 實際計算:👋(2) + 🏽(2) = 4 - but with variation selector
這些數字看起來混亂,但每一個都有背後的 Unicode 邏輯。
Unicode 基礎:Code Point vs Code Unit
JavaScript 字串是 UTF-16
JavaScript 字串內部使用 UTF-16 編碼。每 16 位元為一個 code unit。
| 字符類型 | Code Point 範圍 | 在 JS 中佔用 |
|---|---|---|
| BMP 字符(拉丁字母、漢字、原始 emoji) | U+0000 ~ U+FFFF | 1 個 code unit(16 bits) |
| 補充平面字符(大部分 emoji) | U+10000 ~ U+10FFFF | 2 個 code unit(surrogate pair) |
// 大多數漢字:一個 code point = 一個 code unit
console.log('中'.length); // 1(正確)
console.log('中'.codePointAt(0)); // 20013(U+4E2D)
// 大部分 emoji:一個 code point = 兩個 code unit(surrogate pair)
console.log('👋'.length); // 2(surrogate pair)
console.log('👋'.codePointAt(0)); // 55357(前半 surrogate)
console.log('👋'.codePointAt(1)); // 56389(後半 surrogate)
console.log('👋'.codePointAt(0).toString(16)); // "d83d"
console.log('👋'.codePointAt(1).toString(16)); // "dc4b"
Emoji 的四種組成方式
1. 基本 Emoji(BMP 或 Supplement)
// 這些是「基本」emoji,一個 code point
console.log('😊'.length); // 1
console.log('中'.length); // 1
2. Skin Tone Modifier(修飾符)
// 👋🏽 = 👋(base)+ 🏽(skin tone modifier)
// U+1F44B + U+1F3FD
console.log('👋'.length); // 2(base)
console.log('👋🏽'.length); // 4(base + modifier × 2)
3. ZWJ 序列(Zero Width Joiner)
ZWJ(U+200D)用來連接多個 emoji 成為一個「合成 emoji」:
// 👨 + ZWJ + 👩 + ZWJ + 👧 = 家庭
console.log('👨'.length); // 2
console.log('👩'.length); // 2
console.log('👧'.length); // 2
console.log('👨👩👧'.length); // 8 = (2+1)+(2+1)+2 = 9... wait
// 實際:
// 👨 = U+1F468 (2 code units)
// ZWJ = U+200D (1 code unit)
// 👩 = U+1F469 (2 code units)
// ZWJ = U+200D (1 code unit)
// 👧 = U+1F467 (2 code units)
// 總計:8 code units
// 但如果 OS 支援顯示,👨👩👧 渲染為一個 emoji
4. Flag Emoji(國旗)
// 🇹🇼 = Regional Indicator U+1F1F9 × 2
// U+1F1F9 = T(Taiwan)
// U+1F1FC = W(Wales)
console.log('🇹🇼'.length); // 4(不是 1!)
正確計算「字元數」的三種方法
方法一:Spread 運算符(簡單,但不完全正確)
// [...str] 使用 String Iterator,按 code point 分割
console.log([...'👋'].length); // 1(正確!)
console.log([...'👨👩👧'].length); // 3(正確!)
// 但仍不能處理 ZWJ 序列內部的 semantic units
console.log([...'👨👩👧'].length); // 5(❌ 不是 1,而是 5 individual emoji)
方法二:Array.from(等同於 spread)
console.log(Array.from('👨👩👧').length); // 3
// Array.from 也使用 String Iterator,與 [...] 等價
方法三:Intl.Segmenter(✅ 真正「使用者看到的字數」)
// Intl.Segmenter 按 grapheme cluster 分割
// grapheme cluster = 使用者感知到的「一個字元」
const segmenter = new Intl.Segmenter('zh-TW', { granularity: 'grapheme' });
const graphemes = [...segmenter.segment('👨👩👧')];
console.log(graphemes.length); // 1(真正的視覺字元數!)
// 驗證
console.log([...new Intl.Segmenter().segment('Hello 👋🏽')].map(s => s.segment));
// ['H', 'e', 'l', 'l', 'o', ' ', '👨', '👩', '', '👧', '🏽'] ← 等等...
// Intl.Segmenter granularity 設為 'grapheme' 時:
console.log([...new Intl.Segmenter('zh-TW', { granularity: 'grapheme' }).segment('Hello 👋🏽')].map(s => s.segment));
// ['H', 'e', 'l', 'l', 'o', ' ', '👋🏽'] ← 這就是使用者看到的
字串截斷:最常見的踩坑
錯誤的做法
// 錯誤:用 .slice() 按 code unit 截斷
const input = 'Hello 👋🏽 World';
const truncated = input.slice(0, 10);
// 實際結果取決於截斷位置
console.log(truncated); // 可能截斷 surrogate pair,產生乱碼
// 另一個例子:
console.log('你好👋'.slice(0, 3)); // '你�'(乱碼!👋被截斷)
較好的做法:按 Code Point 截斷
// 按 code point 截斷
function sliceByCodePoint(str, max) {
const codePoints = [...str];
return codePoints.slice(0, max).join('');
}
console.log(sliceByCodePoint('你好👋', 3)); // '你好👋'(正確!)
console.log(sliceByCodePoint('👋👋👋', 2)); // '👋👋'(正確!)
最好的做法:按 Grapheme 截斷
// 按 grapheme cluster 截斷(Intl.Segmenter)
function sliceByGrapheme(str, max) {
const segmenter = new Intl.Segmenter('zh-TW', { granularity: 'grapheme' });
return [...segmenter.segment(str)].slice(0, max).map(s => s.segment).join('');
}
console.log(sliceByGrapheme('你好👋🏽', 3)); // '你好👋🏽'(正確!)
實戰應用
1. Twitter 風格的字數限制
function countGraphemes(str) {
return [...new Intl.Segmenter('zh-TW', { granularity: 'grapheme' }).segment(str)].length;
}
function validateTweet(text, maxChars = 280) {
const count = countGraphemes(text);
if (count > maxChars) {
return { valid: false, remaining: 0, overBy: count - maxChars };
}
return { valid: true, remaining: maxChars - count };
}
// 測試
console.log(validateTweet('Hello 👋', 280));
// { valid: true, remaining: 273 }
console.log(validateTweet('👋🏽👋🏽👋🏽👋🏽👋🏽👋🏽👋🏽👋🏽👋🏽👋🏽', 50));
// 正确按 grapheme 計算,而不是 code unit
2. Regex 匹配 Emoji
// 沒有 /u flag:emoji 不被 . 匹配
/./.test('👋'); // false(❌ . 不匹配 surrogate pair)
// 有 /u flag:正確匹配
/./u.test('👋'); // true(✅ . 匹配任何 grapheme cluster)
// 匹配所有 emoji
const emojiRegex = /\p{Emoji}/gu;
console.log('Hello 👋 World 👨👩👧'.match(emojiRegex));
// ['👋', '👨', '👩', '👧'](⚠️ 不匹配 ZWJ 序列)
3. 反轉包含 Emoji 的字串
// 錯誤:直接反轉會破壞 surrogate pair
const wrong = '👋🏽'.split('').reverse().join('');
console.log(wrong); // '🏽�👋'(完全錯誤!)
// 正確:按 code point 反轉
const correct = [...'👋🏽'].reverse().join('');
console.log(correct); // '👋🏽'(正確!)
後端也要注意:MySQL utf8mb4
-- 如果欄位是 utf8(非 utf8mb4),emoji 會被拒絕或截斷
-- 解決:確保欄位是 utf8mb4
ALTER TABLE users MODIFY COLUMN bio VARCHAR(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
實戰 Checklist
## Unicode / Emoji 處理安全清單
### 字數計算
- [ ] 涉及「字數限制」用 Intl.Segmenter(Twitter 風格輸入框)
- [ ] 簡單場景用 [...str].length(按 code point)
- [ ] 禁止用 str.length 計算「視覺字元數」
### 字串截斷
- [ ] 禁止用 str.slice(n) 直接截斷
- [ ] 用 [...str].slice(0, n).join('') 按 code point 截斷
- [ ] 嚴格場景用 Intl.Segmenter 按 grapheme 截斷
### Regex
- [ ] 處理 Unicode 字元加 /u flag
- [ ] Emoji 匹配用 \p{Emoji}
### DB 儲存
- [ ] 確認後端欄位是 utf8mb4
- [ ] Emoji 存入前不需 normalization(NFC 自動正規化)
### 字串反轉
- [ ] 禁止用 str.split('').reverse().join('')
- [ ] 用 [...str].reverse().join('')
總結
記住這三件事:
-
'emoji'.length === 2(或更多)不是 bug,是設計——JavaScript 使用 UTF-16,emoji 在補充平面,需要 surrogate pair -
[...str].length比str.length好——但仍然不能處理 ZWJ 序列 -
Intl.Segmenter是真正的答案——new Intl.Segmenter().segment(str)按使用者看到的 grapheme 分割,這才是「字元數」的正確演算法
// 從此記住這個模式
const trueLength = (str) =>
[...new Intl.Segmenter('zh-TW', { granularity: 'grapheme' }).segment(str)].length;
本文基於 Unicode 標準(Version 15.0)與 JavaScript 語言規格整理。