Aug 20

小记:手写Input MaxLength的正确处理方式

Lrdcq , 2021/08/20 00:26 , 程序 , 閱讀(1534) , Via 本站原創
在HTML中的input有一个maxLength的属性,可以很好的控制最大输入文本长度。但是其他GUI框架如iOS/OSX,flutter就没有这样的东西,Android的textview和react-native的input的功能也不健全,web开发也有遇到自己处理input的情况,就需要自己处理maxLength的问题。本小记记录标准的处理方案逻辑。

maxLength咋看起来好处理,但是一不留心bug特别多,看看京东web结算页的input出现的问题:
點擊在新視窗中瀏覽此圖片

京东这个问题是典型的直接在change事件中判断input value长度并直接进行裁剪,但是没有考虑到部分输入法会直接在输入框中写入输入中的字母而导致value超长输入法无法完整执行。

实际处理MaxLength可能发生的问题:

1. 即上文描述的,输入法输入中状态被截断。同时也有可能发生的是如果是一个select的区域进行输入并且替换,如果同时处理裁剪的话可能发生替换错误。这里有两种预期,比如maxlength是5的输入框中当前状态是“一共[五]个字”,“五”被选中并进行替换,输入“五十”,那么完成后的裁剪可以是“一共[五十]个”末尾的“字”被裁剪,或者是“一共[五]个字”“十”没输入成功。目前html的input的实现是后者但是实际测评下来更符合用户预期的是前者即“始终让用户输入行为成功但是告诉用户有副作用”。这两者以外的行为均是异常的比如被变更为“一共[五十]字”“个”被覆盖掉。

2. emoji截断问题。emoji和一些其他的特殊字符,一个字符长度是2或者更多,也就是说在计算length的时候一个字符占得更多。当然也可以按实际字符数处理但是确实对字符储存空间有要求。如果直接按length处理的话,就会出现如果只剩下length为1的空间时输入emoji,emoji就会被截断。比如js中"😊".substring(0,1)会得到'\uD83D',这不是一个合法的字符。在js中还好,如果是传输到服务端java或者c++这种支持二进制字符串的语言中,就有可能以原始的形态传输储存,导致不合法的字符串存入db,引发一连串的问题如json穿透,分隔符失效等。这是有实际线上事故发生的。

要解决这两个问题并保障较为友好的用户体验,基本思路为:

1. input是无法判断文字输入状态的,但是非select模式的输入法肯定没问题,select状态肯定是用户在进行变更,那很简单的认为,如果文字中有selection区域并且光标也在selection区域内,就认为用户在输入状态,不在输入状态做任何截断而是在输入完成后做处理。按照上文描述的截断逻辑1,直接做尾截断即可。

2. emoji问题的处理方法是要求在做尾截断或者任意截断时,需要判断截断处是否是一个完整的字符,如果不是则继续往前吃,比如"一个家庭👨‍👩‍👦‍👦"的maxlength是12(👨‍👩‍👦‍👦长度是11),则会吃掉完整的👨‍👩‍👦‍👦变成“一个家庭”。

代码实现上的逻辑,拷贝一份现成的iOS中使用的逻辑即可(注释标记):
//即事件onchange
- (void)textViewDidChange:(UITextView *)textView {
    //获取光标位置
    UITextRange *selectedRange = [textView markedTextRange];
    //获取选择区域的位置,有可能为空
    UITextPosition *pos = [textView positionFromPosition:selectedRange.start offset:0];
    //如果在变化中是高亮部分在变,就不要计算字符裁剪了
    if (selectedRange && pos) {
        return;
    }
    //以上排除后即获得有光标但是没有selection的状态了
    NSRange selection = textView.selectedRange;
    
    NSInteger realLength = textView.text.length;//文本目前的实际长度
    NSString *headText = [textView.text substringToIndex:selection.location];//光标区域前
    NSString *tailText = [textView.text substringFromIndex:selection.location];//光标区域后
    
    NSInteger restLength = MAXLENGTH - tailText.length;//光标输入是剩下的区域
    
    if (realLength > MAXLENGTH) {//执行裁剪
        //以下actRange计算是为了处理末尾出现emoji字符截断的问题并且保证字符数不超
        NSRange actRange = NSMakeRange(0, 0);
        restLength ++;
        //循环的往前寻找一个完整的字符
        do {
            restLength --;
            // rangeOfComposedCharacterSequencesForRange 针对 (0, 0) 会返回至少包含1个字符的range
            if (restLength > 0) {
                actRange = [headText rangeOfComposedCharacterSequencesForRange:NSMakeRange(0, restLength)];
            } else {
                break;
            }
        } while (actRange.length != restLength && actRange.length > 0);
        
        //完成后把裁剪完成的字符和尾字符串拼接起来塞回input
        NSString *subHeadText = [headText substringToIndex:actRange.length];
        textView.text = [subHeadText stringByAppendingString:tailText];
        //并且重置光标
        [textView setSelectedRange:NSMakeRange(restLength, 0)];
    }
    
    //textView.text修改完成,现在获取的长度就是实际长度了
    textView.text.length;
}

这一段代码用到一个Objective-C中的方法rangeOfComposedCharacterSequencesForRange来判断一个range中的合法字符串的位置,来避免emoji被裁剪。Java中也有对应的方法,不过在js中则比较无奈,均是比较trick的方法包括:

- 将字符串拆为数组[...str].length,这样的数组是按实际字符的,也就能获取到按字符的长度与哪里有emoji了。
- 正则比配/[\u{1F600}-\u{1F6FF}]/,不过缺点是如果遇到更罕见的emoji以外的特殊字符,还是会炸。
- 等等tc的新功能,Intl.Segmenter(https://github.com/tc39/proposal-intl-segmenter)提供了新方法可以处理这个事儿。不过看起来还得个四五年了。
关键词:input , range , maxlength
logo