- Published on
中文輸入法與React文字輸入框的問題與解決方案
- Authors
- Name
- Eddy Chang
問題來源是來自這個 React 官方儲存庫的 issue #3926,與這個議題關聯的有很多其他的 issue,來自許多專案,有些是與 React 相關,有些則是 vue 或其它 JS 套件。也已經有其他的專案是專注於解決這個問題,例如react-composition,不過它是一個使用 ES5 語法的 React 元件。在其他的討論區上也有類似的問題與解答。本文的目的是希望能針對這個問題提供一些說明、現在暫時性的解決方案。
下圖為目前解決 React 中"Controlled"(受控制的)input 元件的展示,可以到這裡去測試:
注意事項: 目前的解決方案我認為是暫時性的,結果都放在這個github 庫上。這要分為"Controlled"(受控制的)與"Uncontrolled"(不受控制的)兩個種類的元件,影響的主要是 input 與 textarea 兩個元件,輸入法(IME, input method editor)的問題,不只會發生在中文,同樣的在日文、韓文或其它使用輸入法的語言應該都有同樣問題。
問題何來
React 元件主要使用onChange
人造事件,作為文字輸入框(input)或文字輸入區域(textarea)觸發文字輸入時的事件,這個事件用起來很直覺,理應當是如此。但onChange
在瀏覽器上,只要在這個文字輸入框上,有任何的鍵盤動作它都會觸發,也就是如果你是使用了中文、日文、韓文輸入法(IME),不論是哪一種,拼音的、筆劃的還是其他的,只要有按下一個鍵盤的動作,就會觸發一次瀏覽器上這個元素的change
事件,對於原本就使用鍵盤上的英文字元作為輸入的語言來說,這沒什麼太大的問題,但對於要使用輸入法的語言使用者來說,不停的觸發change
事件,可能會造成程式功能上的運行邏輯問題。
舉出一個實際的應用情況,一個使用 React 撰寫的搜尋電腦書籍的功能,使用者可以在文字輸入框裡輸入要搜尋的書名,程式中是利用onChange
事件觸發,進行比對資料庫中的書籍標題,當你想搜尋一本名為"林哥的 Java 教學",第一個字為"林",拼音輸入法需要輸入"lin"三個鍵盤上的字元,在"林"這個字從輸入法編輯器中加到真正的 input 元素前,onChange
已經捕捉到"lin"三個字元,在列表中已搜尋出一大堆有關"linux"的書籍。細節就不說了,還有可能對字元數量的的檢查之類的問題。不過,這是正確的程式運作邏輯嗎?很明顯的這是一個大問題。
當然,你也可以用對中文字詞檢查的修正方式,或是乾脆不要用change
事件,改用其他按鈕觸發之類的事件來作這事情,或是不要用 React 中的"Controlled"(受控制的)input 或 textare 元件,但這會局限住在程式開發應用上的自由,要如何選擇就看你自己了,是不要使用它還是想辦法正視問題來解決它。
網頁上的 DOM 元素與"Uncontrolled"(不受控制的)的元件
這個問題在瀏覽器中,早就已經有了可應對的解決方法,DOM 事件中有一組額外的CompositionEvent(組成事件)可以輔助開發者,它可以在可編輯的 DOM 元素上觸發,主要是 input 與 textarea 上,所以可以用來輔助解決change
事件的輸入法問題。CompositionEvent(組成事件)共有三個事件,分別為compositionstart
、compositionupdate
與compositionend
,它們代表的是開始進行字的組成、更新與結束,也就是代表開始以輸入法編輯器來組合鍵盤上的英文字元,選字或更新字的組合,到最後輸出字到真實 DOM 中的文字輸入框中,實務上每個中文字在輸入時,compositionstart
與compositionend
都只會會被觸發一次,而compositionupdate
則是有可能多次觸發。
藉由 CompositionEvent 的輔助來解決的方式,也就是說在網頁上的 input 元素,可以利用 CompositionEvent 作為一個信號,如果正在使用 IME 輸入中文時,change
事件中的程式碼就先不要執行,等compositionend
觸發時,接著的change
事件才可以執行其中的程式碼,運作的原理就是這樣簡單而已。
在 React 應用中,如果是一個"Uncontrolled"(不受控制的)的 input 元件,它與網頁上真實 DOM 中的 input 元素的事件行為無差異,也就是說,直接使用 CompositionEvent 的解決方式,就可以解決這個輸入法的問題,以下面的程式碼為範例:
// @flow
import React from 'react'
const Cinput = (props: Object) => {
// record if is on Composition
let isOnComposition: boolean = false
const handleComposition = (e: KeyboardEvent) => {
if (e.type === 'compositionend') {
// composition is end
isOnComposition = false
} else {
// in composition
isOnComposition = true
}
}
const handleChange = (e: KeyboardEvent) => {
// only when onComposition===false to fire onChange
if (e.target instanceof HTMLInputElement && !isOnComposition) {
props.onChange(e)
}
}
return (
<input
{...props}
onCompositionStart={handleComposition}
onCompositionUpdate={handleComposition}
onCompositionEnd={handleComposition}
onChange={handleChange}
/>
)
}
export default Cinput
上面這是一個典型的"Uncontrolled"(不受控制的)input 元件,主要是它不用value
這個屬性。但如果它有來自上層元件的value
屬性與值,也就是上層元件用 props 傳遞給它value
屬性的值,就成了"Controlled"(受控制的)元件,它的事件整個模式就會與網頁上的真實 DOM 中的 input 元素不一樣,這後面再說明。
這個解決方案在幾乎所有能支援 CompositionEvent 的瀏覽器(IE9 以上)都可以運行得很好,不過在 Google Chrome 瀏覽器在 2016 年的版本 53 之後,更動了change
與compositionend
的觸發順序,所以需要針對 Chrome 瀏覽器調整一下,如果是在 Chrome 瀏覽器中觸發compositionend
時,也要執行一次在原本在change
要執行的程式碼,就改成這樣而已。下面在上個程式碼中的handleComposition
函式中,多加了偵測是否為 Chrome 瀏覽器,與觸發原本的 onChange 方法程式碼,修改過的程式碼如下:
// detect it is Chrome browser?
const isChrome = !!window.chrome && !!window.chrome.webstore
const handleComposition = (e: KeyboardEvent) => {
if (e.type === 'compositionend') {
// composition is end
isOnComposition = false
// fixed for Chrome v53+ and detect all Chrome
// https://chromium.googlesource.com/chromium/src/
// +/afce9d93e76f2ff81baaa088a4ea25f67d1a76b3%5E%21/
if (e.target instanceof HTMLInputElement && !isOnComposition && isChrome) {
// fire onChange
props.onChange(e)
}
} else {
// in composition
isOnComposition = true
}
}
"Uncontrolled"(不受控制的)input 或 textarea 元件,解決方式就是這麼簡單而已,利用 CompositionEvent 過濾掉不必要的change
事件。
註: 其它的解決方式還有,像InputEvent中有一個
isComposing
屬性,它也可以作為偵測目前是否正在進行輸入法的組字工作,但 InputEvent 事件目前只有 Firefox 中可以用,看起來沒什麼前景。另外,W3C 新提出的IME API或許是一個未來較佳的解決方案,但目前只有IE11 有實作,其他瀏覽器品牌都沒有。
"Controlled"(受控制的)的元件
在 React 應用中,使用"Controlled"(受控制的)的 input 或 textarea 元件是另一回事,它會開始複雜起來。
"Controlled"(受控制的)的元件並不是只有加上value
這個屬性這麼簡單,input 或 textarea 元件所呈現的值,主要會來自 state,state 有可能是上層元件的,利用 props 一層層傳遞過來的,或是這個元件中本身就有的 state,直接指定給在這個元件中的 render 中的 input 或 textarea 元件。也就是說,input 最後呈現的文字如果要進行改變,就需要改變到元件(不論在何處)的 state,要改變 state 只有透過 setState 方法,而 setState 方法有可能是個異步(延時)執行的情況。
把這整個流程串接在一起後,我相信事件觸發的不連續情況會變得很嚴重,需要對不同情況下作測試與評估。目前我所作的測試還只是最基本的元件運用而已,複雜的元件情況還沒有開始進行。因為 state 有很多種用途,有時候內部使用,有時候要對外部使用者輸入介面的事件,或是有時候要對伺服器端的資料接收或傳送,不論是不是要使用 Redux、MobX 或 Flux 之類的 state 容器函式庫或框架,最終要進行重新渲染的工作,還是得呼叫 React 中的 setState 方法才行。
在基本的測試時,我發現"Controlled"(受控制的)的 input 元件,它不僅事件觸發不連續的情況嚴重,而且有可能在不同瀏覽器上會有不同的結果。完全不會有問題的只有一個瀏覽器,就是上面註解中所說的已經實作出IME API的 IE11,IE11 上可能根本不需要任何解決方案,它的輸入法編輯器是獨立於瀏覽器上的文字輸入框之外的。
目前已測試的結果是有三種情況,"Chrome, Opera, IE, Edge"為一種,"Firefox"為一種,"Safari"為一種。我為這三種情況分別寫了不同的解決方式的程式碼,但這個事件觸發的不連續情況,現在無法有一致性的解決方案,我只能推測這大概可能是 React 內部設計的問題。
不論是三種的那一種解決方案,有一個重點是你不能像上面的一般性解決方案,阻擋change
事件時要執行的程式碼,也就是阻擋setState
變動state
值,因為只要一經阻擋,input
元件的value
值就指定不到值,而且也不會觸發重新渲染。所以你只能讓change
事件不斷觸發,就像往常一樣。
那麼要如何解決程式邏輯運作的問題?
我使用了另一個內部的 state 物件中的值,稱為innerValue
,它是對比在 input 元件上不斷因觸發change
事件而輸入的值,稱為inputValue
。innerValue
是個會經過 CompositionEvent 修正過的值,所以它永遠不會帶有在輸入法組字過程的字串值。
這個解決方案,是一個"掛羊頭賣狗肉"的用法,不論使用者在 input 元件如何輸入,輸入的過程都會改變inputValue
而已,inputValue
是一個暫存與呈現用的值,最終用來進行程式邏輯運算的是innerValue
。以最一開始的例子來說,使用者輸入"林哥的 Java 教學",在一開始的"林"字輸入時,inputValue
是從"lin"到輸入完成變為"林",而innerValue
是在輸入期間是空字串值,輸入完成才會變為"林"。所以,搜尋功能可以用innerValue
來作為運算的依據,用這個值來搜尋對應的資料,這才是正確的運算邏輯,因為innerValue
才是真正的不帶輸入法組字過程的值。
大致上說明一下解決方式的程式碼,首先它有兩個在這個模組作用域中的全域變數,一個用來記錄是否在輸入法的組字過程中,另一個是給專給 Safari 瀏覽器用的:
// if now is in composition session
let isOnComposition = false
// for safari use only, innervalue can't setState when compositionend occurred
let isInnerChangeFromOnChange = false
在專門處理change
事件的handleChange
方法中,判斷isInnerChangeFromOnChange
這一段是專門為了解決 Safari 瀏覽器的問題所寫,Safari 瀏覽器的行為是 CompositionEvent 在觸發時,其中的event.target.value
居然是組字過程中的英文字元,而不是觸發這個事件的 input 元素的所有字串,這也是特別怪異的地方,所以才會利用在compositionend
後會再觸發一次change
的特性,在這裡更新innerValue
。
後面的程式碼,是代表在輸入法的組字過程中,setState 方法使用的差異,在組字過程中(isOnComposition === true
)的話,只會更動inputValue
值,而不會更動到innerValue
的值,這對應了上述所說的一個運作過程,一般的輸入鍵盤上的字元時不會有輸入法的問題,則是兩個值一併更動。程式碼如下:
handleChange = (e: Event) => {
// console.log('change type ', e.type, ', target ', e.target, ', target.value ', e.target.value)
// Flow check
if (!(e.target instanceof HTMLInputElement)) return
if (isInnerChangeFromOnChange) {
this.setState({ inputValue: e.target.value, innerValue: e.target.value })
isInnerChangeFromOnChange = false
return
}
// when is on composition, change inputValue only
// when not in composition change inputValue and innerValue both
if (!isOnComposition) {
this.setState({
inputValue: e.target.value,
innerValue: e.target.value,
})
} else {
this.setState({ inputValue: e.target.value })
}
}
在專門處理composition
事件的handleComposition
方法中,主要是為了在compositionend
觸發時,進行更新innerValue
所撰寫的一些程式碼。在第一種情況時,也就是在 Chrome, IE, Edge, Opera 瀏覽器時,只需要直接用e.target.value
更新innerValue
即可。在第二種情況是 Firefox,它不知道為什麼會掉值,所以還需要幫它再一併更新innerValue
一次。第三種情況,上面有說過了,特別的怪異情況,所以對innerValue
的更新改到compositionend
之後的那個change
事件去作了。程式碼如下:
handleComposition = (e: Event) => {
// console.log('type ', e.type, ', target ', e.target, ',target.value ', e.target.value, ', data', e.data)
// Flow check
if (!(e.target instanceof HTMLInputElement)) return
if (e.type === 'compositionend') {
// Chrome is ok for only setState innerValue
// Opera, IE and Edge is like Chrome
if (isChrome || isIE || isEdge || isOpera) {
this.setState({ innerValue: e.target.value })
}
// Firefox need to setState inputValue again...
if (isFirefox) {
this.setState({ innerValue: e.target.value, inputValue: e.target.value })
}
// Safari think e.target.value in composition event is keyboard char,
// but it will fire another change after compositionend
if (isSafari) {
// do change in the next change event
isInnerChangeFromOnChange = true
}
isOnComposition = false
} else {
isOnComposition = true
}
}
註: 目前這個暫時的解決方式,其方式並不是參考自react-composition項目,解決方式雖然有些類似,但 react-composition 用的是 ES5 的 React 工廠樣式元件語法,我對這種語法並不熟悉。在寫這篇文章時,才仔細看了一下 react-composition 的程式碼,只能說它的作者實際上也有測試過這個問題,也知道只有用另一個 state 中的值才能解決這問題。
總結
如果你是使用"Uncontrolled"(不受控制的)的元件,那麼解決方法很簡單,就如同上面所說的,像一般的網頁上的 DOM 元素的解決方式即可。
但對於"Controlled"(受控制的)的元件來說,目前的解決方案是一種 try-and-error(試誤法)的暫時性解決方案,我目前只能按照已測試的平台與瀏覽器去修正,沒測過的瀏覽器與平台,就不得而知了。
關於這個"Controlled"(受控制的)的元件的事件觸發,目前看到有在不同瀏覽器上的事件觸發不連續情況,我也有發一個議題(Issue)給 React 官方。或許比較好的治本方案,是需要從 state 更動方式的內部程式碼,或是人造事件觸發的順序,進行一些調整,這超出我的能力範圍,就有待開發團隊的回應了。
最後,如果你正好有需要到這個功能,或是你認為這個功能有需要,你可以幫忙測試看看或是提供一些建議。我已經把所有的程式碼、展示、線上測試、解決方案都集中到這個 Github 庫的react-compositionevent中。或許你現在需要一個解決方案,你可以用裡面目前的暫時性解決方式試試也可以。