基於React實現高度簡潔的Form表單方案

NO IMAGE

最近項目裡在做我們自己的組件庫,關於表單這塊,如何實現一個更簡單的表單方案,是我們一直在討論的問題,之前項目裡習慣用 ant-design 的 Form 表單,也覺得蠻好用的,我們希望能做出更簡潔的方案。

下面列出了表單相關的解決方案,React 社區的輪子真是多到無法想象:

以上的表單方案主要聚焦在一下幾點:

  1. 更方便地做數據收集,不手寫 valueonChange,有的表單是增加函數(ant-design)或容器(FormBinderFusion等),為子組件註冊 valueonChange,有的是自定義 Feild 組件(UForm),內部處理相關邏輯
  2. 更高效的渲染,比如字段狀態分佈式管理
  3. 簡單,降低學習成本
  4. 動態表單渲染

關於數據收集與渲染

關於表單數據收集,可以參考雙向數據綁定,下面是雙向數據綁定的討論:

以及關於實現雙向數據綁定的文章:

一個是數據收集,一個是渲染,也就是所謂的雙向數據綁定,總結起來有三個途徑:

  1. 可以在編譯期進行代碼轉換,注入賦值語句和組件數據監聽方法,這個看起來高大上,需要自己寫 Babel 插件
  2. 運行時修改虛擬DOM,比如 ant-designice 等等,確實也都蠻好用的,上面列出的文章都可以研讀一下,很有意義
  3. 手寫 valueonChange,除非你的系統裡只有一個表單。。。

先立個目標

看了大佬們的實現,我們也想造個輪子,希望還可以更簡潔,讓表單寫起來更開心,當系統裡有很多表單,都要手綁 valueonChange 肯定是不行的,即便 ant-designice 等,還要加額外的函數或容器,所以目標就是下面這樣:

import {Form,Input} form 'form';
export default class FormDemo extends Component<any, any> {
public state = {
value: {
name: '',
school: '',
},
}
public onFormChange = (value) => {
console.log(value);
this.setState({
value,
});
}
public onFormSubmit = () => {
// console.log('submit')
}
public render() {
const me = this;
const {
value,
} = me.state;
return (
<Form
value={value}
enableDomCache={false}
onChange={me.onFormChange}
onSubmit={me.onFormSubmit}
>
<div className="container">
<input
className="biz-input"
data-name="name"
data-rules={[{ max: 10, message: '最大長度10' }]}
type="text"
/>
<Input
data-name="school"
data-rules={[{ max: 10, message: '最大長度10' }]}
type="text"
/>
<Button type="primary" htmlType="submit">提交</Button>
</div>
</Form>
)
}
}
  1. 簡潔,貼近原生,學習成本低
  2. 組件兼容所有實現 valueonChange 的組件,比如 ant-design 的表單組件
  3. 表單驗證,沿用 ant-design 設計,使用 async-validator 庫來做

看得出來,我們是 ant-design 的粉絲了,坦白說,大佬們的方案已經足夠簡潔了,ant-design 是先驅,後繼者 Ice , Fusion 等多對標 ant-design ,力圖更給出更簡潔的方案,他們也確實很簡潔,特別是 FusionField 組件,眼前一亮的感覺,UForm 使用類似 JSON Schema(JSchema) 的語法寫表單,Uformfinal-form 強調字段的分佈式管理,高性能,不過,這兩個方案有一定的學習成本,實現方案自然是複雜的。

不過,當我說出我們的實現,大家估計要吐槽,因為我們的實現太簡單(捂臉),簡單到懷疑人生。

實現

要想實現上面的目標,顯然文章開頭文章列表已經有人實踐了,編譯期注入代碼,不過你要新加個 Babel 插件,不知道你喜不喜歡。

我們的實現是採用運行時修改虛擬DOM的,不在編譯期做,也就是運行時來做了,不過,不會在組件外加額外的函數或容器,只是利用 Form 容器來實現,大家一定想到了,那樣是不是要遍歷所有子節點?這樣會不會有額外的性能開銷?

那就先實現,再優化。

首先,需要遍歷所有子 虛擬DOM 節點,深度優先,判斷節點是否有 data-name 或者 name 屬性,如果有,為該組件附加 valueonChange 屬性,像 checkbox, radio, select 等組件,特殊處理。

綁定value和onChange核心代碼(有刪減)如下:

