React 遇上中文輸入法:為什麼 onChange 會把注音當成錯字
打字測驗做完那天,我自己測了一輪:速度、正確率、換題,全部正常。我以為可以收工了。
隔天早上再打開,順手切回注音。
畫面整片紅。
題目是「今天的天氣很適合出門走走」。我按下第一個 ㄐ,那個字立刻變紅——比對邏輯判定「ㄐ ≠ 今」。我繼續按 ㄧ、ㄣ,紅的還是紅的。等我選完字、「今」真的出現,紅字才變綠。
前一天之所以完全沒發現,是因為我測的是英文。
這個測驗在懲罰還沒打完的使用者,而且只懲罰用中文輸入的人。
一個英文使用者永遠遇不到的問題
英文輸入法裡,按鍵就是字元。你按 a,a 就進到輸入框裡,事情結束。
中文輸入法不是這樣。你按 ㄋ、ㄧ、ˇ,這些都還不是最終結果,而是「組字」過程中的中間狀態。要等你選完字,「你」才真正出現。按鍵和字元之間,隔著一段瀏覽器稱為 composition 的過程。
問題是,我們用的框架、讀的文件、抄的範例,預設的都是前面那個世界。
React 的 onChange,其實是原生的 input
先釐清一件事:React 的 onChange 不是原生的 change 事件。
原生 change 要等輸入框失去焦點才觸發,但 React 的 onChange 在你每打一個字時就會跑——因為 React 實際上接的是原生的 input 事件。
而 input 事件在組字還沒結束時就會不斷觸發。
這是我用真實注音在 Chrome 打「你好」量到的完整事件序列:
| # | 事件 | data | value | isComposing | keyCode |
|---|---|---|---|---|---|
| 1 | keydown | (空) | false | 229 | |
| 2 | compositionstart | (空) | (空) | ||
| 3 | compositionupdate | ㄋ | (空) | ||
| 4 | input | ㄋ | ㄋ | true | |
| 5 | keydown | ㄋ | true | 229 | |
| 6 | compositionupdate | ㄋㄧ | ㄋ | ||
| 7 | input | ㄋㄧ | ㄋㄧ | true | |
| 8 | keydown | ㄋㄧ | true | 229 | |
| 9 | compositionupdate | 你 | ㄋㄧ | ||
| 10 | input | 你 | 你 | true | |
| 11 | keydown | 你 | true | 229 | |
| 12 | compositionupdate | 你ㄏ | 你 | ||
| 13 | input | 你ㄏ | 你ㄏ | true | |
| 14 | keydown | 你ㄏ | true | 229 | |
| 15 | compositionupdate | 你ㄏㄠ | 你ㄏ | ||
| 16 | input | 你ㄏㄠ | 你ㄏㄠ | true | |
| 17 | keydown | 你ㄏㄠ | true | 229 | |
| 18 | compositionupdate | 你好 | 你ㄏㄠ | ||
| 19 | input | 你好 | 你好 | true | |
| 20 | keydown | 你好 | true | 229 | |
| 21 | compositionupdate | 你好 | 你好 | ||
| 22 | input | 你好 | 你好 | true | |
| 23 | compositionend | 你好 | 你好 |
看第 4 列和第 7 列:組字根本還沒結束,input 已經觸發了,而且 value 是 ㄋ、ㄋㄧ 這種注音符號。
打「你好」兩個字,總共觸發了 9 次 input,其中 8 次拿到的都是沒有意義的中間狀態。
如果你的 onChange 裡有比對、驗證、字數統計或送 API,這些邏輯全都會拿著半成品跑 9 次。我的打字測驗就是這樣把 ㄋ 判成錯字的。
幾個看起來可行、但其實不行的解法
用正規表達式濾掉注音符號:只能治標。倉頡打出來是英文字母,拼音也是,根本濾不掉;而且使用者真的想輸入字母時,輸入內容反而會被吃掉。
debounce 個幾百毫秒:打字快的人照樣會在組字中間觸發,而且會讓即時比對變得遲鈍。
改用 onBlur:失焦才驗證,那即時回饋就沒了——對打字測驗來說等於整個功能不見。
用 keydown 判斷 keyCode === 229:這是老專案常見的寫法。229 是「這個按鍵被輸入法吃掉了」的歷史訊號,我的實測顯示三家瀏覽器目前都還在送。但它已經被 composition 事件取代,而且出現的位置各家不同(後面會提),不適合當主要判斷依據。
正確的方向:composition 事件
瀏覽器其實有專門描述組字過程的事件:compositionstart(開始組字)、compositionupdate(組字內容變了)、compositionend(選字完成,文字確定)。
原則只有一句:組字過程中照常更新畫面,但不要下判斷。等 compositionend 之後再把值當真。
實作大概長這樣:
const composingRef = useRef(false);
const handleChange = (event) => {
const { value } = event.target;
setTyped(value); // 畫面照常跟著跑,使用者看得到自己在打什麼
if (!composingRef.current) {
commitValue(value); // 但只有非組字狀態才真的採計
}
};
const handleCompositionEnd = (event) => {
composingRef.current = false;
commitValue(event.target.value); // 選字完成,這時的值才算數
};<textarea
value={typed}
onChange={handleChange}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={handleCompositionEnd}
/>注意,畫面還是要跟著每一次 input 更新。使用者要看得到自己正在打 ㄋㄧ,這是輸入法本來就該有的回饋。我們只是不拿這些中間值去做判斷。
為什麼是 useRef,不是 useState
composingRef 用 useRef 而不是 useState,不是為了效能,是因為它必須在同一個事件迴圈裡立刻被讀到。
useState 的更新是非同步的,要等下一次 render 才生效。compositionstart 設定的狀態,緊接著的 handleChange 讀到的會是舊值——而這兩件事往往在同一批事件裡發生。等於守衛完全失效。
useRef 改了就立刻生效。這是少數真的需要 useRef 的場景,不是因為「懶得寫 state」。
大部分講這個題目的文章寫到這裡就結束了。我當時也以為做完了。
第二層:最後一個 input 的 isComposing 還是 true
為了確認自己真的理解事件順序,我寫了一個小工具,把 compositionstart / update / end、input、keydown 全部記錄下來,然後用真實的注音輸入法在三個瀏覽器各打一次「你好」。
結果出乎我意料:三家瀏覽器的收尾順序完全一致。
| Chrome / Brave | Safari | |
|---|---|---|
| 倒數第二 | input,value = 你好,isComposing: true | input,value = 你好,isComposing: true |
| 最後 | compositionend,value = 你好 | compositionend,value = 你好 |
| 之後還有 input 嗎? | 沒有 | 沒有 |
兩個事實合起來看:第一,compositionend 之後不再有任何 input 事件。第二,最後那個 input 雖然 value 已經是確定的「你好」,但 isComposing 仍然是 true。
這推翻了一個流傳很廣的寫法:
// 在 Chrome / Brave / Safari 都拿不到最終值
onChange = (event) => {
if (event.nativeEvent.isComposing) return;
commit(event.target.value);
};InputEvent.isComposing 看起來很優雅——不用自己維護 ref,一行就解決。問題是,帶著最終值的那一次 input,isComposing 是 true,所以被 return 掉了,而它之後沒有別的 input 會再來。
於是你永遠等不到那個值。
結論很明確:compositionend 不是可有可無的優化,它是唯一能取得確定值的地方。isComposing 可以用來濾掉中間狀態,但不能取代 compositionend。
順手量到的三個細節
compositionupdate 的 value 落後一步。在第 3 列,compositionupdate 的 data 已經是「ㄋ」,但 target.value 還是空字串——DOM 的值要等下一個 input 才更新。所以要讀值就讀 input 或 compositionend,不要在 compositionupdate 裡讀 target.value。
Safari 會在送出前閃一次空字串。Safari 在確定文字之前,會多送一個 input,value 是空字串,下一個 input 才補回「你好」。如果你的邏輯裡有「值變空就重置狀態」這類判斷,它會在 Safari 上被觸發。
keyCode 229 三家都還在送,但位置不同。Chrome 和 Brave 在組字開始之前多一個 keydown;Safari 沒有前導的,卻在 compositionend 之後多一個。想靠它判斷「組字結束了沒」並不可靠。
一個我加了、但可能用不到的防護
在確認真實事件順序之前,我先用合成事件模擬組字流程,卻猜錯了順序——我以為 compositionend 之後還會補一個 input。在那個假想的序列下,同一段文字會被結算兩次:字數變兩倍,題目索引也會跳過一段。
所以我加了一個冪等守衛:
const settledValueRef = useRef(''); // 已結算過的那段文字
const commitValue = (value) => {
if (value.length >= passage.length) {
if (value === settledValueRef.current) return; // 同一段只結算一次
settledValueRef.current = value;
// …結算
return;
}
committedRef.current = value;
};然後真實量測推翻了我的前提:三家瀏覽器在 compositionend 之後都不送 input,我防的那條路徑在真實環境裡不會發生。
那守衛還要留嗎?我留了,但理由換了一個。
原本的理由是「防止一個已知的 bug」。現在則是因為 React 的 onChange 和原生 input 並非一對一對應。React 有自己的值變更偵測邏輯,會不會在某些情況下自行補發一次 onChange,我沒有驗證過;而且那是會隨版本改變的內部行為。
守衛的成本只是一次字串比較。花這點成本,就不用拿沒把握的假設下注,划算。
為什麼鍵要選文字,而不是「第幾段」
我第一版的守衛是用「第幾段」當識別鍵:這一段已經結算過就跳過。
這個鍵不穩定。它會跟著 setPassageIndex 前進,只要兩次呼叫之間發生一次 re-render,第二次讀到的就是新索引,守衛直接被繞過。而「中間會不會 re-render」取決於 React 的排程,不是你能保證的事。
settledValueRef 存的是文字內容,而 ref 是跨 render 共用的同一個物件,不受重新渲染影響。
在 React 裡做冪等,鍵要選不會因為狀態更新而改變的東西。任何跟著 state 走的值,都不適合拿來回答「這件事做過了嗎」。
兩次差點走錯的路
修完守衛,我跑測試。數字還是兩倍。
我盯著那個 98 看了很久,開始懷疑整個做法是不是錯的,準備打掉重寫。
後來才發現,我量錯東西了。畫面上那個計數器顯示的是「已結算字數加上輸入框裡現有的字數」,而我在模擬時,硬把整段文字塞回輸入框,所以它顯示 49 + 49 = 98。
我把輸入框清空再讀一次,累加器是 49。從頭到尾都是對的。
那個修法從頭到尾都沒問題。我卻差點因為看錯一個數字就把它丟掉,轉頭去修一個根本不存在的問題。
第二次,還是同一個錯,只是換了個地方犯。
我一度以為 React 把 onCompositionEnd 延後了,因為我送出 compositionend 之後立刻讀畫面,發現什麼都沒變。我甚至為這個「發現」寫了一段筆記。但那只是 React 還沒重新渲染而已,handler 早就跑完了。
直到我改成在 handler 裡插 log,不再從外面看畫面,才拿到正確的順序。
兩次都是同一個病:拿畫面上的數字,當作程式內部的狀態。
在 React 裡,這兩件事之間隔著一次非同步渲染。要驗內部狀態,就得從內部量。這句話我以前也會講,但顯然講過不等於做得到。
驗證
修完之後我跑了三個情境:
| 情境 | 預期 | 實測 |
|---|---|---|
| 真實序列(input → compositionend) | 49 | 49 |
| 假想序列(compositionend → input) | 48 | 48 |
| 一般打字連續三段(無輸入法) | 141 | 141 |
第二列是刻意測的。真實瀏覽器目前不會產生那個順序,但這是當下的觀察,不是規格保證——輸入法和瀏覽器版本都會變。能不賭順序就不要賭。
不是只有打字測驗會踩到
這篇談的是打字遊戲,但同一個坑會出現在任何會即時反應的中文輸入介面。即時搜尋:打「你好」會送出 9 次請求,其中 8 次查的是 ㄋ、ㄋㄧ 這種東西。字數限制:組字中的注音符號被算進字數,使用者還沒打完就被擋。表單即時驗證:欄位在使用者打字過程中一直亮紅。聊天室的「正在輸入中」:每個注音符號送一次狀態更新。
幾個實作上的小提醒
要不要擋貼上:我的打字測驗擋掉了 onPaste,因為貼上不算打字。如果你的場景不是測驗,通常不需要擋。
Android 虛擬鍵盤:有些 Android 輸入法即使在打英文時,也會全程處於 composition 狀態;compositionend 可能很晚才來,行為也可能和桌機不同。這部分我沒有實測,不敢下結論。
不要只在 compositionend 更新畫面:使用者需要看到自己正在打什麼。畫面跟著每次 input 更新是對的,我們只是不拿那些值去做判斷。
這篇文章的實作就在這裡:中文打字速度測試最後
這個 bug 不會出現在任何英文教學裡,因為寫教學的人不用中文輸入法。
第一層坑——組字中的 onChange 會觸發——還算有人寫過,中文資料也找得到幾篇。但第二層,也就是最後一個 input 的 isComposing 仍然是 true,而它之後沒有別的 input,我找不到中文資料,英文資料也很零散。
我們用的框架、讀的文件、抄的範例,預設的都是「一個按鍵一個字元」的世界。那個世界裡沒有組字這件事。中文開發者只能自己補上這一段,然後把它寫下來,讓下一個人少踩一次。
如果你的產品有中文使用者,請真的切到注音打一次。不要只用英文測。
附錄:Safari 的完整事件序列
實測環境:macOS,注音輸入法,輸入「你好」(含選字)。Brave 的序列與 Chrome 完全一致,逐列相同,所以不另外列出。
| # | 事件 | data | value | isComposing | keyCode |
|---|---|---|---|---|---|
| 1 | compositionstart | (空) | (空) | ||
| 2 | compositionupdate | ㄋ | (空) | ||
| 3 | input | ㄋ | ㄋ | true | |
| 4 | keydown | ㄋ | true | 229 | |
| 5 | compositionupdate | ㄋㄧ | ㄋ | ||
| 6 | input | ㄋㄧ | ㄋㄧ | true | |
| 7 | keydown | ㄋㄧ | true | 229 | |
| 8 | compositionupdate | 你 | ㄋㄧ | ||
| 9 | input | 你 | 你 | true | |
| 10 | keydown | 你 | true | 229 | |
| 11 | compositionupdate | 你ㄏ | 你 | ||
| 12 | input | 你ㄏ | 你ㄏ | true | |
| 13 | keydown | 你ㄏ | true | 229 | |
| 14 | compositionupdate | 你ㄏㄠ | 你ㄏ | ||
| 15 | input | 你ㄏㄠ | 你ㄏㄠ | true | |
| 16 | keydown | 你ㄏㄠ | true | 229 | |
| 17 | compositionupdate | 你好 | 你ㄏㄠ | ||
| 18 | input | 你好 | 你好 | true | |
| 19 | keydown | 你好 | true | 229 | |
| 20 | input | (空) | (空) | true | |
| 21 | input | 你好 | 你好 | true | |
| 22 | compositionend | 你好 | 你好 | ||
| 23 | keydown | 你好 | false | 229 |