1. 程式人生 > >用 Rx 實現 Redux Form

用 Rx 實現 Redux Form

背景

form 可以說是 web 開發中的最大的難題之一。跟普通的元件相比,form 具有以下幾個特點:

  1. 更多的使用者互動。 這意味著可能需要大量的自定義元件,比如 DataPicker,Upload,AutoComplete 等等。
  2. 頻繁的狀態改變。 每當使用者輸入一個值,都可能會對應用狀態造成改變,從而需要更新表單元素或者顯示錯誤資訊。
  3. 表單校驗,也就是對使用者輸入資料的有效性進行驗證。 表單驗證的形式也很多,比如邊輸入邊驗證,失去焦點後驗證,或者在提交表單之前驗證等等。
  4. 非同步網路通訊。 當用戶輸入和非同步網路通訊同時存在時,需要考慮的東西就更多了。就比如AutoComplete,需要根據使用者的輸入去非同步獲取相應的資料,如果使用者每輸入一次就發起一次請求,會對資源造成很大浪費。因為每一次輸入都是非同步
    獲取資料的,那麼連續兩次使用者輸入拿到的資料也有可能存在 "後發先至" 的問題。

正因為以上這些特點,使 form 的開發變得困難重重。在接下來的章節中,我們會將 RxJS 和 Form 結合起來,幫助我們更好的去解決這些問題。

HTML Form

在實現我們自己的 Form 元件之前,讓我們先來參考一下原生的 HTML Form。

儲存表單狀態

對於一個 Form 元件來說,需要儲存所有表單元素的資訊(如 value, validity 等),HTML Form 也不例外。 那麼,HTML Form 將表單狀態儲存在什麼地方?如何才能獲取表單元素資訊?

主要有以下幾種方法:

  1. document.forms
    會返回所有
    表單節點。
  2. HTMLFormElement.elements 返回所有表單元素。
  3. event.target.elements
document.forms[0].elements[0].value; // 獲取第一個 form 中第一個表單元素的值

const form = document.querySelector("form");
form.elements[0].value; 

form.addEventListener('submit', function(event) {
  console.log(event.target.elements[0
].value); }); 複製程式碼

Validation

表單校驗的型別一般分為兩種:

  1. 內建表單校驗。預設會在提交表單的時候自動觸發。通過設定 novalidate 屬性可以關閉瀏覽器的自動校驗。
  2. JavaScript 校驗。
<form novalidate>
  <input name='username' required/>
  <input name='password' type='password' required minlength="6" maxlength="6"/>
  <input name='email' type='email'/>
  <input type='submit' value='submit'/>
</form>
複製程式碼

存在的問題

  • 定製化很難。 比如不支援 Inline Validation,只有 submit 時才能校驗表單,且 error message 的樣式不能自定義。
  • 難以應對複雜場景。比如 表單元素的巢狀等。
  • Input 元件的行為不統一,從而難以獲取表單元素的值。 比如 checkbox 和 multiple select,取值的時候不能直接取 value,還需要額外的轉換。
var $form = document.querySelector('form');

function getFormValues(form) {
  var values = {};
  var elements = form.elements; // elemtns is an array-like object

  for (var i = 0; i < elements.length; i++) {
    var input = elements[i];
    if (input.name) {
      switch (input.type.toLowerCase()) {
        case 'checkbox':
          if (input.checked) {
            values[input.name] = input.checked;
          }
          break;
        case 'select-multiple':
          values[input.name] = values[input.name] || [];
          for (var j = 0; j < input.length; j++) {
            if (input[j].selected) {
              values[input.name].push(input[j].value);
            }
          }
          break;
        default:
          values[input.name] = input.value;
          break;
      }
    }

  }

  return values;
}

$form.addEventListener('submit', function(event) {
  event.preventDefault();
  getFormValues(event.target);
  console.log(event.target.elements);
  console.log(getFormValues(event.target));
});
複製程式碼

設計思路

原始碼: github.com/reeli/react…

formState 資料結構

Form 元件中也需要一個 State,用來儲存所有 Field 的狀態,這個 State 就是 formState。

那麼 formState 的結構應該如何定義呢?

在最早的版本中,formState 的結構是長下面這個樣子的:

interface IFormState {
  [fieldName: string]: {
    dirty?: boolean;
    touched?: boolean;
    visited?: boolean;
    error?: TError;
    value: string;
  };
}
複製程式碼

formState 是一個物件,它以 fieldName 為 key,以一個 儲存了 Field 狀態的物件作為它的 value。

看起來沒毛病對吧?

但是。。。。。

最後 formState 的結構卻變成了下面這樣:

interface IFormState {
  fields: {
    [fieldName: string]: {
      dirty?: boolean;
      touched?: boolean;
      visited?: boolean;
      error?: string | undefined;
    };
  };
  values: {
    [fieldName: string]: any;
  };
}

複製程式碼

Note: fields 中不包含 filed value,只有 field 的一些狀態資訊。values 中只有 field values。

