组件「
{name}」 Render 次数:
{renderCntMap[name]}
)
}
export default function App() {
const setCnt = useState(0)[1]
useEffect(
() => {
const timer = window.setInterval(() => {
setCnt(v => v + 1)
}, 1000)
return () => clearInterval(timer)
},
[setCnt]
)
const comp = useMemo(() => {
return
}, [])
return (
{comp}
)
}
debounce、throttle 优化频繁触发的回调
在搜索组件中,当 input 中内容修改时就触发搜索回调。当组件能很快处理搜索结果时,用户不会感觉到输入延迟。
但实际场景中,中后台应用的列表页非常复杂,组件对搜索结果的 Render 会造成页面卡顿,明显影响到用户的输入体验。
在搜索场景中一般使用 useDebounce[26] + useEffect 的方式获取数据。
例子参考:debounce-search[27]。
import { useState, useEffect } from ‘react’
import { useDebounce } from ‘use-debounce’
export default function App() {
const [text, setText] = useState(‘Hello’)
const [debouncedValue] = useDebounce(text, 300)
useEffect(
() => {
// 根据 debouncedValue 进行搜索
},
[debouncedValue]
)
return (
<input
defaultValue={‘Hello’}
onChange={e => {
setText(e.target.value)
}}
/>
Actual value: {text}
Debounce value: {debouncedValue}
)
}
为什么搜索场景中是使用 debounce,而不是 throttle 呢?throttle 是 debounce 的特殊场景,throttle 给 debounce 传了 maxWait 参数,可参考 useThrottleCallback[28]。
在搜索场景中,只需响应用户最后一次输入,无需响应用户的中间输入值,debounce 更适合使用在该场景中。
而 throttle 更适合需要实时响应用户的场景中更适合,如通过拖拽调整尺寸或通过拖拽进行放大缩小(如:window 的 resize 事件)。
实时响应用户操作场景中,如果回调耗时小,甚至可以用 requestAnimationFrame 代替 throttle。
懒加载
在 SPA 中,懒加载优化一般用于从一个路由跳转到另一个路由。
还可用于用户操作后才展示的复杂组件,比如点击按钮后展示的弹窗模块(有时候弹窗就是一个复杂页面 ???)。
在这些场景下,结合 Code Split 收益较高。懒加载的实现是通过 Webpack 的动态导入和 React.lazy
方法,参考例子 lazy-loading[29]。
实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。
import { lazy, Suspense, Component } from ‘react’
import ‘./styles.css’
// 对加载失败进行容错处理
class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error) {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return
这里处理出错场景
}
return this.props.children
}
}
const Comp = lazy(() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.5) {
reject(new Error(‘模拟网络出错’))
} else {
resolve(import(‘./Component’))
}
}, 2000)
})
})
export default function App() {
return (
实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。
)
}
懒渲染
懒渲染指当组件进入或即将进入可视区域时才渲染组件。常见的组件 Modal/Drawer 等,当 visible 属性为 true 时才渲染组件内容,也可以认为是懒渲染的一种实现。懒渲染的使用场景有:
-
页面中出现多次的组件,且组件渲染费时、或者组件中含有接口请求。如果渲染多个带有请求的组件,由于浏览器限制了同域名下并发请求的数量,就可能会阻塞可见区域内的其他组件中的请求,导致可见区域的内容被延迟展示。
-
需用户操作后才展示的组件。这点和懒加载一样,但懒渲染不用动态加载模块,不用考虑加载态和加载失败的兜底处理,实现上更简单。
懒渲染的实现中判断组件是否出现在可视区域内是通过 react-visibility-observer[30] 进行监听。例子参考:懒渲染[31]
import { useState, useEffect } from ‘react’
import VisibilityObserver, {
useVisibilityObserver
} from ‘react-visibility-observer’
const VisibilityObserverChildren = ({ callback, children }) => {
const { isVisible } = useVisibilityObserver()
useEffect(
() => {
callback(isVisible)
},
[callback, isVisible]
)
return <>{children}</>
}
export const LazyRender = () => {
const [isRendered, setIsRendered] = useState(false)
if (!isRendered) {
return (
<VisibilityObserver rootMargin={‘0px 0px 0px 0px’}>
<VisibilityObserverChildren
callback={isVisible => {
if (isVisible) {
setIsRendered(true)
}
}}
)
}
console.log(‘滚动到可视区域才渲染’)
return
}
export default LazyRender
虚拟列表
虚拟列表是懒渲染的一种特殊场景。虚拟列表的组件有 react-window[32] 和 react-virtualized,它们都是同一个作者开发的。
react-window 是 react-virtualized 的轻量版本,其 API 和文档更加友好。
所以新项目中推荐使用 react-window,而不是使用 Star 更多的 react-virtualized。
使用 react-window 很简单,只需要计算每项的高度即可。下面代码中每一项的高度是 35px。
例子参考:官方示例[33]
import { FixedSizeList as List } from ‘react-window’
const Row = ({ index, style }) =>
const Example = () => (
<List
height={150}
itemCount={1000}
itemSize={35} // 每项的高度为 35
width={300}
{Row}
)
如果每项的高度是变化的,可给 itemSize 参数传一个函数。对于这个优化点,笔者遇到一个真实案例。
在公司的招聘项目中,通过下拉菜单可查看某个候选人的所有投递记录。平常这个列表也就几十条,但后来用户反馈『下拉菜单点击后要很久才能展示出投递列表』。
该问题的原因就是这个候选人在我们系统中有上千条投递,一次性展示上千条投递导致页面卡住了。
所以在开发过程中,遇到接口返回的是所有数据时,需提前预防这类 bug,使用虚拟列表优化。
跳过回调函数改变触发的 Render 过程
React 组件的 Props 可以分为两类。
a) 一类是在对组件 Render 有影响的属性,如:页面数据、getPopupContainer[34] 和 renderProps 函数。
b) 另一类是组件 Render 后的回调函数,如:onClick、onVisibleChange[35]。
b) 类属性并不参与到组件的 Render 过程,因为可以对 b) 类属性进行优化。
当 b)类属性发生改变时,不触发组件的重新 Render ,而是在回调触发时调用最新的回调函数。
Dan Abramov 在 A Complete Guide to useEffect[36] 文章中认为,每次 Render 都有自己的事件回调是一件很酷的特性。
但该特性要求每次回调函数改变就触发组件的重新 Render ,这在性能优化过程中是可以取舍的。
例子参考:跳过回调函数改变触发的 Render 过程[37]。
以下代码比较难以理解,可通过调试该例子,帮助理解消化。
import { Children, cloneElement, memo, useEffect, useRef } from ‘react’
import { useDeepCompareMemo } from ‘use-deep-compare’
import omit from ‘lodash.omit’
let renderCnt = 0
export function SkipNotRenderProps({ children, skips }) {
if (!skips) {
// 默认跳过所有回调函数
skips = prop => prop.startsWith(‘on’)
}
const child = Children.only(children)
const childProps = child.props
const propsRef = useRef({})
const nextSkippedPropsRef = useRef({})
Object.keys(childProps)
.filter(it => skips(it))
.forEach(key => {
// 代理函数只会生成一次,其值始终不变
nextSkippedPropsRef.current[key] =
nextSkippedPropsRef.current[key] ||
function skipNonRenderPropsProxy(…args) {
propsRef.current[key].apply(this, args)
}
})
useEffect(() => {
propsRef.current = childProps
})
// 这里使用 useMemo 优化技巧
// 除去回调函数,其他属性改变生成新的 React.Element
return useDeepCompareMemo(
() => {
return cloneElement(child, {
…child.props,
…nextSkippedPropsRef.current
})
},
[omit(childProps, Object.keys(nextSkippedPropsRef.current))]
)
}
// SkipNotRenderPropsComp 组件内容和 Normal 内容一样
export function SkipNotRenderPropsComp({ onClick }) {
return (
跳过『与 Render 无关的 Props』改变触发的重新 Render
Render 次数为:
{++renderCnt}
<button onClick={onClick} style={{ color: ‘blue’ }}>
点我回调,回调弹出值为 1000(优化成功)
)
}
export default SkipNotRenderPropsComp
动画库直接修改 DOM 属性,跳过组件 Render 阶段
这个优化在业务中应该用不上,但还是非常值得学习的,将来可以应用到组件库中。
参考 react-spring[38] 的动画实现,当一个动画启动后,每次动画属性改变不会引起组件重新 Render ,而是直接修改了 dom 上相关属性值。
例子演示:CodeSandbox 在线 Demo[39]
import React, { useState } from ‘react’
import { useSpring, animated as a } from ‘react-spring’
import ‘./styles.css’
let renderCnt = 0
export function Card() {
const [flipped, set] = useState(false)
const { transform, opacity } = useSpring({
opacity: flipped ? 1 : 0,
transform: perspective(600px) rotateX(${flipped ? 180 : 0}deg)
,
config: { mass: 5, tension: 500, friction: 80 }
})
// 尽管 opacity 和 transform 的值在动画期间一直变化
// 但是并没有组件的重新 Render
return (
Render 次数:
{++renderCnt}
<a.div
class=“c back”
style={{ opacity: opacity.interpolate(o => 1 - o), transform }}
/>
<a.div
class=“c front”
style={{
opacity,
transform: transform.interpolate(t => ${t} rotateX(180deg)
)
}}
/>
)
}
export default Card
避免在 didMount、didUpdate 中更新组件 State
这个技巧不仅仅适用于 didMount、didUpdate,还包括 willUnmount、useLayoutEffect 和特殊场景下的 useEffect(当父组件的 cDU/cDM 触发时,子组件的 useEffect 会同步调用),本文为叙述方便将他们统称为「提交阶段钩子」。
React 工作流[40]提交阶段的第二步就是执行提交阶段钩子,它们的执行会阻塞浏览器更新页面。
如果在提交阶段钩子函数中更新组件 State,会再次触发组件的更新流程,造成两倍耗时。一般在提交阶段的钩子中更新组件状态的场景有:
-
计算并更新组件的派生状态(Derived State)。在该场景中,类组件应使用 getDerivedStateFromProps[41] 钩子方法代替,函数组件应使用函数调用时执行 setState[42]的方式代替。使用上面两种方式后,React 会将新状态和派生状态在一次更新内完成。
-
根据 DOM 信息,修改组件状态。在该场景中,除非想办法不依赖 DOM 信息,否则两次更新过程是少不了的,就只能用其他优化技巧了。
use-swr 的源码[43]就使用了该优化技巧。当某个接口存在缓存数据时,use-swr 会先使用该接口的缓存数据,并在 requestIdleCallback
时再重新发起请求,获取最新数据。
如果 use-swr 不做该优化的话,就会在 useLayoutEffect 中触发重新验证并设置 isValidating 状态为 true[44],引起组件的更新流程,造成性能损失。
React Profiler 定位 Render 过程瓶颈
=============================
React Profiler 是 React 官方提供的性能审查工具,本文只介绍笔者的使用心得,详细的使用手册请移步官网文档[45]。
Profiler 只记录了 Render 过程耗时
通过 React Profiler,开发者可以查看组件 Render 过程耗时,但无法知晓提交阶段的耗时。
尽管 Profiler 面板中有 Committed at 字段,但这个字段是相对于录制开始时间,根本没有意义。
所以提醒读者不要通过 Profiler 定位非 Render 过程的性能瓶颈问题。
通过在 React v16 版本上进行实验,同时开启 Chrome 的 Performance 和 React Profiler 统计。
如下图,在 Performance 面板中,调和阶段和提交阶段耗时分别为 642ms 和 300ms,而 Profiler 面板中只显示了 642ms。
拓展知识
- React 在 v17 版本后已移除 User Timing 统计功能,具体原因可参考 PR#18417[46]。
- 在 v17 版本上,笔者也通过测试代码[47]验证了 Profiler 中的统计信息并不包含提交阶段,有兴趣的读者可以看看。
开启「记录组件更新原因」
点击面板上的齿轮,然后勾选「Record why each component rendered while profiling.」,如下图。
然后点击面板中的虚拟 DOM 节点,右侧便会展示该组件重新 Render 的原因。
定位产生本次 Render 过程原因
由于 React 的批量更新(Batch Update)机制,产生一次 Render 过程可能涉及到很多个组件的状态更新。那么如何定位是哪些组件状态更新导致的呢?
在 Profiler 面板左侧的虚拟 DOM 树结构中,从上到下审查每个发生了渲染的(不会灰色的)组件。
如果组件是由于 State 或 Hook 改变触发了 Render 过程,那它就是我们要找的组件,如下图。
结语
==
笔者是从年前开始写这篇文章,到发布时已经写了一个月了,期间断断续续将自己这几年对 React 的理解加入到文章中,然后调整措辞和丰富示例,最后终于在周四前完成(周四是我定的 deadline ???)。既然自己付出了那么多努力,那就希望它能成为一份优秀的 React 优化手册吧。
参考资料
[1]
线上代码: https://codesandbox.io/s/cdm-yu-commit-jieduanzhixingshunxu-fzu1w?file=/src/App.js
[2]
列表项使用 key 属性: #heading-7
[3]
避免在 didMount、didUpdate 中更新组件 State: #heading-18
[4]
React 组件的生命周期图: https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/
[5]
介绍事件循环的视频: https://www.youtube.com/watch?v=u1kqx6AenYw&t=853s
[6]
展开语法: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Spread_syntax
[7]
…state, item]`。这点可参考 Dan Abramov 在[演讲 Redux 时: https://www.youtube.com/watch?v=xsSnOQynTHs&t=690s
[8]
发布者订阅者跳过中间组件 Render 过程: #heading-10
[9]
跳过回调函数改变触发的 Render 过程: #heading-16
[10]
React Server Hooks 代码: https://github.com/facebook/react/blob/ee432635724d5a50301448016caa137ac3c0a7a2/packages/react-dom/src/server/ReactPartialRendererHooks.js#L452
[11]
useCallback: https://github.com/facebook/react/blob/ee432635724d5a50301448016caa137ac3c0a7a2/packages/react-reconciler/src/ReactFiberHooks.new.js#L1590
[12]
useMemo: https://github.com/facebook/react/blob/ee432635724d5a50301448016caa137ac3c0a7a2/packages/react-reconciler/src/ReactFiberHooks.new.js#L1613
[13]
How to memoize calculations: https://reactjs.org/docs/hooks-faq.html#how-to-memoize-calculations
[14]
memoizee: https://www.npmjs.com/package/memoizee
[15]
React 官方推荐: https://reactjs.org/docs/lists-and-keys.html#keys
[16]
没有添加、删除、排序功能的分页列表: https://codesandbox.io/s/meiyoutianjiashanchupaixugongnengdefenyeliebiao-d6zqr?file=/src/App.js
[17]
setState 同步还是异步: https://codesandbox.io/s/setstate-tongbuhuanshiyibu-1bo16
[18]
batchUpdates 批量更新: https://codesandbox.io/s/batchupdates-pilianggengxin-qqdsc
[19]
为什么 setState 是异步的?: https://github.com/facebook/react/issues/11527#issuecomment-360199710
[20]
线上示例: https://codesandbox.io/s/setstate-tongbuhuanshiyibu-1bo16
[21]
官方文档: https://reactjs.org/docs/concurrent-mode-adoption.html#feature-comparison
[22]
CodeSandbox 在线 Demo: https://codesandbox.io/s/youxianjigengxinlijixiangyingyonghucaozuo-eb740
[23]
发布者订阅者模式跳过中间组件的渲染阶段: https://codesandbox.io/s/fabuzhedingyuezhemoshitiaoguozhongjianzujiande-render-guocheng-nm7nt?file=/src/PubSubCommunicate.js
[24]
useDeepCompareMemo: https://github.com/sandiiarov/use-deep-compare#usedeepcomparememo
[25]
useMemo 跳过组件 Render 过程: https://codesandbox.io/s/usememo-tiaoguozujian-render-guocheng-bzz9r
[26]
useDebounce: https://github.com/xnimorz/use-debounce#simple-values-debouncing
[27]
debounce-search: https://codesandbox.io/s/debounce-search-4dkn3
[28]
useThrottleCallback: https://github.com/xnimorz/use-debounce/blob/master/src/useThrottledCallback.ts#L57
[29]
lazy-loading: https://codesandbox.io/s/lazy-loading-bmyd7
[30]
react-visibility-observer: https://www.npmjs.com/package/react-visibility-observer
[31]
懒渲染: https://codesandbox.io/s/lanxuanran-ls65r
[32]
react-window: https://react-window.now.sh/#/examples/list/fixed-size
[33]
官方示例: https://react-window.now.sh/#/examples/list/fixed-size
[34]
getPopupContainer: https://ant.design/components/dropdown/
[35]
onVisibleChange: https://ant.design/components/dropdown/
[36]
A Complete Guide to useEffect: https://overreacted.io/a-complete-guide-to-useeffect/#each-render-has-its-own-event-handlers
[37]
跳过回调函数改变触发的 Render 过程: https://codesandbox.io/s/tiaoguohuidiaohanshugaibianhongfade-render-guocheng-3i59n
[38]
react-spring: https://github.com/pmndrs/react-spring
[39]
CodeSandbox 在线 Demo: https://codesandbox.io/s/donghuakuzhijiexiugai-domtiaoguoxuanranjieduan-ij7px
[40]
React 工作流: #heading-0
[41]
getDerivedStateFromProps: https://reactjs.org/docs/react-component.html#static-getderivedstatefromprops
[42]
函数调用时执行 setState: https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops
[43]
use-swr 的源码: https://github.com/vercel/swr/blob/0.3.8/src/use-swr.ts#L536
[44]
设置 isValidating 状态为 true: https://github.com/vercel/swr/blob/dedc017248e3de9502f5d9ff874d45de3b20ab06/src/use-swr.ts#L352
[45]
官网文档: https://zh-hans.reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html
[46]
PR#18417: https://github.com/facebook/react/pull/18417
[47]
测试代码: https://codesandbox.io/s/react-profiler-shifoutongji-componentdidmount-zhixingshijian-yosid
点个『在看』支持下
最后
除了简历做到位,面试题也必不可少,整理了些题目,前面有117道汇总的面试到的题目,后面包括了HTML、CSS、JS、ES6、vue、微信小程序、项目类问题、笔试编程类题等专题。