【译】 React官方:函数组件与类组件的差异 ?

5,178 阅读13分钟

话外:随着 react 新特性 HOOKS 的提出,最新很多人开始讨论 react 里面的函数组件和 class 组件到底有什么不同?这里是 Dan Abramov 的一篇文章 How Are Function Components Different From Classes, 下面是对这篇文章的翻译,大致意思大致表达出来了,不足的地方,请大家多多指教 :)

React 函数组件和 React 类有何不同?

有一段时间,规范的答案是类提供了更多的功能(如状态)。有了 Hooks,就不再那么正确了。

也许你听说其中一个的性能更好。那是哪一个呢?许多基于此类的论证过程都存在缺陷,因此我会谨慎地从中得出结论。性能主要取决于代码做了什么,而不是您选择的是函数还是类。在我们的观察中,虽然优化策略略有不同,但性能差异可以忽略不计。

在任何一种情况下,除非您有其他原因或者不介意成为第一个使用它的人,否则我们不建议您使用HOOKS重写现有组件。 Hooks 仍然是新的(就像 2014 年的 React 一样),并且一些“最佳实践”还没有在教程中提到。

那我们该怎么办呢? React 函数和类之间是否有任何根本的区别?当然,还有在心理模型中。在这篇文章中,我将看看它们之间的最大区别。函数组件自 2015 年推出以来,它就一直存在,但却经常被忽视:

函数组件捕获已渲染的值。

让我们来看看这是什么意思。


注意:这篇文章不是重在评价类或者函数。我只阐述在 react 中的这两个语法模型之间的区别。关于更广泛地采用函数式组件的问题,请参考 Hooks FQA


思考一下这个组件:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

它显示一个按钮,使用 setTimeout 模拟网络请求,然后显示确认警告弹窗。例如,如果 props.user'Dan',那么调用这个函数3s之后将会显示 'Followed Dan',这很好理解。

(注意在上面这个例子中使用箭头函数或者是函数声明都是可以的,function handleClick() 会以完全相同的方式工作)

我们如何把它写成一个类呢?简单的转换可能看起来像是这样:

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

通常会认为这两个代码片段是等效的。人们经常随意的使用这些模式进行重构,而不会注意到它们的含义。

code
但是,这两个代码片段还是有些细微的差别。仔细的看看他们,你看出差异了吗?就个人而言,我花了一段时间才看到这一点。

提示一下,如果你想自己搞清楚,这是一个线上例子, 本文的其余部分解释了这个差异及其重要性。


在我们开始之前,我想强调的是,我所描述的差异与 React Hooks 本身无关。 上面的例子甚至没有使用Hooks!

它只是 React 中函数和类之间的区别。如果您计划在 React 应用程序中更频繁地使用函数式组件,那你可能想要了解它。


我们将用 React 应用程序中常见的 bug 来说明它们之间的区别。

使用一个即时 profile 选择器和上面的两个 ProfilePage 实现打开这个示例沙箱 - 每个都实现了一个 Follow 按钮。

用两个按钮尝试以下操作序列:

  • 点击其中一个 Follow 按钮。
  • 在3秒之内更改所选的配置。
  • 阅读警告文字

你将会注意到他们结果的差异:

  • 在基于函数的 ProfilePage 上,点击跟随 Dan 的配置,然后改变配置为 Sophie 的,3s 后的警告任然是 'Followed Dan'
  • 在基于类的 ProfilePage 上,则将会警告 'Followed Sophie'

result


在这个例子中,第一个的行为是正确的。如果我跟随了一个人然后导航到另一个人的配置,我的组件不应该对我到底跟随了谁而感到困惑。这个类的实现显然是错误的。

