如何格式化输入框的输入内容

3,109 阅读6分钟

需求

有一个需求:

电话号码输入要格式化,比如 15622223333,要格式化为 156 2222 3333。

前端使用 ant-design-pro 组件库,看了一下 ProForm 的组件 ProFormText 的 API,发现并没有提供类似于 Ant Design InputNumber 组件 formatter 和 parser 属性。那么有办法使用 ProFormText 这个组件实现这个需求吗?

方案一

办法肯定是有的:

  1. initialValues 改造,使电话号码格式从 15622223333 变为 156 2222 3333;

  2. onValuesChange 改造:

    <ProForm
     onValuesChange={(values) => {
        // 格式化手机号
     		// formatPhone 是格式化方法   
        if (values.phone) {
          formRef.current!.setFieldsValue({
            phone: formatPhone(values.phone),
          });
        }
      }}
    />
    
  3. onFinish 改造,使提交给后端的数据,从 156 2222 3333 变为 15622223333。

大概进行以上 3 步,就可以实现这个需求了。撒花!

方案二

方案一实现,步骤不多,但繁琐且散乱。所以,能不能像隔壁 InputNumber 那样,只需要 2 个属性 formatter 和 parser 就能解决了呢?如:

<ProFormText
  formatter={formatter}
  parser={parser}
/>

继续翻文档和源码,然后确认了,ant design pro 的文档的确是没有这样的 API,好吧,白嫖行动宣告失败。

翻看源码时,发现 ProFormText 组件返回的是 ProField 组件,顺藤摸瓜,发现 ProField 组件返回的是 @ant-design/pro-field 的 ProField 组件,继续摸瓜,发现 ProField 组件里有一段有趣的代码,可能会对我们想要实现的功能效果有帮助!

const ProField = (/*忽略参数*/) => {
  /*忽略无关代码*/
  return (
  <React.Fragment>
      {defaultRenderText(
        {
          renderFormItem: renderFormItem
            ? (...restProps) => {
                const newDom = renderFormItem(...restProps);
                // renderFormItem 之后的dom可能没有props,这里会帮忙注入一下
                if (React.isValidElement(newDom))
                  return React.cloneElement(newDom, {
                    placeholder:
                      rest.placeholder || intl.getMessage('tableForm.inputPlaceholder', '请输入'),
                    ...fieldProps,
                    ...((newDom.props as any) || {}),
                  });
                return newDom;
              }
            : undefined,
        },
      )}
    </React.Fragment>
  )
}

查看一下 ProField 的文档,其对 renderFormItem 的描述如下:

renderFormItem: 自定义 mode=update | edit 下的 dom 表现,一般用于渲染编辑框

那利用一下这个属性来造作一下:

export const renderFormItem = (
  text: string,
  props: any,
  dom: ReactElement,
  itemProps: {
    formatter?: (text: string) => string;
    parser?: (text: string) => string;
  },
) => {
  const { formatter, parser } = itemProps;
  if (typeof formatter === 'function' && typeof parser === 'function') {
    return React.cloneElement(dom, {
      value: formatter(text),
      onChange: (e: ChangeEvent<{ value: string }>) => {
        if (typeof props?.onChange === 'function') {
          props.onChange(parser(typeof e === 'object' ? e.target.value : e));
        }
      },
    });
  }
  return dom;
};

<ProFormText
  renderFormItem={
    itemProps.renderFormItem
      ? itemProps.renderFormItem
    : (text, props, dom) => renderFormItem(text, props, dom, { parser, formatter })
	}
          />

我们可以直接使用,如:

const formatter = (text) => {/*do something*/}
const parser = (text) => {/*do something*/}
const Example = () => {
  return (
    <ProFormText
      renderFormItem={
        itemProps.renderFormItem
          ? itemProps.renderFormItem
        : (text, props, dom) => renderFormItem(text, props, dom, { parser, formatter })
      }
          />
  )
}

也可以对 ProFormText 封装使用,如:

