添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

Ant Design 4.0 的一些杂事儿 - maxLength 篇

开发过程中,不同组件对于同一种边界情况有时候会出现差别。这些并非有意为之,就好比盲人摸象,似乎从细节看每个都很合理,但是脱离出来又会发现很多矛盾的地方。

今天,我们就从一个属性 maxLength 说起。看看我们在这个属性上,到底遇到了多少个坑。

maxLength 是不是 single source of truth

第一反应,我们总是会认为当配置 maxLength 时,组件值展示应该按照这个值来截断。但是在业务中,我们发现这会导致展示值和实际值并不一致。举个例子,一个表单存在一个 TextArea,它设置了 maxLength 10 ,但是从后来获取的初始值超过了这个数字:

<Form.Item name="comment" initialValue="Hello World">
  <TextArea maxLength={5} />
</Form.Item>

直觉上看,TextArea 很明显应该截取后展示为 Hello

然而,当用户不修改该文本框时。Form 内 comment 的值将始终为 Hello World ,提交时就会把错误的值发送出去:

{
  "comment": "Hello World"
}

我们也遇到了很多相关问题:


原生行为

综上所述,在受控状态下。组件展示值应该跟随受控值,而非截取值。我们测试了一下原生组件的行为,发现是相同的设计:

(题外话:使用原生表单时,如果 textarea 设置了 maxlength 且值超出了宽度,表单会无法提交并提示 too long 的错误。)

因此, maxLength 的约束逻辑也很简单:

  • 受控时,不生效
  • 非受控时,按照 maxLength 约束展示值
const [value, setValue] = useState('');
const mergedValue = props.value ?? value.slice(0, maxLength);
<textarea
  value={mergedValue}
  onChange={e => {
    const triggerValue = e.target.value.slice(0, maxLength);
    setValue(triggerValue);
    onChange?.(triggerValue);

emoji 之熵

上述代码看起来一帆风顺,但是其实并不是所有字符的 length 都为 1。emoji 就是如此:

当用户传入的字符串最后一个为 emoji 且正好超出 maxLength 时,截取就会导致乱码。比如把 一切为二变成 ? 。为了解决这个问题,需要将 emoji 作为一个字符来处理。好在 js 的 Array.from 正好可以满足该需求:

Array.from(' light');
// [" ", "l", "i", "g", "h", "t"]

因此,我们的截取逻辑改如下即可:

const triggerValue = [...e.target.value].slice(0, maxLength).join('');

输入法之熵

在搞定 emoji 后,一切仍然未完。当字符数接近 maxLength 时使用输入法时会遇到截取问题:

上面为 TextArea,下面为原生 textarea

这是由于在输入过程中,总体字符数已经到达了 maxLength 限制,因而被截取导致 textarea 的 value 被强制设置成了中间状态。比如 maxLength 1 ,而我们需要通过输入法输入 (ni):

  1. n :符合长度,触发 onChange('n')
  2. i : value ni ,超出长度 1 。被截取为 z 并触发 onChange('n')
  3. textarea 强制赋值 n ,输入法状态丢失

为了解决输入法问题,我们需要暂时允许超出 maxLength 的情况。因而我们监听了 onCompositionXXX 事件,当正在使用输入法时暂时不做截取操作:

const [value, setValue] = useState('');
const [compositing, setCompositing] = useState(false);
const mergedValue = props.value ?? value.slice(0, maxLength);
function triggerChange(e, compositing) {
  let triggerValue = e.target.value;
  if (compositing) {
    triggerValue = [...triggerValue].slice(0, maxLength).join('');
  setValue(triggerValue);
  if (mergedValue !== triggerValue) {
    onChange?.(triggerValue);
<textarea
  value={mergedValue}
  onCompositionStart={() => setComposting(true)}
  onChange={e => {
    triggerChange(e, compositing);