13

[译]React函数组件和类组件的差异

 3 years ago
source link: https://zhuanlan.zhihu.com/p/62767474
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

[译]React函数组件和类组件的差异

[译]React函数组件和类组件的差异

原文https://overreacted.io/how-are-function-components-different-from-classes/

React.js 开发中,函数组件(function component) 和 类组件(class component) 有什么差异呢?

在以前,通常认为区别是,类组件提供了更多的特性(比如state)。随着 React Hooks 的到来,这个说法也不成立了(通过hooks,函数组件也可以有state和类生命周期回调了)。

或许你也听说过,这两类组件中,有一类的性能更好。哪一类呢?很多这方面的性能测试,都是 有缺陷的 ,因此要从这些测试中 得出结论 ,不得不谨慎一点。性能主要取决于你代码要实现的功能(以及你的具体实现逻辑),和使用函数组件还是类组件,没什么关系。我们观察发现,尽管函数组件和类组件的性能优化策略 有些不同 ,但是他们性能上的差异是很微小的。

不管是上述哪个原因,我们都 不建议 你使用函数组件重写已有的类组件,除非你有别的原因,或者你喜欢当第一个吃螃蟹的人。React Hooks 还很新(就像2014年的React一样),目前还没有使用hooks相关的最佳实践。

除了上面这些,还有什么别的差异么?在函数组件和类组件之间,真的存在根本性的不同么?"Of course, there are — in the mental model" (这个实在不知道咋表达,贴下作者原文吧 ) 在这篇文章里,我们将一起看下,这两类组件最大的不同。这个不同点,在2015年函数组件函数组件 被引入React 时就存在了,但是被大多数人忽略了。

函数组件和类组件的差异

函数组件会捕获render内部的状态

让我们一步步来看下,这代表什么意思。

注意,本文并不是对函数组件和类组件进行价值判断。我只是展示下React生态里,这两种组件编程模型的不同点。要学习如何更好的采用函数组件,推荐官方文档 Hooks FAQ

假设我们有如下的函数组件:

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

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

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

组件会渲染一个按钮,当点击按钮的时候,模拟了一个异步请求,并且在请求的回调函数里,显示一个弹窗。比如,如果 props.user 的值是 Dan,点击按钮3秒之后,我们将看到 Followed Dan 这个提示弹窗。非常简单。

(注意,上面代码里,使用箭头函数还是普通的函数,没有什么区别。因为没有 this 问题。把箭头函数换成普通函数 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>;
  }
}

通常情况下,我们会认为上面两个组件实现,是完全等价的。开发者经常像上面这样,在函数组件和类组件之间重构代码,却没有意识到他们隐含的差异。

v2-1d3c7a341ee3fcadc79df00e7d872e4b_b.jpg

但是,上面两种实现,存在着微妙的差异 。再仔细看一看,你能看出其中的差异么?讲真,还是花了我一段时间,才看出其中的差异。

如果你想在线看看源代码,你可以 点击这里 。 本文剩余部分,都是讲解这个不同点,以及为什么不同点会很重要。

在我们继续往下之前,我想再次说明下,本文提到的差异性,和react hooks本身完全没有关系!上面例子里,我都没用到hooks呢。

本文只是讲解,react生态里,函数组件和类组件的差异性。如果你打算在react开发中,大规模的使用函数组件,那么你可能需要了解这个差异。

我们将使用在react日常开发中,经常遇到的一个bug,来展示这个差异

接下来,我们就来复现下这个bug。打开 这个在线例子 ,页面上有一个名字下拉框,下面是两个关注组件,一个是前文的函数组件,另一个是类组件。

对每一个关注按钮,分别进行如下操作:

  1. 点击其中1个关注按钮
  2. 在3秒之内,重新选择下拉框中的名字
  3. 3秒之后,注意看alert弹窗中的文字差异

你应该注意到了两次alert弹窗的差别:

  • 在函数组件的测试情况下,下拉框中选中 Dan,点击关注按钮,迅速将下拉框切换到Sophie,3秒之后,alert弹窗内容仍然是 Followed Dan
  • 在类组件的测试情况下,重复相同的动作,3秒之后,alert弹窗将会显示 Followed Sophie
