面向複雜場景的高性能表單解決方案

通過3年的洗禮,由阿里供應鏈平臺前端團隊研發打造的UForm終於發佈!🎉🎉🎉
UForm 諧音 Your Form , 表明,這纔是你想要的Form解決方案
高性能,高效率,可擴展,是UForm的三大核心特點
查看完整文檔請戳 alibaba.github.io/uform

源起

還記得4年前,剛入職天貓的時候,接到了一箇中後臺前端需求,是天貓超市的階梯滿減優惠券建立頁面,那時React正開始普及,年輕氣盛的我毅然決然的選擇了使用React來開發,就一個單純的CRUD錄入頁面,使用redux架構硬是把需求給交付了,可是,當時我總共花了15天才開發完成,固然,不排除當時第一次接觸redux,學習曲線較爲陡峭,更重要的是這個頁面的業務邏輯很是複雜,最複雜的部分主要是:前端

  • 階梯規則,List形式
  • 每一層階梯內的同級字段之間有動態聯動,好比:字段A變化,會控制字段B的顯示隱藏
  • 每一層階梯內的同級字段之間有聯動校驗,好比:字段B的值必須大於等於字段A的值
  • 層級與層級之間的字段有聯動校驗,好比第二階梯的字段A的值要大於第一階梯字段B的值

當時實現這樣的需求,沒有用到任何第三方表單解決方案,純用redux實現,寫了不少不少重複而複雜的麪條代碼,包括,表單的數據收集,字段校驗等等,代碼可維護性極低,最終迫使我開始真正深刻表單領域,探索最佳的表單解決方案。react

慢慢的,接觸了集團內部和業界不少優秀的表單解決方案,它們的核心職能都是幫助你自動收集數據,同時搭配了一套完善的校驗模型,讓你真正作到只關心業務邏輯,可是,這些方案都會多多少少存在一些問題。git

問題

1. 性能問題

由於都是基於React的傳統單向數據流管理狀態的思路來實現的表單解決方案,那麼也會受單向數據流反作用影響,具體的反作用就是,一個UI組件的數據更新,會影響其餘UI組件的從新渲染,其實其餘UI組件並不須要從新渲染,也許你會說,React有shouldComponentUpdate API來控制組件的渲染,因此,你須要對每一個組件的props作diff判斷,是選用淺比較仍是深比較還得仔細斟酌,同時,若是是粗暴的使用shouldComponentUpdate API的話,還頗有可能出現如下問題:github

cosnt App = ()=>{
  const [value,setState] = useState()
  return (
      <div>
          <ComponentA>
           <ComponentB value={value}/>
          </ComponentA>
          <button onClick={()=>setState('changed')}>改變value</button>
      </div>
  )
}複製代碼

假如對ComponentA作了shouldComponentUpdate控制,只要ComponentA的屬性沒有發生任何變化,經過setState觸發App組件從新渲染就不會向下觸發ComponentB組件從新渲染,更不會使得ComponentB接收到最新的value屬性。json

就是說,用shouldComponentUpdate API控制渲染對於存在組件嵌套的場景是頗有可能出現子組件沒有正常接收新屬性的狀況的。redux

還有,在React最新的React Hooks體系中是無法處理shouldComponentUpdate的,只有一個React.memo API,可是它只是對屬性作淺比較,這樣來看,就好像是React官方本身把進一步性能優化的路給堵死了似的,其實主要緣由是由於React官方推崇Immutable數據,全部數據操做須要嚴格走Immutable的方式作數據操做,可是對於通用組件而言,爲了保證組件魯棒性,通常都不會假定用戶傳Immutable的屬性,最終,你到底要不要堅持單向數據流管理一切的數據呢?性能問題卻已經擺在了面前。後端

因此,不少表單解決方案就開始聽任React全局rerender,就像只要知足官方推薦的單向數據流方式就是一種政治正確同樣。設計模式

2. 代碼可維護性問題

說到代碼可維護性,咱們須要判斷,什麼樣的代碼纔是可維護的。能夠大體總結一下,通常具備可維護性的代碼都有如下特徵:性能優化

  • 代碼風格是與團隊代碼風格一致的,這個能夠經過eslint作約束
  • 代碼是模塊化的,不該該一個文件包含全部業務邏輯,必須作物理拆分
  • 邏輯是分層的,Service是一層,View是一層,核心業務邏輯是一層,能夠參考MV*,每一層不該該摻雜其餘層的職能
  • 視圖是組件化的,將通用視圖交互邏輯層層抽象封裝,進一步簡化核心視圖複雜度

