Final Form 設計思路淺析

語言: CN / TW / HK

highlight: vs2015

導讀

為了提高表單開發的效率,筆者開始進行 Form 表單狀態管理工具調研 的工作,並且已經完成了《React Hook Form 設計思路淺析》和《Formik 設計思路淺析》。

又因為 Final Form(以下簡稱 FF)的框架無關性,所以先寫了一篇《Final Form 跨框架 Demo 對比(JS vs React vs Vue)》(以下簡稱《Demo 對比》)進行鋪墊,建議先閱讀一下這篇文章,可以更好的理解本文。

本文是「淺析」系列的最後一篇,接下來就是制定「通用 Form 元件 API 協議」了。

介紹

Final Form 官網的介紹:

Framework agnostic, high performance, subscription-based form state management.

谷歌翻譯:框架無關、高效能、基於訂閱的表單狀態管理。

原生 JS 是最能體現其設計思想的,所以我們以《Demo 對比》中 JS + HTML 的例子來解析,該例改編自 官方 Demo - Vanilla JS,本文只引用程式碼,場景說明及其他詳情見《Demo 對比》一文。

簡單說明下:為了讓 JS 程式碼更聚焦,所以把 formConfig 配置單獨拆出來放到了 constants.js 中,它和 HTML 簡單看一下即可,重點關注 index.js 的程式碼。

HTML

html <form id="form"> <h1>Final Form Demo</h1> <div> <label htmlFor="uname">User Name *</label> <input type="text" name="uname" placeholder="User Name" /> <p>User Name Required!</p> </div> <div> <label htmlFor="pswd">Password *</label> <input type="password" name="pswd" placeholder="Password" /> <p>Password Required!</p> </div> <div> <label htmlFor="confirm">Confirmation *</label> <input type="password" name="confirm" placeholder="Password Confirmation" /> <p>Password Confirmation Required!</p> </div> <div> <button type="submit">Submit</button> <button type="button" id="reset">Reset</button> </div> </form>

constants.js

js export const formConfig = { initialValues: {}, onSubmit(values) { console.log("submiting"); return new Promise((rev) => { setTimeout(() => { console.log("Submit", values); alert(JSON.stringify(values)); rev(); }, 300); }); }, validate(values) { const errors = {}; if (!values.uname) { errors.uname = "User Name Required!"; } if (!values.pswd) { errors.pswd = "Password Required!"; } if (!values.confirm) { errors.confirm = "Password Confirmation Required!"; } if (values.confirm !== values.pswd) { errors.confirm = "Must be same as Password!"; } return errors; }, validateOnBlur: true, };

index.js(重要)

```js import "./style.css"; import { createForm } from "final-form"; import { formConfig } from "./constants"; / Notice 1: createForm / const form = createForm(formConfig);

document.getElementById("form").addEventListener("submit", (event) => { event.preventDefault(); form.submit(); }); document.getElementById("reset").addEventListener("click", () => form.reset());

const registered = {};

function registerField(input) { const { name } = input; / Notice 2: form.registerField / form.registerField( name, (fieldState) => { / Notice 3: fieldState / const { blur, change, error, focus, touched, value } = fieldState; const errorElement = input.nextElementSibling; if (!registered[name]) { // first time, register event listeners input.addEventListener("blur", () => blur()); input.addEventListener("input", (event) => change(event.target.value)); input.addEventListener("focus", () => focus()); registered[name] = true; }

  input.value = value || "";

  // show/hide errors
  if (errorElement) {
    if (touched && error) {
      errorElement.innerHTML = error;
      errorElement.style.display = "block";
    } else {
      errorElement.innerHTML = "";
      errorElement.style.display = "none";
    }
  }
},
{ value: true, error: true, touched: true }

); }

[...document.forms[0]].forEach((input) => { if (input.name) { registerField(input); } }); ``` 程式碼太長不想看,沒關係,我們先捋一下整體思路,然後逐行拆解下。引用《Demo 對比》中的內容,梳理一下 Demo 的程式碼邏輯:

