📋 目錄
不管你是做 SaaS 產品要支援台灣、中國、日本市場,還是做遊戲要處理多國語言介面,只要你的使用者會輸入中文、日文或韓文,CJK(Chinese, Japanese, Korean)文字就會用各種你想不到的方式讓你的 UI 出問題。
本文整理七個最常見的 CJK 文字坑,每個坑都附上「實際怎麼發生」和「現在該怎麼做」,幫你一次搞清楚。
1. 字元寬度與排版:為什麼中文看起來比英文胖
全形 vs 半形的基本事實
在 CSS 的世界裡,一個英文字母預設佔 0.5em 寬度,而一個 CJK 字符幾乎都是 1em 寬(全形)。這不是 bug,是設計上的差异——CJK 字體本來就是為方塊字設計的,每個字元占一個「字面」。
/* 英文每個字 0.5em,中文每個字 1em */
.english-text {
font-size: 16px;
/* 每個英文字母約 8px 寬 */
}
.cjk-text {
font-size: 16px;
/* 每個中文字約 16px 寬,是英文的兩倍 */
}
換行行為:CJK 可以在任何地方換行,英文不行
CSS 的 word-break 和 overflow-wrap 對 CJK 和英文的行為完全不同:
/* 預設:CJK 可以在任何字元間換行,英文單字不會在中間斷開 */
default {
word-break: normal;
overflow-wrap: normal;
}
/* CJK 專用的換行模式 */
.cjk-text {
word-break: break-all; /* CJK 字符可在任意位置換行 */
}
/* 讓英文單字也能在中間斷開 */
.english-text {
word-break: break-word; /* 英文單字過長時允許在中間換行 */
overflow-wrap: break-word;
}
實際 bug 情境:某電商在標題使用了 overflow-wrap: break-word,結果英文商品名稱被切在奇怪的地方,但中文標題完全沒問題——因為 CJK 本來就可以自由換行。
white-space: nowrap 對 CJK 的效果
white-space: nowrap 對 CJK 和英文的影響一致(都不換行),但產生的問題不同:
/* 兩個都有效,但對 UI 的破壞方式不同 */
CJK-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; /* CJK 會在最後一個字後面加省略號 */
}
letter-spacing 對 CJK 的暗藏陷阱
letter-spacing 會加在每個字元後面,包括最後一個字。 對英文來說不明顯,但對 CJK 文字影響很大:
/* 錯誤:最後一個 CJK 字後面也會有間距,造成視覺上的不對稱 */
.bad-spacing {
letter-spacing: 0.1em; /* 每個字後面都加,包括最後一個 */
}
/* 修正:用 negative margin 補償最後一個字 */
.good-spacing {
letter-spacing: 0.1em;
}
.good-spacing::after {
content: '';
display: inline-block;
width: -0.1em; /* 吃掉最後一個字的間距 */
}
實際發生的問題:某設計系統在按鈕上用了 letter-spacing: 0.05em,英文按鈕看起來很優雅,但中文按鈕的最後一個字莫名其妙往外偏了 0.8px,使用者回報「按鈕感覺沒對齊」。
line-height 對 CJK 的特殊需求
CJK 字體普遍需要更大的行高,因為每個字符佔滿一個方塊,沒有英文那種上下 ascender/descender 的留白:
/* 英文常見設定 */
.english-content {
line-height: 1.5; /* 對英文足夠了 */
}
/* CJK 建議設定 */
.cjk-content {
line-height: 1.8; /* CJK 需要更多垂直空間 */
/* 理由:中文字是方塊,沒有 ascender/descender 緩衝,
行高太低會造成上下行文字擠在一起,難以閱讀 */
}
/* 實務建議 */
.article-content {
line-height: 1.8; /* 內文 */
heading-line-height: 1.4; /* 標題可以稍微密一點 */
}
現在該怎麼做:
- CJK 文字的
line-height至少設1.8以上 - 對 CJK 元素用
letter-spacing時,記得處理最後一個字的補償 text-align: justify對 CJK 的效果跟英文不同,確定你的排版需求再使用
2. 輸入法(IME)組字事件:即時搜尋失靈的元兇
這可能是 CJK 開發中最常見、也最惱人的 bug。
組字事件的運作方式
當使用者用注音、拼音或假名輸入文字時,輸入法會經歷三個階段:
// 使用者按下鍵盤 → 輸入法攔截 → 組字 → 送出到 input
input.addEventListener('compositionstart', () => {
console.log('組字開始:使用者正在用 IME 打字');
// 這時候 isComposing === true
});
input.addEventListener('compositionupdate', (e) => {
console.log('組字更新:', e.data);
// 每按一個注音按鍵都會觸發
});
input.addEventListener('compositionend', () => {
console.log('組字結束:最終文字已送入 input');
// 這時候 isComposing === false
});
input.addEventListener('input', () => {
console.log('input 事件觸發');
// 問題來了:每個注音按鍵也都會觸發 input!
});
真實 bug 重現:即時搜尋框的災難
// ❌ 錯誤:每個注音按鍵都觸發一次搜尋
searchInput.addEventListener('input', async (e) => {
const query = e.target.value;
const results = await searchAPI(query); // 輸入「你好」過程中觸發了 6 次!
renderResults(results);
});
// 結果:使用者打「你好」,搜尋 API 被呼叫了 6 次
// 前 5 次是注音「ㄋ一ㄏㄠ」,最後一次才是「你好」
// ✅ 正確:只在組字完成後才搜尋
let isComposing = false;
searchInput.addEventListener('compositionstart', () => {
isComposing = true;
});
searchInput.addEventListener('compositionend', (e) => {
isComposing = false;
// 強制觸發一次 input
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
});
searchInput.addEventListener('input', (e) => {
if (isComposing) return; // 組字中,不處理
const query = e.target.value;
debouncedSearch(query);
});
React 的特殊情況
React 16 之前,onChange 在 IME 組字過程中也會被觸發,React 16+ 已修正,但如果你還在用很舊的版本,要特別注意:
// React 16+ 對 IME 的處理已經正確
function SearchBox() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)} // 這在 React 16+ 不會在組字中觸發
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => {
setIsComposing(false);
handleSearch(query);
}}
/>
);
}
現在該怎麼做:
- 所有即時搜尋框都要監聽
compositionend才觸發查詢 - 使用
isComposingflag 來 guardinput事件處理 - 實作 debounce,但要確保 debounce 內的邏輯也檢查
isComposing
3. 字串排序:為什麼中文 sort() 後順序這麼奇怪
Unicode code point 排序對 CJK 完全沒意義
// ❌ 錯誤:直接 sort() 會按 Unicode code point 排序
const words = ['林', '王', '張', '陳', 'Amy', 'Bob'];
words.sort();
console.log(words);
// 輸出:['Amy', 'Bob', '林', '張', '王', '陳'] <-- 完全不是字母或筆畫順序!
// '林' (U+6797) < '王' (U+738B) < '張' (U+5F20) < '陳' (U+9673)
localeCompare 才是正確做法
// ✅ 正確:指定 locale
const words = ['林', '王', '張', '陳', 'Amy', 'Bob'];
// 繁體中文排序
words.sort((a, b) => a.localeCompare(b, 'zh-TW'));
// 輸出:['Amy', 'Bob', '林', '王', '張', '陳'] <-- 筆畫順序(台灣慣用)
// 簡體中文排序
words.sort((a, b) => a.localeCompare(b, 'zh-CN'));
// 輸出可能不同:'Amy', 'Bob', '王', '張', '林', '陳' <-- 筆畫順序(中國大陸)
// 日文排序
words.sort((a, b) => a.localeCompare(b, 'ja-JP'));
// 輸出:'Amy', 'Bob', '林', '王', '張', '陳'
Intl.Collator 的效能優勢
當你需要對大量資料排序時,Intl.Collator 比反覆呼叫 localeCompare 快很多:
// ✅ Intl.Collator:批量排序時效能更好
const collator = new Intl.Collator('zh-TW', {
sensitivity: 'base', // 不區分音調
numeric: true, // 數字字串正確排序
});
const names = ['林宥嘉', '林俊傑', '張學友', '王菲', '陳奕迅'];
// 單次比較
console.log(collator.compare('林宥嘉', '林俊傑')); // -1(宥 < 俊)
// 陣列排序
names.sort(collator.compare);
// 輸出:['林俊傑', '林宥嘉', '張學友', '王菲', '陳奕迅']
// 批次處理:比 localeCompare 快約 30-50%
const largeDataset = getLargeNameList();
largeDataset.sort(collator.compare);
注意:繁體、簡體、日文的排序規則不同,不要混用:
// 測試各種 locale 的差異
const test = ['台北', '台中', '高雄'];
console.log(test.sort((a, b) => a.localeCompare(b, 'zh-TW')));
// ['台中', '台北', '高雄'] <-- 依注音排列
console.log(test.sort((a, b) => a.localeCompare(b, 'zh-CN')));
// ['台中', '台北', '高雄'] <-- 中國也是筆畫/注音混合
現在該怎麼做:
- CJK 字串排序一律加
localeCompare並指定 locale - 大量資料排序時用
Intl.Collator - 確定你的目標市場用哪種排序規則(台灣、中國、日本、韓國各不相同)
4. 字數計算與截斷:maxlength 對 CJK 的誤會
JavaScript 中 .length 對 CJK 的行為
// CJK 字符在 JavaScript 中 .length === 1(BMP 範圍內)
'中'.length // 1
'英'.length // 1
'a'.length // 1
// 但表情符號就不是了
'😀'.length // 2(surrogate pair)
'👍🏿'.length // 4(表情 + modifier)
HTML maxlength 對 CJK 的實際行為
<!-- 這個 input 限制的是「字元數」,不是「視覺寬度」 -->
<!-- 輸入 10 個中文字或 10 個英文字,都能塞進去 -->
<!-- 但一個中文字的視覺寬度 ≈ 兩個英文字 -->
<input type="text" maxlength="10" />
<!-- 結果:10 個中文字佔 160px,10 個英文字佔 80px -->
<!-- UI 就這樣歪掉了 -->
Twitter/X 的字數計算方式
Twitter(X)對 CJK 字元採用雙倍計數:140 字 = 英文 280 個字母或中文 140 字。這是業界常見的做法。
實作「視覺寬度等效」的字數限制
function getVisualLength(str) {
// CJK 字元算 2,英文/符號算 1
return [...str].reduce((len, char) => {
// CJK Unicode 範圍
const isCJK = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(char);
return len + (isCJK ? 2 : 1);
}, 0);
}
function truncateToVisualWidth(str, maxWidth) {
let result = '';
let currentWidth = 0;
for (const char of str) {
const charWidth = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(char) ? 2 : 1;
if (currentWidth + charWidth > maxWidth) break;
result += char;
currentWidth += charWidth;
}
if (result.length < str.length) {
result += '…';
}
return result;
}
// 使用範例
const bio = '我是前端工程師,專注於打造良好的使用者體驗';
console.log(getVisualLength(bio)); // 38(不含…)
console.log(truncateToVisualWidth(bio, 30)); // '我是前端工程師,專注於…'
// 即時計算顯示字數
input.addEventListener('input', (e) => {
const visualLen = getVisualLength(e.target.value);
counter.textContent = `${Math.ceil(visualLen / 2)} / 100`;
// 即時顯示「已用:15 / 100」(按視覺寬度計算)
});
現在該怎麼做:
maxlength屬性對 CJK 是按字元計數,不是視覺寬度- 需要視覺寬度限制時,自己實作計算邏輯
- Twitter/X 的「雙倍計數」是業界參考做法
5. 字體 fallback 順序:為什麼「骨」字看起來怪怪的
CSS font-family 的 CJK fallback 鏈
同一個 Unicode 字符,繁體、簡體、日文的字形可能完全不同:
/* 同一個字,在不同語言環境下應該顯示不同字形 */
/* 「骨」字:繁體「骨」、簡體「骨」(相同)、日文「骨」(略有差異) */
/* 推薦的 font-family 順序(台灣繁體優先)*/
body:lang(zh-TW) {
font-family:
'Noto Sans TC',
'PingFang TC',
'Microsoft JhengHei',
'Heiti TC',
sans-serif;
}
body:lang(zh-CN) {
font-family:
'Noto Sans SC',
'PingFang SC',
'Microsoft YaHei',
'Source Han Sans',
sans-serif;
}
body:lang(ja) {
font-family:
'Noto Sans JP',
'Hiragino Sans',
'Yu Gothic',
'Meiryo',
sans-serif;
}
font-language-override 和 lang 屬性的作用
/* lang 屬性會影響 font 選擇,務必正確設定 */
<html lang="zh-TW"> <!-- 繁體中文 -->
<html lang="zh-CN"> <!-- 簡體中文 -->
<html lang="ja"> <!-- 日文 */
/* font-language-override 可以強制指定語言覆寫 */
.cjk-text {
font-language-override: 'ZHT'; /* 強制用繁體中文排版規則 */
/* 適用時機:用錯字形時的緊急補救 */
}
Google Fonts Noto 系列:檔案很大,要 subset
<!-- 完整 Noto Sans TC 檔案約 1.5MB,千萬不要直接載入 -->
<!-- 正確做法:用 Google Fonts 的 subset 功能 -->
<!-- 方法 1:只用 Latin + CJK 子集 -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet">
<!-- 方法 2:自己 subset(推薦對效能要求高的網站)-->
<!-- 使用 fonttools + Brotli 壓縮,可以把 1.5MB 壓到 300KB -->
為什麼 fallback 順序很重要
/* 錯誤順序:日文字形優先,結果台灣人看到日文版的「的」*/
.bad {
font-family: 'Yu Gothic', 'Microsoft JhengHei', sans-serif;
/* 「的」的日文字形(U+306E)和繁體(U+7684)筆劃略有不同 */
/* 台灣使用者看到的是日文字形,感覺「怪怪的」但說不出哪裡怪 */
}
/* 正確順序:明確指定語言相符的字形 */
.good-tw {
font-family:
'Noto Sans TC',
'PingFang TC', /* macOS 繁體 */
'Microsoft JhengHei', /* Windows 繁體 */
'Heiti TC', /* macOS 舊版繁體 */
sans-serif;
}
現在該怎麼做:
lang屬性一定要設定正確,這會影響瀏覽器的字體選擇font-family順序要根據目標市場調整- Noto 系列字體雖然好,但要注意檔案大小,用 subset 或 preload
6. 正則表達式:如何正確匹配 CJK 字符
\u4e00-\u9fff 已經不夠用了
// ❌ 舊方法:只匹配常用簡體/繁體(不全)
/[\u4e00-\u9fff]/.test('林'); // true
/[\u4e00-\u9fff]/.test('龜'); // false!(U+9F9C 不在這個範圍)
// 完整的 CJK Unified Ideographs 範圍極大
// U+4E00–U+9FFF:基本 CJK 字符
// U+3400–U+4DBF:擴展 A
// U+20000–U+2A6DF:擴展 B(罕用字)
\p{Script=Han} — 現代正確做法
// ✅ 現代做法:用 Unicode property escapes(需要 /u flag)
/\p{Script=Han}/u.test('林'); // true
/\p{Script=Han}/u.test('龜'); // true(終於包含擴展字了!)
/\p{Script=Han}/u.test('の'); // false(日文假名不在 Han 範圍)
// 日文假名
/\p{Script=Hiragana}/u.test('の'); // true
/\p{Script=Katakana}/u.test('カ'); // true
// 韓文諺文
/\p{Script=Hangul}/u.test('한'); // true
// 實用正則:匹配所有 CJK 字符
const cjkRegex = /\p{Script=Han}/u;
const mixedRegex = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
// 提取字串中的所有中文
'Hello你好World世界123'.match(/\p{Script=Han}/gu);
// ['你', '好', '世', '界']
實用正則範例
// 驗證是否為純 CJK(不含英文、數字、符號)
function isPureCJK(str) {
return /^\p{Script=Han}+$/u.test(str);
}
isPureCJK('繁體中文'); // true
isPureCJK('English中文'); // false
// 驗證姓名(可以是中文或英文,但不能摻雜)
function isValidName(name) {
return /^(\p{Script=Han}{2,4}|[A-Za-z\s]{2,30})$/u.test(name);
}
isValidName('林宥嘉'); // true
isValidName('Jay Lin'); // true
isPureCJK('林宥Jay'); // false(混雜)
// 移除所有 CJK 字符
'Apple蘋果Banana香蕉'.replace(/\p{Script=Han}/gu, '');
// 'AppleBanana'
// 驗證日文假名輸入
function isValidKatakanaName(str) {
return /^\p{Script=Katakana}{1,20}$/u.test(str);
}
isValidKatakanaName('カイドウ'); // true
isValidKatakanaName('康熙'); // false(不是片假名)
瀏覽器支援:\p{} Unicode property escapes 在所有現代瀏覽器都支援(Chrome 64+, Firefox 79+, Safari 11.1+),但 IE 不支援。如果你要支援 IE,需要用 Babel 轉譯或 fallback 到 \u4e00-\u9fff。
現在該怎麼做:
- 匹配 CJK 字符時,用
\p{Script=Han}/u而不是\u4e00-\u9fff - 也要分清楚日文假名(Hiragana/Katakana)和韓文(Hangul)的差異
- 確認你的瀏覽器目標是否支援
\p{}語法
7. 實戰建議 checklist:現在該怎麼做
每個坑的實際對策,濃縮在這裡:
搜尋框 — IME 組字問題
let isComposing = false;
input.addEventListener('compositionstart', () => { isComposing = true; });
input.addEventListener('compositionend', () => {
isComposing = false;
input.dispatchEvent(new Event('input', { bubbles: true }));
});
input.addEventListener('input', (e) => {
if (isComposing) return;
debouncedSearch(e.target.value);
});
字串排序
// 單次排序
arr.sort((a, b) => a.localeCompare(b, 'zh-TW'));
// 大量排序
const collator = new Intl.Collator('zh-TW', { sensitivity: 'base' });
arr.sort(collator.compare);
CSS CJK 排版
.cjk-text {
line-height: 1.8;
word-break: break-word;
/* 避免 letter-spacing 最後一字問題 */
}
.cjk-text::after {
content: '';
display: inline-block;
width: -0.05em;
}
字體 fallback
/* 記得設定 lang 屬性,讓瀏覽器幫你選對 */
html[lang="zh-TW"] {
font-family: 'Noto Sans TC', 'PingFang TC', 'Microsoft JhengHei', sans-serif;
}
正則表達式
// 用 \p{} 就不用背 Unicode 範圍了
/\p{Script=Han}/u // 中文
/\p{Script=Hiragana}/u // 日文平假名
/\p{Script=Katakana}/u // 日文片假名
/\p{Script=Hangul}/u // 韓文
結語
CJK 文字坑的核心觀念只有三個:
- CJK 是方塊字:視覺寬度是英文的兩倍,
line-height需要更大 - IME 組字是特殊階段:搜尋、驗證等即時反應都要 guard
isComposing - 語言決定字形:同一個 Unicode 字符,繁體、簡體、日文、韓文的顯示可能不同,字體 fallback 要針對語言設定
只要記住這三件事,大多數 CJK 相關的 bug 都能避免。剩下的就是多做、踩過,才會變成直覺。
本文屬於「前端工程師實戰坑」系列。如果你也有遇到過其他 CJK 文字問題,歡迎留言分享。