阅读 977

Function 与 Classes 组件的区别在哪?

React function 组件和 React classes 有什么不同?

以前,一个标准答案是说 classes 提供更多的功能(例如 state)。有了 Hooks,便不是这样了。

可能你听过其中一个性能更好。哪一个?许多这样的性能基准都存在缺陷,所以我会小心地从中得出结论。性能主要取决于代码而不是选择一个 function 或者 一个 class。在我们观察中,即使优化策略有所不同,但性能的差距其实微乎其微。

另一方面我们不推荐重写你写好的组件,除非你有其他原因且不介意成为早期试验者。Hooks 仍然很新(就像 2014 年的 React),并且一些“最佳实践”还未写进教程。

React function 和 classes 是否存在本质上的区别?当然,它们 —— 在心智模型中。在这篇文章里,我会看看它们之间的最大区别。这在2015年的 function components 中介绍过,但它经常被忽视了:

Function 组件捕获 render 后的值

让我们来分析下这是什么意思。


注意:这片文章不做 classes 或者 functions 的价值衡量,我只描述两种编程模型在 React 中的区别。更多关于采用 functions 的问题,请参阅 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 会怎么样呢?直接翻译后可能看起来像这样:

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

但是,这两段代码略微不同。 好好看看它们,有看出不同了吗?就个人而言,我花了好一会才发现。

前面有剧透,如果你想自己找到的话,这是一个在线demo。文章接下来的部分来分析这个差异及为什么会这样。


在我们继续之前,我想强调下,我所描述的差异与 React Hooks 自身无关,上面的例子甚至不需要用 Hooks!

这完全是关于 functions 和 classes 在 React 中的区别的,如果你打算在 React 应用中更常用 functions,你可能想去弄懂它。


我们将通过 React 应用中常见的一个 bug 来说明这区别

使用当前的条目选择器和之前两个 ProfilePage 实现来打开这个 sandbox 例子 —— 每个渲染一个 Follow 按钮。

按照这种操作顺序使用两个按钮:

  1. 点击 其中一个按钮。
  2. 在 3 秒中内改变选择条目。
  3. 看下弹出的文本。

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

  • 当为 functionProfilePage 时,点击 Follow Dan 的条目然后切换成 Sophie 的,仍然弹出 'Followed Dan'

  • 当为 classProfilePage 时,它会弹出 'Followed Sophie'

Demonstration of the steps


这个例子中,第一种行为是正确的。如果你关注一个人,然后切换到另外一个人的条目,我的组件不应该困惑于我要关注的是谁。class 的实现明显是个错误。


所以为什么我们的 class 例子会以这种方式运行?

让我们仔细看看 class 中 showMessage 方法:

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

这个 class 方法读取了 this.props.user,Props 在 React 中是不可变的。 但是,this ,且已经改变了

实际上,这就是在 class 中有 this 的目的,React 本身会随着时间的推移而变异,以便你在可以渲染和生命周期中获取到新版本。

所以如果我们组件在处于请求状态时重渲染,this.props 会发生改变。 showMessage 方法从“太新”的 props 中获取 user

这暴露了一个 UI 层性质上的有趣现象。如果我们说 UI 在概念上是当前应用程序状态的函数,则事件处理程序是渲染结果的一部分 —— 就像视觉输出一样。我们的事件处理程序“属于”具有特定 props 和 state 的特定 render。

但是,调度一个回掉读取 this.props 的 timeout 会中断该联系。我们的 showMessage 回调没有“绑定”到任何特定 render 上,因此它“丢失”了正确的 props,而读取了 this 切断这种联系。


可以说 function 组件不存在这个问题。我们要这么解决这个问题?

我们想以某种方式 “修复” 有正确 props 的 render 与获取它们的 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.somethingthis.state.something,我们会再次遇到同样的问题。所以我们不得不将 this.propsthis.state 做为参数传给每个调用了 showMessage 的方法。

这样做通常会破坏通常由 class 提供的人体工程学,也难以记住或强制执行,这就是大家经常出现 bugs 的原因。

类似的,把 alert 放入 handleClick 中也无法解决这个难题。我们希望以允许拆分更多方法的方式构造代码,同时我们还要读取与该调用相关 render 的对应 props 和 state。这个问题甚至不是 React 独有的 —— 你可以在任何将数据放入像 this 可变对象的 UI 库中重现它

或许,我们可以在 constructor 里 bind 方法?

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() {
    // 捕获 props!
    const props = this.props;

    // 注意: 我们在 *render 里面*
    // 这不是 class 方法。
    const showMessage = () => {
      alert('Followed ' + props.user);
    };

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

    return <button onClick={handleClick}>Follow</button>;
  }
}
复制代码

你在 render 时已经“捕获”到 props 了

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

我们在里边添加多少个辅助方法都可以,并且它们全都使用被捕获的 props 和 state,救回了闭包。


上面的例子没有错但看起来奇怪。如果在 render 中定义函数而不是使用 class 的方法,那还要 class 做什么?

