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

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

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

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

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

何時不應該使用受控組件

Angular, Vue, 都有雙向綁定, 而 React 官方文檔也為一個 input 標簽的雙向綁定給了一個官方方案 - 受控組件:

https://reactjs.org/docs/forms.html#controlled-components

本文中提到的代碼都可以直接粘貼至項目中進行驗證.

// 以下是官方的受控組件例子:
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 /> 標簽原本就可以獲取其內部的所有表單內容

// 子組件, 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 非常棘手才能解決的問題. 誠然這有點像在刀尖上跳舞, 但是此文中給出了一些會遇到的問題及解決方案.

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

寫在最后

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。