CJK中文前端IMECSSJavaScript

前端工程師必知的 CJK 文字坑 — 中文、日文、韓文在瀏覽器裡的奇怪行為

深入解析 CJK 文字在前端開發中的七個常見陷阱:從 IME 組字、字元寬度、排版到正則表達式,工程師必知的實戰攻略。

· 12 分鐘閱讀

不管你是做 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-breakoverflow-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 才觸發查詢
  • 使用 isComposing flag 來 guard input 事件處理
  • 實作 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-overridelang 屬性的作用

/* 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 文字坑的核心觀念只有三個:

  1. CJK 是方塊字:視覺寬度是英文的兩倍,line-height 需要更大
  2. IME 組字是特殊階段:搜尋、驗證等即時反應都要 guard isComposing
  3. 語言決定字形:同一個 Unicode 字符,繁體、簡體、日文、韓文的顯示可能不同,字體 fallback 要針對語言設定

只要記住這三件事,大多數 CJK 相關的 bug 都能避免。剩下的就是多做、踩過,才會變成直覺。


本文屬於「前端工程師實戰坑」系列。如果你也有遇到過其他 CJK 文字問題,歡迎留言分享。