v2-386a449110202d5140d67336a0ade5a0_b.jpg

在这个例子里,使用函数组件的实现是正确的,类组件的实现明显有bug。如果我先关注了一个人,然后切换到了另一个人的页面,关注按钮不应该混淆我实际关注的是哪一个

(PS,我也推荐你真的关注下 Sophie)

那么,为什么我们的类组件,会存在问题呢?

让我们再仔细看看类组件的 showMessage 实现:

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

这个方法会读取 this.props.user 。在React生态里,props是不可变数据,永远不会改变。但是,this却始终是可变的

确实,this存在的意义,就是可变的。react在执行过程中,会修改this上的数据,保证你能够在 render和其他的生命周期方法里,读取到最新的数据(props, state)。

因此,如果在网络请求处理过程中,我们的组件重新渲染,this.props改变了。在这之后,showMessage方法会读取到改变之后的 this.props

这揭示了用户界面渲染的一个有趣的事实。如果我们认为,用户界面(UI)是对当前应用状态的一个可视化表达(UI=render(state)),那么事件处理函数,同样属于render结果的一部分,正如用户界面一样 。我们的事件处理函数,是属于事件触发时的render,以及那次render相关联的 propsstate

然而,我们在按钮点击事件处理函数里,使用定时器(setTimeout)延迟调用 showMessage,打破了showMessagethis.props的关联。showMessage回调不再和任何的render绑定,同样丢失了本来关联的props。从 this 上读取数据,切断了这种关联。

假如函数组件不存在,那我们怎么来解决这个问题呢?

我们需要通过某种方式,修复showMessage和它所属的 render以及对应props的关联。

一种方式,我们可以在按钮点击处理函数中,读取当前的 props,然后显式的传给 showMessage,就像下面这样:

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

这种方式 可以解决这个问题 。然而,这个解决方式让我们引入了冗余的代码,随着时间推移,容易引入别的问题。如果我们的 showMessage方法要读取更多的props呢?如果showMessage还要访问state呢?如果 showMessage 调用了其他的方法,而那个方法读取了别的状态,比如this.props.somethingthis.state.something ,我们会再次面临同样的问题。 我们可能需要在 showMessage 里显式的传递 this.props this.state 给其他调用到的方法。

这样做,可能会破坏类组件带给我们的好处。这也很难判断,什么时候需要传递,什么时候不需要,进一步增加了引入bug的风险。

同样,简单地把所有代码都放在 onClick 处理函数里,会带给我们其他的问题。为了代码可读性、可维护性等原因,我们通常会把大的函数拆分为一些独立的小的函数。这个问题不仅仅是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和state是 不可变的(严格来说,我们强烈推荐将props和state作为不可变数据)。props和state的不可变特性,完美解决了使用闭包带来的问题。

这意味着,如果在 render方法里,通过闭包来访问props和state,我们就能确保,在showMessage执行时,访问到的props和state就是render执行时的那份数据:

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

v2-fa483dd5699aac1350c57591770a49be_b.jpg

通过这种方式,render方法里的任何代码,都能访问到render执行时的props,而不是后面被修改过的值。react不会再偷偷挪动我们的奶酪了。

像上面这样,我们可以在render方法里,根据需要添加任何帮助函数,这些函数都能够正确的访问到render执行时的props。闭包,这一切的救世主。

上面的代码,功能上没问题,但是看起来有点怪。如果组件逻辑都作为函数定义在render内部,而不是作为类的实例方法,那为什么还要用类呢?

确实,我们剥离掉类的外衣,剩下的就是一个函数组件:

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

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

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

这个函数组件和上面的类组件一样,内部函数捕获了props,react会把props作为函数参数传进去。和this不同的是,props是不可变的,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。但是在这之前,我们点击关注按钮的事件处理函数,已经捕获了上一次render时的props。

这就是为什么,在 这个例子 的函数组件中,没有问题。

v2-84396c4b3982827bead96912a947904e_b.jpg

