如何编写高性能 React 代码:规则、模式、注意事项
最初发表于https://www.developerway.com。该网站还有更多类似的文章 😉
性能与 React!真是个有趣的话题,却有如此多的争议,短短六个月内,许多最佳实践就发生了巨大的转变。能否在这里给出一些明确的结论或通用的建议?
通常,性能专家都支持“过早优化是万恶之源”和“先测量”的原则。这大致可以理解为“别去修复没坏的东西”,这很难反驳。但我还是会这么做😉
我喜欢 React 的地方在于,它让实现复杂的 UI 交互变得异常简单。我不喜欢 React 的地方在于,它也很容易犯下一些后果严重且难以立即察觉的错误。好消息是,它也很容易避免这些错误,并能够立即编写出在大多数情况下都性能良好的代码,从而显著减少排查性能问题所需的时间和精力,因为性能问题的数量会大大减少。总而言之,对于 React 和性能而言,“过早优化”实际上可能是一件好事,每个人都应该这样做😉。你只需要了解一些需要注意的模式,就能有效地进行优化。
所以这正是我这篇文章想要证明的😊。我将一步步实现一个“真实”的应用,首先以一种“常规”的方式,使用那些你几乎随处可见、并且肯定自己会多次使用的模式。然后,我会在考虑性能的情况下重构每个步骤,并从每个步骤中提取出一条适用于大多数应用的通用规则。最后,比较最终的结果。
让我们开始吧!
我们将为一家在线商店编写一个“设置”页面(我们在之前的“面向 React 开发者的高级 TypeScript”文章中介绍过)。在这个页面上,用户可以从列表中选择一个国家/地区,查看该国家/地区的所有可用信息(例如货币、配送方式等),然后将该国家/地区保存为他们所选的国家/地区。页面看起来会像这样:
左侧会显示国家/地区列表,其中包含“已保存”和“已选中”状态。点击列表中的某个项目时,右侧会显示详细信息。点击“保存”按钮后,“已选中”的国家/地区将变为“已保存”,并显示不同的颜色。
哦,我们当然想要黑暗模式,毕竟现在是 2022 年了!
另外,考虑到 React 中 90% 的性能问题都可以归结为“过多的重新渲染”,本文将主要讨论如何减少这些问题。(另外 10% 的问题包括:“渲染负担过重”和“需要进一步调查的非常奇怪的问题”。)
让我们首先构建我们的应用程序
首先,让我们看一下设计,绘制假想边界,并起草未来应用程序的结构以及我们需要在那里实现的组件:
- 一个根“页面”组件,我们将在其中处理“提交”逻辑和国家选择逻辑
- 一个“国家列表”组件,它将把所有国家呈现在列表中,并在未来处理诸如过滤和排序之类的事情
- “Item” 组件,用于在“国家列表”中呈现国家/地区
- “选定国家”组件,用于显示选定国家的详细信息,并带有“保存”按钮
当然,这并非实现该页面的唯一方法,这正是 React 的魅力所在,也是它的魔咒所在:所有事物都有百万种实现方式,任何事物都没有正确或错误的答案。但从长远来看,在快速增长或规模庞大的应用中,有些模式绝对可以被称为“永远不要这样做”或“这是必须的”。
让我们看看我们能否一起解决它们🙂
实现页面组件
现在,终于到了动手写代码的时候了。我们从“根”开始,实现 Page 组件。
首先:我们需要一个带有一些样式的包装器来呈现页面标题、“国家列表”和“选定的国家”组件。
第二:我们的页面应该从某个地方接收国家列表,然后将其传递给CountriesList
组件,以便它可以呈现这些国家。
第三:我们的页面应该有一个“选定”国家的概念,它将从组件接收CountriesList
并传递给SelectedCountry
组件。
最后:我们的页面应该有一个“已保存”国家的概念,它将从组件接收SelectedCountry
并传递给CountriesList
组件(并在未来发送到后端)。
export const Page = ({ countries }: { countries: Country[] }) => {
const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);
return (
<>
<h1>Country settings</h1>
<div css={contentCss}>
<CountriesList
countries={countries}
onCountryChanged={(c) => setSelectedCountry(c)}
savedCountry={savedCountry}
/>
<SelectedCountry
country={selectedCountry}
onCountrySaved={() => setSavedCountry(selectedCountry)}
/>
</div>
</>
);
};
这就是“Page”组件的完整实现,它是随处可见的最基本的 React 代码,并且这个实现绝对没有任何问题。除了一件事。好奇,你看得出来吗?
重构页面组件——考虑性能
我想现在大家都知道,当 state 或 props 发生变化时,React 会重新渲染组件。在我们的 Page 组件中,当setSelectedCountry
或被调用时,它会重新渲染。如果 Page 组件中的 country 数组(props)发生变化,它也会重新渲染。对于和组件setSavedCountry
也是如此——当它们的任何 props 发生变化时,它们都会重新渲染。CountriesList
SelectedCountry
此外,任何使用过 React 的人都知道 JavaScript 的相等性比较,React 对 props 进行严格的相等性比较,以及内联函数每次都会创建新值。这导致了一种非常普遍(顺便说一句,绝对是错误的)的观念:为了减少CountriesList
和SelectedCountry
组件的重新渲染,我们需要将内联函数包装在 中,从而避免每次渲染时都重新创建内联函数useCallback
。甚至React 文档也在同一句话中提到了useCallback
“防止不必要的渲染”!看看这个模式是否看起来很熟悉:
export const Page = ({ countries }: { countries: Country[] }) => {
// ... same as before
const onCountryChanged = useCallback((c) => setSelectedCountry(c), []);
const onCountrySaved = useCallback(() => setSavedCountry(selectedCountry), []);
return (
<>
...
<CountriesList
onCountryChanged={onCountryChange}
/>
<SelectedCountry
onCountrySaved={onCountrySaved}
/>
...
</>
);
};
你知道最搞笑的地方是什么吗?它实际上根本不起作用。因为它没有考虑到 React 组件重新渲染的第三个原因:当父组件重新渲染时。无论 props 是什么,CountriesList
只要 Page 重新渲染,父组件就会重新渲染,即使它根本没有任何 props。
我们可以将页面示例简化为:
const CountriesList = () => {
console.log("Re-render!!!!!");
return <div>countries list, always re-renders</div>;
};
export const Page = ({ countries }: { countries: Country[] }) => {
const [counter, setCounter] = useState<number>(1);
return (
<>
<h1>Country settings</h1>
<button onClick={() => setCounter(counter + 1)}>
Click here to re-render Countries list (open the console) {counter}
</button>
<CountriesList />
</>
);
};
每次我们点击按钮时,我们都会看到它CountriesList
被重新渲染,即使它根本没有任何 props。Codesandbox代码在这里。
最后,这使我们能够巩固本文的第一条规则:
规则 1.如果你想要将 props 中的内联函数提取到 useCallback 中的唯一原因是为了避免子
组件的重新渲染:不要这样做。这行不通。
现在,有几种方法可以处理上述情况,我将针对这种特殊情况使用最简单的方法:useMemo hook。它的作用是“缓存”你传入的任何函数的结果,并且仅在依赖项useMemo
发生变化时刷新它们。如果我将渲染结果提取CountriesList
到一个变量中const list = <ComponentList />;
,然后将其应用于useMemo
该变量,那么现在只有当 useMemo 依赖项发生变化时,ComponentList
组件才会重新渲染。
export const Page = ({ countries }: { countries: Country[] }) => {
const [counter, setCounter] = useState<number>(1);
const list = useMemo(() => {
return <CountriesList />;
}, []);
return (
<>
<h1>Country settings</h1>
<button onClick={() => setCounter(counter + 1)}>
Click here to re-render Countries list (open the console) {counter}
</button>
{list}
</>
);
};
在本例中,它永远不会被重新渲染,因为它没有任何依赖项。这种模式基本上让我能够摆脱“父级重新渲染 - 不管怎样都重新渲染所有子级”的循环,并掌控它。查看codesandbox 中的完整示例。
这里需要注意的最重要的一点是 的依赖列表useMemo
。如果它依赖的组件与导致父组件重新渲染的组件完全相同,那么每次重新渲染时它都会刷新缓存,最终变得毫无用处。例如,在这个简化的例子中,如果我将值counter
作为依赖项传递给list
变量(注意:甚至没有传递给已记忆组件的 prop!),这将导致useMemo
每次状态更改时都会刷新缓存,并CountriesList
再次重新渲染。
const list = useMemo(() => {
return (
<>
{counter}
<CountriesList />
</>
);
}, [counter]);
好的,所有这些很棒,但是它究竟如何应用于我们未简化的 Page 组件呢?好吧,如果我们再仔细看看它的实现
export const Page = ({ countries }: { countries: Country[] }) => {
const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);
return (
<>
<h1>Country settings</h1>
<div css={contentCss}>
<CountriesList
countries={countries}
onCountryChanged={(c) => setSelectedCountry(c)}
savedCountry={savedCountry}
/>
<SelectedCountry
country={selectedCountry}
onCountrySaved={() => setSavedCountry(selectedCountry)}
/>
</div>
</>
);
};
我们将会看到:
selectedCountry
CountriesList
组件中从未使用过状态savedCountry
SelectedCountry
组件中从未使用过状态
这意味着当selectedCountry
状态改变时,CountriesList
组件根本不需要重新渲染!savedCountry
状态和SelectedCountry
组件也一样。我可以将它们都提取到变量中并进行记忆,以防止它们不必要的重新渲染:
export const Page = ({ countries }: { countries: Country[] }) => {
const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]);
const [savedCountry, setSavedCountry] = useState<Country>(countries[0]);
const list = useMemo(() => {
return (
<CountriesList
countries={countries}
onCountryChanged={(c) => setSelectedCountry(c)}
savedCountry={savedCountry}
/>
);
}, [savedCountry, countries]);
const selected = useMemo(() => {
return (
<SelectedCountry
country={selectedCountry}
onCountrySaved={() => setSavedCountry(selectedCountry)}
/>
);
}, [selectedCountry]);
return (
<>
<h1>Country settings</h1>
<div css={contentCss}>
{list}
{selected}
</div>
</>
);
};
最后,让我们正式确定本文的第二条规则:
规则 2.如果你的组件管理状态,请找到渲染树中不依赖于更改状态的部分并记住它们,以
尽量减少它们的重新渲染。
实施国家名单
现在,我们的 Page 组件已经准备就绪,完美无缺,是时候充实它的子组件了。首先,让我们实现一个复杂的组件:CountriesList
。我们已经知道,这个组件应该接受国家/地区列表,onCountryChanged
在列表中选择一个国家/地区时触发回调,并且应该savedCountry
根据设计将 突出显示为不同的颜色。所以,让我们从最简单的方法开始:
type CountriesListProps = {
countries: Country[];
onCountryChanged: (country: Country) => void;
savedCountry: Country;
};
export const CountriesList = ({
countries,
onCountryChanged,
savedCountry
}: CountriesListProps) => {
const Item = ({ country }: { country: Country }) => {
// different className based on whether this item is "saved" or not
const className = savedCountry.id === country.id ? "country-item saved" : "country-item";
// when the item is clicked - trigger the callback from props with the correct country in the arguments
const onItemClick = () => onCountryChanged(country);
return (
<button className={className} onClick={onItemClick}>
<img src={country.flagUrl} />
<span>{country.name}</span>
</button>
);
};
return (
<div>
{countries.map((country) => (
<Item country={country} key={country.id} />
))}
</div>
);
};
再次强调,这是有史以来最简单的组件,实际上只发生了两件事:
- 我们根据收到的 props 生成
Item
(它取决于onCountryChanged
和savedCountry
) - 我们
Item
循环为所有国家呈现该内容
再说一次,从本质上来说,这并没有什么犯罪行为,我几乎在所有地方都看到过这种模式的使用。
重构国家列表组件 - 考虑性能
是时候再次回顾一下 React 渲染机制了。这次,如果一个组件(比如Item
上面的组件)在另一个组件渲染过程中被创建,会发生什么?简而言之,这没什么好处。从 React 的角度来看,这Item
只是一个每次渲染都会生成新的函数,并且每次渲染都会返回一个新的结果。所以,它所做的就是,每次渲染时都会重新创建这个函数的结果,也就是说,它会将之前的组件状态与当前的状态进行比较,就像在正常的重新渲染过程中一样。它会删除之前生成的组件,包括它的 DOM 树,将其从页面中移除,然后在每次父组件重新渲染时,生成并挂载一个全新的组件,其中包含一个全新的 DOM 树。
如果我们简化国家示例来展示这种效果,它将是这样的:
const CountriesList = ({ countries }: { countries: Country[] }) => {
const Item = ({ country }: { country: Country }) => {
useEffect(() => {
console.log("Mounted!");
}, []);
console.log("Render");
return <div>{country.name}</div>;
};
return (
<>
{countries.map((country) => (
<Item country={country} />
))}
</>
);
};
这是 React 中最繁重的操作。从性能角度来看,10 次“正常”重新渲染与完全重新挂载一个新创建的组件相比根本不算什么。在正常情况下,useEffect
如果依赖项数组为空,则只会触发一次 - 在组件完成挂载和首次渲染之后。之后,React 中的轻量级重新渲染过程启动,组件不会从头开始创建,而是仅在需要时更新(顺便说一下,这就是 React 如此之快的原因)。但在这种情况下并非如此 - 看看这个 codesandbox,在打开控制台的情况下单击“重新渲染”按钮,并享受每次单击时发生的 250 次渲染和挂载。
解决这个问题的方法显而易见且简单:我们只需要将Item
组件移到渲染函数之外。
const Item = ({ country }: { country: Country }) => {
useEffect(() => {
console.log("Mounted!");
}, []);
console.log("Render");
return <div>{country.name}</div>;
};
const CountriesList = ({ countries }: { countries: Country[] }) => {
return (
<>
{countries.map((country) => (
<Item country={country} />
))}
</>
);
};
现在,在我们简化的代码中,每次重新渲染父组件时都不会发生安装。
额外的好处是,这样的重构有助于维护不同组件之间的健康边界,并使代码更简洁、更干净。当我们将这项改进应用到“真实”应用中时,这一点将尤为明显。之前:
export const CountriesList = ({
countries,
onCountryChanged,
savedCountry
}: CountriesListProps) => {
// only "country" in props
const Item = ({ country }: { country: Country }) => {
// ... same code
};
return (
<div>
{countries.map((country) => (
<Item country={country} key={country.id} />
))}
</div>
);
};
后:
type ItemProps = {
country: Country;
savedCountry: Country;
onItemClick: () => void;
};
// turned out savedCountry and onItemClick were also used
// but it was not obvious at all in the previous implementation
const Item = ({ country, savedCountry, onItemClick }: ItemProps) => {
// ... same code
};
export const CountriesList = ({
countries,
onCountryChanged,
savedCountry
}: CountriesListProps) => {
return (
<div>
{countries.map((country) => (
<Item
country={country}
key={country.id}
savedCountry={savedCountry}
onItemClick={() => onCountryChanged(country)}
/>
))}
</div>
);
};
现在,我们摆脱了Item
每次重新渲染父组件时重新挂载组件的麻烦,我们可以提取文章的第三条规则:
规则 #3 . 永远不要在另一个组件的渲染函数中创建新组件。
实施选定的国家
下一步:“选定国家”组件,这将是文章中最短、最无聊的部分,因为那里实际上没有什么可展示的:它只是一个接受属性和回调并呈现一些字符串的组件:
const SelectedCountry = ({ country, onSaveCountry }: { country: Country; onSaveCountry: () => void }) => {
return (
<>
<ul>
<li>Country: {country.name}</li>
... // whatever country's information we're going to render
</ul>
<button onClick={onSaveCountry} type="button">Save</button>
</>
);
};
🤷🏽♀️ 就是这样!它只是为了让演示代码和盒子更有趣 🙂
最终润色:主题
现在到了最后一步:黑暗模式!谁不喜欢呢?考虑到当前主题应该在大多数组件中可用,通过 props 传递到各个组件将是一场噩梦,所以 React Context 是自然的解决方案。
首先创建主题上下文:
type Mode = 'light' | 'dark';
type Theme = { mode: Mode };
const ThemeContext = React.createContext<Theme>({ mode: 'light' });
const useTheme = () => {
return useContext(ThemeContext);
};
添加上下文提供程序和按钮以将其切换到页面组件:
export const Page = ({ countries }: { countries: Country[] }) => {
// same as before
const [mode, setMode] = useState<Mode>("light");
return (
<ThemeContext.Provider value={{ mode }}>
<button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>
// the rest is the same as before
</ThemeContext.Provider>
)
}
然后使用上下文钩子为按钮添加适当的主题颜色:
const Item = ({ country }: { country: Country }) => {
const { mode } = useTheme();
const className = `country-item ${mode === "dark" ? "dark" : ""}`;
// the rest is the same
}
再次强调,这种实施并没有什么犯罪行为,这是一种非常常见的模式,特别是对于主题而言。
重构主题——考虑性能。
在我们能够找出上述实现中存在的问题之前,需要先研究一下为什么 React 组件可以重新渲染的第四个原因,这个原因经常被遗忘:如果组件使用上下文消费者,则每次上下文提供者的值发生变化时它都会重新渲染。
还记得我们简化的示例吗?我们记住了渲染结果以避免重新渲染。
const Item = ({ country }: { country: Country }) => {
console.log("render");
return <div>{country.name}</div>;
};
const CountriesList = ({ countries }: { countries: Country[] }) => {
return (
<>
{countries.map((country) => (
<Item country={country} />
))}
</>
);
};
export const Page = ({ countries }: { countries: Country[] }) => {
const [counter, setCounter] = useState<number>(1);
const list = useMemo(() => <CountriesList countries={countries} />, [
countries
]);
return (
<>
<h1>Country settings</h1>
<button onClick={() => setCounter(counter + 1)}>
Click here to re-render Countries list (open the console) {counter}
</button>
{list}
</>
);
};
Page
每次点击按钮时,组件都会重新渲染,因为它会在每次点击时更新状态。但CountriesList
由于它被记忆化,并且与状态无关,所以它不会重新渲染,最终Item
组件也不会重新渲染。请参阅此处的 codesandbox。
现在,如果我在这里添加主题上下文会发生什么?Page
组件中的提供程序:
export const Page = ({ countries }: { countries: Country[] }) => {
// everything else stays the same
// memoised list is still memoised
const list = useMemo(() => <CountriesList countries={countries} />, [
countries
]);
return (
<ThemeContext.Provider value={{ mode }}>
// same
</ThemeContext.Provider>
);
};
以及 Item 组件中的上下文:
const Item = ({ country }: { country: Country }) => {
const theme = useTheme();
console.log("render");
return <div>{country.name}</div>;
};
如果它们只是普通的组件和钩子,什么都不会发生——Item
它不是Page
组件的子组件,CountriesList
由于存在记忆机制,不会重新渲染,所以也Item
不会重新渲染。但在本例中,它是一个提供者-消费者的组合,因此每次提供者的值发生变化时,所有消费者都会重新渲染。而且由于我们一直在将新对象传递给值,所以Items
每次计数器都会进行不必要的重新渲染。上下文基本上绕过了我们所做的记忆,使其几乎毫无用处。请参阅 codesandbox。
你可能已经猜到了,解决这个问题的方法就是确保value
提供程序中的 不会发生不必要的更改。在我们的例子中,我们只需要记住它即可:
export const Page = ({ countries }: { countries: Country[] }) => {
// everything else stays the same
// memoising the object!
const theme = useMemo(() => ({ mode }), [mode]);
return (
<ThemeContext.Provider value={theme}>
// same
</ThemeContext.Provider>
);
};
现在计数器就可以工作,而不会导致所有项目重新渲染!
我们可以对非简化Page
组件应用完全相同的防止不必要重新渲染的解决方案:
export const Page = ({ countries }: { countries: Country[] }) => {
// same as before
const [mode, setMode] = useState<Mode>("light");
// memoising the object!
const theme = useMemo(() => ({ mode }), [mode]);
return (
<ThemeContext.Provider value={theme}>
<button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle theme</button>
// the rest is the same as before
</ThemeContext.Provider>
)
}
并将新知识提炼到本文的最终规则中:
规则#4:使用上下文时,如果值属性不是数字、字符串或布尔值,请确保始终记住该值属性。
整合所有
终于,我们的应用完成了!完整的实现代码可以在 codesandbox 获取。如果你用的是最新款 MacBook,可以调低 CPU 频率,像普通用户一样体验世界,并尝试在列表中选择不同的国家/地区。即使 CPU 频率降低了 6 倍,速度仍然非常快!🎉
现在,我想很多人都想问一个大问题:“但是 Nadia,React 本身就很快了。你做的那些‘优化’对一个只有 250 个项目的简单列表真的没什么影响吗?你是不是夸大了它的重要性?”
是的,我刚开始写这篇文章的时候也是这么想的。但后来我用“低性能”的方式实现了那个应用。去 codesandbox 看看吧。我甚至不需要降低 CPU 就能看到选择项目之间的延迟 😱。如果降低 6 倍,它可能是地球上最慢的简单列表,甚至无法正常工作(它有一个“高性能”应用没有的焦点 bug)。而且我甚至没有在那里做任何明显邪恶的事情!😅
因此,当 React 组件重新渲染时,让我们刷新一下:
- 当 props 或 state 发生变化时
- 当父组件重新渲染时
- 当组件使用 context 并且其提供程序的值发生变化时
我们提取的规则如下:
规则 1:如果你想要将 props 中的内联函数提取出来的唯一原因useCallback
是为了避免子组件的重新渲染:那么不要这么做。这样做行不通。
规则#2:如果您的组件管理状态,请找到渲染树中不依赖于改变的状态的部分并记住它们以尽量减少它们的重新渲染。
规则 #3 .永远不要在另一个组件的渲染函数中创建新组件。
规则 4.使用上下文时,如果属性不是数字、字符串或布尔值,请确保始终value
记住该属性。
就是这样!希望这些规则能帮助我们从一开始就编写出性能更强大的应用程序,让客户更加满意,不再需要忍受运行缓慢的产品。
奖金:useCallback
难题
在结束这篇文章之前,我觉得我需要解开一个谜团:这怎么可能useCallback
对减少重新渲染毫无用处呢?为什么 React 文档会直接说“在将回调传递给依赖引用相等性来防止不必要渲染的优化子组件时,[useCallback] 很有用”?🤯
答案就在这个短语中:“依赖于引用相等性的优化子组件”。
这里有两种适用的情况。
首先:接收回调的组件被包装起来React.memo
,并将该回调作为依赖项。基本上是这样的:
const MemoisedItem = React.memo(Item);
const List = () => {
// this HAS TO be memoised, otherwise `React.memo` for the Item is useless
const onClick = () => {console.log('click!')};
return <MemoisedItem onClick={onClick} country="Austria" />
}
或者这个:
const MemoisedItem = React.memo(Item, (prev, next) => prev.onClick !== next.onClick);
const List = () => {
// this HAS TO be memoised, otherwise `React.memo` for the Item is useless
const onClick = () => {console.log('click!')};
return <MemoisedItem onClick={onClick} country="Austria" />
}
第二useMemo
:如果接收回调的组件在诸如或useCallback
之类的钩子中将此回调作为依赖项useEffect
。
const Item = ({ onClick }) => {
useEffect(() => {
// some heavy calculation here
const data = ...
onClick(data);
// if onClick is not memoised, this will be triggered on every single render
}, [onClick])
return <div>something</div>
}
const List = () => {
// this HAS TO be memoised, otherwise `useEffect` in Item above
// will be triggered on every single re-render
const onClick = () => {console.log('click!')};
return <Item onClick={onClick} country="Austria" />
}
这些都不能概括为简单的“做”或“不做”,它只能用于解决特定组件的特定性能问题,而不是之前的问题。
现在文章终于完成了,感谢你阅读到这里,希望它对你有帮助!祝你一切顺利,下次再见✌🏼
...
最初发表于https://www.developerway.com。该网站还有更多类似的文章 😉
订阅时事通讯、在 LinkedIn 上联系或在 Twitter 上关注,以便在下一篇文章发布时立即收到通知。
鏂囩珷鏉ユ簮锛�https://dev.to/adevnadia/how-to-write-performant-react-code-rules-patterns-dos-and-donts-40gk