設定式表單渲染器的實現

2023-05-10 12:00:55

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。。

本文作者:奇銘(掘金)

需求背景

前段時間,離線計算產品接到改造資料同步表單的需求。
一方面,資料同步模組的程式碼可讀性和可維護性比較差,相對應的,在資料同步模組開發新功能和定位問題的效率很低;另一方面,整體規劃上,希望在對接新的資料來源時,可以不再關心表單渲染相關問題,從資料來源中心新建資料來源一直到資料來源在資料同步模組的應用全鏈路的表單都可以通過設定化的方式解決;

資料同步表單

資料同步模組整體上分為四個部分,資料來源表單,同步目標表單,欄位對映元件和通道控制表單,

其中前三個部分對應的程式碼非常混亂,程式碼量也很大,單個元件程式碼 5000+ 行。這裡著重說一下資料來源表單和同步目標表單。

資料同步來源和目標表單的主要功能就是收集資料來源對應的設定資訊,並且根據資料來源型別的不同,對應需要渲染的表單項也不相同。

目前離線計算產品資料同步功能的資料來源有多達 50種,在長時間的迭代過程中,日積月累就出現了很多強行復用的程式碼,這些強行復用的程式碼內部又包含著大量的 if else 邏輯;

另外,資料同步模組的表單內部有很多聯動關係,比如:

  • 某個表單項的值變化時,需要發起介面請求,請求的返回值被用作另一個表單項下拉框的資料
  • 某個表單項的值變化時,需要去清空/重置其他一些表單項的值
  • 某個表單項的值變化時,需要顯示/隱藏某個表單項
  • 某個表單項的值變化時,某個表單項的 label 文案、表單項元件(比如從 select 變成 input ) 等隨之發生變化

需求分析

基於上述需求背景,表單渲染器的核心功能是輸入一份設定,輸出表單UI元件。

基於上述資料同步表單背景,我們希望渲染器可以儘可能吸收掉表單內部的複雜度,也就是說在表單的設定中要能夠描述上述的聯動關係

那麼可以大概得出表單的設定需要描述:

  1. 表單項的基礎資訊,比如欄位名、label、表單元件、校驗資訊等
  2. 表單項資料之間的聯動
  3. 表單項UI的聯動(控制顯示/隱藏)
  4. 表單項的值變化時需要觸發的副作用(比如呼叫介面)

表單基礎資訊描述

這裡設定格式使用 JSON 格式,用一個陣列描述所有的表單項資訊,UI 上表單項的渲染順序即設定陣列中表單項設定的順序。表單元件使用 Ant Design Form.

對於表單項基礎資訊的描述設定,大多可以直接搬用 Ant Design Form Item 的 props,比如 label、rules、tooltip 等屬性。這裡不多贅述。比較特殊的是,需要在設定裡描述表單項描述的 UI 元件,比如 Select、Input。 那麼這裡使用 widget 欄位去描述,另外,元件的描述除了元件名稱,還需要描述元件的 props, 所以還需要一個 widgetProps 欄位去描述元件的屬性,比如 placeholder、disabled 等。

那麼一個用於選擇資料來源的表單項應該這樣描述:

{
  "fieldName": "sourceId",
  "label": "資料來源",
  "rules": [
    {
      "required": true,
      "message": "請選擇資料來源!",
    },
  ],
  "widget": "Select",
  "widgetProps": {
    "placeholder": "請選擇資料來源",
    "options": [
      {
        "lable": "資料來源1",
        "value": 1
      }
    ]
  },
}

當然可能會存在某些表單項的UI元件有自定義的情況,比如可編輯表格,程式碼編輯器等。這個時候就需要開發自定義表單元件了,然後把這些元件注入到 formRenderer 中,虛擬碼如下所示

import { Editor, EditableTable } from './customWigets'

export const getWidets = (widgetsName) => {
    switch(widgetsName) {
      case 'Editor': {
        return Editor
      }
      case 'EditableTable': {
        return EditableTable
      }
    }
} 

function Form () {
  return (
    <FormRenderer
        getWidets={getWidets}
    />
  )
}

