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>;
}
}
复制代码
一般会认为这两段代码是等效的,大家经常在这些模式中随意的重构,而不会注意到它们的含义:
但是,这两段代码略微不同。 好好看看它们,有看出不同了吗?就个人而言,我花了好一会才发现。
前面有剧透,如果你想自己找到的话,这是一个在线demo。文章接下来的部分来分析这个差异及为什么会这样。
在我们继续之前,我想强调下,我所描述的差异与 React Hooks 自身无关,上面的例子甚至不需要用 Hooks!
这完全是关于 functions 和 classes 在 React 中的区别的,如果你打算在 React 应用中更常用 functions,你可能想去弄懂它。
我们将通过 React 应用中常见的一个 bug 来说明这区别。
使用当前的条目选择器和之前两个 ProfilePage
实现来打开这个 sandbox 例子 —— 每个渲染一个 Follow 按钮。
按照这种操作顺序使用两个按钮:
- 点击 其中一个按钮。
- 在 3 秒中内改变选择条目。
- 看下弹出的文本。
你会注意到一个特殊的区别:
-
当为 function 的
ProfilePage
时,点击 Follow Dan 的条目然后切换成 Sophie 的,仍然弹出'Followed Dan'
。 -
当为 class 的
ProfilePage
时,它会弹出'Followed Sophie'
:
这个例子中,第一种行为是正确的。如果你关注一个人,然后切换到另外一个人的条目,我的组件不应该困惑于我要关注的是谁。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.something
或 this.state.something
,我们会再次遇到同样的问题。所以我们不得不将 this.props
和 this.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 用参数形式传递它们。不像 this
,props
对象本身没有被 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.props
或 this.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;
};
复制代码
如果我们读取 showMessage
的 message
,我们会看到我们第几发送按钮时的消息。但当我们读取 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 不应该从 userEffect
或 useCallback
的“依赖数组”中被排除。(上面提到常用的适当修复不管是 useReducer
或是 useRef
的解决方案 —— 我们很快会在文档中说明如何在它们之间做选择)
在我们用 functions 写大多数 React 代码时,我们需要适配我们的关于 优化代码 和 什么值会一直改变的情况。
到目前为止我用 hooks 找到的最好的心理规则是 “代码的任何值似乎可以在任意时间改变”。
Functions 也不例外。这需要一些时间才能在 React 学习材料里面变成普遍的知识,从 class 心态过来的需要一些适应,但我希望这篇文章可以帮助你用新的眼光看待它。
React functions 总会捕获它们的值 —— 且现在我们知道为什么了。
它们是一个完全不同的神奇宝贝。
翻译原文How Are Function Components Different from Classes?(2019-03-03)