formily-面向中後臺場景的複雜解決方案

leomYili發表於2020-08-28

正文

在解決企業級應用的前端問題中,表單是個無法繞過的大山,正好最近有時間,調研一下 Formily-來自阿里巴巴的面向中後臺複雜場景的表單解決方案,也是一個表單框架,前身是 UForm.主要解決如何更好的管理表單邏輯,更好的保證表單效能,以及展望未來,讓非技術人員高效開發表單頁面.可以檢視相關連結 https://github.com/alibaba/formily,目前 issues 已關閉了 419 項,還有 21 項待處理,總的來說應該是有潛力的,先 fork,慢慢讀.

準備寫三篇詳細進行介紹,而本篇是簡單介紹以及例項,先會用再看原始碼

本文所涉及到的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 , 它所能起到的作用對於我們目前來說就是承上啟下的一個很重要的特性.會讓我們不再專門針對業務來寫表單,而是通過這種方式達到抽象建模的能力,併為之後的工程化提供了良好的基礎.

當不再針對業務去思考問題,而是站在更高的維度去思考前端如何結合業務場景快速提高生產環境的效率,那麼才能走的更遠.

更多關於這方面的延展,可以參考前端早早聊大會的相關議題 前端搞搭建 的相關內容.

相關文章