需求
有一个需求:
电话号码输入要格式化,比如 15622223333,要格式化为 156 2222 3333。
前端使用 ant-design-pro 组件库,看了一下 ProForm 的组件 ProFormText 的 API,发现并没有提供类似于 Ant Design InputNumber 组件 formatter 和 parser 属性。那么有办法使用 ProFormText 这个组件实现这个需求吗?
方案一
办法肯定是有的:
-
initialValues 改造,使电话号码格式从 15622223333 变为 156 2222 3333;
-
onValuesChange 改造:
<ProForm onValuesChange={(values) => { // 格式化手机号 // formatPhone 是格式化方法 if (values.phone) { formRef.current!.setFieldsValue({ phone: formatPhone(values.phone), }); } }} /> -
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 中:
- 在 onFinish 时对数据进行处理
- 在 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),
});
});
}
}
至此,我们就实现了输入框格式化这个功能。总结一下:
- 处理初始值
- 使用 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 步实现:
- input 的 value 和 onChange 自己维护
- 外部传入的 value 作为 input value 的初始值,在 input 的 onChange / onCompositionEnd 时,设置 format 的值到 input 的 value,并触发传入来的 onChange 事件,将 parse 后的值传递出去
- 光标位置处理
按照以上来实现的例子如下:
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 种方案:
- React.cloneElement;
- 造一个成熟的 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…