react作者“Dan”告诉你React函数组件与类组件有什么不同?

react作者“Dan”告诉你React函数组件与类组件有什么不同?

原文 how-are-function-components-different-from-classes Dan Abramov 发布于2019年03月03日

一段时间以来,规范的答案是类提供了更多功能(如状态)。但是有了Hooks,这种观点已经不再成立。

也许你听说过其中一个对性能更好。是哪一个?很多此类基准测试都有缺陷,所以我会小心从中得出结论。性能主要取决于代码的执行内容,而不是你选择了函数还是类。根据我们的观察,性能差异可以忽略不计,尽管优化策略有些不同。

在任何一种情况下,我们都不推荐重写现有组件,除非你有其他理由并且不介意成为早期采用者。Hooks还很新(就像2014年的React一样),一些“最佳实践”还没有进入教程。

那么我们会怎样呢??React函数和类之间真的有本质区别吗?当然,有 —— 在心智模型中。在这篇文章中,我将探讨它们之间最大的不同。自从2015年引入函数组件以来,这种差异一直存在,但经常被忽视:

Function components capture the rendered values.

让我们来解释一下这意味着什么。


注意:这篇文章不是对类或函数的价值判断。我只是描述 React 中这两种编程模型之间的区别。有关更广泛地采用函数的问题,请参阅 Hooks 常见问题解答。


参考这个组件:

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

它显示了一个按钮,用setTimeout模拟了一个网络请求,然后显示一个确认警告。例如,如果props.user是’Dan’,它会在三秒后显示’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>;
  }
}

人们通常认为这两个代码片段是等效的。人们经常在这些模式之间自由重构,而没有注意到它们的含义:

react作者“Dan”告诉你React函数组件与类组件有什么不同?

然而,这两个代码片段略有不同。 好好看看他们。你看到区别了吗?就我个人而言,我花了一段时间才看到这一点。

前面有剧透,所以如果你想自己弄清楚的话,这里有一个示例。本文的其余部分解释了其中的差异及其重要性。


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

这都是关于 React 中函数和类之间的区别。如果您打算在 React 应用中更频繁地使用函数,您可能想了解它。


我们将通过一个在React应用中常见的错误来说明这种差异。
打开这个包含当前配置文件选择器的示例沙箱**,以及上述两种 ProfilePage 实现 —— 每个都渲染了一个 Follow 按钮。

尝试对两个按钮执行以下操作序列:

  1. 单击 其中一个 Follow 按钮
  2. 在 3 秒内 更改 所选配置文件。
  3. 阅读alert文本。

你会注意到一个特殊的区别:

  • 使用上述 ProfilePage 函数,点击 Dan 的配置文件上的 Follow,然后切换到 Sophie 的配置文件,仍然会警告 ‘Followed Dan’ 。
  • 而使用上述 ProfilePage 类,则会警告 ‘Followed Sophie’:

react作者“Dan”告诉你React函数组件与类组件有什么不同?


在这个示例中,第一种行为是正确的。如果我关注了一个人,然后切换到另一个人的配置文件,我的组件不应该对我关注的是谁感到困惑。这个类实现显然是有问题的。

(You should totally follow Sophie though.)
(不过你应该关注 Sophie。)


那么为什么我们的类示例会这样呢?

让我们仔细看看我们类中的 showMessage 方法:

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

这个类方法从 this.props.user 读取。在 React 中,Props 是不可变的,所以它们永远不会改变。然而,this 是可变的,而且一直都是。

实际上,这是类中 this 的全部用途。React 随时间对其进行变更,以便你可以在 render 和生命周期方法中读取最新的版本。

因此,如果我们的组件在请求进行中重新渲染,this.props 将会改变。showMessage 方法从“最最最新”的 props 中读取用户信息。

这揭示了关于用户界面本质的有趣观察。如果我们说,UI 在概念上是当前应用状态的函数,那么事件处理程序就是渲染结果的一部分——就像视觉输出一样。我们的事件处理程序“属于”特定的渲染,带有特定的 props 和状态。

然而,安排一个超时回调读取 this.props 打破了这种关联。我们的 showMessage 回调不再“绑定”到任何特定的渲染,因此它“失去”了正确的 props。从 this 中读取切断了这种连接。


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

我们希望以某种方式“修复”正确的 props 和读取它们的 showMessage 回调之间的连接。在某个环节上,props 丢失了。

一种做法是在事件发生早期读取 this.props,然后显式地将它们传递到超时完成处理程序中:

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,我们将遇到完全相同的问题。因此,我们将不得不通过从 showMessage 调用的每个方法传递 this.propsthis.state 作为参数。

这样做违背了使用类的便利性。它也很难记住或执行,这就是为什么人们经常选择忍受错误而不是解决它们的原因。

类似地,将警告代码内联到 handleClick 中并不能解决更大的问题。我们想要以一种方式组织代码,使其既能拆分为更多方法,又能读取与该调用相关的渲染的 props 和状态。这个问题甚至不仅限于 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 中,props 和状态是不可变的!(或者至少,强烈建议如此。)这消除了闭包的一个主要隐患。

这意味着,如果你关闭了特定渲染的 props 或状态,你可以始终依赖它们保持完全不变:

class ProfilePage extends React.Component {
  render() {
    // 捕获 props!
    const props = this.props;
 
    // 注意:我们是 *在 render 中*。
    // 这些不是类方法。
    const showMessage = () => {
      alert('Followed ' + props.user);
    };
 
    const handleClick = () => {
      setTimeout(showMessage, 3000);
    };
 
    return <button onClick={handleClick}>Follow</button>;
  }
}

