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 常见问题解答。


参考这个组件:

jsx 复制代码
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()会以完全相同的方式工作。)

我们如何将其转换为一个类?一个简单的转换可能看起来像这样:

jsx 复制代码
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 Hooks 本身无关。上面的例子甚至没有使用 Hooks!

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


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

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

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

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

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

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

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


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

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

jsx 复制代码
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,然后显式地将它们传递到超时完成处理程序中:

jsx 复制代码
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 库中都可以复现它。

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

jsx 复制代码
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 或状态,你可以始终依赖它们保持完全不变:

jsx 复制代码
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:

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

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

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

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

jsx 复制代码
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,这一点会更明显:

jsx 复制代码
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':

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


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

函数组件捕获了渲染值。

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

jsx 复制代码
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":

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

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

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

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

默认情况下,React 在函数组件中不为最新的 props 或状态创建 refs。在许多情况下,你不需要它们,分配它们将是浪费的工作。然而,如果你想,你可以手动跟踪这个值:

jsx 复制代码
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)来自动化这一过程:

jsx 复制代码
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 函数总是捕获它们的值------现在我们知道为什么了。

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


相关推荐
come1123412 分钟前
Vue 响应式数据传递:ref、reactive 与 Provide/Inject 完全指南
前端·javascript·vue.js
前端风云志34 分钟前
TypeScript结构化类型初探
javascript
musk12121 小时前
electron 打包太大 试试 tauri , tauri 安装打包demo
前端·electron·tauri
翻滚吧键盘1 小时前
js代码09
开发语言·javascript·ecmascript
万少2 小时前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL2 小时前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl022 小时前
java web5(黑马)
java·开发语言·前端
Amy.Wang2 小时前
前端如何实现电子签名
前端·javascript·html5
海天胜景2 小时前
vue3 el-table 行筛选 设置为单选
javascript·vue.js·elementui
今天又在摸鱼2 小时前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js