[译] 如何编写高性能的 React 代码:规则、模式、该做和不该做的
原文地址:https://www.developerway.com/posts/how-to-write-performant-react-code
原作者:NADIA MAKAREVICH
原文发布于:2022年1月9日
React 和性能!在六个月的时间内,关于这个有趣的话题的讨论、最佳实践有如此之多,甚至形成了互相对立的情况。是否有可能总结出一些概括性的、已明确的观点呢?我想试试。
通常来说,性能专家是“过早优化是万恶之源”和“基于测量结果谈性能”的倡导者。可以简单的概括为“东西没坏之前不要去修它”,这很难反驳,尽管如此我还是要尝试一下😉。
我喜欢React的原因是,它使实现复杂的 UI 交互变得异常简单。 我不喜欢 React 的地方是,它也让人难以置信地容易犯错,而这些错误的巨大后果并不马上可见。好消息是,在写代码的时候就防止这些错误并让大部分时间写出的代码是高性能的也是非常容易的,因为这样的问题会少得多,从而能大大减少调查性能问题所需的时间和精力。当涉及到React和性能时,"过早的优化 "实际上是一件好事,是每个人都应该做的事😉。你只需要知道一些需要注意的模式,来让你有意识地做到这一点。
而这正是我想在这篇文章中证明的😊。我将通过一步步实现一个 "贴近实际生活 "的应用程序来做到这一点,首先以一种 "正常 "的方式,使用你几乎随处可见的、肯定你自己也多次使用过的模式来实现。然后在考虑到性能的前提下重构每一个步骤,从每一个步骤中提取一个通用的规则,这个规则可以在大多数情况下应用于大多数的应用程序。然后在最后比较结果。
好的,让我们开始吧!
我们将为一个网上商店编写一个"设置"页面(我们在之前的 "React 开发者的 Typscript 进阶使用"文章中介绍过)。在这个页面上,用户将能够从列表中选择一个国家,看到这个国家的所有信息(如货币、交付方式等),然后将这个国家保存为他们选择的国家。这个页面看起来会是这样的。
在页面左边我们会有一个国家的列表,有 "已保存"和 "选定"的状态,当列表中的一个项目被点击时,在右边的栏目中会显示详细的信息。当 "保存 "按钮被按下时,"选定"的国家就会变成 "已保存",并显示不同的背景颜色。
哦,我们当然希望它有黑暗模式,毕竟它是 2022 年开发的。
另外,考虑到 90% 的 React 性能问题都可以归纳为"过多的重新渲染",我们将在文章中主要关注减少这个问题。(另外 10% 是。"渲染过重"和"需要进一步调查的非常奇怪的东西")。
我们首先规划一下应用的结构
首先,我们先规划一下组件结构,根据设想画出应用要实现时使用的组件结构。
- page 表示根组件
Page
,我们在这里实现“提交”逻辑和国家选择逻辑。 - list of countries 表示组件
CountriesList
,负责渲染国家列表,在将来可能会处理诸如过滤、排序的功能。 - 组件
Item
,在CountriesList
组件中负责渲染国家条目的组件。 - selected country 表示组件
SelectedCountry
,负责渲染"选定"状态的国家的详情,并有“保存”按钮。
当然,这不是实现这个页面的唯一可能的方式,这也是 React 的魅力与诅咒:一切都可以用无数种方式实现,任何事情都没有对错之分。但是从一个快速增长的大型应用的角度来看,有一些模式我们可以定义为**“永远不要这样做”和“必须这样做”**的。
来看看我们能不能一起发现这些模式吧🙂
Page
组件实现
现在,是时候让我们下地干活,实际写一点代码了。我们先从根组件 Page
开始实现。
首先:我们需要用一些样式和一些标签来包裹页面的标题、CountriesList
和 SelectedCountry
组件。
第二:页面应该从某个地方接收国家的列表,然后把它传递给 CountriesList
组件,这样它就可以呈现这些国家。
最后:我们的页面应该有一个 "选定" 国家的概念,它从 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 代码,在这个实现看起来没有任何拉低性能的地方。除了一件事,你能发现它吗?
重构 Page 组件 —— 基于性能
我想现在大家都知道,当 state
或 props
发生变化时,react 会重新渲染组件。在我们的 Page
组件中,当 setSelectedCountry
或 setSavedCountry
被调用时,它将重新渲染。如果我们的 Page
组件中的国家数组(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 组件被重新渲染的第三个原因:**当父组件被重新渲染的时候。**无论 CountriesList
接收了什么 props
,如果 Page
被重新渲染,CountryList
总是会重新渲染,即使它根本没有接收任何 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 钩子。它所做的基本上是“缓存”你传递给它的任何函数的结果,并且只有在改变 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}
</>
);
};
在这份代码的情况下,ComponentList
永远不会重新渲染,因为它没有依赖。这种用法基本上可以跳出“子组件因为父组件重新渲染而跟着重新渲染”的循环。codesandbox 的完整示例
最需要注意的是 useMemo
的依赖列表。如果它所依赖的东西与导致父元素重新渲染的东西完全相同的话,那么父组件重新渲染的时候也会导致这个子组件重新渲染,这会导致这个 useMemo
包裹毫无意义。举个例子,在上面这个示例中,如果我把 counter
作为 list
的依赖(注意:即使需要被记忆的组件没有使用到这个依赖作为 props!)这将导致 useMemo
在每次 state 变化的时候刷新自己,并使 CountriesList
重新渲染。
const list = useMemo(() => {
return (
<>
{counter}
<CountriesList />
</>
);
}, [counter]);
好吧,这个方式看起来挺不错的,但究竟应该怎样将它应用到我们的组件当中呢?现在让我们再仔细回顾一下 Page
组件的实现。
可以发现:
CountriesList
组件没有用到selectedCountry
这个状态值。SelectedCountry
组件没有用到savedCountry
这个状态值。
这意味着当 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。**如果你的组件的父组件有使用 state,找到渲染树中不依赖于改变的状态的部分,并将其记忆化,以尽量减少其重新渲染。
实现国家列表组件
现在,我们的 Page
组件已经完美完成了,是时候充实它的子组件了。首先,让我们实现 CountriesList
这个复杂的组件。我们已经知道,这个组件应该接受国家列表 props;当一个国家在列表中被选中时,应该触发 onCountryChanged
回调,并且应该根据设计将保存的国家突出显示为不同颜色。所以让我们用最简单的方法实现。
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>
);
};
同样的,这个简单的组件实现了下面两点:
- 我们根据接收到的参数生成
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 上试操作一下,打开 console,每次点击“re-render”按钮,你可以看到有 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} />
))}
</>
);
};
现在,在我们简化的 codesandbox 中,重新渲染和安装不会发生在每次重新渲染父组件的时候。
作为奖励,这样的重构能保持不同组件之间的健康界限,并让代码更加简洁。当我们把这种改进应用到我们的 app 中时,对比会更加明显,这是之前的代码:
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 解决方案。
首先创建主题的 context:
type Mode = 'light' | 'dark';
type Theme = { mode: Mode };
const ThemeContext = React.createContext<Theme>({ mode: 'light' });
const useTheme = () => {
return useContext(ThemeContext);
};
添加 context provider 和在 Page 组件增加切换主题的按钮:
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>
)
}
使用我们定义的 context hook 来为我们的组件着色:
const Item = ({ country }: { country: Country }) => {
const { mode } = useTheme();
const className = `country-item ${mode === "dark" ? "dark" : ""}`;
// the rest is the same
}
同样的,这是一个很普通的实现,看起来没有什么会拉低性能的代码,主题几乎都是这样实现的。
重构主题实现——基于性能
在我们能够发现上面的代码的问题之前,先研究一下我们经常忽视的会导致 React 组件重新渲染的第四个原因:如果一个组件使用了 context consumer,当 context provider 的值发生变化时,组件会重新渲染。
还记得我们的最简实现中,为了避免重新渲染,我们把组件的渲染结果 memorised 起来了吗。
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
组件会在每次点击按钮的时候重渲染,因为 state 的更新。但 CountriesList
做了 memorised,而且没有依赖变更的那个 state,所以它不会跟着重渲染,同样的 Item
组件也不会跟着重新渲染。这是 codesandbox
现在,如果我在 Page
组件里面增加了 Theme context 的 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>;
};
如果没有使用到 context 的话,和原来的重渲染情况是一样的。因为 Item
不是 Page
的一个子节点,CountriesList
经过了 memoried 也不会触发重渲染,同样的,CountriesList
的子组件 Item
也不会。只是,在 Provide-consumer 组合的情况下,每次 provider 的值发生变更的时候,所有 consumer 都会重新渲染。因为我们一直向 provider 传递新的对象作为新的值,Item
组件在每次 counter
变化的时候也会触发非必要的重渲染。context 绕开了我们借由 memoried 实现的优化,让它失效了。codesandbox
为了修复这个异常,就像你已经猜到的那样,我们只需要保证 provider 使用的 value 只在它真的变化的时候才变化就可以。在我们这个项目的具体实现中,我们只需要把 value 也进行 memoried 处理就可以:
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>
);
};
现在 counter 的变化就不会导致 Item
重新渲染了!
这个解决方案同样可以应用在非简化的 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. **当使用 context 时,值属性如果不是数字、字符串或布尔值是,请确保要进行 memorised。
汇总
终于,我们完成了这个示例应用!完整的实现见 codesandbox。如果你是最新版的 MacBook,可以调低你的 CPU 来体验一下更普遍的客户使用情况,尝试在不同国家之间切换选择。即使降低了 6 倍的 CPU,它仍然是非常快的🎉!
现在,我猜很多人都会想问一个问题:Nadia(原作者),React 本身就是非常快的,你所做的那些“优化”对一个只有 250 个条目的简单列表来说是否不会有太大的影响?你是不是夸大了这些规则的重要性呀?
是的,我在刚开始写这篇文章的时候,我也是这样想的。但是当我以“未进行性能优化”的方式来实现应用,即使我没有降低我的 CPU,我也能看到切换选择条目时的延迟😱(示例见 codesandbox)。将 CPU 降低至 6 分之一时,它可能是世界上最慢的简单列表了,甚至不能正常工作(它有一个聚焦 bug,而“高性能”版没有)。我甚至还没有写一些故意会折损性能的代码!😅
所以让我们回顾一下什么会导致 React 重新渲染:
- 当 props 或 state 变化
- 当父组件重渲染
- 当组件使用到了 context 的值,而且这个值发生了改变
提取出来的规则有:
- **规则#1。**如果你想把 props 中的内联函数提取到 useCallback 中的唯一原因是为了避免子组件的重新渲染:不要。这是不可行的。
- **规则#2。**如果你的组件的父组件有使用 state,找到渲染树中不依赖于改变的状态的部分,并将其记忆化,以尽量减少其重新渲染。
- **规则#3。**不要在另一个组件的渲染函数内创建新的组件。
- **规则#4。**当使用 context 时,值属性如果不是数字、字符串或布尔值是,请确保要进行 memorised。
就是这些了! 希望这些规则能帮助我们从一开始就写出性能更强的应用程序,并使客户更加满意,不用再体验老爷车一样的产品。
奖励环节:useCallback 的难题
我觉得在真正结束这篇文章之前,有必要解决一个难题:如果 useCallback 对解决重渲染没有作用的话,为什么 React 官方文档中说“useCallback 在作为回调传递的时候很有用,可以优化基于值对比的子组件,减少无必要的重渲染”
答案就在文中的“优化基于值对比的子组件”
有两种情况适用于这个情况:
一:这个组件被包裹在 useMemo 中,接收的回调也是在 useMemo 的依赖列表中,大致的示例是:
const MemoisedItem = React.memo(Item);
const List = () => {
// 这必须是 memoised 的, 否则 Item 的 `React.memo` 就没有用了
const onClick = () => {console.log('click!')};
return <MemoisedItem onClick={onClick} country="Austria" />
}
或者这个:
const MemoisedItem = React.memo(Item, (prev, next) => prev.onClick !== next.onClick);
const List = () => {
// 这必须是 memoised 的, 否则 Item 的 `React.memo` 就没有用了
const onClick = () => {console.log('click!')};
return <MemoisedItem onClick={onClick} country="Austria" />
}
二:如果组件接收的参数要被用来作为别的值的依赖项,如 useMemo
、useCallback
、useEffect
const Item = ({ onClick }) => {
useEffect(() => {
// 一些繁重计算
const data = ...
onClick(data);
// 如果 onClick 不是 memoised 的,这个 useEffect 会在每次渲染中触发
}, [onClick])
return <div>something</div>
}
const List = () => {
// 这必须是 memoised 的, 否则 Item 的 useEffect 在每次渲染的时候都会被触发
const onClick = () => {console.log('click!')};
return <Item onClick={onClick} country="Austria" />
}
这导致 useCallback 的使用不能简单的被归纳为“应该”和“不应该”,它只能被用来解决确切组件的确切性能问题,而不是在写之前就能被预知。
现在文章终于写完了,感谢你到目前为止的阅读,希望你觉得它很有用 下次再见吧 ✌🏼