函数组件与类组件有何不同?
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 按钮。
请分别对两个按钮按如下步骤操作:
- 点击其中一个 Follow 按钮。
- 在 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.something
或 this.state.something
,同样会出问题。 于是我们不得不把 this.props
和 this.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.props
或 this.state
,因为 this
是可变的,React 会修改它。在函数组件里,你也可以有一个所有渲染共享的可变值,这就是 “ref”:
function MyComponent() {
const ref = useRef(null);
// 你可以读写 ref.current。
// ...
}
不过,这需要你自己维护。
ref 扮演着 实例字段的角色。它是通往可变命令式世界的“逃生通道”。你可能熟悉“DOM refs”,但其实这个概念更通用。它就是一个可以随意放东西的盒子。
甚至从语法上看,this.something
和 something.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,而是函数组件的特性。例如,useEffect
或 useCallback
的依赖数组里不应该排除函数。(正确的做法通常是用 useReducer
或上面的 useRef
方案——我们很快会写文档讲如何选择。)
当我们用函数写大部分 React 代码时,需要调整我们对代码优化和哪些值会变的直觉。
正如 Fredrik 所说:
我目前发现的 Hooks 最佳心法是:“假设任何值随时都可能变化”。
函数也不例外。这一认知需要时间才能成为 React 学习资料的常识。它需要我们从类组件的思维方式做出调整。希望本文能帮你用全新的视角看待这个问题。
React 函数组件总是会捕获它们的值——现在你知道为什么了。
它们完全是不同的宝可梦。
Pay what you like