事实上,我们可以去掉 class 这个“壳”来简化代码:

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

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

  return (
    <button onClick={handleClick}>Follow</button>
  );
}
复制代码

就像上面那样,props 仍然被捕获了 —— React 用参数形式传递它们。不像 thisprops 对象本身没有被 React 改变

如果在 function 定义时解构 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 回调,它们都完好无损。

这就是为什么,在这个 demo 的 function 版本中,在 Sophie 的条目时点击 Follow 之后切换成 Sunil 会弹出 'Followed Sophie'

这反应是正确的。(虽然你也可能想关注 Sunil!)


现在我们明白了 functions 与 classes 在 React 中的最大不同了:

Function 组件捕获 渲染后的值

使用 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>
    </>
  );
}
复制代码

([这是一个 线上 demo。])

虽然这不是一个好的消息应用 UI,它实现了同样的东西:如果我发送一个特定的信息,这个组件不应该困惑于发送哪个消息。这个 function 组件的消息捕获了 state 且“属于”返回被浏览器点击事件调用的 render。所以这个消息被设定为当我点击”发送“时 input 里的值。


所以我们知道 React 中的 functions 会默认捕获 props 和 state。但如果我们希望读取的是最新的 props 或者 state,它们不属于特定的 render 要怎么办?如果我们想在未来里读取到它们怎么办?

在 classes 中,你可以读取 this.propsthis.state,因为 this 本身是可变的,React 会改变它。在 function 组件中,你也可以有一个共享于所有组件 renders 的可变值,它叫做 “ref”:

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

但是,你需要自己管理它。

ref 和实例字段扮演相同的角色,它是进入可变命令世界的逃脱仓。你可能熟悉 “DOM refs”,但这个原理要通俗的多,它只是一个你可以往里面放东西的箱子。

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

默认情况下,在 function 组件中 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;
  };
复制代码

如果我们读取 showMessagemessage,我们会看到我们第几发送按钮时的消息。但当我们读取 latestMessage.current,我们获取到的是最新的值 —— 即使我们在按下发送按钮后继续输入。

你可以比较这两个 demos 看看区别。ref 是一种“选择退出”渲染一致的方法,在某些情况下可以很方便。

通常你应该避免在渲染期间读取或设置 refs,因为它们是可变的。我们想保持渲染的可预测性。但是,如果我们想获取到特定 prop 或 state 最新的值,手动更新 ref 会很麻烦。我们可以用 effect 自动化它:

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

  // 保持 track 是最新值
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message;
  });

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

(这是一个demo。)

我们在 effect 里面赋值来实现 ref 值只在 DOM 被更新时才改变。这确保我们的变异不会破坏像 Time Slicing 和 Suspense 等中断渲染的功能。

很少会像这样去使用 ref,捕获 props 或 state 默认下是更好的。然而,在处理像定时器和订阅这样的棘手地 APIs 时是很方便的。记住你可以跟踪任何这样的值 —— prop、state 变量、整个 props 对象,甚至是一个 function。

这种模式也可以用来做优化 —— 例如在 useCallback 标示频繁改变时。但是,使用一个 reducer 通常是一个更好的解决方案。(这个会在以后的博客文章中写!)


这片文章里,我们看到在 classes 中的普遍破坏模式,及闭包是如何帮助我们修复它的。但是,你可能注意到了当你试着通过指定依赖数组来优化 Hooks 时,你可能会遇到过时闭包带来的 bugs。这意味着闭包是问题?我也不这么认为。

正如我们之前所见,闭包确实帮助我们修复了难以注意到的细微问题。同样地,它们使编写在并发模式下的代码正常工作变得更简单。这可能是因为组件内部的逻辑在渲染后封锁正确的 props 和 state。

在目前为止看到的所有情况中,“过时闭包”问题发生是由于 “functions 不发生变化” 或 “props 总是相同”的错误假设。事实并非如此,我希望这篇文章有助于澄清。

Functions 锁住它们的 props 和 state —— 所有它们是什么很重要。这不是一个 bug,而是一个 function 组件的特性。例如,Functions 不应该从 userEffectuseCallback 的“依赖数组”中被排除。(上面提到常用的适当修复不管是 useReducer 或是 useRef 的解决方案 —— 我们很快会在文档中说明如何在它们之间做选择)

在我们用 functions 写大多数 React 代码时,我们需要适配我们的关于 优化代码什么值会一直改变的情况。

到目前为止我用 hooks 找到的最好的心理规则是 “代码的任何值似乎可以在任意时间改变”。

Functions 也不例外。这需要一些时间才能在 React 学习材料里面变成普遍的知识,从 class 心态过来的需要一些适应,但我希望这篇文章可以帮助你用新的眼光看待它。

React functions 总会捕获它们的值 —— 且现在我们知道为什么了。

它们是一个完全不同的神奇宝贝。

翻译原文How Are Function Components Different from Classes?(2019-03-03)

关注下面的标签,发现更多相似文章
评论