ReactIMEi18nFrontendIndie Dev

React 遇上中文輸入法:為什麼 onChange 會把注音當成錯字

·閱讀約 12 分鐘

打字測驗做完那天,我自己測了一輪:速度、正確率、換題,全部正常。我以為可以收工了。

隔天早上再打開,順手切回注音。

畫面整片紅。

題目是「今天的天氣很適合出門走走」。我按下第一個 ㄐ,那個字立刻變紅——比對邏輯判定「ㄐ ≠ 今」。我繼續按 ㄧ、ㄣ,紅的還是紅的。等我選完字、「今」真的出現,紅字才變綠。

前一天之所以完全沒發現,是因為我測的是英文。

這個測驗在懲罰還沒打完的使用者,而且只懲罰用中文輸入的人。

一個英文使用者永遠遇不到的問題

英文輸入法裡,按鍵就是字元。你按 a,a 就進到輸入框裡,事情結束。

中文輸入法不是這樣。你按 ㄋ、ㄧ、ˇ,這些都還不是最終結果,而是「組字」過程中的中間狀態。要等你選完字,「你」才真正出現。按鍵和字元之間,隔著一段瀏覽器稱為 composition 的過程。

問題是,我們用的框架、讀的文件、抄的範例,預設的都是前面那個世界。

React 的 onChange,其實是原生的 input

先釐清一件事:React 的 onChange 不是原生的 change 事件。

原生 change 要等輸入框失去焦點才觸發,但 React 的 onChange 在你每打一個字時就會跑——因為 React 實際上接的是原生的 input 事件。

而 input 事件在組字還沒結束時就會不斷觸發。

這是我用真實注音在 Chrome 打「你好」量到的完整事件序列:

#事件datavalueisComposingkeyCode
1keydown(空)false229
2compositionstart(空)(空)
3compositionupdate(空)
4inputtrue
5keydowntrue229
6compositionupdateㄋㄧ
7inputㄋㄧㄋㄧtrue
8keydownㄋㄧtrue229
9compositionupdateㄋㄧ
10inputtrue
11keydowntrue229
12compositionupdate你ㄏ
13input你ㄏ你ㄏtrue
14keydown你ㄏtrue229
15compositionupdate你ㄏㄠ你ㄏ
16input你ㄏㄠ你ㄏㄠtrue
17keydown你ㄏㄠtrue229
18compositionupdate你好你ㄏㄠ
19input你好你好true
20keydown你好true229
21compositionupdate你好你好
22input你好你好true
23compositionend你好你好
Chrome:注音輸入「你好」的完整原生事件序列(macOS)

看第 4 列和第 7 列:組字根本還沒結束,input 已經觸發了,而且 value 是 ㄋ、ㄋㄧ 這種注音符號。

打「你好」兩個字,總共觸發了 9 次 input,其中 8 次拿到的都是沒有意義的中間狀態。

如果你的 onChange 裡有比對、驗證、字數統計或送 API,這些邏輯全都會拿著半成品跑 9 次。我的打字測驗就是這樣把 ㄋ 判成錯字的。

幾個看起來可行、但其實不行的解法

用正規表達式濾掉注音符號:只能治標。倉頡打出來是英文字母,拼音也是,根本濾不掉;而且使用者真的想輸入字母時,輸入內容反而會被吃掉。

debounce 個幾百毫秒:打字快的人照樣會在組字中間觸發,而且會讓即時比對變得遲鈍。

改用 onBlur:失焦才驗證,那即時回饋就沒了——對打字測驗來說等於整個功能不見。

用 keydown 判斷 keyCode === 229:這是老專案常見的寫法。229 是「這個按鍵被輸入法吃掉了」的歷史訊號,我的實測顯示三家瀏覽器目前都還在送。但它已經被 composition 事件取代,而且出現的位置各家不同(後面會提),不適合當主要判斷依據。

正確的方向:composition 事件

瀏覽器其實有專門描述組字過程的事件:compositionstart(開始組字)、compositionupdate(組字內容變了)、compositionend(選字完成,文字確定)。