下面咱們再來看看傳統的表單解決方案,好比Ant Desgin的Form解決方案,下面是它的使用規範:bash

  • 要求使用到表單的業務組件統一使用Form.create()包裝器來對其作包裝
  • 經過props拿到Form實例方法,好比getFieldDecorator,經過getFieldDecorator對錶單字段作再次包裝,用於處理數據收集,數據校驗
  • 聯動邏輯分散在各個具體的表單組件的onChange Handler中,經過this.props.form.setFieldsValue來處理字段間聯動

咱們再想象一下,若是一個表單頁面有不少聯動,咱們將不得不在一個業務組件內寫不少的onChange Handler,最後致使業務組件變得很是臃腫,對於初學者而言,寫大量的onChange Handler是頗有可能直接在jsx中寫匿名函數的,那麼,這樣也會致使jsx層變得很是髒亂差,因此,事件處理邏輯是須要與jsx層作嚴格隔離的,不然代碼的可維護性就堪憂了,固然,對於簡單的場景而言,使用Antd Form是沒任何問題的,不過,Antd Form仍然是採用單向數據流的方式來管理狀態,也就是說,任何字段變更都會致使組件全量渲染,一樣,Fusion Next Form也存在一樣的問題。

3. 表單研發效率問題

說到研發效率,必定就是儘量的讓用戶少寫重複代碼,若是你用Antd Form或者Fusion Next Form,你確定會發現你的組件內部處處都是FormItem組件,處處都是onChange Handler 處處都是{...formItemLayout},這些重複而又低效的代碼實際上是不該該存在的。

4. 後端數據驅動問題

有一些場景,咱們的表單頁面是很是動態化的,某一些字段是否存在,前端徹底感知不到,是由後端建表,由不一樣職業屬性的用戶手工錄入的字段信息,好比電商購物車的表單頁面,交易下單頁面,系統須要千人千面的能力,這樣就須要前端擁有動態化渲染表單的能力了,無論是Antd Form仍是Fusion Next Form都沒有原生就支持這樣的動態化渲染的能力,你只能在上層在封裝一層動態化渲染機制,這樣就得基於某個JSON 協議來驅動渲染,我見過不少不少相似的動態化渲染表單的解決方案,它們所定義的JSON協議都是很是定製化的,或者說不夠標準的,有些根本就沒有考慮全面完備就開始使用,最終致使先後端業務邏輯都變得很是複雜。因此,表單的動態渲染協議最好是標準並且完備的,不然後面的坑是很難填平的。

探索

從上面的幾個問題咱們能夠看出來,在React場景中想要更好的寫出表單頁面是真的很困難,難道,React真的不適合寫表單頁面?

在大量搜索並研究各類表單解決方案以後,本人總算找到了一個能根本上解決性能問題的表單解決方案,就是 final-form , 這個組件是原來 redux-form 的做者從新開發的新型表單解決方案,該方案的思路很是明確,就是每一個字段本身管理狀態,本身作渲染更新,分佈式狀態管理,徹底與redux-form的單向數據流理念背道而馳,可是,收益一會兒就上來了,表單的總體渲染次數大大下降,React的CPU渲染壓力也大大下降,因此,final-form就像它的名字同樣,終結了表單的性能問題。

同時,對於代碼可維護性而言,final-form也有本身的亮點,就是它將表單字段的聯動邏輯作了抽離,在一個獨立的calculate 裏處理,這樣就不會使得業務組件變得很是臃腫。並且,做者對final-form的可擴展設計也是很是清晰的,還有,final-form是一個開箱即用的解決方案,它就是一個殼,經過render props的形式能夠組合使用各類組件庫,總之,final-form解決方案解決了表單領域的大部分問題。

那麼,還有哪些問題final-form是無法解決的呢?本人經過深度研究源碼,用例,同時也結合了我的體會,大體能夠總結一下final-form的問題:

  1. 聯動不能一處編寫,單純calculator不能處理狀態的聯動,好比字段A的值變化會控制字段B的disabled狀態,必須結合jsx層的Field subscription才能作狀態聯動,用戶須要不停的切換寫法,開發體驗較差,好比:codesandbox.io/s/jj94wojl9…
  2. 嵌套數據結構須要手動拼接字段路徑,好比 codesandbox.io/s/8z5jm6x80
  3. 組件內外通信機制過於Hack,好比在外部調用Submit函數 codesandbox.io/s/1y7noyrlm…
  4. 組件外部不能精確控制表單內部的某個字段的狀態更新,除非使用全局rerender的單向數據流機制。
  5. 不支持動態化表單渲染,仍是須要在上層創建一個動態渲染引擎

探索&創新

