原文链接:www.developerway.com/posts/how-t…
性能与React!这个话题如此有趣,却充满争议观点,众多最佳实践在短短半年内就可能彻底翻转。我们还能在此给出任何确定性结论或普遍性建议吗?
性能专家通常奉行"过早优化是万恶之源"和"先测量再优化"的准则,这大致可理解为"别修没坏的东西",确实难以反驳。但我还是要试着辩驳一下😉
我欣赏 React 的地方在于,它让实现复杂的 UI 交互变得异常简单。我不喜欢 React 的地方在于,它同样让犯下严重错误变得异常容易——这些错误往往不会立即显现。好消息是,预防这些错误同样简单,你完全可以立即编写出绝大多数情况下都高效的代码,从而大幅减少排查性能问题所需的时间和精力,因为这类问题本身就会少得多。简而言之,在 React 与性能优化领域,"过早优化"反而能成为有益之事,值得所有人践行 😉。关键在于掌握几个需警惕的模式,方能实现真正有效的优化。
因此,这正是我在这篇文章中想要证明的内容😊。我将通过逐步实现一个"真实场景"的应用来完成论证:首先采用"常规"方式,使用那些随处可见且你自己也多次用过的模式;随后在每个步骤中进行以性能为导向的重构,并从每个步骤中提炼出适用于大多数应用的通用规则;最后对比最终结果。
开始吧!
我们将为某在线商店(此前在《React开发者的高级TypeScript教程》系列中介绍过)编写"设置"页面之一。用户可在该页面从国家列表中选择目标国家,查看该国所有可用信息(如货币、配送方式等),并保存为首选国家。页面大致如下所示:
左侧将显示国家列表,包含"已保存"和"已选中"状态。点击列表项时,右侧列将展示详细信息。点击"保存"按钮后,"已选中"国家将转为"已保存"状态,并采用不同项颜色标识。
对了,当然要支持深色模式——毕竟都2022年了!
此外,考虑到90%的React性能问题可归结为"过多重渲染",本文将重点探讨如何减少重渲染。(其余10%问题包括:"渲染负载过重"和"需深入排查的异常情况"。)
首先构建应用程序结构
首先,让我们审视设计方案,划定虚拟边界,并勾勒出未来应用程序的结构框架及所需实现的组件:
- 一个根级别的“页面”组件,用于处理“提交”逻辑和国家选择逻辑
- 一个“国家列表”组件,用于渲染所有国家列表,未来将支持筛选和排序功能
- “条目”组件,用于在“国家列表”中渲染单个国家信息
- 一个“选定国家”组件,用于展示选定国家的详细信息并包含“保存”按钮
当然,这并非实现该页面的唯一方式——这正是React的魅力所在,也是其诅咒所在:万物皆可千万种方式实现,且不存在绝对的对错之分。但某些模式在快速增长或已具规模的应用中,终究会演变为"切忌如此"或"必备法则"。
让我们一起探索这些模式吧 😊
实现页面组件
现在终于到了动手编码的时候。让我们从“根”开始,实现页面组件。
首先:我们需要一个带有样式的包装器,用于渲染页面标题、"国家列表"和"选定国家"组件。
第二:我们的页面应从某个来源获取国家列表,并将其传递给 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>
</>
);
};
这就是“页面”组件的完整实现,它是最基础的React组件,随处可见,实现过程中绝对没有任何问题。除了一个细节。好奇吗?你能发现吗?
重构页面组件——注重性能优化
我想现在大家都知道,当状态或 props 发生变化时,React 会重新渲染组件。在我们的 Page 组件中,当调用 setSelectedCountry 或 setSavedCountry 时,它会重新渲染。如果 Page 组件中的 countries 数组(props)发生变化,它也会重新渲染。同样的情况也适用于 CountriesList 和 SelectedCountry 组件——当它们的任何 props 发生变化时,它们都会重新渲染。
此外,任何接触过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 如何,只要 Page 重新渲染,CountriesList 就会重新渲染——即使它根本没有 props。
我们可以将 Page 的示例简化为这样:
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 钩子。其核心机制是"缓存"传入函数的计算结果,仅在依赖项发生变化时刷新缓存。若将渲染后的 CountriesList 提取为常量const list = <ComponentList />; 并应用 useMemo ,ComponentList 组件便仅在useMemo依赖项变更时重新渲染。
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 变量(注意:甚至不是传递给缓存组件的 props!),这将导致 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示例,打开控制台点击"re-render"按钮,每次点击都会触发250次渲染与挂载操作。
解决方案显而易见且简单:只需将 Item 组件移出render函数即可。
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} />
))}
</>
);
};
现在在我们的简化codesandbox中,父组件每次重新渲染时都不会触发挂载操作。
额外好处是,此类重构有助于保持不同组件间的健康边界,使代码更简洁明了。当我们将这项改进应用到"真实"应用时,效果将尤为显著。改进前:
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:切勿在其他组件的render函数内部创建新组件。
实现选定国家组件
下一步:实现“选定国家”组件,这将是本文最短且最枯燥的部分,因为该组件实际上没什么可展示的:它只是一个接受属性与回调函数的组件,并渲染几行字符串:
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 组件中添加Provider:
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 组件中的 Context:
const Item = ({ country }: { country: Country }) => {
const theme = useTheme();
console.log("render");
return <div>{country.name}</div>;
};
如果它们只是普通的组件和钩子,就不会发生任何情况——Item 不是 Page 组件的子组件,由于备忘录机制,CountriesList 不会重新渲染,因此 Item 也不会。但在此情境下,这是 Provider-consumer 组合,因此每次 Provider 的值改变时,所有消费者都会重新渲染。由于我们持续向value传递新对象,每次计数器更新时 Items 都会不必要地重新渲染。Context机制实质上绕过了我们设置的备忘机制,使其几乎失去作用。详见codesandbox演示。
解决方法正如你所料:只需确保Provider中的 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倍,运行依然迅如闪电!🎉
此刻想必许多人迫不及待要问:"娜迪亚,React本身就快如闪电, 你做的那些'优化'对仅有250项的简单列表能有多大提升?是不是夸大其词了?"
没错,我刚开始写这篇文章时也这么认为。但随后我用"低效"方式实现了该应用。请在codesandbox中查看。我甚至无需降低CPU性能就能看到选中项时的延迟😱。将性能降低6倍后,它可能成了全球最慢的简单列表——而且还存在焦点错误(而"高性能"版本没有这个问题)。更荒谬的是,我根本没做任何明显恶劣的操作啊!😅
那么让我们回顾React组件何时会重新渲染:
当组件状态改变时 当父组件重新渲染时 当组件使用上下文且其提供者的值改变时
我们总结的规则如下:
规则 #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" />
}
其次:如果接收回调的组件在 hooks(如 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" />
}
这些内容都不能简单概括为"该做"或"不该做",只能用于解决特定组件的具体性能问题,在此之前都不可妄下结论。
本文至此终于完成,感谢您阅读至此,希望对您有所帮助!祝您健康,下次见 ✌🏼