Open varHarrie opened 5 years ago
在 React 中使用表单有个明显的痛点,就是需要维护大量的value和onChange,比如一个简单的登录框:
value
onChange
class App extends React.Component { constructor(props) { super(props); this.state = { username: "", password: "" }; } onUsernameChange = e => { this.setState({ username: e.target.value }); }; onPasswordChange = e => { this.setState({ password: e.target.value }); }; onSubmit = () => { const data = this.state; // ... }; render() { const { username, password } = this.state; return ( <form onSubmit={this.onSubmit}> <input value={username} onChange={this.onUsernameChange} /> <input type="password" value={password} onChange={this.onPasswordChange} /> <button>Submit</button> </form> ); } }
这已经是比较简单的登录页,一些涉及到详情编辑的页面,十多二十个组件也是常有的。一旦组件多起来就会有许多弊端:
setState
总结起来,作为一个开发者,迫切希望能有一个表单组件能够同时拥有这样的特性:
表单组件社区上已经有不少方案,例如react-final-form、formik,ant-plus、noform等,许多组件库也提供了不同方式的支持,如ant-design。
但这些方案都或多或少一些重量,又或者使用方法仍然不够简便,自然造轮子才是最能复合要求的选择。
这个表单组件实现起来主要分为三部分:
Form
Field
FormStore
为了能减少使用ref,同时又能操作表单数据(取值、修改值、手动校验等),我将用于存储数据的FormStore,从Form组件中分离出来,通过new FormStore()创建并手动传入Form组件。
ref
new FormStore()
使用方式大概会长这样子:
class App extends React.Component { constructor(props) { super(props); this.store = new FormStore(); } onSubmit = () => { const data = this.store.get(); // ... }; render() { return ( <Form store={this.store} onSubmit={this.onSubmit}> <Field name="username"> <input /> </Field> <Field name="password"> <input type="password" /> </Field> <button>Submit</button> </Form> ); } }
用于存放表单数据、接受表单初始值,以及封装对表单数据的操作。
class FormStore { constructor(defaultValues = {}, rules = {}) { // 表单值 this.values = defaultValues; // 表单初始值,用于重置表单 this.defaultValues = deepCopy(defaultValues); // 表单校验规则 this.rules = rules; // 事件回调 this.listeners = []; } }
为了让表单数据变动时,能够响应到对应的表单域组件,这里使用了订阅方式,在FormStore中维护一个事件回调列表listeners,每个Field创建时,通过调用FormStore.subscribe(listener)订阅表单数据变动。
listeners
FormStore.subscribe(listener)
class FormStore { // constructor ... subscribe(listener) { this.listeners.push(listener); // 返回一个用于取消订阅的函数 return () => { const index = this.listeners.indexOf(listener); if (index > -1) this.listeners.splice(index, 1); }; } // 通知表单变动,调用所有listener notify(name) { this.listeners.forEach(listener => listener(name)); } }
再添加get和set函数,用于获取和设置表单数据。其中,在set函数中调用notify(name),以保证所有的表单变动都会触发通知。
get
set
notify(name)
class FormStore { // constructor ... // subscribe ... // notify ... // 获取表单值 get(name) { // 如果传入name,返回对应的表单值,否则返回整个表单的值 return name === undefined ? this.values : this.values[name]; } // 设置表单值 set(name, value) { //如果指定了name if (typeof name === "string") { // 设置name对应的值 this.values[name] = value; // 执行表单校验,见下 this.validate(name); // 通知表单变动 this.notify(name); } // 批量设置表单值 else if (name) { const values = name; Object.keys(values).forEach(key => this.set(key, values[key])); } } // 重置表单值 reset() { // 清空错误信息 this.errors = {}; // 重置默认值 this.values = deepCopy(this.defaultValues); // 执行通知 this.notify("*"); } }
对于表单校验部分,不想考虑得太复杂,只做一些规定
rules
name
校验函数
boolean
string
true
false
然后巧妙地通过||符号判断是否校验通过,例如:
||
new FormStore({/* 初始值 */, { username: (val) => !!val.trim() || '用户名不能为空', password: (val) => !!(val.length > 6 && val.length < 18) || '密码长度必须大于6个字符,小于18个字符', passwordAgain: (val, vals) => val === vals.password || '两次输入密码不一致' }})
在FormStore实现一个validate函数:
validate
class FormStore { // constructor ... // subscribe ... // notify ... // get ... // set ... // reset ... // 用于设置和获取错误信息 error(name, value) { const args = arguments; // 如果没有传入参数,则返回错误信息中的第一条 // const errors = store.error() if (args.length === 0) return this.errors; // 如果传入的name是number类型,返回第i条错误信息 // const error = store.error(0) if (typeof name === "number") { name = Object.keys(this.errors)[name]; } // 如果传了value,则根据value值设置或删除name对应的错误信息 if (args.length === 2) { if (value === undefined) { delete this.errors[name]; } else { this.errors[name] = value; } } // 返回错误信息 return this.errors[name]; } // 用于表单校验 validate(name) { if (name === undefined) { // 遍历校验整个表单 Object.keys(this.rules).forEach(n => this.validate(n)); // 并通知整个表单的变动 this.notify("*"); // 返回一个包含第一条错误信息和表单值的数组 return [this.error(0), this.get()]; } // 根据name获取校验函数 const validator = this.rules[name]; // 根据name获取表单值 const value = this.get(name); // 执行校验函数得到结果 const result = validator ? validator(name, this.values) : true; // 获取并设置结果中的错误信息 const message = this.error( name, result === true ? undefined : result || "" ); // 返回Error对象或undefind,和表单值 const error = message === undefined ? undefined : new Error(message); return [error, value]; } }
至此,这个表单组件的核心部分FormStore已经完成了,接下来就是这么在Form和Field组件中使用它。
Form组件相当简单,也只是为了提供一个入口和传递上下文。
props接收一个FormStore的实例,并通过Context传递给子组件(即Field)中。
props
Context
const FormStoreContext = React.createContext(undefined); function Form(props) { const { store, children, onSubmit } = props; return ( <FormStoreContext.Provider value={store}> <form onSubmit={onSubmit}>{children}</form> </FormStoreContext.Provider> ); }
Field组件也并不复杂,核心目标是实现value和onChange自动传入到表单组件中。
// 从onChange事件中获取表单值,这里主要应对checkbox的特殊情况 function getValueFromEvent(e) { return e && e.target ? e.target.type === "checkbox" ? e.target.checked : e.target.value : e; } function Field(props) { const { label, name, children } = props; // 拿到Form传下来的FormStore实例 const store = React.useContext(FormStoreContext); // 组件内部状态,用于触发组件的重新渲染 const [value, setValue] = React.useState( name && store ? store.get(name) : undefined ); const [error, setError] = React.useState( name && store ? store.error(name) : undefined ); // 表单组件onChange事件,用于从事件中取得表单值 const onChange = React.useCallback( (...args) => name && store && store.set(name, valueGetter(...args)), [name, store] ); // 订阅表单数据变动 React.useEffect(() => { if (!name || !store) return; return store.subscribe(n => { // 当前name的数据发生了变动,获取数据并重新渲染 if (n === name || n === "*") { setValue(store.get(name)); setError(store.error(name)); } }); }, [name, store]); let child = children; // 如果children是一个合法的组件,传入value和onChange if (name && store && React.isValidElement(child)) { const childProps = { value, onChange }; child = React.cloneElement(child, childProps); } // 表单结构,具体的样式就不贴出来了 return ( <div className="form"> <label className="form__label">{label}</label> <div className="form__content"> <div className="form__control">{child}</div> <div className="form__message">{error}</div> </div> </div> ); }
于是,这个表单组件就完成了,愉快地使用它吧:
这里只是把最核心的代码整理了出来,功能上当然比不上那些成百上千 star 的组件,但是用法上足够简单,并且已经能应对项目中的大多数情况。
我已在此基础上完善了一些细节,并发布了一个 npm 包——@react-hero/form,你可以通过npm安装,或者在github上找到源码。如果你有任何已经或建议,欢迎在评论或 issue 中讨论。
@react-hero/form
为什么要造轮子
在 React 中使用表单有个明显的痛点,就是需要维护大量的
value
和onChange
,比如一个简单的登录框:这已经是比较简单的登录页,一些涉及到详情编辑的页面,十多二十个组件也是常有的。一旦组件多起来就会有许多弊端:
setState
的使用,会导致重新渲染,如果子组件没有相关优化,相当影响性能。总结起来,作为一个开发者,迫切希望能有一个表单组件能够同时拥有这样的特性:
表单组件社区上已经有不少方案,例如react-final-form、formik,ant-plus、noform等,许多组件库也提供了不同方式的支持,如ant-design。
但这些方案都或多或少一些重量,又或者使用方法仍然不够简便,自然造轮子才是最能复合要求的选择。
怎么造轮子
这个表单组件实现起来主要分为三部分:
Form
:用于传递表单上下文。Field
: 表单域组件,用于自动传入value
和onChange
到表单组件。FormStore
: 存储表单数据,封装相关操作。为了能减少使用
ref
,同时又能操作表单数据(取值、修改值、手动校验等),我将用于存储数据的FormStore
,从Form
组件中分离出来,通过new FormStore()
创建并手动传入Form
组件。使用方式大概会长这样子:
FormStore
用于存放表单数据、接受表单初始值,以及封装对表单数据的操作。
为了让表单数据变动时,能够响应到对应的表单域组件,这里使用了订阅方式,在
FormStore
中维护一个事件回调列表listeners
,每个Field
创建时,通过调用FormStore.subscribe(listener)
订阅表单数据变动。再添加
get
和set
函数,用于获取和设置表单数据。其中,在set
函数中调用notify(name)
,以保证所有的表单变动都会触发通知。对于表单校验部分,不想考虑得太复杂,只做一些规定
FormStore
构造函数中传入的rules
是一个对象,该对象的键对应于表单域的name
,值是一个校验函数
。校验函数
参数接受表单域的值和整个表单值,返回boolean
或string
类型的结果。true
代表校验通过。false
和string
代表校验失败,并且string
结果代表错误信息。然后巧妙地通过
||
符号判断是否校验通过,例如:在
FormStore
实现一个validate
函数:至此,这个表单组件的核心部分
FormStore
已经完成了,接下来就是这么在Form
和Field
组件中使用它。Form
Form
组件相当简单,也只是为了提供一个入口和传递上下文。props
接收一个FormStore
的实例,并通过Context
传递给子组件(即Field
)中。Field
Field
组件也并不复杂,核心目标是实现value
和onChange
自动传入到表单组件中。于是,这个表单组件就完成了,愉快地使用它吧:
结语
这里只是把最核心的代码整理了出来,功能上当然比不上那些成百上千 star 的组件,但是用法上足够简单,并且已经能应对项目中的大多数情况。
我已在此基础上完善了一些细节,并发布了一个 npm 包——
@react-hero/form
,你可以通过npm安装,或者在github上找到源码。如果你有任何已经或建议,欢迎在评论或 issue 中讨论。