函数正在严重拖累你的 React 应用性能
使用记忆功能缓存返回值useMemo
渲染函数开销很大。
儿童功能很有帮助
结论
函数是所有 JavaScript 应用(包括 React 应用)不可或缺的一部分。虽然我之前写过函数的使用方式可能比较特殊,但由于所有函数本质上都是值,它们能够将类似的代码拆分成逻辑片段,从而帮助打破代码库的单调性。
了解函数也是值这一概念,可以帮助你提高 React 应用的性能。
让我们来看看函数通常会以哪些方式减慢 React 应用程序的速度,为什么会这样,以及如何在我们的应用程序中解决这些问题。
在这段冒险旅程中,我们将学习如何:
- 使用 Memoize 返回值
useMemo - 防止因函数不稳定而导致的重新渲染
- 通过组件提取移除昂贵的渲染函数
- 高效地处理子函数
使用记忆功能缓存返回值useMemo
假设我们正在开发一个电子商务应用程序,并且想要计算购物车中所有商品的总价:
const ShoppingCart = ({items}) => {
const getCost = () => {
return items.reduce((total, item) => {
return total + item.price;
}, 0);
}
return (
<div>
<h1>Shopping Cart</h1>
<ul>
{items.map(item => <li>{item.name}</li>)}
</ul>
<p>Total: ${getCost()}</p>
</div>
)
}
这样应该会显示所有物品和总价,但ShoppingCart重新渲染时可能会出现问题。
毕竟,React 函数组件毕竟是一个普通的函数,它会像其他任何函数一样运行,getCost如果你不缓存该值,它会在后续渲染时重新计算。
getCost当购物车中只有一两件商品时,这个函数可能不会太贵,但当购物车中有 50 件或更多商品时,计算成本很容易变得很高。
解决方法?使用缓存机制来缓存函数调用useMemo,使其仅在items数组发生变化时重新运行:
const ShoppingCart = ({items}) => {
const totalCost = useMemo(() => {
return items.reduce((total, item) => {
return total + item.price;
}, 0);
}, [items]);
return (
<div>
<h1>Shopping Cart</h1>
<ul>
{items.map(item => <li>{item.name}</li>)}
</ul>
<p>Total: ${totalCost}</p>
</div>
)
}
# 函数不稳定导致重新渲染
让我们扩展这个购物车示例,添加向购物车中添加新商品的功能。
import {useState, useMemo} from 'react';
import {v4 as uuid} from 'uuid';
const ShoppingItem = ({item, addToCart}) => {
return (
<div>
<div>{item.name}</div>
<div>{item.price}</div>
<button onClick={() => addToCart(item)}>Add to cart</button>
</div>
)
}
const items = [
{ id: 1, name: 'Milk', price: 2.5 },
{ id: 2, name: 'Bread', price: 3.5 },
{ id: 3, name: 'Eggs', price: 4.5 },
{ id: 4, name: 'Cheese', price: 5.5 },
{ id: 5, name: 'Butter', price: 6.5 }
]
export default function App() {
const [cart, setCart] = useState([])
const addToCart = (item) => {
setCart(v => [...v, {...item, id: uuid()}])
}
const totalCost = useMemo(() => {
return cart.reduce((acc, item) => acc + item.price, 0)
}, [cart]);
return (
<div style={{display: 'flex', flexDirection: 'row', flexWrap: 'nowrap'}}>
<div style={{padding: '1rem'}}>
<h1>Shopping Cart</h1>
{items.map((item) => (
<ShoppingItem key={item.id} item={item} addToCart={addToCart} />
))}
</div>
<div style={{padding: '1rem'}}>
<h2>Cart</h2>
<div>
Total: ${totalCost}
</div>
<div>
{cart.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>
</div>
)
}
如果我现在点击任何一个物品的Add to cart按钮,就会:
1) 触发addToCart函数
2)cart使用以下方法更新数组:1)为购物车中的商品setCart
生成新的 UUIDv4
3) 使App组件重新渲染
4) 更新购物车中显示的商品
5) 重新运行totalCost useMemo计算
这正是我们预期在这个应用中看到的情况。然而,如果我们打开React 开发者工具并检查火焰图,我们会发现所有ShoppingItem组件都在重新渲染,尽管传递的参数并没有item改变。
这些组件重新渲染的原因是我们的addToCart属性发生了变化。
这不对!我们每次
addToCart渲染都传递同一个函数!
乍一看这似乎是正确的,但我们可以通过一些额外的逻辑来验证这一点:
// This is not good production code, but is used to demonstrate a function's reference changing
export default function App() {
const [cart, setCart] = useState([])
const addToCart = (item) => {
setCart(v => [...v, {...item, id: uuid()}])
}
useLayoutEffect(() => {
if (window.addToCart) {
console.log("addToCart is the same as the last render?", window.addToCart === addToCart);
}
window.addToCart = addToCart;
});
// ...
}
这段代码:
addToCart在内部设置函数App- 在每次渲染时运行布局效果,以:
- 分配
addToCart给window.addToCart - 检查旧版本是否
window.addToCart与新版本相同
- 分配
通过这段代码,我们预期会看到true函数在渲染之间没有被重新赋值。然而,我们看到的却是:
addToCart 与上次渲染相同吗?否
这是因为,尽管渲染时名称相同,但每次组件渲染都会创建一个新的函数引用。
可以这样理解:在底层,React 将每个(函数式)组件都称为一个函数。
假设我们暂时使用 React,并且有这样一个组件:
// This is not a real React component, but is a function we're using in place of a functional component
const component = ({items}) => {
const addToCart = (item) => {
setCart(v => [...v, {...item, id: uuid()}])
}
return {addToCart};
}
如果我们作为 React 参与者,component多次调用此函数:
// First "render"
const firstAddToCart = component().addToCart;
// Second "render"
const secondAddToCart = component().addToCart;
// `false`
console.log(firstAddToCart === secondAddToCart);
我们可以更清楚地看到为什么addToCart渲染结果不同;这是一个在另一个函数的作用域内定义的新函数。
通过以下方式实现功能稳定性useCallback
所以,如果我们的ShoppingItem页面因为函数发生变化而重新渲染addToCart,我们该如何解决这个问题?
我们从上一节中useMemo了解到,我们可以用它来缓存组件渲染之间函数的返回值;如果在这里也使用这个方法呢?
export default function App() {
const [cart, setCart] = useState([])
const addToCart = useMemo(() => {
return (item) => {
setCart(v => [...v, {...item, id: uuid()}])
}
}, []);
// ...
return (
<div style={{display: 'flex', flexDirection: 'row', flexWrap: 'nowrap'}}>
<div style={{padding: '1rem'}}>
<h1>Shopping Cart</h1>
{items.map((item) => (
<ShoppingItem key={item.id} item={item} addToCart={addToCart} />
))}
</div>
{/* ... */}
</div>
)
}
在这里,我们告诉 React 永远不要重新初始化addToCart函数,方法是将逻辑缓存在函数内部useMemo。
我们可以再次查看 React DevTools 中的火焰图来验证这一点:
window然后使用我们的技巧重新检查函数引用稳定性:
// ...
const addToCart = useMemo(() => {
return (item) => {
setCart(v => [...v, {...item, id: uuid()}])
}
}, []);
useLayoutEffect(() => {
if (window.addToCart) {
console.log("addToCart is the same as the last render?", window.addToCart === addToCart);
}
window.addToCart = addToCart;
});
// ...
addToCart 与上次渲染相同吗?是
这种对内部函数进行记忆化的用例非常常见,甚至还有一个名为“memoize”的简写辅助函数useCallback:
const addToCart = useMemo(() => {
return (item) => {
setCart(v => [...v, {...item, id: uuid()}])
}
}, []);
// These two are equivilant to one another
const addToCart = useCallback((item) => {
setCart(v => [...v, {...item, id: uuid()}])
}, []);
渲染函数开销很大。
所以,我们之前已经演示过类似这样的函数:
<p>{someFn()}</p>
someFn如果开销很大,通常会对用户界面性能产生不利影响。
了解了这些之后,我们对以下代码有何看法?
export default function App() {
// ...
const renderShoppingCart = () => {
return <div style={{ padding: '1rem' }}>
<h2>Cart</h2>
<div>
Total: ${totalCost}
</div>
<div>
{cart.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'nowrap' }}>
<div style={{ padding: '1rem' }}>
<h1>Shopping Cart</h1>
{items.map((item) => (
<ShoppingItem key={item.id} item={item} addToCart={addToCart} />
))}
</div>
{renderShoppingCart()}
</div>
)
}
在这里,我们renderShoppingCart在内部定义了一个函数App,并在渲染语句中调用了它return。
乍一看,这似乎不太好,因为我们在模板内部调用了一个函数。然而,如果我们仔细想想,可能会发现这与 React 的做法并没有本质区别。
毕竟,React
div每次渲染都必须运行,对吧?……对吧?
不完全是。
让我们来看一个更简洁的版本:
const Comp = ({bool}) => {
const renderContents = () => {
return bool ? <div/> : <p/>
}
return <div>
{renderContents()}
</div>
}
现在,让我们在这个函数中更进一步renderContents:
return bool ? <div/> : <p/>
这里,JSX 可以转换成以下内容:
return bool ? React.createElement('div') : React.createElement('p')
毕竟,所有 JSX 代码都会在应用构建过程中转换成这些React.createElement函数调用。这是因为 JSX 并非标准的 JavaScript,需要转换成上述格式才能在浏览器中执行。
React.createElement当您传递 props 或 children 时,此 JSX 到函数的调用方式会发生变化:
<SomeComponent item={someItem}>
<div>Hello</div>
</SomeComponent>
将会变成:
React.createElement(SomeComponent, {
item: someItem
}, [
React.createElement("div", {}, ["Hello"])
])
请注意,第一个参数可以是字符串或组件函数,第二个参数是要传递给该元素的 props。最后,第三个参数createElement是要传递给新创建元素的 children。
了解了这一点,我们来将CompJSX 转换为createElement函数调用。这样做会带来以下变化:
const Comp = ({bool}) => {
const renderContents = () => {
return bool ? <div/> : <p/>
}
return <div>
{renderContents()}
</div>
}
到:
const Comp = ({bool}) => {
const renderContents = () => {
return bool ? React.createElement('div') : React.createElement('p')
}
return React.createElement('div', {}, [
renderContents()
])
}
应用此变换后,我们可以看到,无论是否需要,每次Comp重新渲染时,它都会重新执行该函数。renderContents
这看起来似乎不是什么坏事,直到你意识到我们在每次渲染时都会创建一个全新的div标签p。
如果renderContents函数内部包含多个元素,那么重新运行该函数将会非常耗费资源,因为它renderContents每次都会销毁并重新创建整个子树。我们可以通过在渲染函数内部记录日志来验证这一点div:
const LogAndDiv = () => {
console.log("I am re-rendering");
return React.createElement('div');
}
const Comp = ({bool}) => {
const renderContents = () => {
return bool ? React.createElement(LogAndDiv) : React.createElement('p')
}
return React.createElement('div', {}, [
renderContents()
])
}
export const App = () => React.createElement(Comp, {bool: true});
而且I am re-rendering每次Comp重新渲染都会出现这种情况,无一例外。
我们该如何解决这个问题?
重用useCallback并useMemo避免渲染函数重新初始化
回顾本文前面的章节,我们可能会想到使用我们已经熟悉的工具:`std::vector`useMemo和 ` std::vector` useCallback。毕竟,如果问题在于 `std:: renderContentsvector` 没有提供稳定的值参考,我们现在知道如何解决这个问题了。
让我们把这两个方法应用到我们的代码库中,看看是否能解决问题:
const LogAndDiv = () => {
console.log("I am re-rendering");
return <div/>;
}
const Comp = ({bool}) => {
// This is a suboptimal way of solving this problem
const renderContents = useCallback(() => {
return bool ? <LogAndDiv/> : <p/>
}, [bool]);
const renderedContents = useMemo(() => renderContents(), [renderContents]);
return <div>
{renderedContents}
</div>
}
让我们重新渲染这个组件,然后……
成功!它只在发生变化LogAndDiv时渲染,然后不再重新渲染。booltrue
等等……为什么上面的代码示例中有一条注释说,这不是解决此问题的最优方法?
通过组件提取移除昂贵的渲染函数
之所以不建议使用useMemo或useCallback阻止渲染函数重新渲染,原因如下:
1) 调试起来很困难,堆栈跟踪信息renderContents也更难理解。2
) 它会创建更长的组件,而且无法将这些子元素进行可移植的移动。3
) 你在不知不觉中做了 React 的工作。
比如,如果我们仔细想想它renderContents的作用,它就像一个子组件,与父组件共享相同的词法作用域。这样做的好处是不需要向子组件传递任何元素,但代价是用户体验和性能的下降。
而不是这样:
const LogAndDiv = () => {
console.log("I am re-rendering");
return <div/>;
}
const Comp = ({bool}) => {
// This is a suboptimal way of solving this problem
const renderContents = useCallback(() => {
return bool ? <LogAndDiv/> : <p/>
}, [bool]);
const renderedContents = useMemo(() => renderContents(), [renderContents]);
return <div>
{renderedContents}
</div>
}
我们应该这样写:
const LogAndDiv = () => {
console.log("I am re-rendering");
return <div/>;
}
const Contents = () => {
return bool ? <LogAndDiv/> : <p/>
}
const Comp = ({bool}) => {
return <div>
<Contents bool={bool}/>
</div>
}
这将解决我们的性能问题,而无需使用useMemo或useCallback。
如何?
好,我们再来深入探讨一下JSX是如何进行转换的:
const LogAndDiv = () => {
console.log("I am re-rendering");
return React.createElement('div');
}
const Contents = () => {
return bool ? React.createElement(LogAndDiv) : React.createElement('p')
}
const Comp = ({bool}) => {
return React.createElement('div', {}, [
React.createElement(Contents, {
bool: bool
})
]);
}
让我们仔细看看 ReactcreateElement 实际返回的是什么。开始吧console.log:
console.log(React.createElement('div'));
// We could also: `console.log(<div/>)`
这样做会给我们一个对象:
// Some keys are omitted for readability
{
"$$typeof": Symbol("react.element"),
key: null,
props: { },
ref: null,
type: "div",
}
请注意,此对象不包含任何关于如何创建元素本身的指令。这是
react-dom渲染器的职责。这就是像 React Native 这样的项目如何使用与 JSX 相同的代码渲染到非 DOM 目标的方式react。
该对象被称为“Fiber 节点”,是React 协调器“ React Fiber ”的一部分。
简而言之,React Fiber 是一种基于从 JSX 传递的元素构建树状结构的方法createElement。对于 Web 项目,这棵树是 DOM 树的镜像版本,用于在节点重新渲染时重建 UI。React 随后使用这棵树(称为“虚拟 DOM”或“VDOM”),根据状态和传递的 props 智能地判断哪些节点需要重新渲染,哪些不需要。
即使对于函数式组件来说,情况也是如此。我们不妨这样称呼以下组件:
const Comp = () => {
return <p>Comp</p>;
}
console.log(<Comp/>);
// We could also: `console.log(React.createElement(Comp))`
这将退出登录:
{
"$$typeof": Symbol("react.element"),
key: null,
props: { },
ref: null,
type: function Comp(),
}
注意,这里type仍然是它自身的函数Comp,而不是返回的Fiber 节点。正因如此,如果不需要更新,divReact 就能阻止重新渲染。Comp
但是,如果我们改用以下代码:
const Comp = () => {
return <p>Comp</p>;
}
console.log(Comp());
现在我们获取内部标签的光纤节点p:
{
"$$typeof": Symbol("react.element"),
key: null,
props: { children: [ "Comp" ] },
ref: null,
type: "p",
}
这是因为 React 不再控制Comp代表你进行调用,而是在父组件渲染时始终调用。
那么解决方案是什么呢?永远不要将子组件嵌入到父组件内部。而是将子组件移出父组件的作用域,并传递 props。
例如,转换以下内容:
export default function App() {
// ...
const renderShoppingCart = () => {
return <div style={{ padding: '1rem' }}>
<h2>Cart</h2>
<div>
Total: ${totalCost}
</div>
<div>
{cart.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'nowrap' }}>
<div style={{ padding: '1rem' }}>
<h1>Shopping Cart</h1>
{items.map((item) => (
<ShoppingItem key={item.id} item={item} addToCart={addToCart} />
))}
</div>
{renderShoppingCart()}
</div>
)
}
对此:
const ShoppingCart = ({cart, totalCost}) => {
return <div style={{ padding: '1rem' }}>
<h2>Cart</h2>
<div>
Total: ${totalCost}
</div>
<div>
{cart.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>;
}
export default function App() {
// ...
return (
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'nowrap' }}>
<div style={{ padding: '1rem' }}>
<h1>Shopping Cart</h1>
{items.map((item) => (
<ShoppingItem key={item.id} item={item} addToCart={addToCart} />
))}
</div>
<ShoppingCart cart={cart} totalCost={totalCost} />
</div>
)
}
儿童功能很有帮助
虽然很少用到,但在某些情况下,你可能需要将值从父组件传递到子组件。
ShoppingCart让我们再来看一遍:
const ShoppingCart = ({cart, totalCost}) => {
return <div style={{ padding: '1rem' }}>
<h2>Cart</h2>
<div>
Total: ${totalCost}
</div>
<div>
{cart.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>;
}
如果所有项目都使用相同的组件进行显示,这可能效果很好,但如果我们想要自定义其中显示的每个项目,会发生什么情况呢ShoppingCart?
我们可以选择将购物车商品数组作为子项传递:
const ShoppingCart = ({totalCost, children}) => {
return <div style={{ padding: '1rem' }}>
<h2>Cart</h2>
<div>
Total: ${totalCost}
</div>
<div>
{children}
</div>
</div>;
}
const App = () => {
// ...
return (
<ShoppingCart totalCost={totalCost}>
{cart.map((item) => {
if (item.type === "shoe") return <ShoeDisplay key={item.id} item={item}/>;
if (item.type === "shirt") return <ShirtDisplay key={item.id} item={item}/>;
return <DefaultDisplay key={item.id} item={item}/>;
})}
</ShoppingCart>
)
}
但是,如果我们想将购物车中的每个商品都包裹在一个包装元素中,并对其进行自定义显示,会发生什么情况呢item?
那么,如果我告诉你你可以将一个函数作为选项传递给它呢children?
我们来看一个简单的例子:
const Comp = ({children}) => {
return children(123);
}
const App = () => {
return <Comp>
{number => <p>{number}</p>}
</Comp>
// Alternatively, this can be rewritten as so:
return <Comp children={number => <p>{number}</p>}/>
}
哇。
正确的?
好的,让我们再次将 JSX 从图中移除,以便更详细地分析一下:
const Comp = ({children}) => {
return children(123);
}
const App = () => {
return React.createElement(
// Element
Comp,
// Props
{},
// Children
number => React.createElement('p', {}, [number])
)
}
在这里,我们可以清楚地看到该number函数是如何传递给Comp'schildren属性的。然后,该函数返回它自己的 createElement调用,该调用用作返回的 JSX 以在 中渲染Comp。
在生产环境中使用子函数
现在我们已经了解了子函数的底层工作原理,接下来让我们重构以下组件以使用它们:
const ShoppingCart = ({totalCost, children}) => {
return <div style={{ padding: '1rem' }}>
<h2>Cart</h2>
<div>
Total: ${totalCost}
</div>
<div>
{children}
</div>
</div>;
}
const App = () => {
// ...
return (
<ShoppingCart totalCost={totalCost}>
{cart.map((item) => {
if (item.type === "shoe") return <ShoeDisplay key={item.id} item={item}/>;
if (item.type === "shirt") return <ShirtDisplay key={item.id} item={item}/>;
return <DefaultDisplay key={item.id} item={item}/>;
})}
</ShoppingCart>
)
}
现在我们有了基准,接下来让我们看看如何在生产环境中使用它:
const ShoppingCart = ({totalCost, cart, children}) => {
return <div style={{ padding: '1rem' }}>
<h2>Cart</h2>
<div>
Total: ${totalCost}
</div>
<div>
{cart.map((item) => (
<Fragment key={item.id}>
{children(item)}
</Fragment>
))}
</div>
</div>;
}
const App = () => {
// ...
return (
<ShoppingCart cart={cart} totalCost={totalCost}>
{(item) => {
if (item.type === "shoe") return <ShoeDisplay item={item}/>;
if (item.type === "shirt") return <ShirtDisplay item={item}/>;
return <DefaultDisplay item={item}/>;
}}
</ShoppingCart>
)
}
子函数的问题
让我们再次使用性能分析器运行上述代码的修改版本。不过,这次我们将添加一个与更新状态完全无关的方法,以ShoppingCart确保我们不会在渲染时不必要地重新渲染每个项目:
import { useState, useCallback, Fragment } from 'react';
const items = [
{ id: 1, name: 'Milk', price: 2.5 },
{ id: 2, name: 'Bread', price: 3.5 },
{ id: 3, name: 'Eggs', price: 4.5 },
{ id: 4, name: 'Cheese', price: 5.5 },
{ id: 5, name: 'Butter', price: 6.5 }
]
const ShoppingCart = ({ children }) => {
return <div>
<h2>Cart</h2>
<div>
{items.map((item) => (
<Fragment key={item.id}>
{children(item)}
</Fragment>
))}
</div>
</div>;
}
export default function App() {
const [count, setCount] = useState(0)
// Meant to demonstrate that nothing but `count` should re-render
const addOne = useCallback(() => {
setCount(v => v+1);
}, []);
return (
<div>
<p>{count}</p>
<button onClick={addOne}>Add one</button>
<ShoppingCart>
{(item) => {
if (item.type === "shoe") return <ShoeDisplay item={item} />;
if (item.type === "shirt") return <ShirtDisplay item={item} />;
return <DefaultDisplay item={item} />;
}}
</ShoppingCart>
</div>
)
}
function ShoeDisplay({ item }) {
return <p>{item.name}</p>
}
function ShirtDisplay({ item }) {
return <p>{item.name}</p>
}
function DefaultDisplay({ item }) {
return <p>{item.name}</p>
}
遗憾的是,这样做之后,我们发现它ShoppingCart仍然会重新渲染:
这是因为,正如分析器中的消息所说,children每次渲染时函数引用都会发生变化;导致它表现得好像某个属性发生了变化,需要重新渲染。
解决这个问题的方法与解决任何其他函数更改引用的问题的方法相同:useCallback。
export default function App() {
const [count, setCount] = useState(0)
const addOne = useCallback(() => {
setCount(v => v+1);
}, []);
const shoppingCartChildMap = useCallback((item) => {
if (item.type === "shoe") return <ShoeDisplay item={item} />;
if (item.type === "shirt") return <ShirtDisplay item={item} />;
return <DefaultDisplay item={item} />;
}, []);
return (
<div>
<p>{count}</p>
<button onClick={addOne}>Add one</button>
<ShoppingCart>
{shoppingCartChildMap}
</ShoppingCart>
</div>
)
}
结论
希望本文能帮助您深入了解 React 应用性能改进领域。
useCallback但是,在使用`and`时要谨慎useMemo;并非所有代码库实例都需要它们,而且在某些极端情况下,它们可能会损害性能而不是有所帮助。
想了解更多关于提升 React 应用性能的信息吗?敬请关注我即将出版的系列丛书《框架实战指南》,它不仅讲解 React 的内部原理,还会同时引导你学习 React、Angular 和 Vue 的代码,让你一次性掌握这三种框架。
文章来源:https://dev.to/crutchcorn/functions-are-killing-your-react-apps-performance-222a