那麼目前的結構如圖所示

這份設定寫到這裡的時候,問題出現了,

  • 無法在設定中描述 onChange、onSelect 等事件回撥函數
  • 相比於 jsx 強大的表達能力,json 中只能表達基本的資料結構,而沒辦法直接表達邏輯。
  • 另外,Select 下拉框的資料可能來源於介面,這種情況在業務中相當常見,這裡也沒辦法表達。
  • 不能自定義表單校驗器,無法支援複雜的 Tootip 提示,比如帶有 a 標籤的 tootip

上述問題產生的根本原因,實際上是 JSON 與 jsx 之間表達能力的差距。但是從另一個角度來講,正因為 JSON 的表達能力和靈活性不如 jsx,所以在用來描述 UI 時,JSON 更不容易導致混亂。

我們先思考如何表達Select 下拉框的資料來源於介面,這裡可以拆解為兩個部分:資料獲取取得介面的返回值並在設定項中表達

資料獲取

實際上,select 下拉框中的資料也並不一定來源於介面,也可能是來源於其他業務資料,所以在設定項描述資料獲取邏輯時,不應該關心資料的來源。

很顯然,資料獲取邏輯需要用 js 描述 ,這裡我們抽象出一個 Service 的概念,用於描述/宣告資料獲取邏輯,Service 的宣告使用 js,在 JSON 設定中,只需要去描述 Service 的呼叫邏輯即可
對於 JSON 設定來說, Service 呼叫需要三個要素:

  • Service 的標識/名稱,表示哪一個 Service 被觸發
  • Service 的觸發時機
  • Service 返回的資料如何儲存

Service 的觸發時機

Service 的觸發一般來說是由於使用者的互動引起的,當然也存在在表單項元件掛載時就需要觸發的情況,那麼呼叫時機大概就是以下幾種:

  • onMount
  • onChange
  • onSearch
  • onFocus
  • onBlur

Service 返回的資料如何儲存

這裡 Service 返回的資料儲存需要能被 UI 獲取到,那麼需要將返回的資料都維護在 FormRender 內部,這裡將儲存資料的地方命名為 extraData,那麼我們描述 Service 返回的資料的儲存,可以使用一個 fieldInExtraData 的欄位,描述當前 service 返回的資料被儲存在 extraData 的那個欄位中,取值時:extraData[fieldInExtraData]

那麼在表單項設定中描述 Service,如下所示

{
  "serviceName": "getSourceList",
  "triggers": ["onMount", "onSearch"],
  "fieldInExtraData": "schemaList"
}

Service 的宣告

對於 Service 本身來說,要做的事情就是獲取並處理資料然後返回,當然 Service 本身可能需要接受一些引數比如當前 Form 收集到的資料、Service 是被哪個欄位觸發的、觸發時機是什麼等等,那麼 Service 的格式如下所示

const getSourceList = ({ formData, extraData, trigger, triggerFieldName }) => {
  return Promise((resolve) => {
    resolve(...)
  })
}

由於 Service 可能是非同步的,所以這裡 Service 都返回一個 Promise

然後將所有的 Service 都注入到 FormRenderer 中,FormRenderer 根據表單項設定中宣告的呼叫時機去呼叫Service,整個資料獲取的鏈路就完成了。

獲取 Service 返回值並在設定項中表達

上文中提到,Service 的返回的資料都被儲存在 FormRenderer 內部的 extraData 中,一般情況下如果使用 jsx 當然能很容易的取到對應的值,但是在 JSON 中,是沒辦法表達的。但是我們可以借鑑 jsx 的插值表示式和vue的插值表示式。

<div>{user.name}</div>

在 jsx 中,如果在一對標籤內部寫了一串字串,對應的會有兩種解析策略,第一種是直接識別為字串,第二種如果識別到花括號,則將其視為js表示式。
同理,在JSON 設定中也可以使用這種方式去取值:

