hxh

函数组件与类有什么区别?

原文链接: overreacted.io

React 函数组件与 React 类有什么区别?

规范有段时间给出的答案是,类能提供更多的属性访问功能(例如 state )。借助 Hooks 的话,这点是毫无疑问的。

可能你听说过其中一种的性能更好,到底是哪一种呢? 然而许多此类评判基准都是 不全面的 ,因此我会很谨慎从中的总结出结论 。性能好坏主要取决于代码实现的功能而不是你选择函数还是类的实现方式。我们研究发现,尽管两者的优化策略稍微c有点 不一样,但它们的性能差异是可以忽略的。

此外我们不推荐 重写你的组件,除非你有其他的原因并且不介意当吃螃蟹的人。Hooks 还算是新功能 (就像 2014 年的 React), 而且有的“最佳做法”教程还未采用。

那我们该怎么办呢,React 函数和类的有根本的区别吗?当然,它们的核心思想是不一样的.。在这篇文章中,我将会着眼于它们最大的区别函数组件自从 2015 年 被介绍 后一直存在,但总被忽视:

函数组件捕获渲染值。

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


注意:本文对函数组件或类的评价。我只是在描述这两种编程模式在 React 中的区别。关于如果更广泛得使用函数组件,请参考 Hooks FAQ.


请思考以下组件。

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

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

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

这里有一个按钮,通过 setTimeout 模拟网络请求,然后弹出一个确认框。例如,如果 'props.user' 是'Dan',点击按钮3秒后会显示'Follow 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>;
  }
}

这两段代码通常都被认为是等价的。人们常常能够不受限制的在这两种模式间进行重构,却没有注意到它们的含义。

Spot the difference between two versions

然而,这两段代码有细微的差别 仔细看看,发现了吗?说实话,我观察了一段时间才发现。

如果你想亲自发现的话,这里有个在线例子 作为预告 本文剩余部份解释它们的区别,以为这些区别的重要性。


继续之前,我想强调下,我所描述的区别与 React Hooks 没有任何关系。以上的例子都没有用到 Hooks 呢!

这些都是关于 React 中组件函数与类的差别。如果你打算在 React 应用中大量使用函数组件,你可能会希望了解这些差别。


我们将通过 React 应用中一个普遍的 bug 来说明它们的差别。

打开这个 沙盒例子 ,里面有个当前配置文件选择器,和上面两个 ProfilePage — 分别渲染一个 Follow 按钮。

尝试一下顺序操作:

  1. 点击 其中一个 Follow 按钮。

  2. 改变 3 秒内选择其它配置文件。

  3. 阅读 警告框文字。

你会发现一个神奇得区别:

  • ProfilePage 函数组件, 外 Dan 的配置文件点击 Follow。 然后导航到 Sophie 的配置文件,弹出的警告仍然是 'Followed Dan'。

  • ProfilePage , 则会弹出 'Followed Sophir ':

Demonstration of the steps


在这个例子里,第一种行为才是正确的。如果我关注了一个人,然后导航到另一个人的配置文件,我的组件不应该弄混我所关注的。 这里类的实现很明显是不对的。

(尽管你应该 关注 Sophie though.)


那么为什么我们的类组件会这样呢?

让我们仔细看看类组件里的showMessage 方法:

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

这个类方法读取的是this.props.user. Props 在 React 中是不可变的,因此它们从未改变。 然而, this _变了_,而且一直都是可变的。

确实,类的核心就是 this 。React 自身是不断得变化的,所以你能够获得最新的 render 和生命周期方法。

因此,如果我们触发请求的时候,重渲染了组件,this.props 将会改变。导致showMessage 读取到的 user 来自 “太新” 的props

这里发现了一个关于用户界面的有趣观察。如果说 UI 是当前应用程序概念化的 state,事件处理器是渲染结果的一部分 — 就像视觉输出一样。那么我们的事件处理器 “属于” 带有特定 props 和 state 的特定 render。

然而,设置超时回调读取 this.props Our showMessage 打破了这个关联。回调不再与任何指定的 render “捆绑”,因此 “丢失了” 正确的 props。 从 this 读取的信息切断了这种联系。


如果不存在函数组件。 我们如何解决这个问题?

我们希望有某些办法 “修复” render 与正确的 props 之间的联系, 使得 showMessage 回调执行时读取到他们。props 在某些地方丢失了。

其中一种方法是在事件早期就读取 this.props ,然后将它明确的传递给 timeout 的完成时处理器:

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 怎么办?如果我们需要访问 state 呢? 如果 showMessage 调用其它方法,并且该方法需要读取 this.props.something 或者 this.state.something,我们又会遇到同样的问题。 导致我们必须将 this.propsthis.state 作为参数传递给每一个在 showMessage 中调用到的方法。

这样做会破坏类的效率。 同时也难以记忆和执行,这就是为什么人们经常需要处理 bug 的原因。

alert 内嵌到 handleClick 也不能解决最大的问题。我们希望构建的代码是能够由多个方法组成,同时能够读取到调用时所关联的 render 对应的 props 和 state。这个问题并不只存在 React 中 — 你可以用任何一个 UI 库重构这个例子,将数据存放在一个像 this. 的易变对象中

