【译】函数组件和类组件有什么不同?

3,508 阅读11分钟

原文链接:overreacted.io/how-are-fun…

在很长一段时间内,标准答案是class components提供更多的特性(像state)。但随着Hooks的出现,答案就不再是这样子了。

或许你听说过他们中的一个性能可能更好,哪一个?因为各种的判断标准获取都存在缺陷,所以我们需要小心仔细的得出结论。性能的好坏主要取决于什么?它主要取决于你的代码在做什么,而不是你使用的是function还是class。在我们的观察中,尽管优化的策略可能会有些许的不同,但性能的差异几乎可以忽略不及。

无论是哪种情况,我们都不建议你重写现有的组件,除非你有一些其他的原因或者是想成为Hooks的早期的采用者。Hooks仍然是一个新特性(就像2014年的React一样),一些最佳实践还没有被写入到教程中。

那我们该怎么办?function componentsclass components之间有什么本质的区别吗?显然,在构思模型中是不同的。在这篇文章中,我们将看到他们之间最大的区别,自从2015年推出function componetns以来,它就一直存在着,但是却经常被忽视:

function components捕获渲染值(capture value)

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

思考下面这样一个组件:

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

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

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

这个组件通过setTimeout模拟网络请求,在点击按钮3秒后弹出props.user的值,如果props.user的值是Dan的话,他将在点击后3秒弹出“Followed Dan”。(注意,使用箭头函数还是函数声明的形式并不重要,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>;
  }
}

通常认为这两段代码是等效的。但是大家经常在这两种形式之间来回切换代码而不去关注他们的含义。

然而其实这两段代码是有细微的差别的,就我个人而言,我花费了一段时间才看出来。

如果你自己想弄清楚的话,这里有一个在线demo。本文的剩余部分解释了有什么不同和它的重要性。


再继续之前,我要强调,我所描述的差异本身和React Hooks无关,上面的例子甚至都没有使用Hooks。

这全部都是React中,function和class的差别。如果你想要在React应用中去频繁的使用function components,那么你应该去了结它。


我们将用一个在React应用程序中常见的错误来说明这一区别。

打开这个示例,有一个主页select和两个主页,且每一个包含一个Follow按钮。

尝试按照下面的顺序操作:

  1. 点击一个Follow按钮
  2. 改变select选项然后等待3秒
  3. 查看alert的文字

你会发现一个问题:

  • function components中,在Dan的主页点击follow然后切换到Sophie,alert仍然会展示“Followed Dan”。
  • class components中,alert的却是“Followed Sophie”。

例子


在这个例子中,第一个行为是正确的。如果我Follow A,然后导航B的主页,我的组件不应该Follow到B。这个class显然有缺陷。


所以为什么我们class的例子展示出这样的结果呢?

让我们仔细研究一下class中的showMessage方法:

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

这个方法从this.props.user取值,在React中,props应该是不可变的,但是this却是可变的。

实际上,在React内部会随着时间的推移改变this,以便可以在render和生命周期中取到最新的版本。

所以如果我们的组件在请求过程中re-render,this.props将会改变,showMessage方法将会从“最新”的props中取到user的值。

这就暴露了一个关于UI的有趣问题。如果说UI是一个关于当前应用state的函数,那么事件处理函数就是render的一部分,就像是可视化输出一样。我们的事件处理函数“属于“某一特定state和props的render。

但是在包含超时操作的回调函数内读取this.props会破坏这个关联。showMessage没有“绑定”到任何一个特定的render,因此它“丢失”了正确的props。


我们说function components不会存在这个问题。那么我们该怎么去解决呢?

我们需要去用某种方式“修复”正确的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>;
  }
}

这是可行的。然而,这种方法使代码更加冗长,并且随着时间的推移更容易出错。如果我们需要的不仅仅是一个单一的props怎么办?如果ShowMessage调用另一个方法,而该方法读取this.props.something或this.state.something,我们将再次遇到完全相同的问题。所以我们必须通过在ShowMessage调用的每个方法,将this.props和this.state作为参数传递。

这样做我们通常会破坏一个class,并且会导致很多bug出现。