{
  "fieldName": "sourceId",
  "label": "資料來源",
  "widget": "Select",
  "widgetProps": {
    "placeholder": "請選擇資料來源",
    "options": "{{ extraData.sourceList }}"
  },
  "triggerServices": [
    {
      "serviceName": "getSourceList",
      "triggers": ["onMount", "onSearch"],
      "fieldInExtraData": "sourceList"
    }
  ]
}

函數表示式

上例中,使用一對花括號宣告函數表示式,表面上是借鑑了 jsx 的插值表示式,但是其實兩者有很大的區別。jsx 的插值表示式是在編譯階段就轉化成了 js 表示式。而在 JSON 中的這種自定義的函數表示式要在執行時轉換,上述的函數表示式只能被轉換為函數執行。即:

"{{ extraData.schemaList  }}"
// 轉化為
const valueGetter = new Function('extraData', 'return extraData.schemaList')

出於安全問題考慮,表示式還需要去被放在一個類似沙箱的環境執行,避免表示式內部修改全域性環境變數。建立簡易沙箱使用 proxy + with + symbol.unscopables 的方式,這裡不展開講解了。最終函數表示式的應用大概是如下形式

function Comp () {
  return <Select options={valueGetter(extraData)} />
}

到目前為止,已經有了兩個新概念:Service函數表示式,回到上文中提到的問題,我們已經解決了 Select 下拉框來源於介面的問題,那麼還剩下如下問題:

  • json 中只能表達基本的資料結構,而沒辦法直接表達邏輯。
  • 無法在設定中描述 onChange、onSelect 等事件回撥函數,也不能自定義表單校驗器,
  • 不能自定義表單校驗器,無法支援複雜的 Tootip 提示,比如帶有 a 標籤的 tootip

json 中沒辦法表達邏輯的問題,其實已經可以通過函數表示式來解決了。函數表示式內部支援寫任意的 js 表示式,另外,在函數表示式中也可以支援存取form表單資料,有了資料支援和邏輯表達能力支援,絕大多數情況下的已經能夠滿足UI 渲染中的邏輯表達了。

而描述 onChange、onSelect 等事件回撥函數可以通過設定 Service 來解決。

自定義表達校驗器可以通過函數表示式的變種來解決,可以向 formRenderer 中注入 form 校驗器的集合,然後通過 {{ ruleMap.xxx }} 來指定表單項的某一條校驗規則的校驗器

{
  "fieldName": "sourceId",
  "label": "資料來源",
  "rules": [
    {
    	validator: "{{ ruleMap.checkSourceId }}"
    },
  ],
}

tooltip 提示也是如此。
目前結構如下圖所示

表單資料聯動

表單資料聯動實際上就是當表單中某個表單項值變化時,去重置其他表單項的值,那麼要在設定中描述這種聯動關係有兩種方式

  1. 當前欄位受哪些欄位的影響
  2. 當前欄位的值變化會影響到哪些欄位

一般情況下,在程式碼中描述這種邏輯時都是採用第二種方式,也就是監聽某個欄位的值的變化,然後在回撥函數中去做對應的資料聯動操作。

但是在設定json時,第二種方式就變得不那麼友好了,那會讓欄位設定之間產生更多的耦合,更加友好的方式是在某個欄位內表達本欄位受到哪些欄位的影響,這樣做的另一個好處時,當開發者填寫或者修改某一個欄位的設定時,可以更加聚焦,不用關心其他欄位的設定。

這裡用 dependecies 欄位來表達當前欄位的值受哪些欄位的影響。舉個例子,表單中有資料來源、schema、table 三個欄位,資料來源變化時,schema 的值應該被重置;schema 變化時,table 的值會被重置。那麼在 json 中應該這樣描述:

[
    {fieldName: 'sourceId', dependencies: []},
    {fieldName: 'schema', dependencies: ['sourceId']},
    {fieldName: 'table', dependencies: ['schema']},
]

對應的依賴關係圖:

這裡新的問題產生了,當資料來源變化時,table 的值是否要被重置?一般情況下是肯定的,那麼實際上它們的依賴關係是這樣的:

這裡有兩種方式來解決這種隱式的依賴關係

  1. 開發者在設定時顯式的宣告所有的依賴關係
  2. 渲染器內部解析依賴關係時,將這種隱式的依賴關係也解析出來

