React 应用中性能优化的经验(一)
在阅读之前,推荐先把 react-re-renders-guide 阅读一遍,了解完理论知识之后在通过实际业务场景去深刻的理解。
善用 React Devtool 和 Profiler
React Devtool 中有非常好用的功能叫做 Highlight updates when components render。使用这个功能可以把 re-render 组件高亮出来,一次 re-render 会用绿框高亮,如果是黄框就是在短时间内造成了多次 re-render 就需要考虑哪个组件有性能问题了。
通过这个工具可以快速定位页面上哪个组件存在问题,但是却无法定位具体的问题所在。
我们知道 React 组件的重渲染一定是因为某个 hook 导致的,关键就是如何找到频繁更新的 hook。此时我们就需要借助 React Profiler 去排查了。
切换到 Profiler Tab,点击左上的 Record,然后触发会导致 re-render 的操作之后可以看到如下的页面:
如上图,NodeCards 这是一个列表组件,循环遍历了 NodeCard 这个组件。在鼠标悬浮某个在 NodeCard 时,造成了父级组件 NodeCards 发生了 re-render,导致子代组件全部发生 re-render,这个性能开销是比较大的,在长列表组件中时刻要注意牵一发而动全身。
我们通过 Profiler 发现 NodeCards 的 Hook 136 发生了改变导致了这次 re-render。但是我们并不知道 136 到底是啥(批评 React devtool),我们需要这个时候切回到 Components,可以看到对应的组件右边面板有 Hook 对应的序号,通过序号就能摸到对应的 Hook。当然大部分 Hook 都是有其他多个 hooks 组合起来的,所以只能判断个大概。
比如上图我们可以判断 136 号 Hook 其实是 Jotai 的一个 Hook。
列表组件应该如何规避性能问题
使用 Memo?
上面的例子中,列表中的一个组件更新导致整个列表更新是非常不环保的。又通过 Profiler 的几帧发现,大部分的 NodeCard 本身并没有更新,只是上层 NodeCards 更新了而已。这个时候我们可以使用 memo 去对 NodeCard 包一层。这样父级更新就不会导致列表元素都在更新。在列表(List)场景中,一般都是推荐对 列表元素(ListItem)包裹 memo,用这种空间换时间的在长列表十分受益,除非你确保列表的状态一定不会更新。
const NodeCards = () => {
// some hooks
return <>
{data.map(item => <NodeCard data={item} />)}
</>
}
const NodeCard = memo((props) => {
return <div />
})
传入 id
而不是数据源?
一般的,我们可以直接在列表数据遍历的时候直接传入数据源,如上面所示。但是由于 React 的 immutable 的特征,如果数据源发生了更改,你的组件一定会发生更新,即使在 ListItem 中并没有使用数据源更改的具体值。如下个例子:
const dataMapAtom = atom({
'1': { name: 'foo', desc: '', id: '1' }
// others..
})
const NodeCards = () => {
const [data, setData] = useAtom(dataMapAtom)
// we update data[0].desc
useEffect(() => {
setData((data) => ({ ...data, '1': { ...data['1'], desc: 'bar' } }))
}, [])
return <>
{data.map(item => <NodeCard data={item} />)}
</>
}
const NodeCard = memo((props) => {
const { name } = props.data
return <div>{name}</div>
})
即使 NodeCard 不消费 desc
但是一旦数据发生改变他一定会被 re-render。这是我们不希望看到的。但是如果我们传入是 id,再配合 selector 就不会有这样的不环保行为。
const dataMapAtom = atom({
'1': { name: 'foo', desc: '', id: '1' }
// others..
})
const NodeCards = () => {
const [data, setData] = useAtom(dataMapAtom)
// maybe some state change and hook update here.
// we update data[0].desc
useEffect(() => {
setData((data) => ({ ...data, '1': { ...data['1'], desc: 'bar' } }))
}, [])
return <>
{data.map(item => <NodeCard id={item.id} />)}
</>
}
const NodeCard = memo((props) => {
const { id } = props
const name = useAtomValue(
selectAtom(
dataMapAtom,
useCallback((dataMap) => dataMap.id.name, [])
)
)
return <div>{name}</div>
})
列表元素的状态应该在哪里维护?
也许我们会遇到在列表中,需要根据某个状态展示不同的 UI 表达。比如一个卡片 hover 时,我需要做一些 motion 或者其他不能简单通过 CSS 就能做到的场景。比如:
const NodeCards = () => {
const [activeId, setActiveId] = useState(0)
// some hooks
return <>
{data.map(item => <NodeCard data={item} activeId={activeId} setActiveId={setActiveId} />)}
</>
}
const NodeCard = memo((props) => {
// do thing.
return <div onMouseEnter={() => {
props.setActiveId(props.data.id)
}} />
})
以上代码是非常非常错误的。在列表中你不应该把这些动态可变值直接传入到列表元素。举个例子,上面的写法就会导致鼠标 hover 到一个 NodeCard 时,所有 NodeCard 也被 re-render。
正确的应该是传入一个布尔值,在 NodeCards 就完成判断:
const NodeCards = () => {
const [activeId, setActiveId] = useState(0)
// some hooks
return <>
{data.map(item => <NodeCard data={item} isActive={activeId === item.id} setActiveId={setActiveId} />)}
</>
}
const NodeCard = memo((props) => {
// do thing.
return <div onMouseEnter={() => {
props.setActiveId(props.data.id)
}} />
})
上面修改为 isActive
则不会出现这样的问题。
再者有说了,如果我就是要拿到 activeId
但是 NodeCard 又是一个 ExpensiveComponent 怎么办呢。方法一是我们可以使用 Ref,但是 Ref 是脱离响应式数据流的。方法二借助 Jotai 类似的状态库外置状态 + 拆分轻量组件。
const activeIdAtom = atom(0)
const NodeCards = () => {
// some hooks
return <>
{data.map(item => <NodeCardExpensiveComponent data={item} />)}
</>
}
const NodeCardExpensiveComponent = memo((props) => {
const setActiveId = useSetAtom(activeIdAtom)
// do thing.
return <>
<div onMouseEnter={() => {
props.setActiveId(props.data.id)
}}
/>
{new Array(10000).fill(0).map(() => <div />)}
<ObservedActiveIdHandler />
</>
})
const ObservedActiveIdHandler = () => {
const activeId = useAtomValue(activeIdAtom)
useEffect(() => {
// do thing.
}, [activeId])
}
我们把 activeId
的响应式处理拆分到轻量组件就不会影响开销很大的组件就会非常环保。
这种方法在其他地方的优化一样适用。
从 Performance 火焰图发现性能问题
Chrome devtools 有个很好用的火焰图工具。在页面空载但是 CPU 大量使用,并且 React devtool 没有任何高亮组件的 re-render 的时候,就需要用到它了。
通过截取一段火焰图可以排查到短时间内 CPU 占用过高的问题。上图所示的火焰图时间切片非常密集,在空闲的情况下不应该出现这样的 JS 调用量,就可以使用 Call Tree 进行进一步的排查。
Timer Fired 也就是计时器导致的问题,顺着这个思路去找,最后就发现了同事写了错误的写了一个 delay 为 0 的 setInterval。
以上就是今天的一些分享,计划之后再写写Jotai & Zustand 应该如何使用,粒度化组件和父组件状态下沉到子等一些经验。