由於final-form已經解決了咱們的大部分問題,因此能夠在覈心理念層借鑑 final-form,好比字段狀態分佈式管理,基於pub/sub的方式作字段間通信,可是對於final-form所存在的問題,咱們能夠大體梳理出幾個抓手:

  • 反作用獨立管理,主要是對錶單字段狀態管理邏輯,獨立帶來的收益是View層的可維護性提高,同時統一收斂到一處維護,對用戶而言更加友好
  • 嵌套數據結構路徑自動拼接
  • 更加優雅的組件內外通信方式,外部也能精確控制字段的更新
  • 基於標準JSON Schema數據結構作擴展,構建動態表單渲染引擎

最終,咱們能夠推導出解決方案的雛形:JSON Schema + 字段分佈式管理 + 面向複雜通用組件的通信管理方案

JSON Schema描述表單數據結構

爲何採用JSON Schema?咱們主要有幾方面的考慮:

  • 標準化協議,無論是對前端,仍是對後端都是易於理解的通用協議
  • JSON Schema更側重於數據的描述,而非UI的描述,由於表單,它就是數據的輸入,咱們但願,用戶關心的,更可能是數據,而非UI
  • JSON Schema能夠用在各類數據驅動場景,好比可視化搭建引擎中的組件配置器等

什麼是JSchema?

JSchema至關因而在jsx中的json schema描述,由於考慮到純json schema形式對機器友好,但對人類不夠友好,因此,爲了方便用戶更高效的描述表單的結構,咱們在jsx層構建了一個JSchema的描述語言,其實很簡單:

<Field type="Object" name="aaa">
   <Field type="string" name="bbb"/>
   <Field type="array" name="ccc">
      <Field type="object">
          <Field type="string" name="ddd"/>
       </Field> 
   </Field>
</Field>
​
//========轉換後===========
{
   "type":"object",
    "properties":{
        "aaa":{
            "type":"object",
            "properties":{
                "bbb":{
                    "type":"string"
                },
                "ccc":{
                    "type":"array",
                    "items":{
                        "type":"object",
                        "properties":{
                            "ddd":{
                                "type":"string"
                            }
                        }
                    }
                }
            }
        }
    }
    
}複製代碼

是否是發現,使用JSchema描述表單,比單純用JSON Schema描述代碼少了不少,並且也很清晰,因此,咱們將在jsx層使用JSchema,同時組件是既支持JSchema也支持純JSON Schema形式描述表單的。

JSON Schema屬性擴展

由於JSON Schema本來是用於描述數據的,若是直接用在前端裏,將會丟失不少與UI相關的元數據,那麼這些元數據應該怎樣來描述呢?Mozilla的解決方案是專門抽象了一個叫作UI Schema的協議專門來描述表單的UI結構,能夠看看 github.com/mozilla-ser…。看似是將UI與數據分離,很清晰,可是,若是咱們以組件化的思路來看待這個問題的話,一個表單字段的數據描述應該是一個表單字段的組件描述的子集,二者合爲一體則更符合人類思惟,怎麼合,爲了避免污染json-schema本來協議的升級迭代,咱們能夠對數據描述增長x-*屬性,這樣就能兼顧數據描述與UI描述,同時在代碼層面上,用戶也不須要分兩處去作配置,排查問題也會比較方便。

字段狀態分佈式管理

想要理解什麼是字段狀態分佈式管理,首先得理解什麼是單向數據流,還記得React剛開始普及的時候,人人都在討論單向數據流,就跟如今的React Hooks的概念同樣火,當時我也是花了很長時間才理解什麼纔是單向數據流。

其實,單向數據流總結一句話就是:數據同步靠根組件重繪來驅動,子組件重繪受根組件控制

就像前面所說的,單向數據流模式是有性能問題的,因此,咱們能夠考慮使用狀態分佈式管理,再總結一句話,狀態分佈式管理就是:數據同步靠根組件廣播須要更新的子組件重繪,根組件只負責消息分發

其實,前者跟後者仍是有必定的相同之處的,好比根組件都是消息的分發中心,只不過度發的形式不同,一個是靠組件樹重繪來分發消息,一個是經過pub/sub來廣播消息,讓子組件本身重繪,數據流,仍是一箇中心化的管理數據流,只是分發的形式不同,就這樣的差異,卻可讓整個React應用性能提高數倍。

面向複雜通用組件的通信管理方案

對於複雜通用組件的通信管理方案,使用單向數據流機制作管理性能問題是很嚴重的,因此只能再想一想還有沒有其餘方案,其實也不是沒有方案了,ref就是一個很常見的通信方式,可是,它的問題也很明顯,好比容易被HOC給攔截,雖然有了forwardRef API,但仍是寫的很彆扭,並且還增長了組件層級,提高了組件複雜度。