const FormItem = ({formatter, parser, ...rest}) => {
  return (
    <ProFormText
      {...rest}
      renderFormItem={
        itemProps.renderFormItem
          ? itemProps.renderFormItem
        : (text, props, dom) => renderFormItem(text, props, dom, { parser, formatter })
      }
     />
  )
}
// 使用
const formatter = (text) => {/*do something*/}
const parser = (text) => {/*do something*/}
const Example = () => {
  // 忽略其他无关代码
  return (
  	<FormItem
      formatter={formatter}
      parser={parser}
    />
  )
}

看,这样是不是觉得很优雅,使用也很方便快捷!

其实这里还有一个小细节没处理:如果输入框原本就有值,如 156 2222 3333,在提交的时候,由于此处没改动过,所以会将 format 后的值直接提交上去。这里处理的办法有多种,处理的时机大概有 2 中:

  1. 在 onFinish 时对数据进行处理
  2. 在 ProFormText 初始化时进行处理

由于在 onFinish 处处理,代码会比较分散,达不到咱们想要的效果(仅用 2 个属性方法,一劳永逸),所以第 2 种方案更优异。而第二种方案需要借助 form 来实现(要赋值)。

if (
  typeof formatter === 'function' &&
  typeof parser === 'function' &&
  form?.isFieldTouched(name) === false
) {
  const defaultValue = form.getFieldValue(name);
  if (defaultValue) {
    // 延后 reset,避免阻塞
    Promise.resolve().then(() => {
      form.setFieldsValue({
        [name]: parser(defaultValue),
      });
    });
  }
}

至此,我们就实现了输入框格式化这个功能。总结一下:

  1. 处理初始值
  2. 使用 renderFormItem 处理编辑后的格式化显示以及处理 onChange 事件

实现是实现了,那么实现原理是怎样的呢?

刚才我们翻到 ProField 的源码时就发现了玄机,其实我们可以继续挖下去,通过 ProField 的 defaultRenderText 方法,挖到了 FieldText,而 FieldText 中有一段代码:

if (mode === 'edit' || mode === 'update') {
    const placeholder = intl.getMessage('tableForm.inputPlaceholder', '请输入');
    const dom = <Input ref={inputRef} placeholder={placeholder} allowClear {...fieldProps} />;

    if (renderFormItem) {
      return renderFormItem(text, { mode, ...fieldProps }, dom);
    }
    return dom;
  }

从这里可知,ProFormText 最终是使用了 antd 的 Input 组件,但这不是重点,重点是,假如我们提供了 renderFormItem 属性,这里的 render 将会使用 renderFormItem 方法执行后返回的 ReactElement 作为 render 数据。

而我们通过 React.cloneElement 悄悄地更换了 dom 的属性值,也就是 Input 的 value 值,且连 onChange 方法也改造了一番。所以魔改版的 ProFormText 就拥有了 format 的功能了。

再往深处想,其实这里的实现就是依靠 React.cloneElement 来突破视图跟数据统一的限制。这个 API 可以利用传入的 element 元素,合并一些新的 props 进去,返回一个新的 React 元素。

所以 input 的 value 改变了,但数据中的 value 保持不变。cloneElement 在这里担任了一个 format 和 parse 的通道的角色。举一个简单的例子:

import React, {useState, useEffect} from 'react';

const Test = ({renderFn}) => {
  const [a, setA] = useState('');
  useEffect(() => {
    console.log(a, '---> value')
  }, [a])
  const dom = (
    <input type="text" value={a} onChange={(e) => setA(e)} />
  )
  if (typeof renderFn === 'function') {
    return renderFn(dom);
  }
  return dom;
}

export default function App() {

const fn = (dom) => {
  const {props} = dom;
  return React.cloneElement(dom, {
    value: props.value === '123' ? '23' : props.value,
    onChange: (e) => {
      return props.onChange(e.target.value)
    }
  })
}

  return (
    <div className="App">
      <Test renderFn={fn} />
    </div>
  );
}

