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

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

写在最后

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342