public bindEvent(value, childList) {
const me = this;
if (!childList || React.Children.count(childList) === 0) {
return;
}
React.Children.forEach(childList, (child) => {
if (!child.props) {
return;
}
const { children, onChange } = child.props;
const bind = child.props['data-name'];
const rules = child.props['data-rules'];
// 分析節點類型,獲取對應的屬性名是value,還是checked等
const valuePropName = me.getValuePropName(child);
if (bind) {
child.props[valuePropName] = value[bind];
if (!onChange) {                
child.props.onChange = me.onFieldChange.bind(me, bind, valuePropName);
}
}
me.bindEvent(value, children);
});
}

onFieldChange的代碼:

public onFieldChange(fieldName, valuePropName, e) {
const me = this;
const {
onChange = () => null,
onFieldChange = () => null,
} = me.props;
let value;
if (e.target) {
value = e.target[valuePropName];
} else {
value = e;
}
me.updateValue(fieldName, value, () => {
onFieldChange(e);
const allValues = me.state.formData.value;
onChange(allValues);
})
}

上面代碼即便實現了我們的目標,不用手綁 valueonChange 了。

演示:

基於React實現高度簡潔的Form表單方案

接下來是實現表單驗證,表單驗證,還是沿用了 ant-design 的實現,使用async-validator這個庫來做,配置方式和 ant-design 是一樣的。為了顯示驗證的錯誤信息,加入了 FormItem 容器,使用方式也貼近 ant-design

FormItem 的實現使用 React 的 Context API,具體可以查看實現源碼,因為不是本文重點,就不說了。

ant-design 一樣,只要是實現 valueonChange 接口的組件,都可以在這裡使用,不限於原生的 HTML 組件。

關於性能的疑慮

通過上面的代碼實現我們想要的目標,不過,還是有疑問的地方:這個每次渲染都深度遍歷子節點,會不會有性能問題?

答案是:影響微乎其微

通過測試,1000 以內的表單控件感受不到差別。1000 個子組件對 React 來說,diff算法開銷也很大的。

不過,為了提升性能,我們還是做了優化,加入了虛擬 DOM 緩存

假如我們在首次渲染後,將創建的虛擬 DOM 緩存下來,第二次渲染就不需要需要重新創建了,也不需要深度遍歷節點添加 valueonChange 了,但是為了更新 value,需要獲取具有 data-name 節點的引用,將組件以 data-name 值為 key 放到對象裡,更新的時候通過 data-name 值獲取這個組件,直接更新這個組件的虛擬 DOM 屬性就可以了,直接獲取 DOM 引用更新 DOM,這看起來很 JQuery 吧?

通過上面的優化,性能能提升一倍。

不過,如果表單內組件有動態顯示、隱藏的話,就不能用虛擬DOM緩存了,所以,我們提供了一個屬性 enableDomCache ,它可以是布爾值,也可以是一個函數,參數是之前的表單值,由用戶對當前值和前值比較,來確定下次渲染是否使用緩存。不過,只有遇到性能問題的時候可以考慮用它,多數時候沒有性能問題,這個 enableDomCache 默認設置為 false

示例:

import {Form} form 'form';
export default class FormDemo extends Component<any, any> {
state = {
value: {
name: '',
school: '',
},
}
onFormChange = (value) => {
this.setState({
value,
});
}
onFormSubmit = () => {
// console.log('submit')
}
enableDomCache=(preValue)=>{
const me=this;
const {
value,
} = me.state;
if(preValue.showSchool!==value.showSchool){
return false;
}
return true;
}
render(){
const me=this;
const {
value,
} = me.state;
return (
<Form 
value={value}
enableDomCache={me.enableDomCache}
onChange={me.onFormChange} 
onSubmit={me.onFormSubmit}
>
<input 
data-name={`name`} 
data-rules={[ { max: 3, message: '最大長度3', } ]} 
type="text" 
/>
{
value.showSchool&&(
<input 
data-name={`school`} 
data-rules={[ { max: 3, message: '最大長度3', } ]} 
type="text" 
/>
)
}
</Form>
)
}
}

關於字段的分佈式管理思考

如果每次表單的字段修改,都會導致整個表單重新渲染,確實不夠完美,所以會有字段分佈式管理的想法。

可以考慮給表單加個 reduxstore ,每個表單項組件訂閱 store,維護自己的數據狀態,表單項之間互不影響,這樣表單字段就是分佈式的了,store 存儲了最新的表單數據。

不過,大多數時候,即使重新渲染,用戶也體會不到其中的差別,ant-design 就是重新渲染,這裡說的重新渲染,是重新 render 創建虛擬 DOM,其實 React 進行 diff 後,真是的DOM並未全部渲染。

