由于react遵循 UI=render(data)
的设计哲学,传递进去一个数据,渲染出一个界面,推崇函数式的编程范式。 它没有像Vue那样采用双向绑定的思路,实时监测组件上状态的变化,而是每次进行组件和子孙组件的调和-渲染过程。 如果不注意,可能写的React应用会有性能上的问题。
react的渲染机制
举个例子,说明react的渲染机制。
当组件App上的状态发生了改变,受此影响的子组件链路为:App > div > p
,我们希望变化只发生这一条链路上,而实际的过程确不这样。
默认情况下react会自顶向下,将涉及到的所有子孙结点,都比对一遍,确定是否需要渲染,这就是react中最重要的reconcile(调和)的过程。因为,不确定数据变动,具体造成了哪些组件受到影响,需要重新渲染,所以react会将所有的子孙组件都调和一遍。
状态变化的组件下所有的子孙组件结点都参与一遍调和的过程。当页面比较复杂,组件树上的结点层级和数量较多时,有可能会造成页面的反应卡顿。因为浏览器的刷新频率一般为60HZ,这就要求在屏幕的刷新周期(16.7毫秒内)完成JS的执行,并将程序执行权还给浏览器的渲染线程进行布局和绘制。
而优化的思路的,无外乎,减少不必要的调和渲染过程。
- 从子组件自身的角度,阻断来自父容器的渲染执行过程
- 从父容器组件角度,精准控制哪些子组件的渲染执行过程
- 启动并行的渲染模式
- 懒加载执行
- 业务场景的优化
- 使用新版的过渡API
类子组件的渲染阻断
js
// 子组件 A
class ChildA extends React.Component {
state = {
countA: 0,
}
render() {
console.log('ChildA is rendering...');
return (
<div>
<p>
ChildA count: {this.state.countA}
<button onClick={() => this.setState({countA: this.state.countA + 1})}>+</button>
</p>
<p>ChildA msgA: {this.props.msgA}</p>
</div>
);
}
}
// 子组件B
class ChildB extends React.Component {
state = {
countB: 0,
}
render() {
console.log('ChildB is rendering...');
return (
<div>
<p>
ChildB count: {this.state.countB}
<button onClick={() => this.setState({countB: this.state.countB + 1})}>+</button>
</p>
<p>ChildB msgB: {this.props.msgB}</p>
</div>
);
}
}
// 容器组件
class Parent extends React.Component {
state = {
messageA: Date.now(),
messageB: Date.now(),
}
render() {
console.log('Parent is rendering ...');
return (
<div>
<h2>test react rendering...</h2>
<ChildA msgA={this.state.messageA} />
<ChildB msgB={this.state.messageB} />
<section>
<button onClick={() => this.setState({messageA: Date.now()})}>changeChildA</button>
<button onClick={() => this.setState({messageB: Date.now(0)})}>changeChildB</button>
</section>
</div>
)
}
}
上面是一个简单的react示例,容器组件 Parent
嵌套着两个子组件ChildA
和ChildB
。每个子组件除了自身定义了一个countX
的状态变量外,还接收来自父级容器组件传递的属性msgX
(代表msgA | msgB)。
- 当改变 A子组件的状态,控制台输出:
ChildA is rendering...
- 当改变 B子组件的状态,控制台输出:
ChildB is rendering...
- 当点击父容器组件上的按钮,无论是
changeChildA
或者changeChildB
按钮,都会打印三句log语句
这就是react内部的渲染逻辑导致的结果,即使在子组件中没有引用到相关的属性,当父级组件重新渲染时,所有的子孙组件都会走一遍render
函数。 当修改子组件A的属性时,子组件B本没有必要执行render逻辑的,这就造成了计算资源的浪费。
针对上面这种情况,我们可以使用 shouldComponentUpdate
方法,在子组件内进行属性的对比判断
js
// 子组件内判断属性是否真的改变了
shouldComponentUpdate(prevProp, state) {
return prevProp.msgA !== this.props.msgA
}
shouldComponentUpdate(prevProp, state) {
return prevProp.msgB !== this.props.msgB
}
这样就能有效的解决,子组件被重复执行的问题。当点击父容器组件上的(changeChildA
或 changeChildB
)按钮,只会打印两句log语句了。
同时,react官方也提供了纯组件的方案。
js
// 继承 PureComponent
class ChildA extends React.PureComponent {
state = {
countA: 0,
}
render() {
console.log('ChildA is rendering...');
return (
<div>
<p>
ChildA count: {this.state.countA}
<button onClick={() => this.setState({countA: this.state.countA + 1})}>+</button>
</p>
<p>ChildA msgA: {this.props.msgA}</p>
</div>
);
}
}
// 继承 PureComponent
class ChildB extends React.PureComponent {
state = {
countB: 0,
}
render() {
console.log('ChildB is rendering...');
return (
<div>
<p>
ChildB count: {this.state.countB}
<button onClick={() => this.setState({countB: this.state.countB + 1})}>+</button>
</p>
<p>ChildB msgB: {this.props.msgB}</p>
</div>
);
}
}
这样就不用每次都书写 shouldComponentUpdate
方法了,框架内部会针对继承了 PureComponent
的子组件,做前后属性的浅层比较,当发现组件的属性没有改变时,就不走后面的render过程了。
函数子组件的渲染阻断
上面是类组件的示例,函数式组件也面临同样的问题。
js
// 子组件-A
const ChildA = ({msgA}) => {
const [countA, setCountA] = useState(0);
console.log('ChildA is rendering...');
return (
<div>
<p>
ChildA count: {countA}
<button onClick={() => setCountA(countA + 1)}>+</button>
</p>
<p>ChildA msgA: {msgA}</p>
</div>
)
}
// 子组件-B
const ChildB = ({msgB}) => {
const [countB, setCountB] = useState(0);
console.log('ChildB is rendering...');
return (
<div>
<p>
ChildB count: {countB}
<button onClick={() => setCountB(countB + 1)}>+</button>
</p>
<p>ChildB msgB: {msgB}</p>
</div>
)
}
// 容器组件
const Parent = () => {
const [messageA, setMsgA] = useState(Date.now())
const [messageB, setMsgB] = useState(Date.now())
console.log('Parent is rendering ...');
return (
<div>
<h2>test react rendering...</h2>
<ChildA msgA={messageA} />
<ChildB msgB={messageB} />
<section>
<button onClick={() => setMsgA(Date.now())}>changeChildA</button>
<button onClick={() => setMsgB(Date.now())}>changeChildB</button>
</section>
</div>
)
}
当修改子组件 A的属性,子组件B 会重新渲染;修改子组件 B的属性,子组件A会重新渲染。
函数式组件中,无法使用 shouldComponentUpdate
和 纯组件的方式了,React中针对函数式组件提供了React.memo
的缓存方案。
js
//将 子组件 用 React.memo 包裹起来
const ChildA = React.memo(({msgA}) => {
const [countA, setCountA] = useState(0);
console.log('ChildA is rendering...');
return (
<div>
<p>
ChildA count: {countA}
<button onClick={() => setCountA(countA + 1)}>+</button>
</p>
<p>ChildA msgA: {msgA}</p>
</div>
)
})
React.memo 包装后的子组件,就拥有了匹配类组件中纯组件的能力。同时,React.memo
还接收第二个参数:isEqual,isEqual
是一个函数返回值为Boolean类型,true表示组件的属性前后相等,无需重新渲染,false则需要执行渲染,正好和shouldComponentUpdate
含义相反。
类容器组件的渲染控制
除了从子组件的角度来思考解决方案外,我们还可以从容器组件着手,精准控制子组件的渲染时机。
js
class Parent extends React.Component {
constructor(props) {
super(props);
this.state = {
messageA: Date.now(),
messageB: Date.now(),
}
this.ChildA = <ChildA msgA={this.state.messageA} />
this.ChildB = <ChildB msgB={this.state.messageB} />
}
// 控制子组件的渲染
controlChildRender() {
const { props: propsA } = this.ChildA;
if (propsA.msgA !== this.state.messageA) {
this.ChildA = React.cloneElement(this.ChildA, {msgA: this.state.messageA})
}
const { props: propsB } = this.ChildB;
if (propsB.msgB !== this.state.messageB) {
this.ChildB = React.cloneElement(this.ChildB, {msgB: this.state.messageB})
}
return (
<>
{this.ChildA}
{this.ChildB}
</>
)
}
render() {
console.log('Parent is rendering ...');
return (
<div>
<h2>test react rendering...</h2>
{
this.controlChildRender()
}
<section>
<button onClick={() => this.setState({messageA: Date.now()})}>changeChildA</button>
<button onClick={() => this.setState({messageB: Date.now(0)})}>changeChildB</button>
</section>
</div>
)
}
}
我们在容器组件中声明controlChildRender
方法,里面有子组件的渲染控制逻辑,这样就能按照我们的预期来展示了。 不过这种写法比较繁琐,有一定的代码冗余,不利于后期的维护。下面我们可以借助新版react提供的 hooks API,在函数式组件中以一种更加优雅的方式来实现同等效果。
函数容器组件的渲染控制
react针对函数式组件,有 useMemo 这个hook API 来帮助我们缓存组件,避免不必要的重新渲染。
js
const Parent = () => {
const [messageA, setMsgA] = useState(Date.now())
const [messageB, setMsgB] = useState(Date.now())
// 缓存子组件 A ,仅当 messageA 发生改变时,重新渲染子组件
const memoChildA = useMemo(() => <ChildA msgA={messageA} />, [messageA]);
// 缓存子组件 B ,仅当 messageB 发生改变时,重新渲染子组件
const memoChildB = useMemo(() => <ChildB msgB={messageB} />, [messageB]);
console.log('Parent is rendering ...');
return (
<div>
<h2>test react rendering...</h2>
{memoChildA}
{memoChildB}
<section>
<button onClick={() => setMsgA(Date.now())}>changeChildA</button>
<button onClick={() => setMsgB(Date.now())}>changeChildB</button>
</section>
</div>
)
}
React.useMemo
,接收两个参数,第一个参数是回调函数,第二个参数是依赖项。将缓存回调函数中的返回结果,当且仅当依赖项改变时,重新生成组件。这样就能更加精准细粒度的控制渲染过程了。
当点击容器组件的changeChildA | changeChildB按钮时,只会触发属性相关联的子组件的渲染了。
引用数据类型的属性
当给子组件传递的属性,为引用类型的数据时(对象、数组、函数等),需要特别注意引用值的变化了。
js
const ChildA = React.memo(({msgA, changeMsgA}) => {
const [countA, setCountA] = useState(0);
console.log('ChildA is rendering...');
return (
<div>
<p>
ChildA count: {countA}
<button onClick={() => setCountA(countA + 1)}>+</button>
</p>
<p>
ChildA msgA: {msgA}
{/* 这里子组件A 接收了一个 changeMsgA 函数属性值 */}
<button onClick={() => changeMsgA('child-A changed...')}>changeMsgA</button>
</p>
</div>
)
})
const ChildB = React.memo(({msgB, changeMsgB}) => {
const [countB, setCountB] = useState(0);
console.log('ChildB is rendering...');
return (
<div>
<p>
ChildB count: {countB}
<button onClick={() => setCountB(countB + 1)}>+</button>
</p>
<p>
ChildB msgB: {msgB}
{/* 这里子组件B 接收了一个 changeMsgB 函数属性值 */}
<button onClick={() => changeMsgB('child-B changed...')}>changeMsgB</button>
</p>
</div>
)
})
const Parent = () => {
const [messageA, setMsgA] = useState(Date.now())
const [messageB, setMsgB] = useState(Date.now())
const changeMessageA = (msg) => setMsgA(msg);
const changeMessageB = (msg) => setMsgB(msg);
console.log('Parent is rendering ...');
return (
<div>
<h2>test react rendering...</h2>
<ChildA msgA={messageA} changeMsgA = {changeMessageA}/>
<ChildB msgB={messageB} changeMsgB = {changeMessageB}/>
<section>
<button onClick={() => setMsgA(Date.now())}>changeChildA</button>
<button onClick={() => setMsgB(Date.now())}>changeChildB</button>
</section>
</div>
)
}
这是一个普通的示例,分别给子组件A和B传递了一个事件函数,子组件通过事件函数来修改容器组件内的状态变量。
因为每次渲染父级容器组件的时候,const changeMessageA = (msg) => setMsgA(msg)
都会新生成一个事件函数,即便我们给子组件都加上了 React.memo,控制台依然会输出三次 rendering的日志信息。
- Parent is rendering ...
- ChildA is rendering...
- ChildB is rendering...
说明针对引用类型的属性,每次都是改变的。为了验证我们的猜想,可以在 React.memo
的第二个函数参数中,添加打印语句。
js
const ChildA = React.memo(({msgA, changeMsgA}) => {
const [countA, setCountA] = useState(0);
console.log('ChildA is rendering...');
return (
// 省略......
)
}, (prevProp, nextProp) => {
// 基础类型
console.log(prevProp.msgA === nextProp.msgA); // true
// 事件函数 引用类型
// 说明:这次传递过来的事件函数 和 上次传递过来的事件函数不一样,内存地址变了
console.log(prevProp.changeMsgA === nextProp.nextProp); // false
return prevProp.changeMsgA === nextProp.nextProp && prevProp.msgA === nextProp.msgA
})
这里就涉及JavaScript的基础知识了,在JS中基础类型(number、string、boolean等)的比较是值比较,而引用类型(函数、对象、数组等)是引用比较,比较的是内存中的地址。在容器组每次更新时,都会执行一遍函数逻辑,新生成一个 事件函数的引用,造成了所有关联了引用属性的子组件都会重新渲染。(其实在本例中,传递一个对象、数组也会是同样的效果,事件函数的属性在我们业务开发中更常见)。
该如何解决呢?针对事件函数,我们可以用react提供的 useCallback 包裹起来,告诉react在重新渲染该组件时,还是用上一次缓存的那个就好,不用每次生成新的。
js
// 将事件函数 useCallback包裹,这样就能缓存起来
const changeMessageA = useCallback((msg) => setMsgA(msg), []);
const changeMessageB = useCallback((msg) => setMsgB(msg), []);
在浏览器测试效果:
useCallback 就是 useMemo的变种,二者在框架内部的处理逻辑基本相同,useMemo缓存的是函数执行结果,useCallback缓存的是函数本身。
懒加载&异步渲染
针对需要请求数据接口,后端返回结果后才能正常展示的组件,react提供了 Suspense
的处理方式,搭配 React.lazy
实现异步组件的懒加载。 看下面的示例:
js
// MockRemoteComp
export default function() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
// 加载一个 默认的组件对象
default: () => <div>this is a remote component</div>
})
}, 2000);
})
}
// LazyComp 组件
import MockRemoteComp from "./MockRemoteComp"
const RemoteComp = React.lazy(MockRemoteComp)
const LazyComp = () => {
return (
<div>
<p>test load a remote lazy component</p>
<Suspense
fallback={<div> loading comp ... </div>}
>
<RemoteComp />
</Suspense>
</div>
)
}
React.lazy
接收一个Promise类型的数据,需要给它在成功回调后resolve一个默认的React组件。
这样 2 秒后,页面的状态发生改变,成功加载出异步组件,这里的Suspense
提供了一种占位的功能,并明确了回退fallback
的展示状态。
Suspense组件内部的执行流程如下:
另外在 react 18 中增加了 SuspenseList
这个组件API,用来支持多个 Suspense组件 的渲染控制。
jsx
<SuspenseList revealOrder="forwards">
<Suspense fallback={'loading A Comp...'}>
<CompA />
</Suspense>
<Suspense fallback={'loading B Comp...'}>
<CompB />
</Suspense>
<Suspense fallback={'loading C comp...'}>
<CompC />
</Suspense>
</SuspenseList>
目前还是个体验的API,在V18.2.0版中实测还不能使用。
减少后面结点的插入
在react 调和的过程中,newReactElement 和 oldFiber 进行domDiff,当结点的type 和 key匹配时就会复用之前的结点,避免DOM结点的重新生成。在满足了复用的条件后,遵循下面的交换规律。
因此,在业务开发中,应尽量减少后面的结点移动到前面,来提高效率。
业务场景
在具体的业务中,可能会遇到长列表展示、搜索框、动画效果等场景。
虚拟列表
当列表中要渲染的数据量非常大时,如果不做优化,随着下拉滚动不停地追加新数据,展示在页面中的DOM元素越来越多,页面会逐渐变得反应卡顿。
这时,我们可以设置一个虚拟的滚动条,只展示视窗范围内的有限条数据,超出视窗范围的数据直接不渲染或销毁,并且用空的div结点来占位。 实时计算滚动条的位置,当滚动到某个位置时,展示对应可视范围内的数据。在实际操作的过程中,为避免用户手速过快,出现白屏的尴尬状况,可以上下预留出2屏的缓冲区。
防抖&节流
防抖&节流,就其本质,是降低触发的频次,减少react内部 reconcile的次数。
页面中的搜索输入框,属于高频操作的场景,及时做好防抖的处理。
js
// 防抖 hook的简单实现
const useDebounce = (fn, ms, deps) => {
const timer = useRef(null);
useEffect(() => {
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => {
console.log('debounce action ...');
fn()
}, ms);
}, deps)
const cancel = () => {
clearTimeout(timer);
timer.current = null;
}
return [cancel];
}
当改变视窗的大小,下拉滚动条,拖拽事件等行为发生时,及时做好节流处理。
js
// 节流hook的简单实现
function useThrottle(fn, ms, deps = []) {
let previous = useRef(0);
const [delay, setDelay] = useState(ms);
useEffect(() => {
if (Date.now() - previous.current > delay) {
fn();
previous.current = Date.now()
}
}, deps)
const cancel = () => setDelay(0);
return [cancel]
}
在一般的项目工程中,都会引入lodash这样的工具库,里面内置防抖、节流的功能。
CSS动画
页面上的动效交互,尽量交给浏览器来处理,避免在组件内部用状态变量来控制动画的属性参数。
- 给元素结点添加动画样式类 (✅)
- 操纵原生的DOM结点 (✅)
- 使用定时器反复修改状态变量,来调整动画属性参数(❌)
推荐,使用动画样式类的方式
jsx
<div className={ isAnimation ? 'normal z-moving' : 'normal' } ></div>
避免,使用组件的状态变量,绑定动画属性
jsx
// left, top 为 组件中通过 useState 生成的状态变量
<div className='normal' style={{ transform:`translate(${ left }px,${ top }px )` }} ></div>
必要时,可以通过useRef
获得原生的DOM结点,来直接修改外观样式,避免走react的渲染调和过程。
开启并发渲染
借助于 fiber 模式的重构,react 拥有了并发渲染的能力。当切换到 concurrent 模式下后,可以将耗时的任务分解,将高优先级的任务提前执行,避免阻塞用户在UI上的操作,提升交互体验。
jsx
function App() {
const list = new Array(3000).fill(0);
return (
<ul>
{list.map((_, i) => <li>{i}</li>)}
</ul>
);
}
ReactDOM.render(<App/>, document.getElementById("#root")); // 同步渲染模式
在页面渲染 3000 个li结点,在同步模式下运行
如上图所示,在一次浏览器的事件循环周期内,会出现阻塞页面布局绘制的超长任务。JS执行线程占据了 73.65毫秒,严重超过了屏幕刷新频率的正常周期(16.7毫秒)。由于在浏览器中JS的执行线程和UI的渲染线程是互斥的,当JS执行时间过长,就占据了本该属于UI渲染绘制的时间,页面上出现掉帧卡顿的现象。
jsx
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
改造最后一句代码,开启并发的渲染模式
在新版的react中,增加了 scheduler
模块,可以调度任务的优先级和顺序,所有的渲染调和任务将变成异步可中断的,将耗时长的任务拆解在不同的CPU时间片(默认5毫秒)内执行,这样可以保证页面的流畅运行,不会出现JS执行时间过长而造成页面卡顿的现象。
同时,在react v18中提供的 useTransition
可以在不阻塞UI的前提下更新状态。
useTransition
不需要传入参数,返回 isPending
和 startTransition
一个是过度状态,另一个是开始过渡的回调函数。它能帮助我们处理高优先级的任务,让用户获得更及时的响应。
jsx
import { useTransition } from 'react';
export default function TabButton({ children, isActive, onClick }) {
const [isPending, startTransition] = useTransition();
if (isActive) {
return <b>{children}</b>
}
if (isPending) {
return <b className="pending">{children}</b>;
}
return (
<button onClick={() => {
startTransition(() => {
onClick();
});
}}>
{children}
</button>
);
}
上面的程序中 isPending = true
的时候,表示一个过渡(transition )正在进行中,需要在页面中展示一下loading状态。 当切换到需要消耗大量时间才能装载完成的Tab面板时,根据 pending 状态给出一个过渡效果,不致于让页面卡住。等到处于空闲状态时,再处理 startTransition 中的回调任务。
Transition 本质上是用于一些不是很急迫的更新上,在 React 18 之前,所有的更新任务都被视为急迫的任务,在 React 18 诞生了 concurrent Mode 模式,在这个模式下,渲染是可以中断,低优先级任务,可以让高优先级的任务先更新渲染。
useDeferredValue
是与 useTransition 相似功效的的一个hook。它的作用是延迟更新UI上的某个部分。 比如,一个常见的业务场景,一个搜索框下面紧挨着一个搜索列表,我们期望输入搜索文字时要流畅,下面的查询结果可以有一定的延迟。
jsx
export default function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<label>
Search albums:
<input value={query} onChange={e => setQuery(e.target.value)} />
</label>
<Suspense fallback={<h2>Loading...</h2>}>
<SearchResults query={deferredQuery} />
</Suspense>
</>
);
}
上面的 query
状态对应的输入框会及时响应用户的输入,deferredQuery
对应的搜索结果组件由于使用的是延迟的值(useDeferredValue
),在数据加载完成前会展示之前的结果状态。
总结
本文从react的渲染流程的角度来讲述性能优化的方式,通过减少不必要的渲染流程,减少渲染的频率和次数,引入延迟渲染的机制,加快对用户操作的响应,进而提升用户体验。针对react应用的性能优化,也不仅限于此,还包括工程化的调优,前后端网络请求链路的优化等等。