overreactedby Dan Abramov

函数组件与类组件有何不同?

March 3, 2019

React 函数组件与类组件有何不同?

一段时间以来,标准答案一直是类组件能访问更多特性(比如 state)。但自从有了 Hooks,这个说法就不成立了。

也许你听说过其中一种在性能上更好。是哪一种呢?其实,很多这样的性能对比 并不严谨,所以我建议在此类结论上要 谨慎。性能主要取决于代码实际做了什么,而不是你选择了函数还是类。我们的观察是,两者的性能差异可以忽略,不过优化策略会有些 不同

无论如何,除非你有其他理由,且愿意尝鲜,我们 并不推荐 重写已有组件。Hooks 还很新(就像 2014 年的 React 一样),很多“最佳实践”还没进入教程。

那么,问题来了:React 的函数组件和类组件之间,真的有本质区别吗?当然有——主要体现在思维模型上。本文将探讨它们之间最大的不同。 这个区别自 2015 年函数组件 被引入 起就存在,但常常被忽略:

函数组件会捕获渲染时的值。

让我们来详细解释一下这句话。


注意:本文并不是在评价类组件或函数组件的优劣,只是描述 React 中这两种编程模型的区别。如果你想了解更广泛采用函数组件的建议,请参考 Hooks FAQ


来看这个组件:

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 Hooks 本身无关。上面的例子甚至没有用到 Hooks!

这完全是 React 中函数与类的区别。如果你打算在 React 应用中更多地使用函数组件,最好理解这一点。


我们用一个在 React 应用中常见的 bug 来说明这个区别。

打开这个 示例沙盒,里面有一个当前用户选择器,以及上面两种 ProfilePage 实现——每种都渲染一个 Follow 按钮。

请分别对两个按钮按如下步骤操作:

  1. 点击其中一个 Follow 按钮。
  2. 在 3 秒内切换选中的用户。
  3. 查看弹窗中的提示内容。

你会发现一个奇怪的不同:

  • 用上面的 函数组件,在 Dan 的页面点击 Follow,然后切换到 Sophie,弹窗依然会显示 'Followed Dan'

  • 用上面的 类组件,弹窗却会显示 'Followed Sophie'

操作演示


在这个例子中,前者的行为才是正确的。如果我关注了某个人,然后切换到另一个人的页面,组件不应该混淆我到底关注了谁。 类组件的实现显然是有 bug 的。

(不过你确实可以关注 Sophie。)


那么,为什么类组件会这样?

仔细看看类里的 showMessage 方法:

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

这个类方法读取了 this.props.user。在 React 中,props 是不可变的,永远不会被直接修改。this 本身 却一直是可变的。

事实上,这正是类中 this 的意义所在。React 会不断修改它,这样你在 render 和生命周期方法里总能读到最新的 this

所以,如果组件在请求过程中重新渲染,this.props 就会变。showMessage 方法读取到的 user 就是“太新”的 props。

这揭示了 UI 本质上的一个有趣现象。如果说 UI 本质上是当前应用状态的一个函数,那么事件处理器也是渲染结果的一部分,就像视觉输出一样。事件处理器“属于”某一次渲染,绑定着当时的 props 和 state。

但如果我们调度了一个回调(比如 setTimeout),回调里还读取 this.props,就打破了这种绑定。我们的 showMessage 回调不再“属于”某次渲染,导致 props 读取错乱。读取 this 断开了这种联系。


假设没有函数组件,我们该怎么解决这个问题?

我们需要“修复”带有正确 props 的 render 与读取这些 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 呢?如果还要访问 state 呢?如果 showMessage 又调用了其他方法,而这些方法又读取了 this.props.somethingthis.state.something,同样会出问题。 于是我们不得不把 this.propsthis.state 一路作为参数传递下去。

这样做就失去了类组件本来的便利,也很难记住或强制执行,所以很多人最终选择了“将错就错”。

类似地,把 alert 代码直接写进 handleClick 也解决不了本质问题。我们希望代码结构既能拆分为多个方法,又能让这些方法读取与当前调用相关的 props 和 state。这个问题其实并不只属于 React——任何把数据放进可变对象(比如 this)的 UI 库都能复现。

也许我们可以在构造函数里 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 是不可变的!(或者说,强烈建议不可变。)这就消除了闭包的主要“坑”。

这意味着,如果你在某次渲染时闭包住了 props 或 state,就能保证它们一直保持不变:

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 和 state。 闭包拯救世界!