可以看到,功能完全是正确的。(再次PS,建议你也关注下 Sunil)

现在我们理解了,在函数组件和类组件之间的这个差异:

函数组件会捕获render内部的状态

函数组件配合React Hooks

在有 Hooks 的情况下,函数组件同样会捕获render内部的 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,点击这里)

尽管这是一个很简陋的消息发送组件,它同样展示了和前一个例子相同的问题:如果我点击了发送按钮,这个组件应该发送的是,我点击按钮那一刻输入的信息。

OK,我们现在知道,函数组件会默认捕获props和state。但是,如果你想读取最新的props、state呢,而不是某一时刻render时捕获的数据? 甚至我们想在 将来某个时刻读取旧的props、state 呢?

在类组件里,我们只需要简单的读取 this.props this.state 就能访问到最新的数据,因为react会修改this。在函数组件里,我们同样可以拥有一个可变数据,它可以在每次render里共享同一份数据。这就是hooks里的 useRef

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

但是,你需要自己维护 ref 对应的值。

函数组件里的 ref 和类组件中的实例属性 扮演了相同的角色 。你或许已经熟悉 DOM refs,但是hooks里的 ref 更加通用。hooks里的 ref 只是一个容器,你可以往容器里放置任何你想放的东东。

甚至看起来,类组件里的 this.something 也和hooks里的 something.current 相似,他们确实代表了同一个概念。

默认情况下,react不会给函数组件里的props、state创造refs。大多数场景下,你也不需要这样做,这也需要额外的工作来给refs赋值。当然了,你可以手动的实现代码来跟踪最新的state:

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,我们会得到输入框里最新的值——甚至我们在点击发送按钮后,不断的输入新的内容。

你可以对比 这两个demo 来看看其中的不同。

通常来讲,你应该避免在render函数中,读取或修改 refs ,因为 refs 是可变的。我们希望能保证render的结果可预测。但是,如果我们想要获取某个props或者state的最新值,每次都手动更新refs的值显得很枯燥 。这种情况下,我们可以使用 useEffect 这个hook:

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 在这里)

我们在 useEffect 里去更新 ref 的值,这保证只有在DOM更新之后,ref才会被更新。这确保我们对 ref 的修改,不会破坏react中的一些新特性,比如 时间切分和中断 ,这些特性都依赖 可被中断的render。

像上面这样使用 ref 不会太常见。大多数情况下,我们需要捕获props和state 。但是,在处理命令式API的情况下,使用 ref 会非常简便,比如设置定时器,订阅事件等。记住,你可以使用 ref 来跟踪任何值——一个prop,一个state,整个props,或者是某个函数。

使用 ref 在某些性能优化的场景下,同样适用。比如在使用 useCallback 时。但是,使用useReducer 在大多数场景下是一个 更好的解决方案

在这篇文章里,我们回顾了类组件中常见的一个问题,以及怎样使用闭包来解决这个问题。但是,你可能已经经历过了,如果你尝试通过指定hooks的依赖项,来优化hooks的性能,那么你很可能会在hooks里,访问到旧的props或state。这是否意味着闭包会带来问题呢?我想不是的。

正如我们上面看到的,在一些不易察觉的场景下,闭包帮助我们解决掉这些微妙的问题。不仅如此,闭包也让我们在 并行模式 下更加容易写出没有bug的代码。因为闭包捕获了我们render函数运行时的props和state,使得并行模式成为可能。

到目前为止,我经历的所有情况下,访问到旧的props、state,通常是由于我们错误的认为"函数不会变",或者"props始终是一样的"。实时上不是这样的,我希望在本文里,能够帮助你了解到这一点。

在我们使用函数来开发大部分react组件时,需要更正我们对于 代码优化哪些状态会改变 的认知。

正如 Fredrik说的:

在使用react hooks过程中,我学习到的最重要规则就是,"任何变量,都可以在任何时间被改变"

函数同样遵守这条规则。

react函数始终会捕获props、state,最后再强调一下。

译注: 有些地方不明白怎么翻译,有删减,建议阅读原文!https://overreacted.io/how-are-function-components-different-from-classes/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK