我們或許不需要 React 的 Form 元件

超級大柱子發表於2019-04-22

在上一篇小甜點 《我們或許不需要 classnames 這個庫》 中, 我們 簡單的使用了一些語法代替了 classnames 這個庫

現在我們調整一下難度, 移除 React 中相對比較複雜的元件: Form 元件

在移除 Form 元件之前, 我們現需要進行一些思考, 為什麼會有 Form 元件及Form元件和 React 狀態管理的關係

注意, 接下來的內容非常容易讓 React 開發人員感到不適, 並且極具爭議性

何時不應該使用受控元件

Angular, Vue, 都有雙向繫結, 而 React 官方文件也為一個 input 標籤的雙向繫結給了一個官方方案 - 受控元件:

reactjs.org/docs/forms.…

本文中提到的程式碼都可以直接貼上至專案中進行驗證.

// 以下是官方的受控元件例子:
class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};

    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(event) {
    this.setState({value: event.target.value});
  }

  handleSubmit(event) {
    alert('A name was submitted: ' + this.state.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" value={this.state.value} onChange={this.handleChange} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}
複製程式碼

相信寫過 React 專案的人都已經非常熟練, 受控元件就是: 把一個 input 的 value 和 onChange 關聯到某一個狀態中.

很長一段時間, 使用受控元件, 我們都會受到以下幾個困惑:

  1. 針對較多表單內容的頁面, 編寫受控元件繁瑣
  2. 跨元件的受控元件需要使用 onChange 等 props 擊鼓傳花, 層層傳遞, 這種情況下做表單聯動就會變得麻煩

社群對以上的解決方案是提供一些表單元件, 比較常用的有:

包括我自己也編寫過 Form 元件

它們解決了以下幾個問題:

  1. 跨元件獲取表單內容
  2. 表單聯動
  3. 根據條件去執行或修改表單元件的某些行為, 如:
    • 表單校驗
    • props屬性控制
    • ref獲取函式並執行

其實這些表單都是基於 React 官方受控元件的封裝, 其中 Antd Form 及 no-form 都是參考我們的先知 Dan Abramov 的理念:

單向資料流, 狀態管理至頂而下; 這樣可以確保整個架構資料的同步, 加強專案的穩定性; 它滿足以下 4 個特點:

  1. 不阻斷資料流
  2. 時刻準備渲染
  3. 沒有單例元件
  4. 隔離本地狀態

Dan Abramov 具體的文章在此處: 編寫有彈性的元件

我一直極力推崇單向資料流的方案, 在之前的專案中一直以 redux + immutable 作為專案管理, 專案也一直穩定執行, 直到 React-Hooks 的方案出現(這是另外的話題).

單向資料流的特點是用計算時間換開發人員的時間, 我們舉一個小例子說明:

如果當前元件樹中有 100 個 元件, 其中50個元件被connect注入了狀態, 那麼當發起一個 dispatch 行為, 需要更新1個元件, 這50個元件會被更新, 我們需要使用 immutable 在 shouldComponentUpdate 中進行高效的判斷, 以攔截另外49個不必要更新的元件.

單向資料流的好處是我們永遠只需要維護最頂部的狀態, 減少了系統的混亂程度.

缺點也是明顯的: 我們需要額外的判斷是否更新的開銷

大部分 Form 表單獲取資料的思路也是一個內聚的單向資料流, 每次 onChange 就修改 Form 中的 state, 子元件通過註冊 context, 獲取及更新相應的值. 這是滿足 Dan Abramov 的設計理念的.

而 react-final-form 沒有使用以上模式, 而是通過釋出訂閱, 把每個元件的更新加入訂閱, 根據行為進行相應的更新, 按照以上的例子, 它們是如此運作:

如果當前元件樹中有 100 個 元件, 其中50個元件被Form標記了, 那麼當發起一個 input 行為, 需要更新1個元件, 會找到這一個元件, 在內部進行setState, 並把相應的值更新到 Form 中的 data 中.

這種設計有沒有違背 React 的初衷呢? 我認為是沒有的, 因為 Form 維護的內容是區域性的, 而不是整體的, 我們只需要讓整個 Form 不脫離資料流的管理即可.

通過 react-final-form 這個元件的例子我想明白了一件事情:

  1. 單向資料流是幫我們更容易的管理, 但是並不是表示非單向資料流狀態就一定混亂, 就如 react-final-form 元件所管理的表單狀態.

  2. 既然 react-final-form 可以這麼設計, 我們為什麼不能設計區域性的, 脫離受控元件的範疇的表單?

好的, 可以進入正題了:

表單內部的元件可以脫離受控元件存在, 只需要讓表單本身為受控元件

使用 form 標籤代替 React Form 元件

我們用一個簡單的例子實現最開始React官方的受控元件的示例程式碼:

class App extends React.Component {
  formDatas = {};

  handleOnChange = event => {
    // 在input事件中, 我們將dom元素的值儲存起來, 用於表單提交
    this.formDatas[event.target.name] = event.target.value;
  };

  handleOnSubmit = event => {
    console.log('formDatas: ', this.formDatas);
    event.preventDefault();
  };

  render() {
    return (
      <form onChange={this.handleOnChange} onSubmit={this.handleOnSubmit}>
        <input name="username" />
        <input name="password" />
        <button type="submit" />
      </form>
    );
  }
}
複製程式碼

這是最簡單的獲取值, 儲存到一個物件中, 我們會一步步描述如何脫離受控元件進行值和狀態管理, 但是為了後續的程式碼更加簡潔, 我們使用 hooks 完成以上行為:

獲取表單內容

function App() {
  // 使用 useRef 來儲存資料, 這樣可以防止函式每次被重新執行時無法儲存變數
  const { current: formDatas } = React.useRef({});

  // 使用 useCallback 來宣告函式, 減少元件重繪時重新宣告函式的開銷
  const handleOnChange = React.useCallback(event => {
    // 在input事件中, 我們將dom元素的值儲存起來, 用於表單提交
    formDatas[event.target.name] = event.target.value;
  }, []);

  const handleOnSubmit = React.useCallback(event => {
    // 提交表單
    console.log('formDatas: ', formDatas);
    event.preventDefault();
  }, []);

  return (
    <form onChange={handleOnChange} onSubmit={handleOnSubmit}>
      <input name="username" />
      <input name="password" />
      <button type="submit" />
    </form>
  );
}
複製程式碼

接下來的程式碼都會在此基礎上, 使用 hooks 語法編寫

跨元件獲取表單內容

我們不需要做任何處理,

標籤原本就可以獲取其內部的所有表單內容

// 子元件, form標籤一樣可以獲取相應的輸入
function PasswordInput(){
  return <div>
    <p>密碼:</p>
    <input name="password" />
  </div>
}

function App() {
  const { current: formDatas } = React.useRef({});

  const handleOnChange = React.useCallback(event => {
    formDatas[event.target.name] = event.target.value;
  }, []);

  const handleOnSubmit = React.useCallback(event => {
    console.log('formDatas: ', formDatas);
    event.preventDefault();
  }, []);

  return (
    <form onChange={handleOnChange} onSubmit={handleOnSubmit}>
      <input name="username" />
      <PasswordInput />
      <button type="submit" />
    </form>
  );
}
複製程式碼

表單聯動 \ 校驗

現在我們在之前的基礎上實現一個需求:

如果密碼長度大於8, 將使用者名稱和密碼重置為預設值

我們通過 form, 將input的dom元素儲存起來, 再在一些情況進行dom操作, 直接更新, 程式碼如下:

function App() {
  const { current: formDatas } = React.useRef({});
  const { current: formTargets } = React.useRef({});

  const handleOnChange = React.useCallback(event => {
    // 在input事件中, 我們將dom元素的值儲存起來, 用於表單提交
    formDatas[event.target.name] = event.target.value;
    // 在input事件中, 我們將dom元素儲存起來, 接下來根據條件修改value
    formTargets[event.target.name] = event.target;

    // 如果密碼長度大於8, 將使用者名稱和密碼重置為預設值
    if (formTargets.password && formDatas.password.length > 8) {
      // 修改DOM元素的value, 更新檢視
      formTargets.password.value = formTargets.password.defaultValue;
      // 如果儲存過
      if (formTargets.username) {
        // 修改DOM元素的value, 更新檢視
        formTargets.username.value = formTargets.username.defaultValue;
      }
    }
  }, []);

  const handleOnSubmit = React.useCallback(event => {
    console.log('formDatas: ', formDatas);
    event.preventDefault();
  }, []);

  return (
    <form onChange={handleOnChange} onSubmit={handleOnSubmit}>
      <input defaultValue="hello" name="username" />
      <input defaultValue="" name="password" />
      <button type="submit" />
    </form>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));
複製程式碼

如上述程式碼, 我們很簡單的實現了表單的聯動, 因為直接操作 DOM, 所以整個元件並沒有重新執行 render, 這種更新方案的效能是極佳的(HTML的極限).

在寫 React 的時候我們都非常忌諱直接操作DOM, 這是因為, 如果我們操作了 DOM, 但是通過React對Node的Diff之後, 又進行更新, 可能會覆蓋掉之前操作DOM的一些行為. 但是如果我們確保這些 DOM 並不是受控元件, 那麼就不會發生以上情況.

它會有什麼問題麼? 當其他行為觸發 React 重繪時, 這些標籤內的值會被清空嗎?

明顯是不會的, 只要React的元件沒有被銷燬, 即便重繪, React也只是獲取到 dom物件修改其屬性:

function App() {
  const { current: formDatas } = React.useRef({});
  const { current: formTargets } = React.useRef({});
  const [value, setValue] = React.useState(10);

  // 我們這裡每隔 500ms 自動更新, 並且重繪我們的輸入框的字號
  React.useEffect(() => {
    setInterval(() => {
      setValue(v => v + 1);
    }, 300);
  }, []);

  const handleOnChange = React.useCallback(event => {
    formDatas[event.target.name] = event.target.value;
    formTargets[event.target.name] = event.target;

    if (formTargets.password && formDatas.password.length > 8) {
      formTargets.password.value = formTargets.password.defaultValue;
      if (formTargets.username) {
        formTargets.username.value = formTargets.username.defaultValue;
      }
    }
  }, []);

  const handleOnSubmit = React.useCallback(event => {
    console.log('formDatas: ', formDatas);
    event.preventDefault();
  }, []);

  return (
    <form onChange={handleOnChange} onSubmit={handleOnSubmit}>
      <p>{value}</p>
      <input defaultValue="hello" name="username" />
      {/* p 標籤會一直被 setState 更新, 字號逐步增大, 我們輸入的值並沒有丟失 */}
      <input defaultValue="" name="password" style={{ fontSize: value }} />
      <button type="submit" />
    </form>
  );
}
複製程式碼

但是, 如果標籤被銷燬了, 非受控元件的值就不會被儲存

以下例子, input輸入了值之後, 被消耗再被重繪, 此時之前input的值已經丟失了

function App() {
  const { current: formDatas } = React.useRef({});
  const { current: formTargets } = React.useRef({});
  const [value, setValue] = React.useState(0);

  React.useEffect(() => {
    setInterval(() => {
      setValue(v => v + 1);
    }, 500);
  }, []);

  const handleOnChange = React.useCallback(event => {
    formDatas[event.target.name] = event.target.value;
    formTargets[event.target.name] = event.target;
  }, []);

  const handleOnSubmit = React.useCallback(event => {
    console.log('formDatas: ', formDatas);
    event.preventDefault();
  }, []);

  return (
    <form onChange={handleOnChange} onSubmit={handleOnSubmit}>
      {/* 如果 value 是 5 的整數倍, input 會被銷燬, 已輸入的值會丟失 */}
      {value % 5 !== 0 && <input name="username" />}
      {/* 我們可以使用 defaultValue 去讀取歷史的值, 讓重繪時讀取之前輸入的值 */}
      {value % 5 !== 0 && <input defaultValue={formDatas.password} name="password" />}
      {/* 如果可能, 我們最好使用 display 代替條件渲染 */}
      <input name="code" style={{ display: value % 5 !== 0 ? 'block' : 'none' }} />
      <button type="submit" />
    </form>
  );
}
複製程式碼

如程式碼中的註釋所述:

  1. 如果 input 被銷燬, 已輸入的值會丟失
  2. 我們可以使用 defaultValue 去讀取歷史的值, 讓重繪時讀取之前輸入的
  3. 如果可能, 我們最好使用 display 代替條件渲

好了, 我們在瞭解了直接操作DOM的優點和弊端之後, 我們繼續實現表單常見的其他行為.

跨層級元件通訊

根據條件執行某子元件的函式, 我們只需要獲取該元件的ref即可, 但是如果涉及到多層級的元件, 這就會很麻煩.

傳統 Form 元件會提供一個 FormItem, FormItem會獲取 context, 從而提供跨多級元件的通訊

而我們如何既然已經獲取到dom了, 我們只需要在dom上捆綁事件, 就可以無痛的做到跨層級的通訊. 這個行為完全違反我們平時編寫React的思路和常規操作, 但是通過之前我們對 "標籤銷燬" 的理解, 通常可以使它在可控的範圍內.

我們看看實現的程式碼案例:

// 此為子子元件
function SubInput() {
  const ref = React.useRef();

  React.useEffect(() => {
    if (ref.current) {
      // 在DOM元素上捆綁一個函式, 此函式可以執行此元件的上下文事件
      ref.current.saved = name => {
        console.log('do saved by: ', name);
      };
    }
  }, [ref]);

  return (
    <div>
      {/* 獲取表單的DOM元素 */}
      <input ref={ref} name="sub-input" />
    </div>
  );
}

// 此為子元件, 僅引用了子子元件
function Input() {
  return (
    <div>
      <SubInput />
    </div>
  );
}

function App() {
  const { current: formDatas } = React.useRef({});
  const { current: formTargets } = React.useRef({});

  const handleOnChange = React.useCallback(event => {
    formDatas[event.target.name] = event.target.value;
    formTargets[event.target.name] = event.target;

    // 直接通過dom元素上的屬性, 獲取子子元件的事件
    event.target.saved && event.target.saved(event.target.name);
  }, []);

  const handleOnSubmit = React.useCallback(event => {
    console.log('formDatas: ', formDatas);
    event.preventDefault();
  }, []);

  return (
    <form onChange={handleOnChange} onSubmit={handleOnSubmit}>
      {/* 我們應用了某個子子元件, 並且沒用傳遞任何 props, 也沒有捆綁任何 context, 沒有獲取ref */}
      <Input />
    </form>
  );
}
複製程式碼

根據此例子我們可以看到, 使用 html 的 form 標籤,就可以完成我們絕大部分的 Form 元件的場景, 而且開發效率和執行效率都更高.

爭議

通過操作DOM, 我們可以很天然解決一些 React 非常棘手才能解決的問題. 誠然這有點像在刀尖上跳舞, 但是此文中給出了一些會遇到的問題及解決方案.

我非常歡迎對此類問題的討論, 有哪些還會遇到的問題, 如果能清晰的將其原理及原因描述並回復到此文, 那是對所有閱讀者的幫助.

寫在最後

請不要被教條約束, 試試挑戰它.

相關文章