React-Hooks 设计动机与工作模式(上)

597 阅读6分钟

这是我参与11月更文挑战的第19天,活动详情查看:2021最后一次更文挑战

  提起 React-Hooks,可能很多人的第一反应,都会是 useStateuseEffectuseContext 这些琐碎且繁多的 API。似乎 React-Hooks 就是一坨没有感情的工具性代码,压根没有啥玄妙的东西在里面,那些面试官天天让咱聊 React-Hooks,到底是想听啥呢?

  当我们由浅入深地认知一样新事物的时候,往往需要遵循“Why→What→How”这样的一个认知过程。这三者是相辅相成、缺一不可的:当我们了解了具体的“What”和“How”之后,往往能够更加具象地回答理论层面“Why”的问题;而我们对“Why”的探索和认知,也必然会反哺到对“What”的理解和对“How”的实践。

  对于一个工程师来说,对"why"的执着程度,很大程度上能够反映职业天花板的高度。

React-Hooks 设计动机初探

  React-Hooks 这个东西比较特别,这背后其实涉及React开发团队对类组件和函数组件两种组件形式的思考和侧重。因此,首先得知道,什么是类组件、什么是函数组件,并完成对这两种组件形式的辨析。

何谓类组件(Class Component)

  所谓类组件,就是基于 ES6 Class 这种写法,通过继承 React.Component 得来的 React 组件。以下是一个典型的类组件:

class DemoClass extends React.Component {
  // 初始化类组件的 state
  state = {
    text: ""
  };
  // 编写生命周期方法 didMount
  componentDidMount() {
    // 省略业务逻辑
  }
  // 编写自定义的实例方法
  changeText = (newText) => {
    // 更新 state
    this.setState({
      text: newText
    });
  };
  // 编写生命周期方法 render
  render() {
    return (
      <div className="demoClass">
        <p>{this.state.text}</p>
        <button onClick={this.changeText}>点我修改</button>
      </div>
    );
  }
}

何谓函数组件/无状态组件(Function Component/Stateless Component)

  函数组件顾名思义,就是以函数的形态存在的 React 组件。早期并没有 React-Hooks 的加持,函数组件内部无法定义和维护 state,因此它还有一个别名叫“无状态组件”。以下是一个典型的函数组件:

function DemoFunction(props) {
  const { text } = props
  return (
    <div className="demoFunction">
      <p>{`function 组件所接收到的来自外界的文本内容是:[${text}]`}</p>
    </div>
  );
}

函数组件与类组件的对比:无关“优劣”,只谈“不同”

  基于上面的两个 Demo,从形态上对两种组件做区分。它们之间肉眼可见的区别就包括但不限于:

  • 类组件需要继承 class,函数组件不需要;
  • 类组件可以访问生命周期方法,函数组件不能;
  • 类组件中可以获取到实例化后的 this,并基于这个 this 做各种各样的事情,而函数组件不可以;
  • 类组件中可以定义并维护 state(状态),而函数组件不可以;

  根据以上推论,是否意味着类组件比函数组件更好呢,答案显然是否定的。 在 React-Hooks 出现之前的世界里,类组件的能力边界明显强于函数组件,但要进一步推导“类组件强于函数组件”,未免显得有些牵强。 讨论这两种组件形式时,不应怀揣“孰优孰劣”这样的成见,而应该更多地去关注两者的不同,进而把不同的特性与不同的场景做连接。

重新理解类组件:包裹在面向对象思想下的“重装战舰”

  类组件是面向对象编程思想的一种表征。面向对象是一个老生常谈的概念了,当我们应用面向对象的时候,总是会有意或无意地做这样两件事情。

  1. 封装:将一类属性和方法,“聚拢”到一个 Class 里去。
  2. 继承:新的 Class 可以通过继承现有 Class,实现对某一类属性和方法的复用。

React 类组件也不例外。我们再次审视一下这个典型的类组件 Case:

class DemoClass extends React.Component {
  // 初始化类组件的 state
  state = {
    text: ""
  };
  // 编写生命周期方法 didMount
  componentDidMount() {
    // 省略业务逻辑
  }
  // 编写自定义的实例方法
  changeText = (newText) => {
    // 更新 state
    this.setState({
      text: newText
    });
  };
  // 编写生命周期方法 render
  render() {
    return (
      <div className="demoClass">
        <p>{this.state.text}</p>
        <button onClick={this.changeText}>点我修改</button>
      </div>
    );
  }
}

  不难看出,React 类组件内部预置了相当多的“现成的东西”等着你去调度/定制,state 和生命周期就是这些“现成东西”中的典型。要想得到这些东西,难度也不大,你只需要轻轻地继承一个 React.Component 即可。这种感觉就好像是你不费吹灰之力,就拥有了一辆“重装战舰”,该有的枪炮导弹早已配备整齐,就等你操纵控制台上的一堆开关了。

  毋庸置疑,类组件给到开发者的东西是足够多的,但“多”就是“好”吗?其实未必。React 类组件,也有同样的问题——它提供了多少东西,你就需要学多少东西。假如背不住生命周期,你的组件逻辑顺序大概率会变成一团糟。“大而全”的背后,是不可忽视的学习成本

  类组件太重了,对于解决许多问题来说,编写一个类组件实在是一个过于复杂的姿势。复杂的姿势必然带来高昂的理解成本,这也是我们所不想看到的。类组件固然强大, 但它绝非万能

