背景
事情大约发生在一年半之前,当时项目中有一类表单的输入框不允许输入一些特殊字符,其中就包括单引号',组件库是Antd,框架为React。接到需求的时候想这还不简单,加个校验规则直接就搞定了。然而最终的效果并不理想,在输入拼音的时候,拼音的音节分隔符号就是单引号,拼音输入过程中也会触发校验,导致使用拼音输入汉字时频繁出现校验提示。
代码简化如下,为了排除其它干扰,就只校验单引号'。
const checkValue = (value) =>
value.match(/'/) ? Promise.reject("包含非法字符") : Promise.resolve();
<FormItem
rules={[
{
validator: (_, value) => checkValue(value),
},
]}
>
<Input/>
</FormItem>
这显然不能当成一个feature。
解决思路
输入拼音时输入法自己添加的分隔符号最终在转化为汉字时会被去掉,所以很自然地就想到能不能在输入拼音时忽略掉对单引号'的校验,输入结束后再开启对它的校验呢?
经过查阅MDN,得知浏览器有一类CompositionEvent,其中常用的事件包括compositionstart、compositionupdate和compositionend。此类事件在用户间接输入文本(如使用输入法)时触发。例如,在开始输入拼音时会触发compositionstart,拼音变化时(包括输入的第一个字母)会触发compositionupdate,拼音输入结束会触发compositionend。
这样问题就很好解决了,我们只需要添加一个flag,在输入拼音时去掉对单引号'的校验,输入结束后再开启校验即可。对应的事件是compositionstart和compositionend。
const checkValue = (value,isComposition) => {
if (isComposition) {
return Promise.resolve();
}
return value.match(/'/) ? Promise.reject("包含非法字符") : Promise.resolve();
}
const isCompositionRef = useRef(false); //用于标记是否在输入拼音的flag
const handleComposition = (flag)=> isCompositionRef.current = flag;
<FormItem
rules={[
{
validator: (_, value) => checkValue(value, isCompositionRef.current),
},
]}
>
<Input
onCompositionStart={()=>handleComposition(true)}
onCompositionEnd={()=>handleComposition(false)}
/>
</FormItem>
效果如下
看起来效果很完美。你以为这就完了吗?不不不,后面还有坑呢。
踩坑过程
使用拼音输入时还有一种场景,我们本意是想输入英文,这时可以用回车键把我们键入的字符作为最终结果,包括我们自己键入的单引号分隔符。
单引号被成功输入却没有被校验出来。我猜想原因应该是最后一次校验时onCompositionEnd事件还没有被触发。查阅Antd文档得知Form.Item默认的校验时机是onChange触发的时候。打印下事件触发顺序来验证下我的猜想。
<Input
onCompositionStart={()=>console.log("composition start")}
onCompositionEnd={()=>console.log("composition end")}
onChange={()=>console.log("value change")}
/>
果然是这样。onChange先触发,此时还没有开启对单引号的校验。这个问题也好解决,修改下校验的时机,onCompositionEnd触发时也校验就好了。
validateTrigger={["onChange","onCompositionEnd"]}
这回再试就没问题了,通过回车被保留的单引号被成功校验出来。好了,总算没问题了,打包代码部署到测试环境。 结果没多久,测试妹子找上门。
“你这输入框拼音输汉字怎么输不了,空格和回车都会把拼音输进去?!”
“怎么可能,你看我这不是好好的嘛。”
“那怎么回事。等下,我用的火狐,你把Chrome换成火狐试试。”
试了下,果然复现了问题。
哎,这就奇怪了。
分析下,所有的改动里可能会有影响的就是对校验时机的修改。难道两种浏览器表现不一样?把校验时机的修改注释掉,然后在火狐浏览器里再打印下触发顺序。
可以看到,跟Chrome不一样,火狐浏览器在拼音输入过程中不会像Chrome那样每次输入字母都触发onChange事件,而是只触发一次,然后在输入结束转换为汉字时再触发一次。并且在拼音输入结束时,火狐会先触发onCompositionEnd再触发onChange。
好嘛,还有这种事...
火狐浏览器出问题的原因是先触发onCompositionEnd再触发onChange,在onCompositionEnd触发时校验不合法打断了拼音输入。火狐onChange后触发,所以并不需要在onCompositionEnd触发时进行校验。
好像也没别的办法了,加个浏览器适配吧。
validateTrigger={navigator.userAgent.includes("Firefox") ? ["onChange"] : ["onChange","onCompositionEnd"]}
搞定,这回总算没问题了。
后来在查阅MDN文档时,又有了新的发现。原生的输入框输入事件input的类型是InputEvent,它有一个属性isComposing。表示input事件是否在compositionstart之后与compositionend之前触发。这不就是我当时加的flag嘛,原来input事件本来就有这个属性。抱着这个想法我决定用这个属性改造下以前的代码,不再用onCompositionStart和onCompositionEnd。
onInput={event => handleComposition(event.nativeEvent.isComposing)}
然而效果并不理想。我期望在拼音输入结束转为汉字时可以立即打开对单引号的校验并校验一次。但这样改动之后开启对单引号的校验是在非拼音状态下输入其它内容时。换句话说,如果一直用拼音进行输入,就一直不会校验单引号。这就回到了原本的问题,通过回车把自己键入的拼音内容直接输入到输入框,其中包括单引号时,也不会被校验出来。那像之前那样再监听下onCompositionEnd然后处理flag呢?当然可以,但这样做的意义是啥?还不如之前的方案呢。
总结
在浏览器中通过拼音输入汉字的过程中会触发compositionstart、compositionupdate和compositionend三个与拼音相关的事件。Chrome浏览器与火狐浏览器在输入拼音时的表现不同,火狐输入过程中change只会触发一次,且结束时change比compositionend后触发;而Chrome每次输入字母都会触发change,结束时change比compositionend先触发。输入框原生的input事件有一个isComposing属性,表示input事件是否在compositionstart之后与compositionend之前触发。