那麼如何選擇使用哪一種方式呢?

如果採用第一種方式,優點是渲染器不再需要關心這種隱式的依賴關係了,但是在設定時的心智負擔可能比較大,很容易出現漏配依賴關係的情況。

如果採用第二種方式,優點是設定起來心智負擔低,但是也有可能出現 table 確實不依賴 sourceId 的情況,也就是間接依賴不生效的情況

結合實際業務看,目前的業務中,所有的欄位之間間接依賴其實都是隱式依賴,也就是需要生效的,這裡採用第二種方式,前文中也提到了,期望是 formRenderer 可以儘可能的吸收掉表單內部的複雜度。

特殊的表單資料聯動

在實際業務中還存在著一些比較特殊的表單資料聯動,比如

  • 選擇資料來源時,除了需要收集資料來源的 id,還需要收集資料來源型別
  • 選擇資料來源後,需要將資料來源的其他資訊展示為表單項,比如下圖中的表單

對於這種業務場景,我們可以理解為某個表單項的值是由其他表單項的值派生出來的,那麼就需要去描述這種派生邏輯。當然,這種派生邏輯可以在業務程式碼中描述,只需要在資料來源變化時,手動的 setFieldValue 就可以了。但是還是上文中提到的期望,formRenderer 可以儘可能吸收掉複雜度。

處理這種情況,需要新增一個設定項去描述派生邏輯,這裡設定項定為 valueDerived,這個設定項的值應該為一個取值表示式,那麼以第一個例子為例,設定應該這樣子:

[
  {
    "fieldName": "sourceId",
    "label": "資料來源",
    "widget": "Select",
    "widgetProps": {
      "placeholder": "請選擇資料來源",
      "options": "{{ extraData.sourceList }}"
    },
  },
  {
    "fieldName": "sourceType",
    "label": "資料來源型別",
    "hidden": true,
    "valueDerived": "{{ extraData.sourceList.find(s => s.value === formData.sourceId).type }}",
  },
]

formRenderer 內部根據設定的 valueDerived 去自動更新表單中對應欄位的值

表單UI聯動

表單UI聯動可以分為兩個部分:

  • 表單項UI文案、樣式等根據資料聯動。
  • 表單項 UI 的顯示與隱藏

表單項UI文案、樣式等根據資料聯動

表單項的 UI 聯動在 React 和 JSX 中,都能很輕易、很自然的發生。但是想要在 JSON 中描述,由於JSON本身不具備表達邏輯的能力,還是要藉助函數表示式。只需要支援對應的設定項可以使用函數表示式就能完成表單項的聯動。舉個例子:

[
  {
    "fieldName": "time",
    "label": "{{ extraData.type === 1 ? '開始時間' : '結束時間' }}",
    "widget": "Input",
    "widgetProps": {
      "placeholder":"{{ extraData.type === 1 ? '請輸入開始時間' : '請輸入結束時間' }}",
    },
  }
]

那麼它們實際渲染時等同於以下虛擬碼

function Comp (props) {
  const {fieldName, label, widget, widgetProps, extraData} = props
  
  const form = useFormInstance()
  const formData = form.getFieldsValue()
  
  const tarnsformer = (configItem) => {
    const fn = new Function('formData', 'extraData', `return $[configItem}`)
    return fn.call(null, formData, extraData)
  }

  return 
    <Form.Item
      name=fieldName
      label={tarnsformer(label)}
    >
    	<widget  placeholder={tarnsformer(widgetProps.placeholder)}/>
  	</Form.Item>
}

這樣就能做到表單項的文案樣式等根據資料變化自然的聯動。

表單項的顯示與隱藏

表單項的隱藏也能拆分為兩種情況

  • 隱藏但不銷燬,表單項的值仍然會被收集和保留
  • 銷燬,不再保留/收集表單項的值

隱藏但不銷燬的情況,antd form 本身就有 hidden 設定支援,那麼這裡只需要支援 hidden 設定使用函數表示式就可以了。