将方法绑定到构造函数中能解决吗?

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 中,props 和 state 都是不可变的! (或者至少是强烈建议不要改变。) 这就消除了闭包最大的绊脚石。

这意味着,如果关闭了特定 render的 props 或者 state,你依然可以正确的获取到它们:

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>;
  }
}

你能够 “捕获到” 当时 render 的 props:

Capturing Pokemon

这种方式能确保任何的内部代码 (包括 showMessage) 都能 得到指定 render 的 props。React 再也不会 “动我们的奶酪了”。

我们能够在内部添加各种想要辅助函数,并且它们都能捕获到正确的 props 和 state。 闭包拯救了我们!


上面的例子 是正确的,但看起来很奇怪。如果一个类将方法定义在 render 里而不是使用类方法的意味着什么?

的确,我们可以通过移除类这个 “壳” 以简化代码:

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

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

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

像上面,依然能够捕获到 props — React 将它们作为参数传递。 不像 thisprops 不会被 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 函数。但我们已经触发的点击事件处理器还是“属于”之前的拥有自己 user 值的 render,showMessage 调用时会读取这个值。 它们都完好无损。

这就是为什么,函数组件的 例子,点击 Follow Sophie 的配置文件,再选择 Sunil 仍然弹出 'Followed Sophie' 的原因:

Demo of correct behavior

这表现是正确的。 (尽管你可能是想 follow Sunil too!)


现在我们明白 了React 中函数组件和类的最大区别了:

函数组件能够捕获渲染过的值。

通过 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 捕获到的 state “属于” 浏览器触发点击事件处理器时的 render。 因此 message 被设置为我点击 “Send” 时 input 的值。


我们知道了 React 中的函数默认会捕获 props 和 state。 但是如果我们 _希望_ 读取最新的,而不是属于特定 render 的 props 或者 state 时怎么办? 如果我们希望 “读取将来的数据呢”?

在类中,你能够通过读取this.props 或者 this.state 做到这些,因为 this 自身是可变的。React 改变了它。在函数组件中,你也可以拥有一个被所有组件 render 共享的可变值。 它就是 “ref”:

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

然而你必须自己管理它。

A ref 与实例属性一样 。它是进入可变世界的安全舱。你可能熟悉 “DOM refs”,但 ref 的概念更广泛。它仅仅是一个能够让你放置东西的盒子。

视觉上,this.somethingsomething.current一样。实际上它们概念上也是一样的。

默认情况下,React 在函数组件里不会为最新的 props 或者 state 创建 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 ,我们将会看到按下发送按钮时的消息。但当我们读取 latestMessage.current,我们得到的是最新的值, —即使在按下发送按钮后继续输入。

你可以自行比较这 两个 示例 的差别。 ref 是一种 “退出” 渲染一致性的方法,在某些情况下能够用得上。

通常,你应该避免在渲染时读取或者设置 refs,因为它们是可变的。我们希望保持渲染的可预测性。 然而,如果我们希望得到特定 prop 或者 state 的最新值,手动更新 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 和 Suspense 等依赖于可中断渲染的特性。

通常情况下不需要这样使用 ref。 捕获 props 或者 state 是更好的选择。 不过, 使用imperative APIs能够很方便的解决 intervals 和订阅这类问题。请记住,你能跟踪任一值,如 — 一个 prop,一个 state 变量,整个 props 对象,,甚至是一个函数。

这种方式能够很方便的优化像useCallback频繁变化这种情况。不过,使用 reducer往往是 更好的解决方式. (未来的一个博客主题!)


本文中,我们研究了类中常见的 bug,以及如果使用闭包修复。然而,你可能会注意到,当你尝试使用指定的依赖数组来优化 Hooks 时,会遇到由闭包未更新导致的 bug。那么闭包是问题所在吗?我不这么认为。

正如我们上面看到的,闭包确实帮助我们 修复了 这个难以发现的问题。同样的,在并发模式下使用闭包能更容易编写正确的代码。因为组件内部逻辑屏蔽了渲染过的 props 和 state。

目前为止我所见过的情况, “旧闭包” 问题都是由于错误的假设 “函数是不会变化的”或者 “props 永远保持不变”导致的。 事实并非如此,我希望本文有助于澄清。

函数更新时会覆盖它们的 props 和 state — 因此它们的标识也同样重要。这不是 bug,是函数组件的一个特性。函数不应该被如 useEffect 或者 useCallback的“依赖数组”排除在外, (通常是通过useReducer 或者 the useRef来解决以上问题 — 接下来将分析如何选择它们。)

当我们的 React 代码主要使用函数编写时,我们需要调整对 优化代码 以及 会随着时间变化的值的认识。

正如 Fredrik 所说:

至今为止我所发现的应用 hook 最好的中心思想是 ”代码想任一值一样,任何时候都能改变”。

函数也不例外。在 React 的学习材料中普及需要点时间。它需要对类的认识做些调整。我希望这篇文章能够你以新的眼光看待类。

React 函数总是能捕获到它们需要的值 — 现在我们明白了其中的原因。

Smiling Pikachu

它们是完全不同的精灵。