為什麼呢???

其實在實現最基本的 Form 和 Field 元件時,以上兩種資料結構都可行。

那問題到底出在哪兒?

這裡先買個關子,目前你只需要知道 formState 的資料結構長什麼樣就可以了。

資料流

rx-form-flow.svg | center | 635x621

為了更好的理解資料流,讓我們來看一個簡單的例子。我們有一個 Form 元件,它的內部包含了一個 Field 元件,在 Field 元件內部又包含了一個 Text Input。資料流可能是像下面這樣的:

  1. 使用者在輸入框中輸入一個字元。
  2. Input 的 onChange 事件會被 Trigger。
  3. Field 的 onChange Action 會被 Dispatch。
  4. 根據 Field 的 onChange Action 對 formState 進行修改。
  5. Form State 更新之後會通知 Field 的觀察者。
  6. Field 的觀察者將當前 Field 的 State pick 出來,如果發現有更新則 setState,如果沒有更新則什麼都不做。
  7. setState 會使 Field rerender,新的 Field Value 就可以通知給 Input 了。

核心元件

首先,我們需要建立兩個基本元件,一個 Field 元件,一個 Form 元件。

Field 元件

Field 元件是連線 Form 元件和表單元素的中間層。它的作用是讓 Input 元件的職責更單一。有了它之後,Input 只需要做顯示就可以了,不需要再關心其他複雜邏輯(validate/normalize等)。況且,對於 Input 元件來說,不僅可以用在 Form 元件中,也可以用在 Form 元件之外的地方(有些地方可能並不需要 validate 等邏輯),所以 Field 這一層的抽象還是非常重要的。

  • 攔截和轉換。 format/parse/normalize。
  • 表單校驗。 參考 HTML Form 的表單校驗,我們可以把 validation 放在 Field 元件上,通過組合驗證規則來適應不同的需求。
  • 觸發 field 狀態的 改變(如 touched,visited)
  • 給子元件提供所需資訊。 向下提供 Field 的狀態 (error, touched, visited...),以及用於表單元素繫結事件的回撥函式 (onChange,onBlur...)。

利用 RxJS 的特性來控制 Field 元件的更新,減少不必要的 rerender。 與 Form 進行通訊。 當 Field 狀態發生變化時,需要通知 Form。在 Form 中改變了某個 Field 的狀態,也需要通知給 Field。

onChange => onValueChange 對比 Formik 的 handleChange,無法支援自定義的 Input。

Form 元件

  • 管理表單狀態。 Form 元件將表單狀態提供給 Field,當 Field 發生變化時通知 Form。
  • 提供 formValues。
  • 在表單校驗失敗的時候,阻止表單的提交。

通知 Field 每一次 Form State 的變化。 在 Form 中會建立一個 formSubject$,每一次 Form State 的變化都會向 formSubject$ 上傳送一個數據,每一個 Field 都會註冊成為 formSubject$ 的觀察者。也就是說 Field 知道 Form State 的每一次變化,因此可以決定在適當的時候進行更新。 當 FormAction 發生變化時,通知給 Field。 比如 startSubmit。

元件之間的通訊

  1. Form 和 Field 通訊 Context 主要用於跨級元件通訊。在實際開發中,Form 和 Field 之間可能會跨級,因此我們需要用 Context 來保證 Form 和 Field 的通訊。Form 通過 context 將其 instance 方法和 formState 提供給 Field。

  2. Field 和 Form 通訊 Form 元件會向 Field 元件提供一個 d ispatch 方法,用於 Field 和 Form 進行通訊。所有 Field 的狀態和值都由 Form 統一管理。如果期望更新某個 Field 的狀態或值,必須 dispatch 相應的 action。

  3. 表單元素和 Field 通訊 表單元素和 Field 通訊主要是通過回撥函式。Field 會向表單元素提供 onChange,onBlur 等回撥函式。

介面的設計

對於介面的設計來說,簡單清晰是很重要的。所以 Field 只保留了必要的屬性,沒有將表單元素需要的其他屬性通過 Field 透傳下去,而是交給表單元素自己去定義。

通過 Child Render,將對應的狀態和方法提供給子元件,結構和層級更加清晰了。

Field:

type TValidator = (value: string | boolean) => string | undefined;

interface IFieldProps {
  children: (props: IFieldInnerProps)=> React.ReactNode;
  name: string;
  defaultValue?: any;
  validate?: TValidator | TValidator[];
}

複製程式碼

Form:

interface IRxFormProps {
  children: (props: IRxFormInnerProps) => React.ReactNode;
  initialValues?: {
      [fieldName: string]: any;
  }
}
複製程式碼

到這裡,一個最最基本的 Form 就完成了。接下來我們會在它的基礎上進行一些擴充套件,以滿足更多複雜的業務場景。

Enhance

FieldArray

FieldArray 主要用於渲染多組 Fields。

