本文作者系360奇舞团前端开发工程师
本文为翻译,原文标题:How to write performant React code: rules, patterns, do's and don'ts,原文作者:Nadia Makarevich,原文地址:www.developerway.com/posts/how-t...
写在开头
关于 React 性能!是一个非常有趣且有争议性的话题,在短时间内(6个月)的最佳实践也可能是不准确的, 在这里我们可以一起探讨出一些明确的观点以及提出一些的建议。
通常,性能专家都主张"过早优化是万恶之源"和"测量优先"的原则。大致可以理解为"不要修复没有问题的代码程序",这个观点很难反驳。但我还是要反驳一下。
我喜欢 React
的地方在于它使得实现复杂的 UI 交互变得非常容易。我不喜欢 React
的地方在于它也让人们很容易写出"错误 "的代码,而开发者却意识不到。好消息是,我们可以非常轻松地避免这些错误,并编写具备良好性能的代码,从而显著减少分析性能问题所需花费的时间和精力,因为会出现更少有关性能的问题。基本上,在涉及 React
代码性能时,"过早优化 "实际上可能是件好事,我们每个人都应该这样做。你需要知道一些最佳实践,以便更好的进行优化。
所以,这正是我想在本文中证明的。我将逐步实现一个"真实"的应用,首先按照"正常"的方式实现,且你自己肯定多次使用过的代码来完成。然后,在考虑性能的情况下对每个组件进行重构,并从每个组件中提取出一条可以应用于大多数应用的最佳实践。
让我们开始吧
我们将为在线商城编写一个"设置"页面。在这个页面上,用户可以从列表中选择一个国家,查看该国家的所有可用信息(如货币、快递方式等),还可以将选中的国家保存。页面大致如下所示:
image.png
左边有一个国家列表,带有"选择"和"保存"状态。当点击列表中的项目时,在右侧列中显示详细信息。当点击"保存"按钮时,可以将选择的国家保存起来,且当前选中的国家会被高亮显示。
并且我们希望使用深色模式!
此外,考虑到90%的情况下React
性能问题可以总结为re-render次数过多,本文我们将主要专注于减少这个问题。
构建应用程序
首先,让我们来看一下设计图,并确定我们未来应用的结构以及需要实现的组件:
- "Page"根组件,在这里我们处理
提交
逻辑和国家选择
逻辑 - "CountriesList"组件,用于以列表形式渲染所有的国家,并且将来可以处理过滤和排序等功能
- "Item"组件,在"国家列表"中渲染每个国家
- "Selected"组件,用于展示所选国家的详细信息并包含"保存"按钮
image.png
当然这不是实现此页面的唯一方式,但这就是 React 的魅力所在:可以用无数种方式来实现,这些方式也没有对错之分。但在长期快速增长或已经很大的应用程序中,有一些代码绝对可以被称为"永远不要这样做"或"必须这样做"。
让我们看看能否一起找出它们。
Page 组件
现在,是时候开始编写代码了。让我们从"根组件"开始实现页面。
1、我们需要一个带有一些样式的包装器,用来渲染页面标题、"国家列表"和"选定的国家"组件。
2、页面应该从某个地方接收到国家列表,并将其传递给CountriesList
组件以便进行渲染。
3、页面应该有一个"已选择"的国家状态,在CountriesList
组件中接收并传递给SelectedCountry
组件。
4、页面应该有一个"已保存"的国家状态,在SelectedCountry
组件中接收并传递给CountriesList
组件。
ini
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 组件(性能考虑)
大家应该都知道,组件的状态或属性发生变化时,React 会re-render
。在我们的 Page 组件中,当调用setSelectedCountry
或 setSavedCountry
时,组件会re-render
。如果 Page 组件中的 countries 数组(props)发生变化,也同样会re-render
。对于 CountriesList
和 SelectedCountry
组件也是如此,当它们的任何 props 发生变化时,组件将re-render
。
此外,任何使用过一段时间 React 的人都知道 JavaScript 的相等比较,即 React 对 props 进行严格的相等比较,并且内联函数在每次渲染中都会重新创建。这导致了一个非常普遍(而且绝对错误)的观点,即为了减少CountriesList
和SelectedCountry
组件的重新渲染,我们需要通过将内联函数包装在 useCallback
中来摆脱在每次渲染时重新创建内联函数。甚至 React 文档也提到了使用useCallback
来"防止不必要的渲染"!看看这段代码是否熟悉:
javascript
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 组件re-render
的原因:当父组件re-render
时。无论 props 如何,如果 Page re-render
,CountriesList
也同样会re-render
,即使它根本没有任何 props。
我们可以将 Page 的示例简化为这样:
typescript
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,我们也会看到它重新渲染了。
最后,我们得出了本文的第一条规则:
规则1 :如果你想将内联函数中的 props 提取到useCallback
中的原因是为了避免子组件re-render
,那么不要这样做,它不会起作用。
现在,有其他几种处理上述情况的方法,我们将使用最简单的方法来处理此特定场合:使用 useMemo hook
。它所做的就是"缓存"传递给它的任何函数的结果,并且仅当useMemo
依赖项发生变更时才重新执行。如果我们只是将渲染后的CountriesList
提取到一个变量const list = <ComponentList />
,然后对其使用useMemo
,在只有当useMemo
依赖项发生变更时ComponentList
组件才会re-render
。
typescript
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}
</>
);
};
在这种情况下CountriesList
永远不会re-render
,因为它没有任何依赖项。这打破了"父组件re-render
,所有子组件也将re-render
"的规律。
最重要的是要注意useMemo
的依赖列表。如果它依赖于导致父组件re-render
的完全相同的东西(上例中的counter
),那么它将在每次re-render
时重新执行一次,实际上没什么用。例如,在下面这个简化的示例中,如果我们将counter
作为 list 的依赖项传递进去,那么每次状态改变都会导致useMemo
重新执行,并使CountriesList re-render
。
javascript
const list = useMemo(() => {
return (
<>
{counter}
<CountriesList />
</>
);
}, [counter]);
以上的优化,如何将它应用到我们的页面组件上呢?仔细看一下我们页面的实现方式:
ini
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>
</>
);
};
我们可以看到:
在CountriesList
组件中,selectedCountry
状态从未被使用。
在SelectedCountry
组件中,savedCountry
状态从未被使用。
image.png
这意味着当selectedCountry
状态改变时,CountriesList
组件是不需要re-render
的!而且savedCountry
状态和SelectedCountry
组件也是同样的情况。我们可以将它们都使用useMemo
包裹,以防止它们不必要的re-render
:
ini
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 :如果组件使用了状态,找到那些不依赖于其他状态的组件,并对它们进行memo
缓存,以最小化re-render
次数。
CountriesList 组件
我们的Page
组件已经准备好了,是时候写其他子组件了。
首先,让我们来实现:CountriesList
。我们已经知道,这个组件应该接受国家列表,并在选择一个国家时触发onCountryChanged
回调函数,并根据设计要求将保存的国家以不同颜色进行高亮显示。
让我们开始吧:
ini
type CountriesListProps = {
countries: Country[];
onCountryChanged: (country: Country) => void;
savedCountry: Country;
};
export const CountriesList = ({
countries,
onCountryChanged,
savedCountry
}: CountriesListProps) => {
const Item = ({ country }: { country: Country }) => {
const className = savedCountry.id === country.id ? "country-item saved" : "country-item";
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>
);
};
这是一个比较简单的组件,但两个值得注意的地方:
1、我们根据接收到的 props 生成 Item (它取决于 onCountryChanged
和 savedCountry
)
2、循环渲染出所有国家的 Item
上例代码并没有什么"错误"之处,我们在很多项目中都见到过类似的代码。
重构 CountriesList 组件
再来回顾一下我们对于 React 渲染的了解,这一次是关于一个组件(Item组件)在另一个组件渲染期间被创建会发生什么。答案是:很糟糕。
从 React 的角度来看,这个 Item 只是一个函数,在每次渲染时都会重新创建,并且返回一个新的值。
所以它将在每次渲染时重新创建该函数,也就是说它将仅仅比较前一个组件状态和当前状态,就像正常重新渲染时发生的那样。它将删除先前生成的组件,包括其DOM树,然后从页面中移除它,并且每当父级组件re-render
时都会重新生新的组件并挂载。
一个简化版本的,类似于以下内容:
javascript
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次 re-renders
微不足道。在正常情况下,只有当组件完成挂载和第一次渲染后,才会触发具有空依赖数组的useEffect,而且仅触发一次。之后,在 React 中的重新渲染过程,组件并不是从头开始创建,而是在需要时进行更新(这就是 React 如此快的原因)。但在这种情况下并非如此-请查看此codesandbox,在打开控制台后点击"re-render"按钮,查看每次点击时发生的250个渲染和挂载。
解决方法很明显且简单:我们只需将Item组件移到render函数之外即可。
javascript
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} />
))}
</>
);
};
作为一个额外的好处,这样重构有助于保持不同组件之间的健康边界,并使代码更清晰、更简洁。当我们将这个改进应用到我们之前的代码中时,这一点尤其明显。 之前:
javascript
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>
);
};
现在:
ini
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>
);
};
现在,我们摆脱了每次父组件re-render
时重新挂载 Item 组件的问题。
最后,我们得出了本文的第三条规则:
规则3、 永远不要在另一个组件的渲染函数中创建新的组件。
实现 SelectedCountry 组件
这将是文章中最短、最无聊的部分,因为没有什么可展示的内容:它只是一个接受属性和回调函数,并渲染一些字符串的组件。
javascript
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 是最佳的解决方案之一。
首先创建 ThemeContext
ini
type Mode = 'light' | 'dark';
type Theme = { mode: Mode };
const ThemeContext = React.createContext<Theme>({ mode: 'light' });
const useTheme = () => {
return useContext(ThemeContext);
};
将 context provider
和切换按钮添加到页面组件中。
javascript
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
为我们设置颜色属性。
javascript
const Item = ({ country }: { country: Country }) => {
const { mode } = useTheme();
const className = `country-item ${mode === "dark" ? "dark" : ""}`;
// the rest is the same
}
这个实现中没有任何不合适的地方,这是一种非常常见的模式,尤其适用于主题设计。
重构主题
在我们能够找出上述实现有什么问题之前,让我们来回顾一下,为什么一个 React 组件会re-render
,这通常是:如果一个组件使用了context consumer
,那么每当context provider
的值发生变化时,它都会re-render
。
还记得我们简化的例子吗?我们对渲染结果使用useMemo
以避免re-render
typescript
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}
</>
);
};
页面组件将在每次点击按钮时re-render
,因为它在每次点击时更新状态。但是CountriesList
使用了 useMemo
,并且与该状态无关,所以它不会re-render
,所以 Item 组件也不会re-render
。
现在,如果我们在这里添加ThemeContext provider
会发生什么?
Page 组件:
javascript
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 组件:
javascript
const Item = ({ country }: { country: Country }) => {
const theme = useTheme();
console.log("render");
return <div>{country.name}</div>;
};
如果它们只是普通的组件和hooks,什么都不会发生,Item 不是 Page 的子组件,CountriesList
因为使用useMemo
而不会re-render
,所以 Item 也不会。除非使用了Provider-Customer
,所以每当Provider
的value值发生变化时,所有Consumer
都将re-render
。由于我们传递的value一直都是新对象,Item 将在每次计数器改变后进行不必要的re-render
。Context
绕过了我们的 memo
缓存,使其几乎无用。
修复方法如你可能已经猜到的那样,就是确保Provider
中的值更改得尽量少。在我们的例子中,我们只需要对其使用useMemo
包裹:
javascript
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>
);
};
现在计数器将正常工作,而不会导致所有组件re-render
!
为了防止不必要的re-render
,我们可以将其应用到我们的页面组件上:
javascript
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
时,确保如果 value 属性不是数字、字符串或布尔值,则始终使用 memo
缓存。
总结
最后,我们的应用终于完成了!整个实现都可以在codesandbox中找到。如果你使用的是最新款的MacBook,请降低CPU速度,以体验普通用户所经历的,并尝试在列表中选择不同国家。即使减少了6倍的CPU速度,它仍然非常快!🎉
现在,我想很多人都有一个迫切想问的问题:"Nadia,React 本身就已经非常快了。你做出来的那些'优化'对于只有250个国家的简单列表来说并没有太大影响吧?是不是你夸大它重要性了"。
是啊,在我刚开始写这篇文章时,我也是这么认为的。但后来我以"非高效"的方式实现了该应用程序。我甚至不需要降低CPU速度就能看到选择项目之间存在延迟 😱 。将其降低6倍,它可能是世界上最慢、甚至无法正常工作(与"高性能"应用相比) 的简单列表之一。而且,在那里面我甚至没有做任何明显的恶意操作!
让我们回顾下什么时候 React 组件会re-render:
当组件的状态改变时
当父组件re-render
时
当一个组件使用Context
,并且Provider
的值发生变化时
而我们从中总结出来了4个规则:
1、如果你想将内联函数中的 props 提取到useCallback
中的原因是为了避免子组件re-render
,那么不要这样做,它不会起作用。
2、如果组件使用了状态,找到那些不依赖于其他状态的组件,并对它们进行memo
缓存以最小化re-render
次数。
3、永远不要在另一个组件的渲染函数中创建新的组件。
4、在使用Context
时,确保如果 value 属性不是数字、字符串或布尔值,则始终使用 memo
缓存。
希望这些规则能够帮助您从一开始就编写出性能更高的应用,并使客户更加满意,他们再也不必体验缓慢的产品了。
useCallback 的困境
在真正结束这篇文章之前,我觉得有一个谜题需要解决:为什么 useCallback 对于减少re-render
是无用的,而 React 文档却明确表示"useCallback
将回调传递给依赖引用相等的子组件,以防止不必要渲染时很有用"?
答案就在这句话中:"优化依赖引用相等的子组件"。
这里适用两种情况。
第一种:接收到回调函数的组件被包裹在React.memo中,并且该回调函数作为其依赖项。大体是这样的:
ini
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" />
}
或者这样
ini
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这样的hooks中将此回调作为依赖项。
javascript
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" />
}
这些都不能简单地概括为"做"或"不做",它只能用于解决具体组件的确切性能问题。
感谢您的阅读,希望您觉得有用!下次再见。