原則只有一句:組字過程中照常更新畫面,但不要下判斷。等 compositionend 之後再把值當真。

實作大概長這樣:

tsx
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);   // 選字完成,這時的值才算數
};
tsx
<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 / BraveSafari
倒數第二input,value = 你好,isComposing: trueinput,value = 你好,isComposing: true
最後compositionend,value = 你好compositionend,value = 你好
之後還有 input 嗎?沒有沒有
三家瀏覽器的收尾序列一致:input 在前,compositionend 在後,之後沒有任何 input

兩個事實合起來看:第一,compositionend 之後不再有任何 input 事件。第二,最後那個 input 雖然 value 已經是確定的「你好」,但 isComposing 仍然是 true。

這推翻了一個流傳很廣的寫法:

js
// 在 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。在那個假想的序列下,同一段文字會被結算兩次:字數變兩倍,題目索引也會跳過一段。

所以我加了一個冪等守衛:

tsx
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)4949
假想序列(compositionend → input)4848
一般打字連續三段(無輸入法)141141

第二列是刻意測的。真實瀏覽器目前不會產生那個順序,但這是當下的觀察,不是規格保證——輸入法和瀏覽器版本都會變。能不賭順序就不要賭。

不是只有打字測驗會踩到

這篇談的是打字遊戲,但同一個坑會出現在任何會即時反應的中文輸入介面。即時搜尋:打「你好」會送出 9 次請求,其中 8 次查的是 ㄋ、ㄋㄧ 這種東西。字數限制:組字中的注音符號被算進字數,使用者還沒打完就被擋。表單即時驗證:欄位在使用者打字過程中一直亮紅。聊天室的「正在輸入中」:每個注音符號送一次狀態更新。

幾個實作上的小提醒

要不要擋貼上:我的打字測驗擋掉了 onPaste,因為貼上不算打字。如果你的場景不是測驗,通常不需要擋。

Android 虛擬鍵盤:有些 Android 輸入法即使在打英文時,也會全程處於 composition 狀態;compositionend 可能很晚才來,行為也可能和桌機不同。這部分我沒有實測,不敢下結論。

不要只在 compositionend 更新畫面:使用者需要看到自己正在打什麼。畫面跟著每次 input 更新是對的,我們只是不拿那些值去做判斷。

這篇文章的實作就在這裡:中文打字速度測試

最後

這個 bug 不會出現在任何英文教學裡,因為寫教學的人不用中文輸入法。

第一層坑——組字中的 onChange 會觸發——還算有人寫過,中文資料也找得到幾篇。但第二層,也就是最後一個 input 的 isComposing 仍然是 true,而它之後沒有別的 input,我找不到中文資料,英文資料也很零散。

我們用的框架、讀的文件、抄的範例,預設的都是「一個按鍵一個字元」的世界。那個世界裡沒有組字這件事。中文開發者只能自己補上這一段,然後把它寫下來,讓下一個人少踩一次。

如果你的產品有中文使用者,請真的切到注音打一次。不要只用英文測。

附錄:Safari 的完整事件序列

實測環境:macOS,注音輸入法,輸入「你好」(含選字)。Brave 的序列與 Chrome 完全一致,逐列相同,所以不另外列出。

#事件datavalueisComposingkeyCode
1compositionstart(空)(空)
2compositionupdate(空)
3inputtrue
4keydowntrue229
5compositionupdateㄋㄧ
6inputㄋㄧㄋㄧtrue
7keydownㄋㄧtrue229
8compositionupdateㄋㄧ
9inputtrue
10keydowntrue229
11compositionupdate你ㄏ
12input你ㄏ你ㄏtrue
13keydown你ㄏtrue229
14compositionupdate你ㄏㄠ你ㄏ
15input你ㄏㄠ你ㄏㄠtrue
16keydown你ㄏㄠtrue229
17compositionupdate你好你ㄏㄠ
18input你好你好true
19keydown你好true229
20input(空)(空)true
21input你好你好true
22compositionend你好你好
23keydown你好false229
Safari:注音輸入「你好」的完整原生事件序列。注意第 20 列的空字串。