你已经“捕获”了渲染时的 props:

react作者“Dan”告诉你React函数组件与类组件有什么不同?

这样,其中的任何代码(包括 showMessage)都保证看到这个特定渲染的 props。React 不再“移动我们的奶酪”。

然后,我们可以在里面添加尽可能多的辅助函数,它们都将使用捕获的 props 和状态。闭包来拯救!

上面的示例是正确的,但看起来很奇怪。如果你在 render 内定义函数而不是使用类方法,那么拥有一个类的意义是什么?

确实,我们可以通过移除围绕它的类“壳”来简化代码:

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

就像上面一样,props 仍然被捕获 —— React 将它们作为参数传递。与 this 不同,React 永远不会改变 props 对象本身。

如果你在函数定义中解构 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 值和读取它的 showMessage 回调。它们都保持完好无损。

这就是为什么,在这个demo的函数版本中,点击 Sophie 的配置文件上的关注按钮,然后改变选择到 Sunil 会警告 ‘Followed Sophie’:

react作者“Dan”告诉你React函数组件与类组件有什么不同?

这种行为是正确的。(尽管你可能也想关注 Sunil!)


现在我们理解了 React 中函数和类的一个重大差异:

函数组件捕获了渲染值。

有了 Hooks,这个原则同样适用于状态。考虑以下示例:

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,但它说明了同样的观点:如果我发送了一个特定消息,组件不应该对哪条消息被发送感到困惑。这个函数组件的消息捕获了“属于”返回了浏览器调用的点击处理器的渲染的状态。所以消息设置为我点击“发送”时输入框中的内容。

因此,我们知道 React 中的函数默认捕获 props 和状态。但如果我们想读取不属于这个特定渲染的最新 props 或状态怎么办?如果我们想““从未来读取它们””怎么办?

在类中,你可以通过读取 this.propsthis.state 来做到这一点,因为 this 本身是可变的。React 会改变它。在函数组件中,你也可以有一个可变值,由所有组件渲染共享。它称为“ref”:

function MyComponent() {
  const ref = useRef(null);
  // 你可以读取或写入 `ref.current`。
  // ...
}

然而,你需要自己管理它。

ref 扮演着实例字段相同的角色。它是进入可变命令式世界的逃生舱。你可能熟悉“DOM refs”,但这个概念要广泛得多。它只是一个你可以放入某些东西的盒子。

即使从视觉上看,this.something 看起来像 something.current 的镜像。它们代表相同的概念。

默认情况下,React 在函数组件中不为最新的 props 或状态创建 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 或状态的最新值,手动更新 ref 可能会感到烦恼。我们可以通过使用效果(effect)来自动化这一过程:

function MessageThread() {
  const [message, setMessage] = useState('');
 
  // 跟踪最新值。
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message;
  });
 
  const showMessage = () => {
    alert('You said: ' + latestMessage.current);
  };
}

(Here’s a demo.)

我们在效果(effect)内进行赋值,以便只有在 DOM 更新后 ref 的值才会改变。这确保了我们的变更不会破坏依赖可中断渲染的功能,如 Time Slicing 和 Suspense。

使用像这样的 ref 并不是经常必要的。默认情况下捕获 props 或状态通常是更好的选择。然而,当处理诸如间隔和订阅等命令式 API 时,它可能很方便。记住,你可以像这样跟踪任何值——一个 prop,一个状态变量,整个 props 对象,甚至是一个函数。

这种模式对优化也可能很有用——比如当 useCallback 的标识变化太频繁时。然而,使用 reducer 通常是一个更好的解决方案。(一个未来博客文章的主题!)

在这篇文章中,我们探讨了类中常见的破碎模式,以及闭包如何帮助我们修复它。然而,你可能已经注意到,当你尝试通过指定依赖数组来优化 Hooks 时,你可能会遇到过时闭包的问题。这是否意味着闭包是问题所在?我不这么认为。

正如我们上面看到的,闭包实际上帮助我们修复难以察觉的微妙问题。同样,它们使编写在并发模式下正确工作的代码变得更加容易。这是因为组件内的逻辑闭合了与其渲染时的正确 props 和状态。

到目前为止,我所看到的所有“过时闭包”问题都是由于错误地假设“函数不会改变”或“props 总是相同的”。我希望这篇文章有助于澄清这一点。

函数关闭了它们的 props 和状态——因此它们的标识同样重要。这不是 bug,而是函数组件的一个特性。例如,函数不应该从 useEffect 或 useCallback 的“依赖数组”中排除。(正确的修复通常是 useReducer 或上面的 useRef 解决方案——我们将很快记录如何在它们之间选择。)

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

正如 Fredrik 所说:

我到目前为止找到的最好的心理规则是“假设任何值都可以随时改变”。

函数也不例外。这个观点成为 React 学习材料中的常识需要一些时间。它需要从类思维模式中做出一些调整。但我希望这篇文章帮助你用新的眼光看待它。

React 函数总是捕获它们的值——现在我们知道为什么了。

react作者“Dan”告诉你React函数组件与类组件有什么不同?

他们是完全不同的皮卡丘。


原文链接:https://juejin.cn/post/7348760857848184844 作者:吴彦祖火星分祖

(0)
上一篇 2024年3月22日 下午4:31
下一篇 2024年3月22日 下午4:41

相关推荐

发表回复

登录后才能评论