阅读 907

我们或许不需要 React 的 Form 组件

在上一篇小甜点 《我们或许不需要 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个组件的会被更新, 我们需要在 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 非常棘手才能解决的问题. 诚然这有点像在刀尖上跳舞, 但是此文中给出了一些会遇到的问题及解决方案.

我非常欢迎对此类问题的讨论, 有哪些还会遇到的问题, 如果能清晰的将其原理及原因描述并回复到此文, 那是对所有阅读者的帮助.

写在最后

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

关注下面的标签,发现更多相似文章
评论