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

janryWang發表於2019-04-19
經過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實現,寫了很多很多重複而複雜的麵條程式碼,包括,表單的資料收集,欄位校驗等等,程式碼可維護性極低,最終迫使我開始真正深入表單領域,探索最佳的表單解決方案。

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

問題

1. 效能問題

因為都是基於React的傳統單向資料流管理狀態的思路來實現的表單解決方案,那麼也會受單向資料流副作用影響,具體的副作用就是,一個UI元件的資料更新,會影響其他UI元件的重新渲染,其實其他UI元件並不需要重新渲染,也許你會說,React有shouldComponentUpdate API來控制元件的渲染,所以,你需要對每個元件的props做diff判斷,是選用淺比較還是深比較還得仔細斟酌,同時,如果是粗暴的使用shouldComponentUpdate API的話,還很有可能出現以下問題:

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屬性。

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

還有,在React最新的React Hooks體系中是沒法處理shouldComponentUpdate的,只有一個React.memo API,但是它只是對屬性做淺比較,這樣來看,就好像是React官方自己把進一步效能優化的路給堵死了似的,其實主要原因是因為React官方推崇Immutable資料,所有資料操作需要嚴格走Immutable的方式做資料操作,但是對於通用元件而言,為了保證元件魯棒性,一般都不會假定使用者傳Immutable的屬性,最終,你到底要不要堅持單向資料流管理一切的資料呢?效能問題卻已經擺在了面前。

所以,很多表單解決方案就開始放任React全域性rerender,就像只要滿足官方推薦的單向資料流方式就是一種政治正確一樣。

2. 程式碼可維護性問題

說到程式碼可維護性,我們需要判斷,什麼樣的程式碼才是可維護的。可以大致總結一下,一般具有可維護性的程式碼都有以下特徵:

  • 程式碼風格是與團隊程式碼風格一致的,這個可以通過eslint做約束
  • 程式碼是模組化的,不應該一個檔案包含所有業務邏輯,必須做物理拆分
  • 邏輯是分層的,Service是一層,View是一層,核心業務邏輯是一層,可以參考MV*,每一層不應該摻雜其他層的職能
  • 檢視是元件化的,將通用檢視互動邏輯層層抽象封裝,進一步簡化核心檢視複雜度

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

  • 要求使用到表單的業務元件統一使用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


相關文章