上面的例子是正确的,但看起来有点奇怪。如果函数都写在 render 里,还要类组件干嘛?

其实,我们可以直接去掉类的“外壳”:

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

和上面一样,props 依然被捕获了——React 会把它作为参数传入。this 不同,props 对象本身永远不会被 React 修改。

如果你在函数定义时解构 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 回调。它们都不会被影响。

这就是为什么在这个演示的函数组件版本里,点击 Sophie 的 Follow,然后切换到 Sunil,弹窗依然会显示 'Followed Sophie'

正确行为演示

这种行为才是正确的。(当然你也可以关注 Sunil!)


现在我们明白了 React 中函数和类的最大区别:

函数组件会捕获渲染时的值。

有了 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>
    </>
  );
}

(这里有个在线演示。)

虽然这个消息 UI 并不完美,但它说明了同样的道理:如果我发送了一条消息,组件不应该混淆到底发的是哪条。这个函数组件的 message 捕获了“属于”那次渲染的 state,也就是点击按钮时输入框里的内容。


所以我们知道,React 中的函数默认会捕获 props 和 state。**但如果我们想要读取最新的 props 或 state(而不是当前渲染的)怎么办?**如果我们想要“读取未来的值”呢?

在类组件里,你可以直接读取 this.propsthis.state,因为 this 是可变的,React 会修改它。在函数组件里,你也可以有一个所有渲染共享的可变值,这就是 “ref”:

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

不过,这需要你自己维护。

ref 扮演着 实例字段的角色。它是通往可变命令式世界的“逃生通道”。你可能熟悉“DOM refs”,但其实这个概念更通用。它就是一个可以随意放东西的盒子。

甚至从语法上看,this.somethingsomething.current 也很像。它们表达的是同一个概念。

默认情况下,React 不会为函数组件的最新 props 或 state 创建 ref。在大多数情况下你也不需要,否则每次都赋值反而浪费。但如果你愿意,可以手动追踪这个值:

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,看到的是点击 Send 按钮时的内容。但如果读取 latestMessage.current,就能拿到最新的值——即使点击后还继续输入。

你可以自己对比这两个 演示。ref 是一种“跳出”渲染一致性的方式,在某些场景下很有用。

通常,不建议在渲染期间读写 ref,因为它们是可变的。我们希望渲染是可预测的。但如果我们确实想获取某个 prop 或 state 的最新值,手动更新 ref 会很烦。 我们可以用 effect 自动同步:

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

(这里有个演示。)

我们在 effect 里赋值,这样 ref 只会在 DOM 更新后变化。这样可以确保不会破坏 Time Slicing 和 Suspense 等依赖可中断渲染的特性。

这种用法其实很少需要。捕获 props 或 state 通常是更好的默认选择。 但在处理 命令式 API(比如定时器、订阅)时会很有用。你可以用这种方式追踪任何值——prop、state、整个 props 对象,甚至是函数。

这个模式在做优化时也很有用——比如 useCallback 的标识符变化太频繁时。不过,用 reducer 通常是更好的方案。(这个话题以后再聊!)


本文我们分析了类组件中常见的错误模式,以及闭包如何帮我们解决它。不过你可能注意到,当你用 Hooks 并指定依赖数组优化时,也可能遇到“闭包过时(stale closure)”的 bug。这是不是闭包的问题?我认为不是。

如上所述,闭包其实帮我们修复了那些难以察觉的微妙问题。同样,它也让我们更容易写出在 Concurrent Mode 下能正确工作的代码。因为组件内部的逻辑闭包住了渲染时的 props 和 state。

我见过的所有案例中,“闭包过时”问题都是因为错误地假设“函数不会变”或“props 永远不变”。其实并不是这样,希望本文能帮你理解这一点。

函数会闭包住它们的 props 和 state——所以它们的标识同样重要。这不是 bug,而是函数组件的特性。例如,useEffectuseCallback 的依赖数组里不应该排除函数。(正确的做法通常是用 useReducer 或上面的 useRef 方案——我们很快会写文档讲如何选择。)

当我们用函数写大部分 React 代码时,需要调整我们对代码优化哪些值会变的直觉。

正如 Fredrik 所说

我目前发现的 Hooks 最佳心法是:“假设任何值随时都可能变化”。

函数也不例外。这一认知需要时间才能成为 React 学习资料的常识。它需要我们从类组件的思维方式做出调整。希望本文能帮你用全新的视角看待这个问题。

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

皮卡丘微笑

它们完全是不同的宝可梦。

Pay what you like

Edit on GitHub