同样,在handleClick中用alert展示也不能暴露出更深的问题。如果我们想要去结构化我们的代码,将代码拆分出不同的方法,并且在读取props和state时也能保持一样的展示结果,而且不仅仅在React中,你也可以在任何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太晚了,而不是我们使用何种语法。但是,如果我们完全依赖于js的闭包,问题就会得到解决。

闭包通常是被避免的,因为它很难考虑一个随时间变化的值。但是在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。

demo

这样,它里面的任何代码(包括showMessage)都可以保证取到某个特定render中的props了。

然后我们可以添加很多的helper函数,他们都可以捕获到props和state。闭包救了我们。


上面的例子是正确的,但看起来很奇怪。如果只是在render中定义函数而不是使用类方法,那么我们使用一个class又有什么意义呢?

实际上我们可以通过移除class来简化代码:

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

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

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

就像上面所说的,props仍然可以被捕获到,React将它作为一个参数传递。不同的是,props对象本身不会因React而发生变化了。

在下面中就更明显了:

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

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

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

当父组件根据不同的props渲染时,React将会再次调用function,但是我们点击的事件处理函数是上一个包含user值的render,并且showMessage函数已经拿到了user这个值

这就是为什么在这个版本的function demo中在Sophie主页点击Follow,然后改变select,将会alert “Followed Sophie”。

demo


现在我们知道了在React中 function 和 class的最大不同。

function components捕获渲染值(capture value)

对于钩子,同样的原理也适用于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

这个例子说明了相同点:在点击send按钮后,再次修改输入框的值,3秒后的输出依然是点击前输入框的值。这说明function Hooks同样具有capture value的特性。


所以我们知道了在React中function默认情况下会捕获props和state(capture value)。但是如果我们想要去避免这个capture value呢?

在class中,我们可以通过使用this.props和this.state,因为this本事是可变的,React改变了它,在function components中,还有一个被所有组件所共享的可变值,被叫做ref

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

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

ref和实例字段有着相同的作用,你也许更为熟悉“dom refs”,但是这个概念更为普遍,它仅仅是一个“放置一些东西的通用容器”。

尽管看起来它像是某一些东西的镜像,但实际上他们表示着相同的概念。

React默认不会为function components创建保存最新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;
  };

这时我们发现,在点击send按钮后继续输入,3秒后alert的是点击按钮后输入的值而不是点击按钮钱输入的值。

通常,应该避免在渲染期间读取或设置refs,因为它们是可变的。我们希望保持渲染的可预测性。但是,如果我们想要获取特定props或state的最新值,手动更新ref可能会很烦人。我们可以通过使用一种效果来实现自动化(useEffect在每次render都会执行):

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

这里是demo

我们在effect中进行赋值,以便ref的值只在DOM更新后才更改。

像这样使用ref不是经常需要的。通常capture props或state才是默认更好的选择。但是,在处理诸如间隔和订阅之类的命令式API时,它非常方便。记住,您可以跟踪任何这样的值:一个prop、一个state变量、整个props对象,甚至一个函数。


在本文中,我们研究了class中常见的中断模式,以及闭包如何帮助我们修复它。但是,你可能已经注意到,当你试图通过指定依赖数组来优化Hooks时,可能会遇到带有过时闭包的错误。这是否意味着闭包是问题所在?我不这么认为。

正如我们上面所看到的,闭包实际上帮助我们解决了难以注意到的细微问题。类似地,它们使在并发模式下正确地编写代码变得更加容易。

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

function components没有props和state,因此它们的也同样重要。这不是bug,而是function components的一个特性。例如,函数不应该从useEffect或useCallback的“依赖项数组”中被排除。(正确的解决方案通常是上面的useReducer或useRef解决方案。)

当我们用函数编写大多数React代码时,我们需要调整优化代码的直觉,以及什么值会随着时间而改变。

正如Fredrik所说:

对于Hooks,我迄今为止发现的最好的规则是“代码就像任何值在任何时候都可以改变”。

React的function总是捕捉它们的值(capture value)—— 现在我们知道为什么了。