中文輸入法與React文字輸入框的問題與解決方案


問題來源是來自這個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(組成事件)共有三個事件,分別為compositionstartcompositionupdatecompositionend,它們代表的是開始進行字的組成、更新與結束,也就是代表開始以輸入法編輯器來組合鍵盤上的英文字元,選字或更新字的組合,到最後輸出字到真實DOM中的文字輸入框中,實務上每個中文字在輸入時,compositionstartcompositionend都只會會被觸發一次,而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之後,更動了changecompositionend觸發順序,所以需要針對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事件而輸入的值,稱為inputValueinnerValue是個會經過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中。或許你現在需要一個解決方案,你可以用裡面目前的暫時性解決方式試試也可以。