当输入 123 时,你将看到界面上输入框显示的是 23,而 state 值打印出来却是 123。

大概原理就这样。使用 cloneElement 之前须谨慎,因为这使 React 的受控性出现”异常“,即数据跟视图产生了不一致的表现。

那如果我没有使用 ProFormText 呢?该如何实现输入内容格式化?比如 ant design 的 Input 组件。

嗯,cloneElement yyds。

// formatter 和 parser 自己补充进去
const InputTest = ({renderFn}) => {
  const [a, setA] = useState('');
  useEffect(() => {
    console.log(a, '---> value')
  }, [a])
  const dom = (
    <Input type="text" value={a} onChange={(e) => setA(e)} />
  )
  if (typeof renderFn === 'function') {
    return renderFn(dom);
  }
  return dom;
}

const fn = (dom) => {
  console.log(dom)
  const {props} = dom;
  return React.cloneElement(dom, {
    value: formatter(props.value),
    onChange: (e) => {
      return props.onChange(parser(e.target.value))
    }
  })
}

ReactDOM.render(<InputTest renderFn={fn} />, document.getElementById('container'));

方案三

那有没有其他的方案呢?比如我不想使用 ant design 的组件,使用其他/自己造一个轮子。

可以参考 InputNumber 组件的实现,其实是 rc-input-number 的实现。大略看了下,大概 3 步实现:

  1. input 的 value 和 onChange 自己维护
  2. 外部传入的 value 作为 input value 的初始值,在 input 的 onChange / onCompositionEnd 时,设置 format 的值到 input 的 value,并触发传入来的 onChange 事件,将 parse 后的值传递出去
  3. 光标位置处理

按照以上来实现的例子如下:

const formatter = (str) => {
  // return str;
  str = parser(str);
  str = str.split("").reduce((acc, cur, index) => {
    if (index % 3 === 0) {
      acc += ` ${cur}`;
    } else {
      acc += cur;
    }
    return acc;
  }, "");
  return str.substring(1);
};
const parser = (str) => str.replace(/ /g, "");

const InputTest = ({ onChange, value, formatter, parser }) => {
  const [a, setA] = useState("");
  const [focus, setFocus] = React.useState(false);
  const inputRef = useRef(null);
  const [recordCursor, restoreCursor] = useCursor(inputRef.current, focus);

  useEffect(() => {
    setA(formatter(value));
  }, [value]);

  const fn = (e) => {
    recordCursor();
    const _value = e.target.value;
    const parseValue = parser(_value);
    if (parseValue === value) {
      setA(_value);
      return;
    }
    setA(formatter(_value));
    onChange(parseValue);
  };

  useLayoutEffect(() => {
    restoreCursor();
  }, [a]);

  const dom = (
    <input
      ref={inputRef}
      type="text"
      value={a}
      onChange={fn}
      onFocus={() => setFocus(true)}
      onBlur={() => {
        setFocus(false);
        setA(formatter(a));
      }}
    />
  );
  return dom;
};

export default function App() {
  const [b, setB] = useState("12345");

  return (
    <div className="App">
      <InputTest 
        onChange={(e) => setB(e)} 
        value={b} 
        formatter={formatter}
        parser={parser}
      />
    </div>
  );
}

useCursor 这个 hook 请看 InputNumber 库。

简单地测试了一下,可以满足需求。

总结

总结一下,论如何实现格式化输入内容这个功能,有以下 2 种方案:

  1. React.cloneElement;
  2. 造一个成熟的 input 组件,其符合 React 的受控要求,并会自己处理光标位置,且将干净的内容输出给外部。

好了,至此已经完成了这个需求。今日份知识收益 +1。祝大家越学越有。

参考:

InputNumber ant.design/components/…

ProFormText procomponents.ant.design/components/…

ProFiled procomponents.ant.design/components/…

cloneElement zh-hans.reactjs.org/docs/react-…

rc-input-number github.com/react-compo…

受控组件 zh-hans.reactjs.org/docs/forms.…

useCursor github.com/react-compo…