以下會涉及到的技術點:react mobx compositionstart compositionupdate compositionend
問題描述
在使用 input 時,通常會對輸入的內容做校驗,校驗的方式無非兩種:
- 允許使用者輸入,並且做錯誤提示;
- 不允許使用者輸入正則或者函式匹配到的字元。
現有如下需求:“僅允許輸入英文、數字和漢字,不允許輸入其他特殊字元和符號”。顯然這種場景需要使用第二種校驗方式。
然後我自以為很機智的寫了下面的程式碼(引入了元件庫 cloud-react),在輸入值變化的時候(onChange 事件),處理繫結到 input 上的 value,將除了英文、數字、和漢字之外的字元都替換成空字串。
export default class CompositionDemo extends Component {
constructor() {
this.state = {
value: ''
};
}
onChange(evt) {
this.setState({
value: evt.target.value.replace(/[^a-zA-Z0-9\u4E00-\u9FA5]/g, '')
});
};
render() {
return <Input
onChange={this.onChange.bind(this)}
value={this.state.value}
/>
}
}
平平常常,普普通通,一切看起來都是正常的操作,結果,當我輸入拼音的時候,神奇的事情發生了:連拼的時候除了最後一個字,前面的都變成了字元。
what??? 小問號,你是否有很多朋友?
於是,我踏上了一條不歸路,呸呸呸,是開啟了新世界的大門,就是這個門對於我來說可能有點沉,推了兩天才看到新世界。
糾其原因:拼音輸入是一個過程,確切的說,在這個過程中,你輸入的每一個字母都觸發了 onChange 事件,而你輸入過程中的這個產物在校驗中被吃掉了,留下了一坨空字串,所以就發生了上面那個神奇的現象。
解決方案
這裡需要用到兩個屬性:
簡單點來說,就是當你開始使用輸入法進行新的輸入的時候,會觸發 compositionstart ,中間過程其實也有一個函式 compositionupdate,顧名思義,輸入更新時會觸發它;當結束輸入法輸入的時候,會觸發 compositionend。
下面進入正題:
首先,我們先看一下 Input 元件的一個很正常的實現:
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class InputDemo1 extends Component {
constructor(props) {
super(props);
this.state = {
value: '',
};
}
static getDerivedStateFromProps({ value }, { value: preValue }) {
if (value !== preValue) {
return { value };
}
return null;
}
onChange = evt => {
this.props.onChange(evt);
};
render() {
return <input
value={this.state.value}
type="text"
onChange={this.onChange}
/>
}
}
Input 元件有兩種應用場景:
- 不受控的輸入框:業務方不給元件傳入 value,無法控制輸入框的值;
- 受控的輸入框:業務方可以通過給元件傳入 value,從而可以在外部控制輸入框的值。
不受控的輸入框在我使用過程中並沒有什麼 bug,此處不做贅述,此處只談受控的輸入框,也就是我們需求(僅允許輸入英文、數字和漢字,不允許輸入其他特殊字元和符號)中需要使用的場景。
前面提到的 compositionstart 和 compositionend 該出場了:利用這兩個屬性的特點,在輸入拼音的“過程中”不讓 input 觸發 onChange 事件,自然就不會觸發校驗,好了,既然有了思路,開始碼程式碼。
我們定義一個變數 isOnComposition 來判斷是否在“過程中”
isOnComposition = false;
handleComposition = evt => {
if (evt.type === 'compositionend') {
this.isOnComposition = false;
return;
}
this.isOnComposition = true;
};
onChange = evt => {
if (!this.isOnComposition) {
this.props.onChange(evt);
}
};
render() {
const commonProps = {
onChange: this.onChange,
onCompositionStart: this.handleComposition,
onCompositionUpdate: this.handleComposition,
onCompositionEnd: this.handleComposition,
};
return <input
value={this.state.value}
type="text"
{...commonProps}
/>
}
你以為就這麼輕鬆解決了麼?
呵,你想多了!
我仍然使用開篇那個 demo 來測試這個程式碼,發現事情又神奇了一點呢,這次拼音壓根就輸不進去了哇~
我檢視了下在輸入拼音時函式的呼叫:
是的,寧沒有看錯,只觸發了onCompositionstart 和 onCompositionupdate這兩個函式,我起初以為是邏輯被我寫扣圈了,想了想原因(其實我想了好久,人略笨,見笑):
罪魁禍首就是繫結在 input 上的那個 value,輸入拼音的過程中,state.value 一直沒變,input 中自然不會有任何輸入值,沒有輸入值,也就完成不了輸入過程,觸發不了 compositionend,一直處於“過程中”。
所以這次不是程式邏輯扣圈,是中斷了。
於是我又想如何把中斷的程式接起來(對的,垮掉了我們就撿起來,哈),完成這個鏈條。
我想了好多辦法,也在網上看了好多辦法,可惜都解決不了我的困境。
各種心酸不堪回首,幸好最後找到了一個辦法:其實想想原來程式碼中用 state.value 去控制 input 值的變化,還是沒有把 input 中何時輸入值的控制權放在自己手裡,“過程中”這個概念也就失去了意義。只要 state.value 還和 input 綁在一起,就是我自己玩我自己的,人家玩人家的。於是,就有了下面讓控制權回到我手中的程式碼。
import React, { Component, createRef } from 'react';
import PropTypes from 'prop-types';
export default class InputDemo extends Component {
inputRef = createRef();
isOnComposition = false;
componentDidMount() {
this.setInputValue();
}
componentDidUpdate() {
this.setInputValue();
}
setInputValue = () => {
this.inputRef.current.value = this.props.value || ''
};
handleComposition = evt => {
if (evt.type === 'compositionend') {
this.isOnComposition = false;
return;
}
this.isOnComposition = true;
};
onChange = evt => {
if (!this.isOnComposition) {
this.props.onChange(evt);
}
};
render() {
const commonProps = {
onChange: this.onChange,
onCompositionStart: this.handleComposition,
onCompositionUpdate: this.handleComposition,
onCompositionEnd: this.handleComposition,
};
return <input
ref={this.inputRef}
type="text"
{...commonProps}
/>
}
}
測了一下,大致上是沒問題了。
還要看一下谷歌瀏覽器和火狐瀏覽器,果然還有坑:
- 火狐瀏覽器中的執行順序:compositionstart compositionend onChange
- 谷歌瀏覽器中的執行順序:compositionstart onChange compositionend
最後再做一下相容處理,修改一下 handleComposition 函式
handleComposition = evt => {
if (evt.type === 'compositionend') {
this.isOnComposition = false;
// 谷歌瀏覽器:compositionstart onChange compositionend
// 火狐瀏覽器:compositionstart compositionend onChange
if (navigator.userAgent.indexOf('Chrome') > -1) {
this.onChange(evt);
}
return;
}
this.isOnComposition = true;
};
因為不管中間執行了那些函式,最後都是需要執行 onChange 事件的,因此加了判斷,對谷歌瀏覽器做了特殊處理(其它瀏覽器暫時沒做考慮和處理)。
完整程式碼 https://github.com/liyuan-meng/my-react-app/tree/master/src/inputAndComposition
後記
到此,正文結束了,我還要說兩個需要注意的地方,其實也是踩了的坑:
- 如果 Input 元件的實現使用了 React.PureComponent ,在以上需求中會出現的問題:輸入特殊字元時,外部通過正則將其 replace 掉了,傳入 Input 元件內部的 value 實際上沒有任何變化,也不會觸發元件 render。這是因為 PureComponent 對 shouldComponentUpdate 函式做了優化,如果發現 props 和 state 上的屬性都沒有變化,不會重新渲染元件,因此我暫時的處理是:使用 React.Component ,元件實現中對 shouldComponentUpdate 封裝。
- 在外部使用 mobx 的時候,如果使用 observable 監聽 value,會出現和上面蕾絲的情況—輸入特殊字元時,通過正則將其 replace 掉了,mobx 也發現 value 沒有任何變化,就不會觸發 render,我暫時的處理是使用 state,雖然我覺得這不是最好的辦法,但我目前還想不到其它的處理方式。