回到我們之前的那個問題,為什麼要把 formState 的結構分為 fileds 和 values?

其實問題就出在 FieldArray,

  • 初始長度由 initLength 或者 formValues 決定。
  • 刪除第一個導致最後一個刪除的問題。整體更新。

FormValues

通過 RxJS,我們將 Field 更新的粒度控制到了最小,也就是說如果一個 Field 的 Value 發生變化,不會導致 Form 元件和其他 Feild 元件 rerender。

既然 Field 只能感知自己的 value 變化,那麼問題就來了,如何實現 Field 之間的聯動?

於是 FormValues 元件就應運而生了。

每當 formValues 發生變化,FormValues 元件會就把新的 formValues 通知給子元件。也就是說如果你使用了 FormValues 元件,那麼每一次 formValues 的變化都會導致 FormValues 元件以及它的子元件 rerender,因此不建議大範圍使用,否則可能帶來效能問題。

總之,在使用 FormValues 的時候,最好把它放到一個影響範圍最小的地方。也就是說,當 formValues 發生變化時,讓儘可能少的元件 rerender。

在下面的程式碼中,FieldB 的顯示與否需要根據 FieldA 的 value 來判斷,那麼你只需要將 FormValues 作用於 FIeldA 和 FieldB 就可以了。

<FormValues>
    {({ formValues, updateFormValues }) => (
        <>
            <FieldA name="A" />
            {!!formValues.A && <FieldB name="B" />}
        </>
    )}
</FormValues>
複製程式碼

FormSection

FormSection 主要是用於將一組 Fields group 起來,以便在複用在多個 form 中複用。主要是通過給 Field 和 FieldArray 的 name 新增字首來實現的。

那麼怎樣給 Field 和 FieldArray 的 name 新增字首呢?

我首先想到的是通過 React.Children 拿到子元件的 name,再和 FormSection 的 name 拼接起來。

但是,FormSection 和 Field 有可能不是父子關係!因為 Field 元件還可以被抽成一個獨立的元件。因此,存在跨級元件通訊的問題。

沒錯!跨級元件通訊我們還是會用到 context。不過這裡我們需要先從 FormConsumer 中拿到對應的 context value,再通過 Provider 將 prefix 提供給 Consumer。這時 Field/FieldArray 通過 Consumer 拿到的就是 FormSection 中的 Provider 提供的值,而不再是由 Form 元件的 Provider 所提供。因為 Consumer 會消費離自己最近的那個 Provider 提供的值。

<FormConsumer>
  {(formContextValue) => {
    return (
      <FormProvider
        value={{
          ...formContextValue,
          fieldPrefix: `${formContextValue.fieldPrefix || ""}${name}.`,
        }}
      >
        {children}
      </FormProvider>
    );
  }}
</FormConsumer>
複製程式碼

測試

Unit Test

主要用於工具類方法。

Integration Test

主要用於 Field,FieldArray 等元件。因為它們不能脫離 Form 獨立存在,所以無法對其使用單元測試。

Note: 在測試中,無法直接修改 instance 上的某一個屬性,以為 React 將 props 上面的節點都設定成了 readonly (通過 Object.defineProperty 方法)。 但是可以通過整體設定 props 繞過。

instance.props = {
  ...instance.props,
  subscribeFormAction: mockSubscribeFormAction,
  dispatch: mockDispatch,
};
複製程式碼

Auto Fill Form Util

如果專案中的表單過多,那麼對於 QA 測試來說無疑是一個負擔。這個時候我們希望能夠有一個自動填表單的工具,來幫助我們提高測試的效率。

在寫這個工具的時候,我們需要模擬 Input 事件。

input.value = 'v';
const event = new Event('input', {bubbles: true});
input.dispatchEvent(event);
複製程式碼

我們的期望是,通過上面的程式碼去模擬 DOM 的 input 事件,然後觸發 React 的 onChange 事件。但是 React 的 onChange 事件卻沒有被觸發。因此無法給 input 元素設定 value。

因為 ReactDOM 在模擬 onChange 事件的時候有一個邏輯:只有當 input 的 value 改變,ReactDOM 才會產生 onChange 事件。

React 16+ 會覆寫 input value setter,具體可以參考 ReactDOM 的 inputValueTracking。因此我們只需要拿到原始的 value setter,call 呼叫就行了。

const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
nativeInputValueSetter.call(input, "v");

const event = new Event("input", { bubbles: true});
input.dispatchEvent(event);

複製程式碼

Debug

列印 Log

在 Dev 環境中,可以通過 Log 來進行 Debug。目前在 Dev 環境下會自動列印 Log,其他環境則不會列印 Log。 Log 的資訊主要包括: prevState, action, nextState。

Note: 由於 prevState, action, nextState 都是 Object,所以別忘了在列印的時候呼叫 cloneDeep,否則無法保證最後打印出來的值的正確性,也就是說最後得到的結果可能不是列印的那一時刻的值。

最後

Github 地址: github.com/reeli/react…