可是,參考ref的設計思路,其實仍是能夠借鑑的,ref,就像它的名字同樣,是做爲一個引用而存在,可是,它只是表明了組件的引用,並無表明組件的API,因此不少人使用ref就會遇到被HOC攔截的問題,並且,使用ref還會存在私有API有可能被使用的風險,因此,對於大多數場景,其實咱們只是須要一個能夠脫離於單向數據流場景的API管理機制,這樣一想,其實就很簡單了,咱們徹底不須要用ref,本身幾行代碼就能實現:

class MyComponent extends React.Component {
    constructor(props){
        super(props)
        this.state = {
            data:{}
        }
        if(props.actions){
            props.actions.getData = ()=>{
                return this.state.data
            }
            props.actions.setData = (data)=>{
                this.setState({data})
            }
        }
    }
}複製代碼

這就是最原始的相似ref的API,在使用組件的時候,咱們只須要

const actions = {}
<div>
   <MyComponent actions={actions} />
    <Button onClick={()=>{
            actions.setData('hello world')
    }}>設置狀態</Button>
</div>複製代碼

就這樣的方案,徹底不會被HOC給攔截,也不會出現私有API會被使用的風險,可是,這個方案是用於外部—>內部的數據流通信,那麼,內部—>外部的數據流通信又該是怎樣的呢?我曾想過就基於本來的onXXX屬性模式,在組件props上暴露出各類響應事件 API,可是,這樣一來,就又會出現我前面提到過的邏輯過於分散致使代碼可維護性下降的問題,參考redux設計模式,它的核心亮點就是:將actions收斂扁平化,將業務邏輯收斂聚合到reducer上,因此,咱們也須要一個收斂聚合業務邏輯的容器來承載,這樣既能提高架構的清晰度,也能提高代碼可維護性。

最後,經過大量的探索實踐,咱們發現,rxjs是很適合事件邏輯的收斂聚合的。因此,咱們能夠大體的實現這樣一個原型

class MyComponent extends React.Component {
    constructor(props){
        super(props)
        this.state = {
            data:{}
        }
        if(props.actions){
            props.actions.getData = ()=>{
                return this.state.data
            }
            props.actions.setData = (data)=>{
                this.setState({data})
            }
        }
        if(typeof props.effects === 'function'){
            this.subscribes = {}
            props.effects(this.selector)
        }
    }
    
    selector = (type)=>{
        if (!this.subscribes[type]) {
          subscribes[type] = new Subject() //rxjs的核心API Subject
        }
        return subscribes[type]
    }
    
    dispatch = (type,payload)=>{
        if(this.subscribes[type]){
            subscribes[type].next(payload)
        }
    }
    
    render(){
        return <div>
             {JSON.stringify(this.state.data)}
             <button onClick={()=>dispatch('onClick','clicked')}>點擊事件觸發</button>
        </div>
    }
}複製代碼

因此,咱們最終使用的時候,只須要

const actions = {}
const effects = ($)=>{
    $('onClick').subscribe(()=>{
        actions.setData('data changed')
    })
}
<div>
   <MyComponent actions={actions} effects={effects} />
    <Button onClick={()=>{
            actions.setData('hello world')
    }}>設置狀態</Button>
</div>複製代碼

就這樣,咱們實現了組件的API與事件收斂的能力,固然,對於一個大型應用,咱們可能會有不少組件,一樣也能夠以相似的模式進行管理狀態:

const actions = {}
const effects = ($)=>{
    $('onClick').subscribe(()=>{
        actions.setData('data changed')
    })
}
<div>
    <MyComponentA actions={actions} effects={effects} />
    <MyComponentB actions={actions} effects={effects} />
    <MyComponentC actions={actions} effects={effects} />
    <Button onClick={()=>{
            actions.setData('hello world')
    }}>設置狀態</Button>
</div>複製代碼

咱們徹底能夠共享同一個actions引用與一個effects處理器,更進一步,咱們能夠把actions與effects以獨立js文件作管理,這樣一來,effects就像redux的reducer同樣了,可是,它比redux能力更增強大,由於結合rxjs,它自然具備解決各類時序型異步問題的能力。相反redux則得藉助redux-saga之類的方案來處理。

好了,前面的都是原型,咱們能夠將這部分邏輯作進一步抽象,最終,便成了 react-eva

沉澱

就這樣,咱們的表單解決方案的三大要素能夠改成:

JSON Schema(JSchema) + 字段分佈式管理 + React EVA

因此,UForm誕生了,它就是嚴格按照以上思路設計出來的,歡迎你們嚐鮮!有問題儘管吐槽吧!

廣告

阿里供應鏈平臺前端,持續招人中… 歡迎簡歷投遞 zhili.wzl@alibaba-inc.com

相關文章
相關標籤/搜索