通用 Form API 協議 - 基礎版

語言: CN / TW / HK

highlight: vs2015

背景

筆者的目標非常明確,就是「提高中後臺系統的開發效率」,目前經歷了 3 個階段(背景有點長,但是很重要):

第一階段,由於業務需要,先著手進行了 開箱即用的工具 - BI 的調研,最終得到的結論是首選 DataEase,次選 Metabase。但是 BI 只是中後臺系統的一部分,這點成果還遠遠不夠,於是進入了第二階段。

第二階段,進行了 低程式碼漫談 系列調研,希望能夠通過現有的低程式碼平臺大幅提升開發效率。但是,整體引入一個低程式碼平臺,對於現有的開發流程來說不太現實,最大的問題就是現有的專案程式碼不好處理。筆者之前有一段低程式碼產品的開發經歷,所以還是有一定認知深度的,於是開始進行更深一步的研究,希望能夠從更抽象的底層尋求更合適的解決方案。

研究發現,百度的 amis 和阿里的 lowcode-engine 文件非常完善,後者甚至出了一本白皮書。在細心研習了一番之後有了比較大的收穫和啟發(amis 核心概念淺析lowcode-engine 協議淺析),筆者意識到:

低程式碼產品最重要的技術核心是協議

如何理解這句話呢?表面看起來,低程式碼產品是通過視覺化拖拽操作生成 APP。這個過程中最核心的一步就是:拖拽畫布輸出的產物(資料),輸入到 APP 生成器,最終生成 APP。這個「產物」非常重要,通常需要網路傳輸,所以都會選擇 JSON 格式,而這個 JSON 攜帶並表達了整個 APP 的資訊

大家知道,一個 APP 是非常複雜的,科學合理的設計好這個 JSON 是非常難的,而這個 JSON 的格式就是所謂的「協議」。為了更直觀,我們舉一個 lowcode-engine 的 Demo 例子: ```jsx import ReactRenderer from '@ali/lowcode-react-renderer'; import ReactDOM from 'react-dom'; import { Button } from '@alifd/next';

/ 符合協議格式的 schema / const schema = { componentName: 'Page', props: {}, children: [ { componentName: 'Button', props: { type: 'primary', style: { color: '#2077ff' }, }, children: '確定', }, ], };

const components = { Button, };

/ 傳入 ReactRenderer 就能渲染出 APP / ReactDOM.render(( ), document.getElementById('root')); `` 在 Demo 中,只需要傳入一個樹狀結構(符合協議)的 schema 和一個元件列表,ReactRender` 就可以將整個 APP 渲染出來,供人使用。

有了以上認知,一個解決思路就出現了: 1. 首先,協議是語言無關的,所以無論前端專案用的 React、Vue 甚至後端直出的模板,都可以應用; 2. 再者,協議的實現可以用多種方式提效,比如低程式碼產品使用的方式是 可拖拽畫布。說到底,協議就是一個大 JSON,我們還可以用表單、程式碼片段甚至外掛來高效的生成它。 但是!上來就搞這麼大的動作幾乎是不可能成功的。所以理論要想落地,還是要有一定規劃和里程碑的,通常不變形地落地才是工程的最大難點,於是來到了第三階段。

第三階段,也就是現階段。路要一步一步走,飯要一口一口吃。經過慎重思考,筆者決定要走的第一步,就是先提升表單開發的效率。表單和列表佔據了中後臺系統絕大部分的內容,所以如果提升了表單的開發效率,實際上對於整體開發效率的提升還是有一定效果的。於是筆者進行了 表單狀態管理 的調研,分析了現在主流表單狀態管理框架的設計思路後,結合低程式碼協議的思路,整理成了本文。旨在制定一個 框架無關的、未來可以方便的擴充套件成低程式碼協議表單協議,來指導表單元件的封裝,從而全範圍提效。

目標

  1. 統一所有前端技術棧(React、Vue、甚至 RN)下的表單開發方式,只需要編寫符合協議的 JS Object 配置,傳入封裝好的表單元件即可,即配置化
  2. 制定出表單元件的 API 以及具體格式,以承接符合協議的配置,指導元件的封裝;

正文

分析

筆者在《Form 元件 API 對比 - AntD vs Element vs Naive》中總結到:

  • 元件分 2 級FormField(或 Item),二者的 API 分類比較類似;
  • API 分 2 類UI 樣式類功能類
  • UI 樣式類,基本上處理好 layout、label、validateMessage 三方面就夠了,其它複雜的佈局,就交給自定義元件來兜底;
  • 功能類,基本上都可以歸到表單狀態管理的範疇裡。如果用過 React Hook Form、Formik、Final Form 之類的表單狀態管理工具,就會發現幾乎全部的功能類 API 都能對上;