對於表單項的銷燬,就需要新增一個欄位了,這裡命名為 destory,同樣通過支援使用函數表示式完成聯動,但是這裡需要考慮一些其他情況。比如從銷燬狀態變成顯示狀態時,需要去觸發 mount service 等。

思路小結

回顧上文需求分析中所說的需要實現的功能

  1. 表單項的基礎資訊,比如欄位名、label、表單元件、校驗資訊等
  2. 表單項資料之間的聯動
  3. 表單項UI的聯動(控制顯示/隱藏)
  4. 表單項的值變化時需要觸發的副作用(比如呼叫介面)

目前在思路上,都是有上述功能都是可以實現的。除了基礎的渲染功能以外,FormRender 要額外實現的功能有

  1. 內建一個 extraData 儲存 Service 返回的資料
  2. 支援根據設定在正確的時機觸發 Service
  3. 支援函數表示式
  4. 支援根據設定在內部處理資料聯動邏輯

大體實現

整體上,匯出一個 FormRenderer 元件,上文中提到的 json config、Service宣告、自定義的表單校驗器,自定義表單項元件等,都通過 FormRenderer 的 props 傳入。

內建 extraData

由於 extraData 內部儲存的資料變化可能導致檢視更新,那麼只能使用 React.Context 或者 state,事實上即使使用 Context 也還是需要宣告 state 來觸發檢視更新,但是 Conetxt 在傳遞資料時有著獨特的優勢,這裡直接使用 Context 儲存資料。

// 避免閉包問題
export function useExtraData(init: IExtraDataType) {
    const stateRef = useRef<IExtraDataType>(init);
    const [_, updateState] = useReducer((preState, action) => {
        stateRef.current =
            typeof action === 'function'
                ? { ...action(preState) }
                : { ...action };
        return stateRef.current;
    }, init);

    return [stateRef, updateState] as const;
}

// 建立context
const ExtraContext = React.createContext<ExtraContextType>({
    extraDataRef: { current: { } },
    update: () => void 0,
});

使用

import { useExtraData, ExtraContext } from 'extraDataContext.ts'

const FormRenderer: React.FC = () => {
  const [extraDataRef, updateExtraData] = useExtraData({});
  // ....
  return(
    <ExtraContext.Provider
      value={{ extraDataRef, update: updateExtraData }}
    >
      {....}
    </ExtraContext.Provider>
  )     
}

在正確的時機觸發 Service

在JSON 設定中 service 相關描述如下所示

[
  {
    "fieldName": "sourceId",
    "label": "資料來源",
    "triggerServices": [
      {
        "serviceName": "getSourceList",
        "triggers": ["onMount", "onSearch"],
        "fieldInExtraData": "sourceList"
      },
      {
        "serviceName": "getSchemaList",
        "triggers": ["onChange"],
        "fieldInExtraData": "schemaList"
      },
    ]
  }
]

triggerServices 已經很清楚直觀的描述了,該欄位在什麼時機應該呼叫哪個 service,在程式碼實現上,為了這部分觸發邏輯與檢視渲染分離,採用釋出訂閱模式。大體流程如下圖所示:

這裡流程已經走通了,但是可以發現,renderer 中仍然需要去處理訂閱的邏輯,Service 觸發邏輯與檢視渲染邏輯分離的不夠徹底,那麼可以繼續優化一下,加入一個訂閱器去處理這部分邏輯,優化後的邏輯如下圖所示:

支援函數表示式

上文中提到了,函數表示式的實現是用 new Function,以及處於安全問題考慮需要將函數表示式放到模擬沙箱環境中執行,執行流程如下所示

實現程式碼如下所示(不包含正則處理)

class FnExpressionTransformer {
    private sandboxProxiesMap: WeakMap<ScopeType, InstanceType<typeof Proxy>> =
        new WeakMap();