深入理解函数组件:呼应 React 设计思想的“轻巧快艇”

  我们再来看这个函数组件的 case:

function DemoFunction(props) {
  const { text } = props
  return (
    <div className="demoFunction">
      <p>{`function 组件所接收到的来自外界的文本内容是:[${text}]`}</p>
    </div>
  );
}

  你以为函数组件的简单是因为它只能承担渲染这一种任务,那可就太小瞧它了。它同样能够承接相对复杂的交互逻辑,像这样:

function DemoFunction(props) {
  const { text } = props 
  const showAlert = ()=> {
    alert(`我接收到的文本是${text}`)
  } 
  return (
    <div className="demoFunction">
      <p>{`function 组件所接收到的来自外界的文本内容是:[${text}]`}</p>
      <button onClick={showAlert}>点击弹窗</button>
    </div>
  );
}

  相比于类组件,函数组件肉眼可见的特质自然包括轻量、灵活、易于组织和维护、较低的学习成本等。这些要素毫无疑问是重要的,它们也确实驱动着 React 团队做出改变。

  React 作者 Dan 早期特意为类组件和函数组件写过的一篇非常棒的对比文章,这篇文章很长,但是通篇都在论证这一句话:

函数组件会捕获 render 内部的状态,这是两类组件最大的不同。

  类组件和函数组件之间,纵有千差万别,但最不能够被我们忽视掉的,是心智模式层面的差异,是面向对象和函数式编程这两套不同的设计思想之间的差异,函数组件更加契合 React 框架的设计理念。

 React 组件本身的定位就是函数,一个吃进数据、吐出 UI 的函数。作为开发者,我们编写的是声明式的代码,而 React 框架的主要工作,就是及时地把声明式的代码转换为命令式的 DOM 操作,把数据层面的描述映射到用户可见的 UI 变化中去。这就意味着从原则上来讲,React 的数据应该总是紧紧地和渲染绑定在一起的,而类组件做不到这一点

  *为什么类组件做不到?*首先我们来看这样一个类组件:

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };
  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };
  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

  看起来好像没啥毛病,但是如果你在这个在线 Demo中尝试点击基于类组件形式编写的 ProfilePage 按钮后 3s 内把用户切换为 Sophie。

  这个现象必然让许多人感到困惑:user 的内容是通过 props 下发的,props 作为不可变值,为什么会从 Dan 变成 Sophie 呢?

  虽然 props 本身是不可变的,但 this 却是可变的,this 上的数据是可以被修改的this.props 的调用每次都会获取最新的 props,而这正是 React 确保数据实时性的一个重要手段。

  多数情况下,在 React 生命周期对执行顺序的调控下,this.props this.state 的变化都能够和预期中的渲染动作保持一致。但在这个案例中,我们通过 setTimeout 将预期中的渲染推迟了 3s,打破了 this.props 和渲染动作之间的这种时机上的关联,进而导致渲染时捕获到的是一个错误的、修改后的 this.props

  但如果我们把 ProfilePage 改造为一个像这样的函数组件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };
  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };
  return (
    <button onClick={handleClick}>Follow</button>
  );
}

  props 会在 ProfilePage 函数执行的一瞬间就被捕获,而 props 本身又是一个不可变值,因此我们可以充分确保从现在开始,在任何时机下读取到的 props,都是最初捕获到的那个 props。当父组件传入新的 props 来尝试重新渲染 ProfilePage 时,本质上是基于新的 props 入参发起了一次全新的函数调用,并不会影响上一次调用对上一个 props 的捕获。这样一来,我们便确保了渲染结果确实能够符合预期。

Hooks 的本质:一套能够使函数组件更强大、更灵活的“钩子”

  React-Hooks 是一套能够使函数组件更强大、更灵活的“钩子”。函数组件比起类组件“少”了很多东西,比如生命周期、对 state 的管理等。这就给函数组件的使用带来了非常多的局限性,导致我们并不能使用函数这种形式,写出一个真正的全功能的组件。React-Hooks 的出现,就是为了帮助函数组件补齐这些(相对于类组件来说)缺失的能力。