(尽管你可能想去关注 sophie


为什么我们使用类的结果是这样的呢?

让我们仔细观察我们类中的 showMessage 方法。

class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

这个类方法读取 this.props.user,Props 在 React 中是不可变的所以它永远不会改变,但是 this 总是可变的。

实际上,这就是类中存在this的全部意义。React 本身会随着时间而改变,以便您可以在 render 和生命周期函数中读取新版本。

因此,如果我们的组件在请求运行时更新。this.props 将会改变。showMessage 方法从“最新”的 props 中读取 user

这是揭示了关于用户界面本质的一个有趣的现象。如果我们说UI在概念上是当前应用程序状态的函数,那么事件处理函数就是渲染输出的一部分,就像可视化输出一样。我们的事件处理程序“属于”具有特定的 propsstate 的特定的 render

但是当定时器的回调函数读取 this.props 时 打破了这种规则。我们的 showMessage 回调函数没有绑定到任何特定的 render,所以它失去了正确的 props, 从 this 里读取 props 切断了这种关联。


假设说函数组件不存在,我们该怎么解决这个问题呢?

我们希望以某种方式在渲染之后“修复” props 与读取它们的 showMessage 回调之间的连接。因为Props 在传递的过程中失去了正确的意义。

一个方法是在事件处理函数初始就读取 this.props,然后精确的将它传递到 setTimeout 的回调函数中。

class ProfilePage extends React.Component {
  showMessage = (user) => {
    alert('Followed ' + user);
  };

  handleClick = () => {
    const {user} = this.props;
    setTimeout(() => this.showMessage(user), 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

这是有效的。但是,这种方法会使代码随着时间的推移变得更加冗长和容易出错。如果我们需要不止一个 prop 怎么办?如果我们还需要访问状态怎么办?如果 showMessage 调用另一个方法,并且该方法读取 this.props.somethingthis.state.something,我们将再次遇到完全相同的问题。所以我们必须将 this.props 和 this.state 作为参数传递给 showMessage 调用的每个方法。

这么做不仅不符合通常我们对类的认知,同时也极其难以记录并施行,最后代码就会不可避免的出现 bug。

同样,在 handleClick 中 alert 代码并不能解决更大的问题。我们想要使用一种可以拆分为多个方法的方式来构造代码,同时也要读取被调用时与之对应的参数和状态,这个问题甚至不是 React 独有的 - 您可以将数据放入像 this 的这样的可变对象一样来在任何UI库中来重现它。

可能,我们可以在构造函数中绑定方法?

class ProfilePage extends React.Component {
  constructor(props) {
    super(props);
    this.showMessage = this.showMessage.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }

  showMessage() {
    alert('Followed ' + this.props.user);
  }

  handleClick() {
    setTimeout(this.showMessage, 3000);
  }

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

但是,这不会修复任何东西。记住,这个问题是因为我们太晚读取 this.props,而不是我们正在使用的语法。我们可以完全依赖 JavaScript 闭包来解决这个问题

闭包经常因为很难去理解在整个过程中值是怎么改变的而被避免使用。但在 React 中,propsstate 是不可变的(至少这是被强烈推荐的)。这消除了使用闭包时的主要的问题。

这意味着如果你从特定渲染中屏蔽 propsstate,你可以认为它们保持完全相同:

class ProfilePage extends React.Component {
  render() {
    // Capture the props!
    const props = this.props;

    // Note: we are *inside render*.
    // These aren't class methods.
    const showMessage = () => {
      alert('Followed ' + props.user);
    };

    const handleClick = () => {
      setTimeout(showMessage, 3000);
    };

    return <button onClick={handleClick}>Follow</button>;
  }
}

你可以在渲染的时候获取 props

photo

这样,在这个特定的 render 函数内部的任何代码(包括 showMessage)都可以保证取到正确的 props。React 不会再“动我们的奶酪”。

然后我们可以在里面添加我们想要的任意数量的辅助函数,它们都会使用被捕获的 propsstate。这多亏了闭包的帮助!


上面的例子是正确的但看起来有些奇怪, 如果在 render 中定义函数而不是使用类的方式,那使用类有什么意义呢?

实际上,我们可以通过移除包裹在他外层的 class “壳”来简化代码:

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

就像上面这样,prop 依旧被捕获到了。React 将他们像参数一样进行传递进来,不同于 this, 这个 props 对象本身永远不会被React改变。

如果你在函数定义的时候对 props 解构赋值,效果会更加明显

function ProfilePage({ user }) {
  const showMessage = () => {
    alert('Followed ' + user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

当父组件使用不同的 props 渲染 ProfilePage 时,React 会再一次调用 ProfilePage 函数。但是我们已经点击的事件处理程序“属于”前一个 render 函数,他自己的 showMessage 回调函数读取的 user 值没有任何改变。

这就是为什么在这个例子的函数版本中,点击跟随 sophie's 配置之后改变配置为 sunil 时会显示 ‘Followed Sophie’,

result
这个行为是正确的(尽管你可能也想要 follow sunil)


现在我们理解了在 React 中函数和类之间最大的区别了:

函数组件获取已经渲染过的值(Function components capture the rendered values.)

使用 Hooks,同样的原则也适用于 state。思考一下这个例子:

function MessageThread() {
  const [message, setMessage] = useState('');

  const showMessage = () => {
    alert('You said: ' + message);
  };

  const handleSendClick = () => {
    setTimeout(showMessage, 3000);
  };

  const handleMessageChange = (e) => {
    setMessage(e.target.value);
  };

  return (
    <>
      <input value={message} onChange={handleMessageChange} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

(这里是线上例子)

虽然这不是一个非常好的消息应用 UI,但它说明了同样的观点:如果我发送特定消息,组件不应该对实际发送的消息感到困惑。此函数组件的 message 捕获到的是渲染的数据,然后被浏览器调用的单击事件处理函数返回。因此当我点击发送的时候 message 会被设置成我输入的东西。


所以我们知道 React 里面的函数会默认捕获 propsstate。但如果我们想读取不属于特定 render 的最新 propsstate 时该怎么办呢?就是如果我们想从未来读取它们该怎么办?

在类里面,你可以通过读取 this.propsthis.state 来做到,因为他们本身是可变的。由 React 来改变。在函数组件里,你同样有一个可以被所有组件渲染共享的可变值 ,他被叫做 "ref"。

function MyComponent() {
  const ref = useRef(null);
  // You can read or write `ref.current`.
  // ...
}

但是你必须自己去管理它。

ref 与实例字段扮演同样的角色。它是进入可变命令世界的逃脱舱。您可能熟悉 “DOM refs”,但这个的概念更为通用。它只是一个你可以把东西放进去的盒子。

即使在视觉上,this.something 看起来就像是 something.current 一样,它们表达的概念是相同的。

在函数组件里 React 不会默认为最新的 propsstate 创造一个 refs, 通常情况下,您不需要它们,而且还要浪费时间去分配它们,但是,你喜欢的话也可以手动跟踪该值:

function MessageThread() {
  const [message, setMessage] = useState('');
  const latestMessage = useRef('');

  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };

  const handleSendClick = () => {
    setTimeout(showMessage, 3000);
  };

  const handleMessageChange = (e) => {
    setMessage(e.target.value);
    latestMessage.current = e.target.value;
  };

如果我们在 showMessage 里读取 message,当我们按下 send 按钮的时候我们会看见这个 message,但当我们读取 latestMessage.current 时,我们会得到最新的值,即使是在我们按下send按钮之后继续输入的值。

你可以通过这两个例子来比较他们的不同。ref 是一种“选择性退出”渲染一致性的方案,在某些情况下可以很方便。

通常情况下,如果想要保持渲染的可预测性,您应该避免在渲染的时候读取或者设置refs,因为他们是可变的。但是如果我们想要获得特定的 propsstate 的最新值,手动更新 ref 可能很烦人。我们可以通过使用 effect 自动更新它:

function MessageThread() {
  const [message, setMessage] = useState('');

  // Keep track of the latest value.
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message;
  });

  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };

这是一个例子

我们在 effect 里面做了赋值,所以 ref 的值只会在 DOM 更新之后才会改变,这确保我们的变化不会破坏依赖于可中断渲染的功能。比如 Time Slicing and Suspense

通常我们不需要经常使用 ref。默认获取 propsstate 通常更好。但是,在处理 intervals 和 subscriptions 等命令式 API 时它会很方便。请记住,您可以跟踪任何值,比如:prop 和 state 变量、整个 prop 对象、甚至是函数。

此模式的优化也很方便 - 例如,当 useCallback 标识经常更改时。但是,使用 reducer 通常是更好的解决方案。 (未来博客文章的主题!)


在这篇文章中,我们研究了类中常见的 bug,以及如何使用闭包来帮助我们修复它。但是,您可能已经注意到,当您试图通过指定依赖项数组来优化 Hooks 时,可能会遇到闭包还未来得及更新导致的 bug。这是否意味着闭包是问题所在呢?我不这么认为。

正如我们在上面看到的,闭包实际上帮助我们解决了难以注意到的细微问题。类似地,它们使编写在并发模式下正确工作的代码变得容易得多。这是可能的,因为组件内部的逻辑在渲染它时屏蔽了正确的 props 和 state。

到目前为止,我所见过的所有情况下,“陈旧的闭包”问题都是由于错误地假设“函数不会改变”或 “ props 总是相同的”而发生的。事实并非如此,我希望这篇文章有助于澄清这一点。

函数屏蔽了他们更新的 propsstate ——因此它们的标识也同样重要。这不是一个 bug,而是函数组件的一个特性。例如,对于 useEffect 或 useCallback,函数不应该被排除在“相关数组”之外。(正确的修复通常是 useReducer 或上面的 useRef 解决方案——我们很快将在文档中解释如何在两者之间进行选择)。

当我们用函数编写大多数 React 代码时,我们需要调整我们关于优化代码的直觉以及哪些值会随时间变化

就像 Fredrik 写的那样:

到目前为止,我所发现的关于 hook 的最好的心理预期是“代码里好像任何值都可以随时更改”。

函数也不例外。这需要一些时间才能成为 React 学习材料的常识。它需要从 class 的思维方式进行一些调整。但我希望这篇文章可以帮助你以新的眼光看待它。

React 函数总是捕获它们的值 - 现在我们知道原因了。

口袋精灵