UI 類:因為筆者的目的是配置化,那麼配置資訊的易讀性就更加重要,所以筆者會盡量將同一類 API,集合到一個物件中。比如 label-width、label-align 等,都聚合到 label 物件當中,大概如下: ts interface FormLabel { suffix?: string; // ":" visible?: boolean; // true placement?: "left" | "top"; // "left" requiredMark?: "left" | "right" | "hidden"; // "right" style?: CSSProperties; }

功能類:理論上參考表單狀態管理工具的 API 就行,再細一點就是 useFormuseField 之類的入參。但是,調研的三個工具 API 的設計還是有比較大的區別的,所以筆者會在設計時取交集,作為最重要的 API,其他的會視情況進行取捨和聚合。

Form Props

| 引數 | 說明 | 型別 | 預設值 | | ---- | ---- | ---- | ------ | | initialValues | 表單預設值,只有初始化以及重置時生效 | object | - | | onSubmit | 提交表單的回撥事件 | function(values) | - | | validateTriggers | 統一設定欄位觸發驗證的時機 | Array\<"onChange" \| "onBlur"> | [ "onChange" ] | | formEvents | 除 onSubmit 外的其他回撥事件,詳見下文 | FormEvents | - | | labelOptions | label 樣式相關配置,詳見下文 | LabelOptions | - | | layoutOptions | 佈局相關配置,詳見下文 | FormLayout | - | | validateMessageOptions | 驗證提示相關配置,詳見下文 | ValidateMessageOptions | - |

FormEvents

| 引數 | 說明 | 型別 | 預設值 | | ---- | ---- | ---- | ------ | | onValuesChange | 欄位值更新時觸發回撥事件 | function(changedValues, allValues) | - | | beforeValidate | 觸發校驗之前的回撥事件,返回 false 則停止後續邏輯 | function(values): boolean | - | | afterValidate | 觸發校驗之後的回撥事件 | function(values) | - | | beforeSubmit | 表單提交之前的回撥事件,返回 false 則停止後續邏輯 | function(values): boolean | - | | afterSubmit | 表單提交之後的回撥事件 | function(values) | - |

LabelOptions

| 引數 | 說明 | 型別 | 預設值 | | ---- | ---- | ---- | ------ | | visible | label 標籤是否可見 | boolean | true | | suffix | label 標籤字尾 | string | ":" | | placement | label 標籤位置 | "left" \| "top" | "left" | | requiredMark | 表示「必選」的 * 位置 | "left" \| "right" \| "hidden" | "right" | | style | label 標籤的樣式 | CSSProperties | - |

FormLayout

| 引數 | 說明 | 型別 | 預設值 | | ---- | ---- | ---- | ------ | | align | 垂直對齊方式 | "top" \| "middle" \| "bottom" | "top" | | gutter | 柵格間隔,單位 px | number | 0 | | justify | 水平排列方式 | "start" \| "end" \| "center" \| "space-around" \| "space-between" \| "space-evenly" | "start" | | offset | 柵格左側的間隔格數,間隔內不可以有柵格 | number | 0 | | pull | 柵格向左移動格數 | number | 0 | | push | 柵格向右移動格數 | number | 0 | | size | 元件尺寸 | "mini" \| "small" \| "medium" \| "large" | "medium" | | span | 柵格佔位格數 | number | 24 | | wrap | 是否自動換行 | boolean | true |

ValidateMessageOptions

| 引數 | 說明 | 型別 | 預設值 | | ---- | ---- | ---- | ------ | | visible | 驗證提示是否可見 | boolean | true | | placement | 驗證提示位置 | "right" \| "bottom" | "bottom" | | validateFirst | 只顯示第一條驗證提示 | boolean | false | | style | validate message 的樣式 | CSSProperties | - |

Field Props

| 引數 | 說明 | 型別 | 預設值 | | ---- | ---- | ---- | ------ | | name | 欄位標識,具有唯一性 | string | - | | rules | 校驗規則 | Array<Rule> | - | | validateTriggers | 統一設定欄位觸發驗證的時機 | Array<"onChange" \| "onBlur"> | ["onChange"] | | labelOptions | label 樣式相關配置 | LabelOptions | - | | layoutOptions | 佈局相關配置 | Omit<FormLayout, "gutter" \| "wrap"> | - | | validateMessageOptions | 驗證提示相關配置 | Omit<ValidateMessageOptions, "validateFirst"> | - | | trigger | 設定收集欄位值變更的時機 | string | "onChange" | | valueOptions | 對於 value 的預處理,詳見下文 | ValueOptions | - | | fieldEvents | 表單域其他回撥事件 | FieldEvents | - | | dependences | | string[] | - |

ValueOptions

| 引數 | 說明 | 型別 | 預設值 | | ---- | ---- | ---- | ------ | | formatOutput | 對元件輸出後的 value 進行處理 | function(value): any | - | | parseInput | 傳入元件之前,對 value 的預處理邏輯 | function(value): any | - | | propName | 子節點的值的屬性,如 Switch 的是 "checked" | string | "value" |

FieldEvents

| 引數 | 說明 | 型別 | 預設值 | | ---- | ---- | ---- | ------ | | before/afterChange | change 之前/後的回撥函式 | function(value, values) | - | | before/afterBlur | blur 之前/後的回撥函式 | function(value, values) | - | | before/afterFocus | focus 之前/後的回撥函式 | function(value, values) | - | | before/afterSubmit | submit 之前/後的回撥函式 | function(value, values) | - |

協議

注意,因為 Form 也有可能作為 Field,即子表單的情況,所以協議必須是一個「完全遞迴」的結構,參考 lowcode-engine 的協議 結構,定義如下: ```ts interface FieldSchema { componentName: string; props: { fieldProps?: FieldProps, // ...other private props }; children?: Array; }

// example: const schema = { componentName: "FormRender", props: { labelOptions: { suffix: "", placement: "top", }, onSubmit(values) { console.log(values); }, }, children: [ { componentName: "Input", props: { fieldProps: { name: "username", rules: [{ required: true }, { min: 5 }], }, }, }, { componentName: "Input", props: { fieldProps: { name: "password", rules: [{ min: 8 }, { pattern: /[0-9a-zA-Z]{0,8}/ }], }, type: "password", placeholder: "Please input set a password", }, }, ], };

`` 所以,只要實現FormRender` 元件,能夠遞迴動態解析 schema 即可。這似乎不是特別難,只是細節會多一些,有了協議在,只需要按照協議來實現就行了。

但是!請注意!這裡結束還很遠,甚至只是剛剛開始!我為什麼這麼說呢?

完整版協議預告

我們來看一個例子: image.png 初看佈局特別複雜,這個問題其實比較好解決,上述的協議已經可以靠 layoutOptions 的配置來解決了。實際上最難處理的是一些非 Field 的自定義元件,比如 Sub Title,Notice Icon,甚至還有一個 Tabs 元件。這些元件都是不需要 fieldProps 屬性的,相當於表單狀態管理不關心的元件。它們要怎麼用協議表示?

另外,還可能有展開/收起的模組。我們當然可以自定義一個這樣的「容器元件」,但是無疑成本會比較大。而且還有不可窮盡的其他「小互動」需求,這些都涉及到了區域性狀態(比如 state.expand)管理,這個區域性狀態實際上與表單狀態也是無關的。這個功能協議怎麼表示?

我們稍微抽象一下,其實 Form 可以看成一個小型的頁面,理論上裡面有可能出現任何佈局和元素,不僅僅只有 Field 元素。這意味著如果想要實現完美的 Form 配置化,其復難度與實現頁面的配置化差不多了,而實現了頁面配置化也基本等於實現了低程式碼了,看來事情比筆者最初想象的要複雜啊。

不過既然涉及到頁面配置化了,那麼 lowcode-engine 的協議 也就派上用場了,由於內容過於複雜,所以會另起一篇。

總結

表單之所以複雜,主要取決於兩個維度:UI 和狀態管理。前者參考業內目前比較流行的 UI 框架,後者參考業內比較流行的表單狀態管理框架,再考慮到易用性,就產生了本文的協議。

另外,由於暫時不需要跨端或進行網路傳輸,所以協議採用了 JS Object 的語法表示,可以承載表示式、函式甚至元件等資訊。如果用 JSON 實現這些,協議的結構會複雜很多。不過可以保證的是,即使有一天需要把現在的協議轉化成 JSON,也是資訊完備的,用 AST 的工具就可以完成自動轉換。

最後,正如結尾的預告說的。本文實際上只完成了「表單配置化協議」的基礎部分,只適用於非常簡單的表單場景,雖然估計這些簡單場景已經佔到了實際情況的 60% 以上,但終究是不完美。再考慮到後續還要實現「列表配置化協議」,現在協議結構的包容性明顯是不夠的,所以接下來就是協議的升級了,敬請期待。

“謹記,你是在尋找最好的答案,而不是你自己能得出的最好答案。”——Ray Dalio