大概的邏輯是:

  1. createForm 傳入 formConfig 生成 form 物件;
  2. 給 button 繫結上 form.submitform.reset 事件;
  3. (核心)宣告一個註冊函式 registerField,入參為 input DOM,內部呼叫 form.registerField 為 input 繫結 blur、input、focus 事件,並控制錯誤提示 DOM 的內容和顯隱
  4. 迴圈 HTML 中所有 input 元件,呼叫 registerField 函式。

FFAPI 非常簡單,可以說核心就只是 createForm,其他的 fieldSubscriptionItemsformSubscriptionItemsARRAY_ERRORFORM_ERROR 不太常用,所以我們只重點解析一下 createForm 和它的核心方法即可,其實還是有一定複雜度的。

API 解析

createForm

js const form = createForm(formConfig); // code... form.submit(); // code... form.reset() // code... form.registerField(...) 從程式碼我們可以看出,這個方法返回一個 form 物件,自帶很多方法,所有的邏輯都是圍繞這個 form 展開的。Final Form 的文件中有明確說明:

Final Form utilizes the well-known Observer pattern to subscribe to updates about specific portions of state.

谷歌翻譯:Final Form 利用著名的觀察者模式來訂閱有關特定狀態部分的更新。

其底層採用了「觀察者」模式的設計,所以這個 form 物件當中一定維護著一個「可訂閱物件」Subject,所有 form.xxx 的方法都能夠訪問 form 的上下文,也就是能夠訪問到 Subject

再說的具體一點,form.submit 在被觸發的時候,有能力更改 Subject 的某些屬性,比如 Subject.isSubmitting,這時所有監聽了該屬性的 Observer 都會做出相應的動作,比如觸發自己的校驗,執行使用者註冊的函式等。

這也解釋了為什麼不能像 <form onSubmit={formConfig.onSubmit} /> 這樣,直接將 onSubmit 的回撥傳給表單元件了,因為在提交之前還有觸發校驗、更改狀態等一系列複雜的邏輯。說的高階點,是有一整套生命週期要經歷的,這也是表單狀態管理元件的核心功能,也是難點。通過 createForm 處理之後,返回的 form.submit 就具有了在觸發 onSubmit 邏輯之前觸發校驗的能力,其他方法也類似。

入參(Config)有 8 個:debug、destroyOnUnregister、initialValues、keepDirtyOnReinitialize、mutators、onSubmit、validate、validateOnBlur,都不需要太多解釋,具體可檢視文件

返回值(FormApi)有 19 個,絕大多數是 change、blur、submit、reset 這種執行函式,具體可以檢視文件。這裡面有一個最重要的方法要單獨拿出來說一下,那就是 registerField

FormApi.registerField

這可以算是 FF 最核心的功能了,簡單描述其功能就是:表單輸入元件(field)的註冊。field 的相關邏輯非常複雜,比如 輸入時觸發其它 field 的校驗、沒被訪問過時不觸發校驗、顯示/隱藏錯誤提示 等,其實絕大多數都是跟校驗相關。但是多種觸發校驗的方式、校驗規則的聯動依賴、同/非同步校驗同時存在的情況,決定了幾乎是一個 n*n*n 複雜度的問題,這也是我們需要表單狀態管理工具的原因。

說回 registerField,它接收 4 個引數: ts ( name: string, subscriber: FieldState => void, subscription: { [string]: boolean }, config?: FieldConfig ) => Unsubscribe 其它引數比較好理解,檢視文件即可,我們重點看一下第二個引數 subscriber。它是一個 register 註冊函式,自帶 fieldState 入參(上下文),從 Demo 來簡單看一下這個入參的能力: js const { blur, change, error, focus, touched, value } = fieldState; 除了上述展示的屬性外,它的屬性還有很多一共有 20 多個,能力十分強大,詳見文件。為了方便理解,我們簡單將其分為 2 類:一類是 blur、change、focus執行函式,毫無疑問是用來傳給表單輸入元件的「onXXX」這種回撥 API 的。另一類是 error、touched、value狀態資料,主要用來判斷、輸出。

