React组件设计 - Input

React组件设计 - Input

Input 在一个表单里面具有极高的地位。提及表单,相信大家脑海里首先浮现的就是 Input,所以如果要实现比较完整的表单,我的选择是先实现 Input,然后才是 Checkbox,Radio 之类。

type 的划分

一个原生 HTML input 元素,它的 type 有很多可选值,如:textpasswordnumbercheckboxradiodate 等等,集万千功能于一身,简直万金油啊,但是在啥都组件化、啥都模块化的今天当中,我觉得这种集大成者不仅让用户的使用体验得不到保证,也让开发者难以维护下去。因此,我把一些比较特别,需要另外专门设计的 type 独立出去,例如 checckboxdate 等。

把一些功能独立出去之后,Input 组件的设计以 textpassword 为主也没什么大问题,加 number 也行,要加原生的 min / max 的话也可以,但这几个都可以用后续的 Form 组件的表单验证功能代替一下,并且可以获得更为友好的错误提示😋

功能分析

Input 组件,不用废话最主要的功能是输入,原本的 HTML input 元素的输入功能就很好啦,虽然在 React 里面 input 是受控组件,但这就不在这一节讨论了。

然后,我们经常可以看到这几种表单的外观设计:

  • 锤子官网

    锤子官网 t.tt

  • 有赞官网

    有赞官网 youzan.com

可以看到,这两种 Input 组件都有一个共同的特点:都有前缀 / 后缀。啥是前缀后缀呢?按锤子的图,人像和钥匙图标就是前缀;按有赞的图,登录密码验证码是前缀,验证码图片是后缀。

所以我认为在输入框前后加点东西是一个常见需求。再细分一下的话,前后缀有图标、按钮、图片,甚至下拉框…太多太夸张了,都不敢想了。既然如此,那就把这块挖空了吧,留个空位,让开发者自行把后续内容填进去,这让 Input 组件拥有更好的灵活性和兼容性。

实现

大致结构

因为有前缀和后缀,所以可以将 Input 组件分为左中右三块,中间自然就是 input 元素了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div>
{prefix &&
<div>
{prefix}
</div>
}
<input />
{suffix &&
<div>
{suffix}
</div>
}
</div>

这里只是演示个大致的结构,我略去了其他属性。

受控

当一个 React 组件需要更新的时候,主要有两种方式:

  • 由外部传进新的 props

  • 自身调用 setState() 方法

在 React 里面 input 是受控组件,所以我们封装的 Input 组件也应该是受控的。如果通过 setState() 的方式更新自身状态,外部是难以通过直观优雅的方式获取到输入值的。既然这个组件主要是依靠 props 来更新的,那也得先把值暴露给父级组件,以让父级组件决定是否对 Input 组件进行更新。

而如果子组件要与父级组件通信:

  • 父组件粗暴直接地获取子组件的数据

  • 父组件将一个函数的引用(乃至自身)传递给子组件,而后子组件在调用这个函数时顺便将数据作为参数,这样父组件就间接拿到子组件传递过来的数据了

对于第一种通信方式,React 是反对的,因为这样会搞乱数据流,而清晰的数据流向对于一个系统而言是至关重要的,不仅可以减少 BUG 隐藏的机会,也方便后续的维护和高层级组件的集成。

对于第二种通信方式,其实还分为两种途径,一是将函数作为 props 传递给子组件,然而有时候通信双方并不是直接相连的父子组件,而是相隔一个或多个组件的祖孙后代组件,这种情况下虽然还能继续将函数通过 props 层层传递下去,但也是挺麻烦的了。还有种更特殊的情景是,我们并不能明确知道两个组件之间将会有多少层中间组件,可能是 0 层,也可能是很多层,这就没办法把 props 层层传递下去了,因为存在着未知的组件。综上就有了第二种途径,上层组件将函数放在 context 上下文当中,下层组件通过 context 获得这个引用,具体 api 可以查看相关文档。

有点扯远了,回到 Input 组件的设计上来。按刚才说的,既然 Input 是受控组件,我们就将一个 onChange 函数作为 prop 传递进来,但是我并不直接将这个函数挂在真正的 input 元素的 onChange 事件上,而是在组件内部再定义一个 handleChange 函数,而后在这里面调用传递进来的 props 上的 onChange 方法:

1
2
3
4
handleChange(ev) {
const { onChange } = this.props;
onChange && onChange({ value: ev.target.value }, ev);
}

然后再将此函数挂在 input 元素上:

1
2
3
4
5
6
7
<input
type={type}
name={name}
value={value}
placeholder={placeholder}
onChange={this.handleChange}
/>

父组件获取数据的方式

在上面可以看到:

1
onChange({ value: ev.target.value }, ev);

这里我把 value 放进对象里面,并且作为函数的第一个参数,而第二个参数才是事件。这是为了对外保持一致的数据获取的接口,当然,这只是我个人的喜好,这里说下我的考量:

  • 在开发了一些组件之后,我发现各组件不同的数据获取方式(或通过 event,或直接作为第一个参数,或作为对象的一部分)常常让我感到困惑,以至于要去翻看文档或组件的实现。所以我觉得有必要统一数据获取的接口,简化 api。

  • 之所以选择第一个参数,是我看到 bind 方法会将数据作为在函数的前部分参数,而把 event 置后,例如:

    1
    <div onClick={this.handleClick.bind(this, arg1, arg2)}>

    最终调用时会变成 handleClick(arg1, arg2, event)

  • 将数据放进一个对象里,是因为或许数据不止一个,当有多个数据的时候,把数据全横列在参数里是不够优雅的:

    1
    handleClick(value1, value2, value3, ..., event);

    为了统一,我选择放进对象里,即使只有一条数据。

自身状态

虽说 Input 是受控组件,但这并非说它不能拥有自己的状态,受控只是为了通过清晰的数据流将重要数据传递给外部,对于一个表单组件来说,输入值是第一重要的,是外部必须获取的。而一些不是很重要的数据,比如获得焦点 focused,就没必要暴露给外部了,组件内部自给自足就行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
constructor(props) {
// ...
this.state = {
focused: false
};
this.handleFocus = this.handleFocus.bind(this);
this.handleBlur = this.handleBlur.bind(this);
// ...
}
handleFocus(ev) {
this.setState({
focused: true
});
}
handleBlur(ev) {
this.setState({
focused: false
});
}

这个 focused 状态有什么用呢?其实是用来控制样式的。

CSS样式

细节控制

写 CSS 样式的时候,我给中间的 input 和两边的前后缀 div 都设置了左右内边距,然后前后缀的内容都是居中的。在前后缀是字符串和图标时,这种样式是完全没问题的。前面说了,前缀和后缀是五花八门的,啥都有,虽然比较普通的情形是字符串和图标,但如果想占满整个预留区,即不留任何内边距的话,就需要我们做进一步的控制了。

为此,我在 render 里面判断一下前后缀是什么类型的元素,如果是字符串或者图标,则另作样式控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
isStrOrIcon(fix) {
return (typeof fix === 'string')
|| (fix.props.className && fix.props.className.startsWith('icon'));
}
render() {
{prefix &&
<div
className={`z-input__prefix
${this.isStrOrIcon(prefix)'z-input__prefix--str' : ''}`}
>
{prefix}
</div>
}
}

没了

好像也没什么讲的了,剩下一些原生属性 autoFocusreadOnlydisabled,还有样式 props,比如 outlineround 之类的。