    private createProxy(scopeObj: ScopeType) {
        /** 儲存建立的 proxy 避免重複建立 */
        if (this.sandboxProxiesMap.has(scopeObj)) {
            return this.sandboxProxiesMap.get(scopeObj);
        }
        const scope = {
            extraData: scopeObj.extraDataRef,
            formData: scopeObj.formData,
            Math: Math,
            Date: Date,
        };
        const proxy = new Proxy(scope, {
            has() {
                return true;
            },
            get(target, prop) {
                if (prop === Symbol.unscopables) return undefined;
                if (prop === 'extraData') {
                    return target[prop]['current'];
                }
                return target[prop];
            },
        });
        this.sandboxProxiesMap.set(scopeObj, proxy);
        return proxy;
    }

    transform = (code: string): TransformedFnType => {
        return (scope: ScopeType) => {
            const proxy = this.createProxy(scope);
            const fnBody = `with(scope) {  return ${code} }`;
            const fn = new Function('scope', fnBody);
            return fn(proxy);
        };
    };
}

比如在 label 設定中使用了函數表示式

[
  {
    "fieldName": "name",
    "label": "{{ extraData.xxx ? '使用者名稱' : '暱稱' }}"
  }
]

那麼經過轉換後,就是等同於以下函數

function lableValue (scope) {
  return scope.extraData.xxx;
} 

應用:

<FormItem
  name={name}
  label={lableValue({ formData, extraData })}
>
{/* xxxx */}
</FormItem>

支援根據設定在內部處理資料聯動邏輯

與上文中service 觸發邏輯一樣,將這部分聯動的邏輯通過釋出訂閱與檢視渲染邏輯分離。但是相比於service 觸發邏輯,這裡多了分析依賴的步驟
比如,有如下 json 設定

[
   {fieldName: 'schema', dependencies: []},
   {fieldName: 'table', dependencies: ['schema']},
   {fieldName: 'partition', dependencies: ['schema', 'table']},
   {fieldName: 'coprate', dependencies: ['table', 'partition']}
]

那麼生成的依賴關係圖就應該是:

[
  {fieldName: 'schema', isField: true]},
  {fieldName: 'table', isField: true},
  {fieldName: 'partition', isField: true},
  {fieldName: 'coprate', isField: true},
  {fieldName: 'schema', dependBy: 'table', isRelation: true},
  {fieldName: 'schema', dependBy: 'partition', isRelation: true},
  {fieldName: 'table', dependBy: 'partition', isRelation: true},
  {fieldName: 'table', dependBy: 'coprate', isRelation: true},
  {fieldName: 'partition', dependBy: 'coprate', isRelation: true},
]

生成上述依賴關係後,剩下的流程與觸發service 的流程類似,在這裡不多做贅述了。

總結

回顧本文開頭需求分析部分中提到的需要實現的功能,到目前為止,似乎都實現了。但是其實很容易就產生一些疑問,這個東西它好用嗎?

作為開發者,我很難客觀的評價它好不好用。不過個人認為針對好不好用這個問題,還是有一些客觀條件去評價的

  • 穩定性
  • 可維護性
  • 使用成本

對於穩定性, 目前還沒有在真實業務場景去落地,目前看不出。但是最近正在將部分業務中表單遷移到 FormRenderer,目前給我的感覺不是很穩定,經常需要去修改FormRenderer 的原始碼去修復一些小bug,或者是讓它的某些表現更符合實際的業務場景。貼一個 TODO LIST

這只是一部分,很多修改也沒有記錄在這上面。

可維護性,只針對於文章開頭提到的需求背景來說來說,使用 FormRenderer,顯然比已有的程式碼更容易維護。

使用成本,這個成本要分為多方面來講,首先是學習使用 FormRenderer 的成本,這個成本顯然要比直接用 JSX 和 Antd 去開發表單的成本要高的多,使用者不僅需要了解 Antd 表單的使用,也需要熟悉 FormRenderer 的使用。其次是維護成本,我個人感覺它的維護成本會比資料同步的表單的維護成本要低。最後是開發成本,相比於開發元件,這個表單的開發成本主要體現在沒有自動提示以及糾錯,但是這個問題是可以通過開發一個類似 PlayGround 的線上編輯器去降低的。另外編寫詳細的說明檔案也能顯著降低使用成本。