咱们或许不须要 React 的 Form 组件

在上一篇小甜点 《咱们或许不须要 classnames 这个库》 中, 咱们 简单的使用了一些语法代替了 classnames 这个库html

如今咱们调整一下难度, 移除 React 中相对比较复杂的组件: Form 组件react

在移除 Form 组件以前, 咱们现须要进行一些思考, 为何会有 Form 组件及 Form 组件和 React 状态管理的关系git

注意, 接下来的内容很是容易让 React 开发人员感到不适, 而且极具争议性github

单向数据流及受控组件

Angular, Vue, 都有双向绑定, 而 React 官方文档也为一个 input 标签的双向绑定给了一个官方方案 - 受控组件:redux

reactjs.org/docs/forms.…架构

本文中提到的代码均可以直接粘贴至项目中进行验证.dom

// 如下是官方的受控组件例子:
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 关联到某一个状态中.函数

很长一段时间, 使用受控组件, 咱们都会受到如下几个困惑:post

  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个组件的会被更新, 咱们须要在 mapPropsToState 中过滤没必要要的状态数据, 而后在使用 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 很是棘手才能解决的问题. 诚然这有点像在刀尖上跳舞, 可是此文中给出了一些会遇到的问题及解决方案.

我很是欢迎对此类问题的讨论, 有哪些还会遇到的问题, 若是能清晰的将其原理及缘由描述并回复到此文, 那是对全部阅读者的帮助.

写在最后

请不要被教条约束, 试试挑战它.

相关文章
相关标签/搜索