用 RxJS 實現 Redux Form

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

寫在前面的話

看這篇文章以前,你須要掌握的知識:html

  • React
  • RxJS (至少須要知道 Subject 是什麼)

背景

form 能夠說是 web 開發中的最大的難題之一。跟普通的組件相比,form 具備如下幾個特色:java

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

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

HTML Form

在實現咱們本身的 Form 組件以前,讓咱們先來參考一下原生的 HTML Form。git

保存表單狀態

對於一個 Form 組件來講,須要保存全部表單元素的信息(如 value, validity 等),HTML Form 也不例外。 那麼,HTML Form 將表單狀態保存在什麼地方?如何才能獲取表單元素信息?github

主要有如下幾種方法:web

  1. document.forms 會返回全部 <form> 表單節點。
  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

表單校驗的類型通常分爲兩種:typescript

  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));
});
複製代碼

React Rx Form

感興趣的同窗能夠先去看一下源碼 github.com/reeli/react…瀏覽器

React 與 RxJS

RxJS 是一個很是強大的數據管理工具,但它並不具有用戶界面渲染的功能,而 React 卻特別擅長處理界面。那何不將它們的長處結合起來?用 React 和 RxJS 來解決咱們的 Form 難題。既然知道了它們各自的長處,因此分工也就比較明確了:網絡

RxJS 負責管理狀態,React 負責渲染界面。

設計思路

與 Redux Form 不一樣的是,咱們不會將 form 的狀態存儲在 store 中,而是直接保存在 <Form/> 組件中。而後利用 RxJS 將數據通知給每個 <Field/> ,而後 <Field/> 組件會根據數據去決定本身是否須要更新 UI,須要更新則調用 setState ,不然什麼也不作。

舉個例子,假設在一個 Form 中有三個 Field (以下),當只有 FieldA 的 value 發生變化時, 爲了避免讓

和 其子組件也 re-render,Redux Form 內部須要經過 shouldComponentUpdate() 去限制。

// 僞代碼
<Form>
    <FieldA/>
    <FieldB/>
    <FieldC/>
</Form>
複製代碼

而 RxJS 能把組件更新的粒度控制到最小,換句話說,就是讓真正須要 re-render 的 <Field/> re-render,而不須要 re-render 的組件不從新渲染 。

核心是 Subject

從上面的設計思路能夠總結出如下兩個問題:

  1. Form 和 Field 是一對多的關係,form 的狀態須要通知給多個 Field。
  2. Field 須要根據數據去修改組件的狀態。

第一個問題,須要的是一個 Observable 的功能,並且是可以支持多播的 Observable。第二個問題須要的是一個 Observer 的功能。在 RxJS 中,既是 Observable 又是 Observer,並且還能實現多播的,不就是 Subject 麼!所以,在實現 Form 時,會大量用到 Subject。

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。

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 組件提供一個 dispatch 方法,用於 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 決定。
  • formState 總體更新。

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,不然沒法保證最後打印出來的值的正確性,也就是說最後獲得的結果可能不是打印的那一時刻的值。

最後

這篇文章只講了關於 React Rx Form 的思路以及一些核心技術,你們也能夠按照這個思路本身去實現一版。固然,也能夠參考一下我寫的,歡迎來提建議和 issue。Github 地址: github.com/reeli/react…

相關文章
相關標籤/搜索