這些上下文都是提供給使用者去實現自己想要的註冊邏輯的,也就是 register 註冊函式函式體裡的內容。在 Demo 中就是為 input 繫結事件,控制錯誤提示等程式碼。這個 register 函式理論上會在各種觸發條件下執行,比如 blur、change、submit 等時機。試著推測一下其底層實現,方式有兩種:

  1. 每次執行 blur、change、focus 等方法(handleFunc),會改變 value、state 等值(reactive values),同時執行 register 函式,以觸發顯示/隱藏錯誤資訊等功能。即:handleFunc => reactive values + handleFunc => register
  2. handleFunc 觸發 value、state 等值的變化,register 函式監聽 value、state 的變化,從而間接觸發了 register。即: handleFunc => reactive values => register

handleFucreactive values 特別多時,第 2 種實現方式在擴充套件性和維護性上就具有明顯的優勢了,它可以讓新增功能的複雜度維持在常數級,而第 1 種複雜度至少是線性遞增的。再加上官方文件中的描述,筆者大膽推測 FF 採用的是第 2 種方式。篇幅關係,實現細節就不展開了,有機會再說。

對於 Array 的處理

FF 沒有特別的對 Array 值的處理,這點與 React Hook FormFormik 不同,後兩者都提供了 insert、push、swap、pop、move 等運算元組的方法。如果從根本上來說,這些方法本質上都是觸發 change 邏輯而已,如果框架不提供,那就需要在所有承載 Array 的元件內部去實現這些陣列操作邏輯,然後主動呼叫 change 函式。如果將這些方法的實現邏輯收斂到框架中,無疑會節省很多重複勞動,理論上更易用,更合理。所以筆者大膽建議,可以在 FieldState 中再多返回一個 arrayHandlers,其中附帶各種運算元組的方法。

當然,如果落地到實際使用,是可以自己二次封裝實現的,封裝成 hooks 或者 render props,甚至自己新擴充套件一個 FormApi.registerArrayField 都可以。

總結

3 個表單狀態管理框架的淺析(另見《React Hook Form 設計思路淺析》和《Formik 設計思路淺析》)終於都寫完了,收穫頗多,現總結部分結論如下: - React Form Hook 無疑是目前最符合 hooks 時代的框架。只是概念稍微有點多,其核心概念 control 其實不太好理解; - Formik 作為先驅者,提供了基本的設計思路,比如對於 Array 資料的處理。但是在 hooks 時代,Render Props 的痕跡太重,有點不太「時尚」,關鍵在 Array 的 hook 實現上還存在瑕疵,所以被 RFH 追趕並在不遠的將來超越,也是可以理解得了; - Final Form 是最能體現表單狀態管理本質的框架,因為其用原生 JS 實現了框架無關,所以最觸及根本。但是這也限制了其易用性與時尚性。不過還是可以以其為底層,自己封裝更好用的工具的。

不過筆者還是有個疑惑:為什麼 Vue 生態中沒有一個 stars 和 download 資料比較高的表單狀態管理工具呢?也許是筆者沒有查到,有相關資訊的朋友請留言知悉,筆者感激不盡。

接下來就是設計通用的表單元件 API 了,目前來看參考 Final Form 的引數設計的可能性比較大,比較它的概念最少,設計相對最清晰,各位敬請期待。

“在激烈競爭中,取勝的系統在最大化或者最小化一個或幾個變數上會走到近乎荒謬的極端。”

"In the fierce competition, the winning system will go to the absurd extreme in the maximization or minimization of one or several variables"——Charlie Thomas Munger