Tip: 这里提到的“底层API”在React官方文档中为React Top-Level API,由于并没有统一的中文译名,所以我把它叫做底层API。不过简单来说就是写业务几乎接触不到的一些API。
写在前面
作为一个刚入行的前端,清楚作为入行开发的困难。没有具体的目标,到底要做什么,虽说获取信息很简单,但大部分大部分搜出来的东西更像是一些学习的笔记,转述一遍官方文档,再给出几个foo,bar的示例。跟着敲了几遍这些例子,但不清楚它们到底能做什么,只知道要它们都很重要,要记住它们,随着学的新语法,新特性越来越多,面对新东西内心都是抵触的,心里不免产生“求求你不要更新了,我学不动了!”。
学不动了
掌握多少知识点不应该是学习程序的重点,代码最终只是人表达逻辑思路的语言,知识总是在不停更新的,早点跳出潜意识“我要把它记下来”的桎梏,对程序员的个人发展尤其重要,起码能让写代码的幸福感提升不少。
我想写一篇告诉你xxAPI
能做什么的文章,这篇文章的主线是思路,虽然代码仍然占据了比较大的篇幅(主要是限于本人能力问题,没有精力去用其他方式表述出来)。 虽然名字起得似乎有点吓人,但这篇文章门槛并不会太高,只要你使用js写过程序,用React
切过图(假如你js基础好的话这一要求也可去掉)就能读下去。
虽说文章最终写出来似乎整个流程都是行云流水,一气呵成。但是在制作过程中完全不是这样的,我在制作过程中有很多设想都被否定,最后要提的一点就是,文章最终写完之前,里面涉及到的知识点有很多我也不清楚,大部分都是在官方文档上现查现验证现写的,很多思路一开始是不切实际/最优的,实际上写程序不也应该是这样吗?
代码仓库地址:Qpumpkin/UI-component,如果你正在学习React的话,可以clone下来,注释掉写好的部分,再按照文章的描述一步一步去验证我所说的东西。
结构:
- 我想要做一个什么样的
Form
组件 - 需要解决的问题和解决方案的猜想和实施。
- 目前成熟的UI库(ant-design为例)是怎么做的。
表单业务算是在前端业务中较为复杂的部分了,并且表单在整个网站的交互中也一定会是最复杂的那一部分,但大致都有一定的套路。最近在做个人项目的时候很多地方需要用到表单,每次写表单的那些重复性的代码实在让人感到心烦,写到第三遍的时候我已经完全不想继续写下去了,因此萌生了做一个通用的Form组件的想法,帮我省省代码和脑细胞。
我们先来总结一下一个表单类组件最基础的需求:
- 监听内部表单元素的change事件
- 实时更新,保存各个表单元素的状态
- 表单值的的检查,由于正则表达式我不是很熟,并且我猜
checkValid
的复杂程度可以单独写个几千字了,以后有兴趣的话我会单独写一篇。 - 发起请求
在以上几个需求中,①和②是所有表单和都必备的,并且具体要求是完全一致并不需要额外的规则,而③和④则是需要额外的个性化配置,所以组件需要依赖外部的传值。以一个登录板块为例,我们先来看两份代码,一个符合React官方标准的写法是下图这样,详细可以看这里 React Form
class Login extends Component { // react标准表单
constructor() {
super()
this.state = {
username: '',
password: '',
}
}
handleSubmit() {...}
handleChange() {...}
checkValid() {...}
render() {
return (
<div>
<h1>登录</h1>
<form onSubmit={(e) => this.handleSubmit(e)}>
<input
name="username"
value={this.state.data.username}
onChange={(e) => this.handleChange(e)}
placeholder="请输入用户名"
/>
<input
name="password"
value={this.state.data.password}
onChange={(e) => this.handleChange(e)}
placeholder="请输入密码"
/>
<button value="提交" />
</form>
</div>)
}
}
可以看到在真正切图之前先得写一套配套的事件处理函数,这是每个表单组件都必须要写的,在这里因为还没有添加逻辑代码,似乎还能看,但一旦添加最少也得有一两百行。我想有一类代码,不仅是表单业务,面对它的时候总会有这样一种心情,无论怎么想办法去调整它的格式,减少它的代码量,在自己去看的时候难免都会产生抵触的心情,关注的事情太细节,动辄几百行,一旦出了什么bug,又得在这一圈逻辑中去扫一圈,实在是一件折磨人的事情,下面再看我理想中的业务表单代码。
class Login extends Component { // 我理想的表单业务代码
handleSubmit() {...}
render() {
return (
<div>
<h1>登录</h1>
<Form
onSubmit={() => this.handleSubmit()}
defaultValue={{
username: '',
password: '',
}}
>
<Field name="username" placeholder="请输入用户名" />
<Field name="password" placeholder="请输入密码" />
<button value="提交" />
</Form>
</div>)
}
}
我不用再去关心Form
和整个表单元素之间的关系,只需要设置好整个数据有什么字段,初始值是什么,提交时需要处理的事情(其实这一部分也可以抽象到Form
组件内部完成,后面会写到),与其附带的好处还有代码量大大减少,这显然是更好的方式。
开始准备写代码,先搭建起能满足需求的React
状态组件(带state的):
Form
整个表单的数据由其控制。 受控组件(接受props的):Form
元素下的子表单元素,即Field
。Form
组件根据传入的defaultValue
字段创建state
,再把里面的值与Field
组件绑定。
需要解决的核心问题:让Form
实例下Field
实例自动变为受Form
控制的组件。
按照官方的标准每一个表单元素的value要与绑定,其实好像表单元素不绑定状态借助Dom事件冒泡/捕获也能让
Form
拿到每个表单变化的值,但不符合标准还是不太好,说不定就出什么问题了。 从React的代码风格来看,好像没有什么方法可以让两个抽象出来的组件之间直接建立关联。只记得有一个this.props.children
可以让父元素访问到自己的子元素,大致解决方法已经想出来了,就是在Form
的render
函数内对this.props.children
进行拦截加工即可。
先把格式搭建好
// UI
<Form defaultValue={{ username: '' }}>
<Filed name="username" placeholder="请输入用户名">
</Form>
// 组件
class Form extends Component {
constructor(props) {
super(props)
this.state = props.defaultValue || {}
}
render() {
return (
<form>
{this.props.children}
</form>);
}
}
function Field(props) {
//{...props}这个操作相当于把props内的各个字段直接加在在input元素中。
return <input {...props} />
}
先在Form
上console.log(this.props.children)
一下,看看children
有哪些接口。
控制台打印出的log
分析一下各个字段:
- _开头的变量都是一些私有变量肯定用不到。(我点开看了以后确实用不到)
-
ref
:这个东西应该和React文档里讲的Ref有关,不过在介绍它之前就一再强调除非很特殊否则不要使用,本身也是800年都用不到的API。虽然说看功能的介绍,好像可以也可以实现对最终的表单组件的value进行控制,尝试了一些手段发现ref
字段只读,绑定失败。 -
type
:指向构成他的组件,类似对象的constructor -
props
:这个 children 接受到的 props
看来只能在props上找方法了,了解React
的人都应该知道业务中<Component atr={value}... />
这样的写法,里面的atr
最终在经过编译运行后都变成了元素对象props
字段里的属性。看来只要想方法在props
直接增加我们想要的属性就能可以了。那先来试一试:
this.children.props.value = '试试看!'
使用之后报错-> TypeError: Cannot add property value, object is not extensible
果然失败了,这里根据我对js的理解整个对象肯定是经过Object.freeze()
的。看来直接修改或者替换都是行不通的,那么曲线救国,直接复制它呢?试试看吧。
const control = Object.assign({}, this.props.children)
control.props.value = '试试看!' // 这里直接这么做会报错,你可以想想看为什么。
// 所以要换成下面的
control.props = Object.assign({ value: '试试看' }, control.props)
return <form>{control}</form>
页面上的效果,右边为Field组件此时的props
果然成功了,Form
和Field
之间绑定value
的问题已经解决了,看来剩下的只要给Form
添加上事件逻辑,接下来就是把表单那些必要的需求添加进去了。所有表单子元素都有onChange
这个事件,所以只要在props
中增加onChange
字段,Field
就会接住再在input
上添加事件。看来比较顺利呢,开心!能跑的代码如下:
// 粗糙版
class Form extends Component {
constructor(props) {
super(props)
this.state = props.defaultValue || {}
}
handleChange({ target: input }) { // es6的解构赋值
const { name, value } = input
this.setState({ [name]: value })
}
render() {
const control = Object.assign({}, this.props.children)
control.props = Object.assign({
value: this.state[control.props.name],
onChange: (e) => this.handleChange(e),
}, control.props)
console.log(control)
return (
<form>
{control}
</form>
)
}
}
那么这么粗暴的替换会不会影响到虚拟DOM-DIFF算法呢?我在查看器里也没有看到Dom的刷新,看起来似乎接下来就是在这个基础上逐步完善了。
不过你觉得直接写成这样合适吗?有没有考虑过下面这些问题
- 当一个
Form
实例有多个Field
子元素的时候呢? -
Form
实例内部有除了Field
以外的直系子元素呢? - 这么直接复制
children
会不会丢掉一些不可枚举的属性,然后在将来某个时间段爆发某种未知bug呢?
接下来就是这篇文章主题————React-TopLevel-API登场!
之前的代码只是证实了确实可以使用这样的方法来达到目的,具体做出来还需要①遍历子元素。②在已有的元素基础上创建新元素。React确实提供了这样的API……
- React关于Children的API React Children
在总览描述上可以看到这系列的api就是用来处理this.props.child这个不透明的数据结构的 找到一个比较符合我们需求的的Api:
React.children.map(children: 要遍历的子元素, mapper: Function)
类似数组的高阶函数map,把一个children对象传进去,对这个对象每一个直系子元素执行mapper(child)
。当对象为null时直接返回null。作为官方API它肯定已经帮我们考虑到了各种corner case,所以只管用就可以了。
2. React关于生成元素的API React Element
React.cloneElement(element: 目标元素, props: 给它新传的props, [...children]: 子元素)
一目了然,不用多解释了。我想这个函数内部应该也是类似上面写的深度复制,不过它考虑的情况肯定比我多多了,绝对可靠放心用。
现在再来看看render
应该怎么写
...
render() {
const controls = React.Children.map(this.props.children, (children) => {
if (children.type !== Field) return children
// 前面提到的children上的type字段
const { name } = children.props
const control = React.cloneElement(children,{
value: this.state[name],
onChange: (e) => this.handleChange(e),
})
return control;
}
return (
<form onSubmit={(e) => this.handleSubmit(e)}>
{controls}
</form>
);
}
现在还剩下一个需求:表单提交事件的添加 按照之前的设想,提交事件因为本身需要个性化的配置,所以放到了外部由板块传给Form
实例的props
。回到登录板块上
class Login extends Component {
state = {}
render() {
console.log(this)
return (
<Form defaultValue={{ username: '' }}>
<Field name="username" placeholder="请输入用户名" />
</Form>
);
}
}
写到这里突然意识到一个很严重的问题,额...数据都保存在了Form
实例的state
里,外部怎么拿得到啊?(你可以思考一下为什么无法访问到子元素的state
)再像刚刚那样从children
里拿?……试试看吧,console.log
一下。
打印出了undefined
怎么回事?和刚刚怎么不一样,照理来讲Login应该是有Form
实例这个Children的啊,猜猜看吧,根据React生命周期construct
-> render
-> componentDidMount
,那第一遍render
的时候显然整个组件都没生成,自然就没有什么children,嗯……,看起来好像很机智,那再试试在componentDidMount
里再看看。
都没有
并不是我想的那样。(其实假设就错了,如果真的是像我猜的那样,为什么Form就能打印出来呢?) 看来this.props.children
并不是我想当然的那样的代表在HTML里的子元素的概念。那log一下this
看看吧,好像到这里已经卡死了。
Login板块的this
tip: 写到这里的时候在
state
和props
上钻研了太久,结果实现组件不需要涉及到那些东西,放到这里又太占篇幅,放弃吧又觉得可惜,实在太鸡肋了,只能折中放个链接吧state与props。感兴趣可以看看。如果你对它们不感兴趣,或已经很熟了的话可以直接跳过,直接到下一阶段 “奇技淫巧实现提交功能”
...不过我又想到了其他方法,并不一定要拿到state,只要能拿到Form元素的this
就可以了。(如果你对js比较熟的话这里应该想到解决方案了)
只要在Form
中再处理一下传进去的onSubmit
不就可以了,刚好函数原型链上就有bind
方法可以干这个事,只要保证handleSubmit
在运行时的this
指向这个Form
就可以了,(为了简洁,异步逻辑就不弄上了)。哈哈...现在再看代码。
// UI
<Form
onSubmit={() => {
const { username, password } = this.state;
axios.post(
'http://localhost:3001/auth/login',
{ username, password})
}}
>
<Field name="username" placeholder="请输入用户名" />
<Field name="password" type="password" placeholder="请输入密码" />
<Button label="提交" />
</Form>
class Form extends Component {
略...
handleSubmit(e) {
e.preventDefault()
const submit = this.props.onSubmit.bind(this)
submit()
}
render() {
return (
<form onSubmit={(e) => this.handleSubmit(e)}>
...以下略
</form>)
}
}
试试看能不能成功发送请求,
看来这个方法是可以达到需求的。 但是这么写真的好吗?如果只是自己一人写的项目还好,一旦要与他人协作的话,在一个无状态组件里使用setState
是非常让人感到迷惑的。应该有更好的方法来达到这个目的。
再来重新理一下需求:
对于一个提交事件来说有两件事是一定绕不开的
1. 收集要提交的数据 (在Form组件的state中哪些字段是要提交的?)
2. 提交到指定地址 (api地址)
3. 请求完成后要进行的操作 (Promise.then)
现在外部拿不到state,我通过传一个函数进去接受Form
里的state
就可以了。这些信息都可以配置到一个对象内。
const config = {
api: 'http://localhost:3001/login',
atr: ['username', 'password'],
success: function(res) {
localStorage.setItem('user', res.request.responseText)
window.location = '/'
},
}
// ui
<Form config={config} />
// 这里使用的是fetch,如果觉得繁琐可以跳过这个看下面的简洁版。
class Form extends Component {
...
handleSubmit(e) {
e.preventDefault()
const { config } = this.props // 最好不要解构config,可以保留住函数的this。
let data = {}; // 采集数据
if ('atr' in config) {
config.atr.forEach(key => data[key] = this.state[key])
} else { // 没有专门设置提交的字段的话就默认全部提交
data = this.state
}
fetch(config.api, { // 因为fetch跨域限制严格的原因弄了好久。前后端都是我自己写的。
method: 'POST',
body: JSON.stringify(data),
headers: new Headers({
'Content-Type': 'application/json',
'mode': 'cors', // 就是这个字段坑了我
})
})
.then( res => {
const state = config.success(res)
if (state) { // 如果配置的 success 有返回值的话就更新form的状态
this.setState({...state})
}
})
}
render() {
return <form onSubmit={(e) => this.handleSubmit(e)} />
}
}
// fetch还是比较太底层了,还是用axios一把梭吧
// 简洁版
handleSubmit(e) {
e.preventDefault()
const { config } = this.props
let data = {}
if ('atr' in config) {
config.atr.forEach(key => data[key] = this.state[key])
} else { // 没有专门设置提交的字段的话就默认全部提交
data = this.state
}
axios.post(config.api, data)
.then((res) => {
const state = config.success(res)
if (state) {
this.setState(state)
}
})
}
到目前为止,一个具备基础功能的表单组件已经完成了。 大致的完整代码如下
// form.jsx
import React, { Component } from 'react'
import axios from 'axios'
export class Form extends Component {
constructor(props) {
super(props)
this.state = props.config.defaultValue || {}
}
handleChange({ target: input }) {
const { name, value } = input
const data = { ...this.state.data }
data[name] = value
this.setState({ data })
}
async handleSubmit(e) { // 用了语法糖
e.preventDefault()
const { config } = this.props
let data = {}
if ('atr' in config) {
config.atr.forEach(key => data[key] = this.state[key])
} else {
data = this.state
}
const res = await axios.post(config.api, data)
const state = config.success(res.data);
// 在这里是我自己开的一个后端,如果登录成功的话会返回
/* {
message: ’登陆成功‘,
userInfo: { name: '用户名', id: '用户id’ }
} */
if (state) {
this.setState(state)
}
}
render() {
return (
<form onSubmit={(e) => this.handleSubmit(e)}>
{React.Children.map(
this.props.children,
(children) => {
if (children.type !== Field) return children
const { name } = children.props
const control = React.cloneElement(children,{
value: data[name],
onChange: (e) => this.handleChange(e),
});
return control;
})}
</form>
);
}
}
export function Field(props) {
return <input {...props} />
}
/* ------------------------------------------- */
// login.jsx
import React from 'react'
import { Form, Field } from './form'
export function Login() {
const config = { // 突然想到defaultValue也可以写到config里面
defaultValue: { username: '', password: '' },
api: 'http://localhost:3001/login',
atr: ['username', 'password'],
success: function(res) {
localStorage.setItem('user', JSON.stringify(res.userInfo))
window.location = '/'
},
}
return (
<Form config={config}>
<Field name="username" placeholder="请输入用户名..." type="text"/>
<Field name="password" placeholder="请输入密码..." type="password" />
</Form>
)
}
可以看到原来冗长的业务代码,在经过抽象后,只用10多行就搞定了,背后的脏活累活都交给了Form
组件帮我们解决了。
组件的原型已经完成了,如果仅作为个人使用的组件后续再加点扩展是可以满足使用的,但离企业级还是有很大的距离的。还有很多需求都没做到,因为绑定props
工作是直接写死的,因此后续使用只能固定住组件结构,或者增加遍历子节点的复杂度。不过基本逻辑拆分还是可行的,Form
作为表单枢纽关注整个表单的配置,Field
只用关注自身。后续要添加新功能也只用在组件内部内部修改代码,业务代码不用改太多。
因为限于篇幅所以目前Field
组件是直接写死绑定了返回的是input
元素,其实Field
完全可以成为一个表单子元素的分流入口,type字段来决定最终呈现的DOM
此外把最终渲染元素抽离出来还有一个好处就是这些元素单独使用的时候也是可以的,至于表单的基础功能中的checkValid
也是可以后续在Form
增加的。
我们再去看一下Ant design是怎么设计的的把点击查看。
// ant design 登录Form组件使用示例 简化版
import { Form, Input } from 'antd'
<Form layout="inline" onSubmit={this.handleSubmit}>
<Form.Item
validateStatus={userNameError ? 'error' : ''}
help={userNameError || ''}
>
{getFieldDecorator('userName', {
rules: [{ required: true, message: 'Please input your username!' }],
})(
<Input prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />} placeholder="Username" />
)}
</Form.Item>
<Form.Item>
{getFieldDecorator('password', {
rules: [{ required: true, message: 'Please input your Password!' }],
})(
<Input prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />} type="password" placeholder="Password" />
)}
</Form.Item>
<Form.Item>
</Form>
毕竟是企业级的UI框架,考虑的东西显然比我要多太多了,我猜<Form.Item />
应该是作为一个元素样式Wrapper
,应该是出于checkValid
功能的以及各种交互的考虑,另外getFieldDecorator
的作用应该和我的Field
主要作用是一样的,让表单成为受控组件。getFieldDecorator
是一个生成高阶组件的函数。prefix
显然只是输入框前方那个装饰性的图标,看来我的Form在设计思路大方向上是没问题的,不应该让开发者在业务中还去关心数据绑定,状态之类的细节,由于把表单装饰抽离了出来因此组件结构上比较自由只需要关心表单的配置,那么在实现方案上antd又是怎么做到的?不过还是先验证一下我的猜想吧。
tip: 毕竟不是科班出生,讲得啰嗦了一些,装饰器之类的概念我在写的时候根本不懂。
getFieldDecorator Api介绍
看来确实和我想的方向一致,具体是怎么做到的,就要去看源码了,我大致看了一下,代码也是很老的了,考虑情况也都是很复杂的,不过我发现在getFieldDecorator
中有一段熟悉的代码(下图),你可以点击这里查看……我想不用我再去解释了吧,我前面已经讲过了。
getFieldDecorator(...args) {
...
return React.cloneElement(fieldElem, {
...props,
...this.fieldsStore.getFieldValuePropValue(fieldMeta),
});
}
antd
的Form是基于rc-form
之上的一个再封装,目前我实现的Form
还太简陋,应用场景很小,考虑一下以下需求:
- 当表单需要分域 (HTML的fieldset)
- 数据重置/清空
- 表单数据 view 实际储存数据 提交数据并不一致,(例: 提交图片,时间)
- 有效检查以及配套的样式响应
-Form
上遍历children
中的Field
由简单的数组遍历变为树的遍历。
-defaultValue
需要拆分为两部分,储存对象和配置对象。
- 样式响应需要配套的各种状态检查函数,但表单的状态大致可分为
输入前:可用/不可用
输入中:有效/非法
提交:发起提交/提交中/中断/失败/完成
这么复杂,把逻辑混在UI肯定是不合适的,拓展性以及维护性都会比较差。然而实现这样的组件显然是需要经验和大量具体的实际场景应用才能做到的,作为学习者强行跟着敲源码没有任何意义,重要的是体会一下设计模式,我目前写不出来……。在具有足够实力之前就深扎框架是非常低效的。我试着看了一下rc-form
的源码如果你感兴趣的话可以点击这里查看。
实现过程中并没有那么容易,一开始我是在想用Context或Ref这两个API去实现,但发现并不适合,Context在语意上是表示全局变量,全局只存在一个。ref在官方文档中说使用场景是很小的。作为程序员,接受系统性训练还是重要的,在看rc-form
源码的时候,涉及到一些写法就是符合一般程序设计思想的写法,但作为非科班出生的我看到就会很疑惑,为什么要这么写,在过程中查阅了很多资料,虽然自己最后想通了,但无法用合适的语言表述出来。