纯函数只执行计算,不做其他任何事情。这使得你的代码更易于理解、调试,并允许 React 自动正确地优化你的组件和 Hook。
为什么纯度很重要?
使 React 成为 React 的关键概念之一就是 纯度。纯组件或 Hook 是指:
- 幂等的 – 每次使用相同的输入(组件输入的 props、state、context;以及 Hook 输入的参数)运行它时,你始终会得到相同的结果。
- 渲染过程中没有副作用 – 具有副作用的代码应该与渲染分开运行。例如,作为事件处理程序(用户与 UI 交互并导致其更新)或Effect(在渲染后运行)。
- 不改变非局部值:组件和 Hook 永远不应该修改未在本地创建的值。
当保持渲染纯净时,React 可以理解如何确定哪些更新对用户来说最重要,并优先显示它们。这是因为渲染的纯度:由于组件在渲染中没有副作用,React 可以暂停渲染不那么重要的组件,只在需要时才返回更新它们。
具体来说,这意味着渲染逻辑可以多次运行,从而使 React 能够为用户提供良好的用户体验。但是,如果你的组件具有未跟踪的副作用(例如在渲染期间修改全局变量的值),则当 React 再次运行你的渲染代码时,你的副作用将会以一种与你期望不符的方式被触发。这通常会导致意外的错误,从而降低用户体验你的应用程序的方式。你可以在“保持组件纯净”页面中看到一个示例。
React 如何运行你的代码?
React 是声明式的:你告诉 React 要渲染*什么*,React 会找出*如何*最好地将其显示给用户。为此,React 有几个阶段来运行你的代码。你不需要了解所有这些阶段就可以很好地使用 React。但从高层次上讲,你应该了解*渲染*中运行的代码以及渲染之外运行的代码。
渲染是指计算 UI 的下一个版本应该是什么样子。渲染完成后,副作用 会被 刷新(意味着它们会一直运行,直到没有剩余的副作用),并且如果副作用对布局有影响,则可能会更新计算结果。React 会获取这个新的计算结果,并将其与用于创建 UI 上一个版本的计算结果进行比较,然后 提交 对 DOM(用户实际看到的内容)的最小必要更改,以使其与最新版本保持一致。
深入探讨
判断代码是否在渲染时运行的一个快速方法是检查它所在的位置:如果它像下面的示例一样写在顶层,那么它很可能是在渲染时运行的。
function Dropdown() {
const selectedItems = new Set(); // created during render
// ...
}
事件处理程序和副作用不会在渲染时运行
function Dropdown() {
const selectedItems = new Set();
const onSelect = (item) => {
// this code is in an event handler, so it's only run when the user triggers this
selectedItems.add(item);
}
}
function Dropdown() {
const selectedItems = new Set();
useEffect(() => {
// this code is inside of an Effect, so it only runs after rendering
logForAnalytics(selectedItems);
}, [selectedItems]);
}
组件和 Hooks 必须是幂等的
对于相同的输入(属性、状态和上下文),组件必须始终返回相同的输出。这被称为 幂等性。幂等性 是函数式编程中流行的一个术语。它指的是每次使用相同的输入运行一段代码时,你 总是得到相同的结果。
这意味着为了遵守此规则,在渲染期间 运行的 所有 代码也必须是幂等的。例如,这行代码不是幂等的(因此,组件也不是幂等的)
function Clock() {
const time = new Date(); // 🔴 Bad: always returns a different result!
return <span>{time.toLocaleString()}</span>
}
new Date()
不是幂等的,因为它总是返回当前日期,并且每次调用时都会更改其结果。当你渲染上面的组件时,屏幕上显示的时间将停留在组件渲染时的那个时间。类似地,像 Math.random()
这样的函数也不是幂等的,因为即使输入相同,它们每次调用时也会返回不同的结果。
这并不意味着你不应该 完全 使用像 new Date()
这样的非幂等函数——你应该避免 在渲染期间 使用它们。在这种情况下,我们可以使用 副作用 将最新日期 同步 到此组件
import { useState, useEffect } from 'react'; function useTime() { // 1. Keep track of the current date's state. `useState` receives an initializer function as its // initial state. It only runs once when the hook is called, so only the current date at the // time the hook is called is set first. const [time, setTime] = useState(() => new Date()); useEffect(() => { // 2. Update the current date every second using `setInterval`. const id = setInterval(() => { setTime(new Date()); // ✅ Good: non-idempotent code no longer runs in render }, 1000); // 3. Return a cleanup function so we don't leak the `setInterval` timer. return () => clearInterval(id); }, []); return time; } export default function Clock() { const time = useTime(); return <span>{time.toLocaleString()}</span>; }
通过将非幂等的 new Date()
调用包装在一个副作用中,它将该计算移动到 渲染之外。
如果你不需要将某些外部状态与 React 同步,你也可以考虑使用 事件处理程序,如果它只需要响应用户交互进行更新。
副作用必须在渲染之外运行
副作用 不应 在渲染时 运行,因为 React 可以多次渲染组件以创建最佳的用户体验。
虽然渲染必须保持纯净,但副作用在某些时候是必要的,以便你的应用程序执行任何有趣的操作,比如在屏幕上显示内容!此规则的关键在于,副作用不应 在渲染时 运行,因为 React 可以多次渲染组件。在大多数情况下,你将使用 事件处理程序 来处理副作用。使用事件处理程序明确地告诉 React,这段代码不需要在渲染时运行,从而保持渲染的纯净。如果你已经用尽了所有选项,并且仅作为最后的手段,你也可以使用 useEffect
来处理副作用。
什么时候可以进行突变?
局部突变
副作用的一个常见例子是突变,在 JavaScript 中,突变是指更改非 原始值 的值。一般来说,虽然突变在 React 中并不常见,但 局部 突变是绝对可以的
function FriendList({ friends }) {
const items = []; // ✅ Good: locally created
for (let i = 0; i < friends.length; i++) {
const friend = friends[i];
items.push(
<Friend key={friend.id} friend={friend} />
); // ✅ Good: local mutation is okay
}
return <section>{items}</section>;
}
不需要为了避免局部突变而扭曲你的代码。Array.map
也可以在这里使用以简化代码,但在 渲染期间 创建一个本地数组,然后将元素推送到其中并没有什么问题。
即使看起来我们在修改 items
,但需要注意的关键是,此代码仅在*局部*执行此操作 – 当组件再次渲染时,不会“记住”修改。换句话说,items
的存在时间只与组件一样长。因为每次渲染 <FriendList />
时,都会*重新创建* items
,所以组件将始终返回相同的结果。
另一方面,如果在组件外部创建 items
,它会保留其先前的值并记住更改
const items = []; // 🔴 Bad: created outside of the component
function FriendList({ friends }) {
for (let i = 0; i < friends.length; i++) {
const friend = friends[i];
items.push(
<Friend key={friend.id} friend={friend} />
); // 🔴 Bad: mutates a value created outside of render
}
return <section>{items}</section>;
}
当 <FriendList />
再次运行时,我们将在每次运行该组件时继续将 friends
追加到 items
,从而导致多个重复的结果。此版本的 <FriendList />
在渲染 期间 会产生明显的副作用,并违反了规则。
懒惰初始化
懒惰初始化也是可以的,尽管它不是完全“纯”的
function ExpenseForm() {
SuperCalculator.initializeIfNotReady(); // ✅ Good: if it doesn't affect other components
// Continue rendering...
}
更改 DOM
在 React 组件的渲染逻辑中不允许出现用户可以直接看到的副作用。换句话说,仅仅调用一个组件函数本身不应该在屏幕上产生任何变化。
function ProductDetailPage({ product }) {
document.window.title = product.title; // 🔴 Bad: Changes the DOM
}
在渲染之外更新 window.title
的一种方法是 将组件与 window
同步。
只要多次调用一个组件是安全的,并且不会影响其他组件的渲染,React 并不关心它是否在函数式编程的严格意义上是 100% 纯的。更重要的是,组件必须是幂等的。
属性和状态是不可变的
组件的属性和状态是不可变的 快照。切勿直接修改它们。相反,请向下传递新的属性,并使用 useState
中的设置器函数。
您可以将属性和状态值视为在渲染后更新的快照。因此,您不要直接修改属性或状态变量:而是传递新的属性,或使用提供给您的设置器函数来告诉 React 在下次渲染组件时需要更新状态。
不要修改属性
属性是不可变的,因为如果您修改它们,应用程序将产生不一致的输出,这可能很难调试,因为它可能会或可能不会根据情况起作用。
function Post({ item }) {
item.url = new Url(item.url, base); // 🔴 Bad: never mutate props directly
return <Link url={item.url}>{item.title}</Link>;
}
function Post({ item }) {
const url = new Url(item.url, base); // ✅ Good: make a copy instead
return <Link url={url}>{item.title}</Link>;
}
不要修改状态
useState
返回状态变量和用于更新该状态的设置器。
const [stateVariable, setter] = useState(0);
我们需要使用 useState
返回的设置器函数来更新状态变量,而不是就地更新它。更改状态变量上的值不会导致组件更新,从而使您的用户使用过时的 UI。使用设置器函数会通知 React 状态已更改,并且我们需要排队重新渲染以更新 UI。
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
count = count + 1; // 🔴 Bad: never mutate state directly
}
return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1); // ✅ Good: use the setter function returned by useState
}
return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}
Hook 的返回值和参数是不可变的
将值传递给 Hook 后,您不应修改它们。与 JSX 中的属性一样,值在传递给 Hook 后会变为不可变的。
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
if (icon.enabled) {
icon.className = computeStyle(icon, theme); // 🔴 Bad: never mutate hook arguments directly
}
return icon;
}
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
const newIcon = { ...icon }; // ✅ Good: make a copy instead
if (icon.enabled) {
newIcon.className = computeStyle(icon, theme);
}
return newIcon;
}
React 中的一个重要原则是*局部推理*:通过单独查看组件或 Hook 的代码来理解其功能的能力。调用 Hook 时,应将其视为“黑盒”。例如,自定义 Hook 可能已将其参数用作依赖项,以在其内部记忆值
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
return useMemo(() => {
const newIcon = { ...icon };
if (icon.enabled) {
newIcon.className = computeStyle(icon, theme);
}
return newIcon;
}, [icon, theme]);
}
如果您要修改 Hook 参数,则自定义 Hook 的记忆将变得不正确,因此避免这样做非常重要。
style = useIconStyle(icon); // `style` is memoized based on `icon`
icon.enabled = false; // Bad: 🔴 never mutate hook arguments directly
style = useIconStyle(icon); // previously memoized result is returned
style = useIconStyle(icon); // `style` is memoized based on `icon`
icon = { ...icon, enabled: false }; // Good: ✅ make a copy instead
style = useIconStyle(icon); // new value of `style` is calculated
同样,不要修改 Hook 的返回值也很重要,因为它们可能已经被记忆了。
值在传递给 JSX 后是不可变的
不要在 JSX 中使用值后修改它们。在创建 JSX 之前移动修改。
当您在表达式中使用 JSX 时,React 可能会在组件完成渲染之前急切地计算 JSX。这意味着在将值传递给 JSX 后对其进行更改可能会导致 UI 过时,因为 React 不会知道要更新组件的输出。
function Page({ colour }) {
const styles = { colour, size: "large" };
const header = <Header styles={styles} />;
styles.size = "small"; // 🔴 Bad: styles was already used in the JSX above
const footer = <Footer styles={styles} />;
return (
<>
{header}
<Content />
{footer}
</>
);
}
function Page({ colour }) {
const headerStyles = { colour, size: "large" };
const header = <Header styles={headerStyles} />;
const footerStyles = { colour, size: "small" }; // ✅ Good: we created a new value
const footer = <Footer styles={footerStyles} />;
return (
<>
{header}
<Content />
{footer}
</>
);
}