正文
在解決企業級應用的前端問題中,表單是個無法繞過的大山,正好最近有時間,調研一下 Formily-來自阿里巴巴的面向中后台復雜場景的表單解決方案,也是一個表單框架,前身是 UForm.主要解決如何更好的管理表單邏輯,更好的保證表單性能,以及展望未來,讓非技術人員高效開發表單頁面.可以查看相關鏈接 https://github.com/alibaba/formily,目前 issues 已關閉了 419 項,還有 21 項待處理,總的來說應該是有潛力的,先 fork,慢慢讀.
本篇是簡單介紹以及實例的相關筆記,先會用再看源碼,相關源碼閱讀也會放在個人github上,之后會放出鏈接
本文所涉及到的demo均放在github https://github.com/leomYili/formilyDemo 上.
特點:解決復雜表單問題,跨端,擴展度較好,動態渲染,視圖邏輯分離,支持可視化搭建配置,高性能(聯動)
缺點:學習成本較高,包體積過大,最好根據需求進行選擇,需要二次封裝
本質
構造了一個 Observable Form Graph
從官網的介紹來看,使用了 RxJS,當然這里只是簡略介紹,之后會詳細介紹;
表達式
setFieldState(
Subscribe(
FormLifeCycle, // 表單的生命周期
Selector(Path) // Path是字段路徑,
),
TargetState // 操作具體字段的狀態
)
從表達式上來看, Formily 遵循了 發布訂閱者 的設計模式,希望用戶不再通過業務邏輯去組裝表單,而是通過簡單的調用 api,以及路徑映射模式更清晰簡單的來描述聯動的方式,以及在跨終端場景下實現通用表單解決方案.
架構
這里可以看出 Formily 的野心,也說明了其不是一個簡單的類似 rc-form 的項目,而是以框架的方式呈現出來,野心很大.
對於面向復雜場景的企業管理應用以及工具類型的應用來說,也確實更需要一整套的框架來解決研發效率以及后續擴展問題.
而且,采用與 UI 無關的方式來構建核心,做跨終端也比較簡單,還是期待下通用組件庫吧,目前官方提供的通用庫應該還沒開發完成,但從兼容ant-design
以及fusion-design
等庫來說,基礎框架是有了.從 api 上來看,與 antd v4 的 form 相似程度還是比較高的.
當然,同出一源,Formily 的 API 會更加豐富,學習成本會比 antd 要高.不過 JSON Schema 的使用在一定程度上來說,會比單純的 UI 描述在可維護性,效率,協作上帶來一定的提升,但與之相對的學習成本,反認知都是問題.這里也只是作為其中一種的方案提供出來.主要還是為了之后的動態渲染吧.
開發模式
官方提供了三種開發模式,分別針對
-
純 JSX 開發表單: 用於純前端 jsx 開發方式,自定義表單項以及復合形態居多.
<Form labelCol={7} wrapperCol={12} onSubmit={console.log}> <div style={{ padding: 20, margin: 20, border: '1px solid red' }}> Form組件內部可以隨便插入UI元素了 </div> <FormItem label="String" name="string" component={Input} /> <FormButtonGroup offset={7}> <Submit>提交</Submit> </FormButtonGroup> </Form>
-
JSON Schema 開發表單: 后端動態渲染表單,可視化配置能力.
<SchemaForm components={{ Input }} labelCol={7} wrapperCol={12} onSubmit={console.log} schema={{ type: 'object', properties: { string: { type: 'string', title: 'String', 'x-component': 'Input' } } }} > <FormButtonGroup offset={7}> <Submit>提交</Submit> </FormButtonGroup> </SchemaForm>
-
JSX Schema 開發表單: 用於后端動態渲染表單的過度形態.(過度形態意味着 schema 與 field 可並存,極大的方便了協作與溝通)
<SchemaForm components={{ Input }} labelCol={7} wrapperCol={12} onSubmit={console.log} > <div style={{ padding: 20, margin: 20, border: '1px solid red' }}> 這是一個非Field類標簽,會被挪到最底部渲染 </div> <Field type="string" title="String" name="string" x-component="Input" /> <FormButtonGroup offset={7}> <Submit>提交</Submit> </FormButtonGroup> </SchemaForm>
Formily 的聯動以及生命周期
這里放在一起講,Formily 提供的 schema 屬性與表達式細節請參考文檔.
聯動
這里的聯動分為 schema 協議層面簡單聯動以及 actions/effects 復雜聯動.先說簡單聯動:
聯動協議
使用 x-linkages 屬性,編輯其結構達到聯動配置的效果:
{
"type": "string",
"x-linkages": [
{
"type": "value:visible",
"target": "aa",
"condition": "{{ $self.value === 123 }}"
}
]
}
當然,這里的上下文就尤為重要了,通過合法的上下文參數,才能更好的控制表單項進行聯動.
目前注入的環境變量:
{
...FormProps.expressionScope, //代表SchemaForm屬性上通過expressionScope傳遞下來的上下文
$value, //代表當前字段的值
$self, //代表當前字段的狀態
$form, //代表當前表單實例
$target //代表目標字段狀態
}
包括內置的聯動類型:
- value:visible,由值變化控制指定字段顯示隱藏
- value:schema,由值變化控制指定字段的 schema
- value:state,由值變化控制指定字段的狀態
可以看出這里的語法是以 condition
為核心的,進而控制表單的兩大核心屬性:props 與 state;
進而滿足日常需求,同樣提供了可擴展的方法.
生命周期聯動
通過對於生命周期的理解,就像 react 提供的 component 的生命周期一樣,可以在相應的生命周期里完成各種操作.
詳細的內容在生命周期章節細講
理解表單生命周期
通俗來講,就是因為formily
使用了 RxJS
,之后,返回的 Observer
對象所帶來的能力包括 dispatch 與 notify,可以看出 API 基本保持一致.好處是可以借用 RxJS
的 method,對表單的事件做各種操作,相關內容請參考 https://cn.rx.js.org/class/es6/Observable.js~Observable.html
雖然會帶來學習成本的提高,但相對的,針對復雜系統,使用 RxJS
可以保證清晰的業務邏輯以及良好的性能,所以需要權衡利弊.
formily
已提供了很多內置的事件類型,可分為全局型生命周期觸發事件類型與字段型生命周期觸發事件類型.
當然,既然使用 RxJS
,那么相對應的自定義生命周期的語法也就類似了
自定義生命周期
import SchemaForm, { FormEffectHooks } from '@formily/antd'
const {
/**
* Form LifeCycle
**/
onFormWillInit$, // 表單預初始化觸發
onFormInit$, // 表單初始化觸發
onFormChange$, // 表單變化時觸發
onFormInputChange$, // 表單事件觸發時觸發,用於只監控人工操作
onFormInitialValueChange$, // 表單初始值變化時觸發
onFormReset$, // 表單重置時觸發
onFormSubmit$, // 表單提交時觸發
onFormSubmitStart$, // 表單提交開始時觸發
onFormSubmitEnd$, // 表單提交結束時觸發
onFormMount$, // 表單掛載時觸發
onFormUnmount$, // 表單卸載時觸發
onFormValidateStart$, // 表單校驗開始時觸發
onFormValidateEnd$, //表單校驗結束時觸發
onFormValuesChange$, // 表單值變化時觸發
/**
* FormGraph LifeCycle
**/
onFormGraphChange$, // 表單觀察者樹變化時觸發
/**
* Field LifeCycle
**/
onFieldWillInit$, // 字段預初始化時觸發
onFieldInit$, // 字段初始化時觸發
onFieldChange$, // 字段變化時觸發
onFieldMount$, // 字段掛載時觸發
onFieldUnmount$, // 字段卸載時觸發
onFieldInputChange$, // 字段事件觸發時觸發,用於只監控人工操作
onFieldValueChange$, // 字段值變化時觸發
onFieldInitialValueChange$ // 字段初始值變化時觸發
} = FormEffectHooks
更詳細的內容可以看 https://formilyjs.org/#/0yTeT0/aAIRIjiou6
觸發事件
1.在外部環境中,通過全局綁定的 actions 對象觸發(通過 actions.dispatch 發送自定義事件)
actions.dispatch('custom_event',payload)
2.在 effects 中 const {dispatch} = createFormActions();
dispatch('custom_event',payload)
3.在自定義組件中// 在 useFormEffects 函數中
useFormEffects(($, {notify}) => {
$("onFieldValueChange",'aa').subscribe(()=>{
notify('custom_event',payload)
})
})
// 帶fieldProps的自定義組件中。from可直接從 props中取得。
const { from } = props;
form.notify('custom_event',payload)
//
// 不帶 fieldProps的自定義組件中。需要通過 useField創建from對象
const { form } = useField({});
form.notify('custom_event',payload)
消費事件
消費自定義事件和消費系統事件一樣。觸發事件時參數 payload 中,即為 subscribe 中的傳入參數。payload 中如果有 name 屬性,則監聽時可通過 name 來過濾。
// effects中消費
// 自定義組件內useFormEffects中消費
$('custom_event').subscribe(payload=>{})
$('custom_event','aa').subscribe(payload=>{}) //則payload中必須含有name=aa
actions/effects
這里承接上文的生命周期,提供了除 ref 之外的方式來達到:
- 外部調用組件內部 api 的問題,主要是使用 actions
- 組件內部事件通知外部的問題,同時借助了
RxJS
可以方便的處理異步事件流競態組合問題,主要是使用 effects
這里可以分享兩個使用 RxJS
進行處理的案例:
const customEvent$ = createEffectHook('CUSTOM_EVENT')
const useMultiDepsEffects = () => {
const { setFieldState, dispatch } = createFormActions()
onFormMount$().subscribe(() => {
setTimeout(() => {
dispatch('CUSTOM_EVENT', true)
}, 3000)
})
onFieldValueChange$('aa')
.pipe(combineLatest(customEvent$()))// 使用combineLatest解決生命周期依賴聯動的問題
.subscribe(([{ value, values }, visible]) => {
setFieldState('bb', state => {
state.visible = visible
})
})
})
//借助 merge 操作符對字段初始化和字段值變化的時機進行合流,這樣聯動發生的時機會在初始化和值變化的時候發生
merge(onFieldValueChange$('bb'), onFieldInit$('bb')).subscribe(fieldState => {
if (!fieldState.value) return linkage.hide('cc')
linkage.show('cc')
linkage.value('cc', fieldState.value)
})
}
這是一種類似 react-eva 的分布式狀態管理解決方案,詳情可以參考 https://github.com/janrywang/react-eva
表單路徑系統
路徑系統代表了 Form 與 field 之間的關聯.
這里可以看看匹配語法:
-
全通配:
"*"
-
擴展匹配:
"aaa~" or "~" or "aaa~.bbb.cc"
-
部分通配:
"a.b.*.c.*"
-
分組通配:
"a.b.*(aa.bb.dd,cc,mm)"
-
嵌套分組通配:
"a.b.*(aa.bb.*(aa.b,c),cc,mm)" or "a.b.*(!aa.bb.*(aa.b,c),cc,mm)"
-
范圍通配:
"a.b.*[10:100]" or "a.b.*[10:]" or "a.b.*[:100]"
-
關鍵字通配:
"a.b.[[cc.uu()sss*\\[1222\\]]]"
案例
這里我覺得比較好用的是字段解耦,對 name 用 ES Deconstruction 語法做解構,需要注意的是,不支持...語法:
<Field
type="array"
name="[startDate,endDate]"
title="已解構日期"
required
x-component="DateRangePicker"
/>
與自定義的組件配合達到最佳效果
傳值屬性
在 Formily 中,不管是 SchemaForm 組件還是 Form 組件,都支持 3 個傳值屬性
1.value 受控值屬性
主要用於外部多次渲染同步表單值的場景,但是注意,它不會控制默認值,點擊重置按鈕的時候值會被置空
2.defaultValue 同步初始值屬性
主要用於簡單同步默認值場景,限制性較大,只保證第一次渲染生效,重置不會被置空
3.initialValues 異步初始值屬性
主要用於異步默認值場景,兼容同步默認值,只要在第 N 次渲染,某個字段還沒被設置默認值,第 N+1 次渲染,就可以給其設置默認值
表單狀態
FormState
狀態名 | 描述 | 類型 | 默認值 |
---|---|---|---|
displayName | Form 狀態標識 | string | "FormState" |
modified | 表單 value 是否發生變化 | boolean | false |
valid | 表單是否處於合法態 | boolean | true |
invalid | 表單是否處於非法態,如果校驗失敗則會為 true | boolean | False |
loading | 表單是否處於加載態 | boolean | false |
validating | 表單是否處於校驗中 | boolean | false |
initialized | 表單是否已經初始化 | boolean | false |
submitting | 表單是否正在提交 | boolean | false |
editable | 表單是否可編輯 | boolean | false |
errors | 表單錯誤信息集合 | Array<{ path: string, messages: string[] }> | [] |
warnings | 表單警告信息集合 | Array<{ path: string, messages: string[] }> | [] |
values | 表單值 | object | {} |
initialValues | 表單初始值 | object | {} |
mounted | 表單是否已掛載 | boolean | false |
unmounted | 表單是否已卸載 | boolean | false |
擴展狀態 | 通過 setFormState 可以直接設置擴展狀態 | any |
FieldState
狀態名 | 描述 | 類型 | 默認值 |
---|---|---|---|
displayName | Field 狀態標識 | string | "FieldState" |
dataType | 字段值類型 | "any" | "array" |
name | 字段數據路徑 | string | |
path | 字段節點路徑 | string | |
initialized | 字段是否已經初始化 | boolean | false |
pristine | 字段 value 是否等於 initialValue | boolean | false |
valid | 字段是否合法 | boolean | false |
invalid | 字段是否非法 | boolean | false |
touched | 字段是否被 touch | boolean | false |
visible | 字段是否顯示(如果為 false,字段值不會被提交) | boolean | true |
display | 字段是否 UI 顯示(如果為 false,字段值可以被提交) | boolean | true |
editable | 字段是否可編輯 | boolean | true |
loading | 字段是否處於加載態 | boolean | false |
modified | 字段的 value 是否變化 | boolean | false |
active | 字段是否被激活(onFocus 觸發) | boolean | false |
visited | 字段是否被 visited(onBlur 觸發) | boolean | false |
validating | 字段是否正在校驗 | boolean | false |
values | 字段值集合,value 屬性相當於是 values[0],該集合主要來源於組件的 onChange 事件的回調參數 | any[] | [] |
errors | 字段錯誤消息集合 | string[] | [] |
effectErrors | 人工操作的錯誤消息集合(在 setFieldState 中設置 errors 會被重定向到設置 effectErrors) | string[] | [] |
ruleErrors | 校驗規則的錯誤消息集合 | string[] | [] |
warnings | 字段警告信息集合 | string[] | [] |
effectWarnings | 人工操作的警告信息集合(在 setFieldState 中設置 warnings 會被重定向到設置 effectWarnings) | string[] | [] |
ruleWarnings | 校驗規則的警告信息集合 | string[] | [] |
value | 字段值 | any | |
initialValue | 字段初始值 | any | |
rules | 字段校驗規則 | ValidatePatternRules | [] |
required | 字段是否必填 | boolean | false |
mounted | 字段是否已掛載 | boolean | false |
unmounted | 字段是否已卸載 | boolean | false |
inputed | 字段是否主動輸入過 | true | |
props | 字段擴展 UI 屬性(如果是 Schema 模式,props 代表每個 SchemaField 屬性,如果是 JSX 模式,則代表 FormItem 屬性) | {} | |
擴展狀態 | 通過 setFieldState 可以直接設置擴展狀態 | any |
表單布局
布局各家公司要求都不相同,就不列出來了,比較定制化
表單擴展
擴展性是衡量一個框架的重要指標, formily
提供了很多的擴展入口:
-
擴展 Form UI 組件:
registerFormComponent(props => { return <div>全局擴展Form組件{props.children}</div> }) const formComponent = props => { return <div>實例級擴展Form組件{props.children}</div> } <SchemaForm formComponent={formComponent} components={{ Input }} onSubmit={values => { console.log(values) }} />
-
擴展 FormItem UI 組件
registerFormItemComponent(props => { return <div>全局擴展 FormItem 組件{props.children}</div> }) const formItemComponent = props => { return <div>實例級擴展FormItem組件{props.children}</div> } <SchemaForm formItemComponent={formItemComponent} components={{ Input }} onSubmit={values => { console.log(values) }} />
-
擴展 Field 組件
提供的擴展方式主要有:- SchemaForm 中傳入 components 擴展(要求組件滿足 value/onChange API)
- SchemaForm 中傳入 components 組件擁有 isFieldComponent 靜態屬性,可以拿到 FieldProps, 獲取更多內容,則可以通過 useSchemaProps 方法
- registerFormField 全局注冊擴展組件,要求傳入組件名和具體組件,同時,如果針對滿足 value/onChange 的組件,需要用 connect 包裝,不包裝,需要手動同步狀態(借助 mutators)
- registerFormFields 全局批量注冊擴展組件,同時,如果針對滿足 value/onChange 的組件,需要用 connect 包裝,不包裝,需要手動同步狀態(借助 mutators)
-
擴展 VirtualField 組件
-
擴展校驗模型(規則、文案、模板引擎)
registerValidationMTEngine registerValidationRules setValidationLocale
-
擴展聯動協議
-
擴展生命周期
-
擴展 Effect Hook
-
擴展狀態(FormState/FieldState/VirtualFieldState)擴展狀態的方式主要有以下幾種:
- 直接調用 actions.setFormState/actions.setFieldState 設置狀態,這種方式主要在 Form 組件外部調用,在 effects 里消費
- 使用 useFormState/useFieldState 設置狀態,這種方式主要在自定義組件內部使用,使用這兩個 API,我們可以將狀態掛在 FormGraph 里,這樣就能統一走 FormGraph 對其做時間旅行操作
性能優化
這是官方提供的方法:
大數據場景
推薦使用內置 BigData 數據結構進行包裝
import { BigData, SchemaForm } from '@formily/antd'
const specialStructure = new BigData({
compare(a, b) {
//你可以定制當前大數據的對比函數,也可以不傳,不傳則是引用對比
},
clone(value) {
//你可以定制當前大數據的克隆函數,也可以不傳,如果不傳,拷貝則是引用傳遞
}
})
const App = () => (
<SchemaForm
initialValues={{ aa: specialStructure.create(BigData) }} //注意要保證create傳入的數據是Immutable的數據
effects={$ => {
$('onFieldChange', 'bb').subscribe(() => {
actions.setFieldState('aa', state => {
state.props.enum = specialStructure.create(BigData) //注意要保證create傳入的數據是Immutable的數據
})
})
}}
/>
)
主要原因是 Formily 內部會對狀態做深度拷貝,同時也做了深度遍歷臟檢測,這種方式對於用戶體驗而言是更好了,但是在大數據場景下,就會出現性能問題,借助 BigData 數據結構,我們可以更加定制化的控制臟檢查和拷貝算法,保證特殊場景的性能平滑不受影響.
多字段批量更新
這種場景主要在聯動場景,比如 A 字段要控制 B/C/D/E 等等字段的狀態更新,如果控制的字段數量很少,那么相對而言是收益最高的,但是控制的字段數量很多,100+的字段數量,這樣做,如果還是以精確渲染思路來的話,相當於會執行 100+的渲染次數,同時 Formily 內部其實還會有一些中間狀態,就相當於一次批量更新,會導致 100 * n 的渲染次數,那這樣明顯是起到了反作用,所以,針對這種場景,我們倒不如直接放開,讓表單整樹渲染,一次更新,這樣對於多字段批量操作場景,性能一下子就上來了。下面是具體的 API 使用方法
onFieldValueChange$('aa').subscribe(() => {
actions.hostUpdate(() => {
actions.setFieldState('bb.*', state => {
state.visible = false
})
})
})
使用 hostUpdate 包裝,可以在當前操作中阻止精確更新策略,在所有字段狀態更新完畢之后直接走根組件重渲染策略,從而起到合並渲染的目的.
自增列表組件
這里都使用了 ArrayList https://github.com/alibaba/formily/blob/master/packages/react-shared-components/src/ArrayList.tsx 作為底層庫.
官方提供了兩個案例, ArrayTable 與 ArrayCards 有興趣可以去看看.
這里主要還是想分析一下如何自定義實現一個自增列表組件.
這里使用 IMutators API來完成
屬性名 | 說明 | 類型 | 默認值 |
---|---|---|---|
change | 改變當前行的值 | change(...values: any[]): any | |
focus | 聚焦 | ||
blur | 失焦 | ||
push | 增加一行數據 | (value?: any): any[] | |
pop | 彈出最后一行 | change(...values: any[]): any | |
insert | 插入一行數據 | (index: number, value: any): any[] | |
remove | 刪除某一行 | (index: number | string): any |
unshift | 插入第一行數據 | (value: any): any[] | |
shift | 刪除第一行是數據 | (): any[] | |
exist | 是否存在某一行 | (index?: number | string): boolean |
move | 將指定行數據移動到某一行 | ($from: number, $to: number): any[] | |
moveDown | 將某一行往下移 | (index: number): any[] | |
moveUp | 將某一行往上移 | (index: number): any[] | |
validate | 執行校驗 | (opts?: IFormExtendedValidateFieldOptions): Promise |
案例
可以簡單看下代碼:
結語
formily的思想還是值得借鑒的,不過也正如官網所說,它並不是一個簡單的輪子,而是一套解決方案,所以需要權衡利弊,充分考慮到業務場景是否需要這么復雜的一套方案.
當然,真實用到生產環境時,還需要大量的擴展以及與業務結合,所幸這方面formily提供了完備的擴展方式.
但關鍵還是 schema
,這其實是外部的 DSL
, 它所能起到的作用對於我們目前來說就是承上啟下的一個很重要的特性.會讓我們不再專門針對業務來寫表單,而是通過這種方式達到抽象建模的能力,並為之后的工程化提供了良好的基礎.
當不再針對業務去思考問題,而是站在更高的維度去思考前端如何結合業務場景快速提高生產環境的效率,那么才能走的更遠.
更多關於這方面的延展,可以參考前端早早聊大會的相關議題 前端搞搭建 的相關內容.