當然,為了追求完美,避免 React 進行 diff,那就是最好了,所以對於表單內的重型組件,考慮利用 shouldComponentUpdate 進行更新控制,用過 Redux 同學都知道,connect 高階組件內部是做了屬性的對比來控制組件是否更新的。

還有一點,受控組件和非受控組件的影響,如果表單本身是受控組件,那麼它的屬性改變,肯定導致本身的重新渲染計算,所以要想更好的性能,最好是使用非受控組件模式,這個還是要看具體需要,因為目前多數時候,狀態都會選擇全局狀態,非受控組件不會因為外部狀態改變而更新,所以可能會有UI狀態和全局狀態不一致的可能,如果表單數據的修改只有表單本身來控制,那就可以放心使用非受控模式了。

補充,不論是受控和非受控,都可以利用 shouldComponentUpdate 進行組件本身的優化。

關於表單嵌套

在之前的文章討論中,看到用戶對錶單嵌套的需求,這個想起來不難,只要表單本身符合 value onChange 接口,那麼表單也可以嵌套表單了,就像下面這樣:

import {Form,Input} form 'form';
export default class FormDemo extends Component {
render(){
const me=this;
const {
value,
} = me.state;
return (
<Form value={value} onChange={me.onFormChange} onSubmit={me.onFormSubmit} >
<input  data-name="name" type="text" />
<Input  data-name="school" type="text" />
<Form name="children1">
<input  data-name="name" type="text" />
<Input  data-name="school" type="text" />
<Form name="children2">
<input  data-name="name" type="text" />
<Input  data-name="school" type="text" />
<Form name="children3">
<input  data-name="name" type="text" />
<Input  data-name="school" type="text" />
</Form>
<Form name="children4">
<input  data-name="name" type="text" />
<Input  data-name="school" type="text" />
</Form>
</Form>
</Form>
</Form>
)
}
}

演示:

基於React實現高度簡潔的Form表單方案

雖然實現了表單嵌套,但是這個實現是有問題的,子表單的數據變更,會沿著 onChange 方法逐級向上傳遞,當數據量大,嵌套層級深的時候,會有性能問題。

嵌套表單數據變更演示:

基於React實現高度簡潔的Form表單方案

最好類似於字段的分佈式管理一樣,每個表單只負責自己的渲染,不會導致其他表單重新渲染,為了提升性能,我們進行了優化,提供了 FormGroup 容器,這個容器可以遍歷 Form 節點,構建 Form 節點的引用關係,為每個 Form 生成一個唯一 ID,將所有 Form 的狀態統一由 FormGroup 的 state 管理,相當於進行了扁平化,而不是像原來一樣,子級 FormValue 由父級的來管理。

狀態偏平化後,每個表單的變更只會導致自身重新渲染,不影響其他表單。

演示:

基於React實現高度簡潔的Form表單方案

但是,上面的優化僅限於非受控狀態下,因為受控狀態下,還是要由外部屬性傳入 valueFormGroup,而內部 value 的和屬性傳入的 value 結構不一致,一個是扁平的結構,一個樹形結構,由樹形結構轉扁平結構的條件不充分,因為不知道表單的嵌套結構,所以 value 的轉換做不到了。

總之,簡單的樹形結構可以不使用 FormGroup 。複雜的可以考慮使用 FormGroup ,並且設置 defaultValue 而不是 value,來使用非受控的模式。

最後

本文嘗試構建了一個更簡潔的表單方案,利用深度遍歷子節點的方法為子組件賦值 value 以及註冊 onChange 事件,表單的書寫上更加貼近原生,更加簡潔,也利用緩存虛擬DOM的方法對深度遍歷子節點這種方式進行了性能優化,嘗試實現表單嵌套,並且利用 FormGroup 容器進行數據更新扁平化,不知道你有沒有收穫。

最後的最後

這看起來很像Vue是吧?,React不像Vue有那麼多指令可以輔助,所以表單這塊會有那麼多的方案來簡化,不過想起來,上面的做法和ast的解析執行很類似,雖然不能編譯期做,但是運行期做也可以,那麼會不會出現一個Template組件,來提供魔法指令?

然後寫出下面的代碼:

<Template>
<div v-if={true}>
{name}
</div>
<div v-show={true}>
<div/>
</div>
</Template>

文章僅供參考,提供解決問題的思路,歡迎大家評論,謝謝!

相關文章

簡化Redux開發的實踐和思考

如何簡化網絡請求接口開發

如何讓交流代碼成文團隊文化

基於React的滾動條方案