纯函数只执行计算,仅此而已。这使得您的代码更易于理解和调试,并允许 React 自动正确优化您的组件和 Hook。
为什么纯净很重要?
React 的关键概念之一是 *纯净性*。纯组件或 Hook 是指:
- 幂等的 – 使用相同的输入(组件输入的 props、state、context;以及 Hook 输入的参数)运行时,每次都得到相同的结果。
- 在渲染中没有副作用 – 具有副作用的代码应单独运行,而不是在渲染中运行。例如,作为事件处理程序 – 用户与 UI 交互并导致其更新;或者作为Effect – 在渲染后运行。
- 不修改非局部值:组件和 Hook 应该永远不要修改在渲染中非本地创建的值。
当渲染保持纯净时,React 可以理解如何优先处理哪些更新对用户来说最重要。这是因为渲染纯净性:由于组件在渲染中没有副作用,React 可以暂停渲染不太重要的组件更新,只在需要时再返回。
具体来说,这意味着渲染逻辑可以多次运行,从而使 React 能够为您的用户提供良好的用户体验。但是,如果您的组件具有未跟踪的副作用 – 例如在渲染期间修改全局变量的值 – 当 React 再次运行您的渲染代码时,您的副作用将以与您想要不匹配的方式触发。这通常会导致意外错误,从而降低用户体验。您可以在保持组件纯净页面中看到此示例。
React 如何运行您的代码?
React 是声明式的:您告诉 React *要渲染什么*,React 将找出*如何*最好地将其显示给您的用户。为此,React 有几个阶段来运行您的代码。您不需要了解所有这些阶段才能很好地使用 React。但在高级别上,您应该了解在*渲染*中运行哪些代码,以及在渲染之外运行哪些代码。
*渲染*是指计算 UI 的下一个版本应该是什么样子。渲染后,Effect 被*刷新*(这意味着它们会一直运行到没有更多为止),如果 Effect 对布局有影响,可能会更新计算。React 获取此新的计算结果,并将其与用于创建 UI 的先前版本的计算结果进行比较,然后仅将最小必要的更改提交到DOM(用户实际看到的内容)以使其赶上最新版本。
深入探讨
快速判断代码是否在渲染过程中运行的一个启发式方法是检查代码位置:如果代码像下面示例一样写在顶层,那么它很有可能在渲染过程中运行。
function Dropdown() {
const selectedItems = new Set(); // created during render
// ...
}
事件处理程序和 Effect 不会在渲染过程中运行。
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]);
}
组件和 Hook 必须是幂等的
组件必须始终根据其输入(props、状态和上下文)返回相同的输出。这被称为幂等性。幂等性是函数式编程中流行的一个术语。它指的是每次使用相同的输入运行这段代码时,你总是得到相同的结果。
这意味着为了遵守此规则,在渲染过程中运行的所有代码也必须是幂等的。例如,以下代码行不是幂等的(因此,组件也不是幂等的)
function Clock() {
const time = new Date(); // 🔴 Bad: always returns a different result!
return <span>{time.toLocaleString()}</span>
}
new Date()
不是幂等的,因为它总是返回当前日期,并且每次调用时结果都会改变。当您渲染上面的组件时,屏幕上显示的时间将停留在组件渲染时的那个时间。类似地,诸如Math.random()
之类的函数也不是幂等的,因为即使输入相同,它们每次调用的结果也可能不同。
这并不意味着您根本不应该使用new Date()
之类的非幂等函数——您只是应该避免在渲染过程中使用它们。在这种情况下,我们可以使用Effect将最新的日期同步到此组件。
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()
调用包装在 Effect 中,它将该计算移到渲染之外。
如果您不需要将某些外部状态与 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
只会在组件存在期间存在。因为items
每次渲染<FriendList />
时都会被重新创建,所以组件将始终返回相同的结果。
另一方面,如果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.title = product.title; // 🔴 Bad: Changes the DOM
}
在渲染之外更新document.title
的一种方法是使用document
同步组件。
只要多次调用组件是安全的,并且不会影响其他组件的渲染,React并不关心它在严格的功能编程意义上是否100%纯净。更重要的是组件必须是幂等的。
Props和状态是不可变的
组件的props和状态是不可变的快照。切勿直接修改它们。相反,传递新的props,并使用useState
中的setter函数。
您可以将props和状态值视为在渲染后更新的快照。因此,您不会直接修改props或状态变量:而是传递新的props,或使用提供的setter函数来告诉React在下一次组件渲染时需要更新状态。
不要修改Props
Props是不可变的,因为如果您修改它们,应用程序将产生不一致的输出,这可能很难调试,因为它可能根据情况有效或无效。
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>;
}
不要修改State
useState
返回状态变量和一个用于更新该状态的setter。
const [stateVariable, setter] = useState(0);
我们不需要就地更新状态变量,而是需要使用useState
返回的setter函数来更新它。更改状态变量上的值不会导致组件更新,从而导致用户的UI过时。使用setter函数会通知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中的props一样,值在传递给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}
</>
);
}