前言
useDeferredValue是react18新增的一个用于优化性能的一个hook,它的作用是延迟获取一个值,实际开发中的常规用法与我们之前所用的防抖和节流很相似,但是也有一定的区别。本篇文章我们就逐步分析它的设计原理和用法,并且讨论它与防抖节流的区别和它自身的优势。在讨论useDeferredValue之前,我们要先了解react的两个知识点,嵌套组件的渲染过程和记忆组件memo原理作用。
嵌套组件的渲染过程
子组件正常渲染
提到组件嵌套我们非常熟悉,因为整个react页面都是只有一个根组件,所有组件都是这个跟组件的子组件,那我们就分析一下有子组件的时候,父组件重现渲染会发生什么。
其实当我们的父组件重新渲染的时候,我们的所有子组件也会全部重新渲染一遍,这样设计主要是为了保持组件树的一致性和子组件数据更新的及时性。
例如一些子组件与父组件存在数据传递的情况,如果子组件不重新渲染,那么就无法得到最新的父组件传递过去的数据,也就无法及时更新页面。下面我们使用一个小案例测试这一场景:
tsx
import React from 'react'
function Test01(props: {count: number}) {
const { count } = props
console.log('Test01 render')
return (
<div>
<p>This is Test01 Page {count}</p>
</div>
)
}
function Test02() {
console.log('Test02 render')
return (
<div>
<p>This is Test02 Page</p>
</div>
)
}
function Demo01() {
console.log('Demo01 render')
const [count, setCount] = React.useState(0)
return (
<div>
<p>This is Demo01 Page</p>
<button onClick={() => { setCount(count + 1) }}>AddButton</button>
<Test01 count={count} />
<Test02 />
</div>
)
}
export default Demo01
根据测试,我们发现当我们点击AddButton时,Test01组件的count值在持续增加,并且控制台也会依次打印出如下内容,说明我们的子组件也根据使用顺序依次渲染,并且子组件得到了父组件传入的最新值。
子组件渲染缓慢
在上面这种场景下,假如我们的其中一个子组件渲染遇到了大量计算,渲染很慢,会发生什么呢,我们稍微修改一下代码,我们把Test01组件中加入一个两亿次的循环,模拟大量计算导致的渲染变慢,同时我们在Demo01组件中加入另一个状态number并将其传入Test02组件,当分别点击AddButton和AddNumber时,测试页面和控制台打印情况:
tsx
import React from 'react'
function Test01(props: {count: number}) {
const { count } = props
console.log('Test01 render')
let k = 0
for (let i = 0; i <= 200000000; i += 1) {
k = i
}
return (
<div>
<p>{k}This is Test01 Page Count {count}</p>
</div>
)
}
function Test02(props: {number: number}) {
const { number } = props
console.log('Test02 render')
return (
<div>
<p>This is Test02 Page Number {number}</p>
</div>
)
}
function Demo01() {
console.log('Demo01 render')
const [count, setCount] = React.useState(0)
const [number, setNumber] = React.useState(0)
const handleAddCount = () => {
console.log('handleAddCount')
setCount(count + 1)
}
const handleAddNumber = () => {
console.log('handleAddNumber')
setNumber(number + 1)
}
return (
<div>
<p>This is Demo01 Page</p>
<button onClick={handleAddCount}>AddButton</button>
<button onClick={handleAddNumber}>AddNumber</button>
<Test01 count={count} />
<Test02 number={number} />
</div>
)
}
export default Demo01
根据我们测试会发现,不管我们点击的是哪个按钮,页面数字显示都会卡顿,没有及时显示出来,同时控制台都会打印出来下图结果,根据结果我们可以看出,当我们修改父组件的状态时,不管修改的是哪一个,子组件都会全部渲染,而且当遇到一个渲染缓慢的子组件时,父组件和其他子组件都会等待它渲染完成才会启动下次渲染,这就导致了无论我们修改了哪个状态,我们组件都会渲染的很缓慢。
不过我们发现当我们点击AddNumber时,count的值一直保持不变,Test01的渲染结果也是一直保持不变,这个是我们react组件要求必须是纯函数的一个特性,当输入的props不发生改变的时候,返回结果永远都是一样的。既然如此那当我们点击AddNumber时,Test01组件完全没有重新渲染的必要,所以react官方为了解决这一问题,引入可记忆组件的概念,下面我们就详细分析记忆组件的作用。
记忆组件memo原理作用
react引入记忆组件,就是为了避免不必要的渲染,也就是说当我们向子组件传入的props不发生改变的时候,子组件不需要重新渲染。想要组件变成记忆组件,我们只需要把组件包裹在memo函数中就可以了,我们把上述案例使用memo进行改造,此时我们把Test01,Test02使用memo函数返回,那么这两个组件就变成了记忆组件,那么以后只有该组件的props发生改变,才会重新渲染此组件。
tsx
import React, { memo } from 'react'
const Test01 = memo((props: {count: number}) => {
const { count } = props
console.log('Test01 render')
let k = 0
for (let i = 0; i <= 200000000; i += 1) {
k = i
}
return (
<div>
<p>{k}This is Test01 Page Count {count}</p>
</div>
)
})
Test01.displayName = 'Test01'
const Test02 = memo((props: {number: number}) => {
const { number } = props
console.log('Test02 render')
return (
<div>
<p>This is Test02 Page Number {number}</p>
</div>
)
})
Test02.displayName = 'Test02'
function Demo01() {
console.log('Demo01 render')
const [count, setCount] = React.useState(0)
const [number, setNumber] = React.useState(0)
const handleAddCount = () => {
console.log('handleAddCount')
setCount(count + 1)
}
const handleAddNumber = () => {
console.log('handleAddNumber')
setNumber(number + 1)
}
return (
<div>
<p>This is Demo01 Page</p>
<button onClick={handleAddCount}>AddButton</button>
<button onClick={handleAddNumber}>AddNumber</button>
<Test01 count={count} />
<Test02 number={number} />
</div>
)
}
export default Demo01
我们使用改造后的代码进行测试,此时我们快速点击AddNumber按钮,我们发现当我们快速点击AddNumber时,并不会像之前那样有卡顿的现象变得非常丝滑,并且我们会在控制台看到如下结果,这也说明当我们点击AddNumber时,count没有发生改变,Test01组件也没有重新渲染,这样就起到了避免渲染无关组件带来的额外开销,也不会因为一个组件的渲染缓慢导致整个渲染的缓慢,对项目性能可以有个很好的优化。不过当我们点击AddButton时依然会有卡顿,这是不可避免的,所以代码中一定避免这样的大量循环。
useDeferredValue详解
我们搞懂上面两个概念之后,我们下面就正式开始逐步分析useDeferredValue的原理和使用方法,首先我们需要对useDeferredValue进行一个简单的介绍。
了解useDeferredValue
useDeferredValue是react18引入的一个用于性能优化的hooks,它用于延迟获取某个值,并且在延迟获取之间将会返回旧的值。
单从官方定义我们难以理解它的实际含义和作用,这里我来翻译一下,官方表达的意思就是使用useDeferredValue传入一个参数,这个参数是一个任意类型的值,例如我们就传入一个使用useState定义的变量value,value的初始值是字符串'abc',当我们修改value时,他就会延迟返回一个最新的value值,例如下面代码
tsx
const [value, setValue] = useState('abc')
const deferredValue = useDeferredValue(value)
此时我们修改value值为'abcd'那么接下来会发生什么呢,首先由于value的改变,当前组件会被重新渲染,而这次渲染useDeferredValue(value)会返回之前的值,也就是'abc',然后后台会安排一次重新渲染,此时useDeferredValue(value)会返回最新值'abcd'。
我们直接在代码中测试,在如下代码中,我们将count值传入useDeferredValue并返回一个延迟的count,我们测试当我们点击一次AddButton时查看打印情况。
tsx
import React, { useDeferredValue } from 'react'
function Demo01() {
console.log('Demo01 Render')
const [count, setCount] = React.useState(0)
const handleAddCount = () => {
console.log('handleAddCount')
setCount(count + 1)
}
const deferredCount = useDeferredValue(count)
console.log('count: ', count)
console.log('deferredCount: ', deferredCount)
return (
<div>
<p>This is Demo01 Page</p>
<button onClick={handleAddCount}>AddButton</button>
</div>
)
}
export default Demo01
当我们点击一次AddButton时,控制台会有如下打印,首先我们点击了AddButton给count设置了新的值,组件由于状态的改变进行第一次渲染,而此时deferredCount返回值是0,也就是初始传入的值,这就对应了官方所说的,首次渲染不会返回最新值,而是返回之前的旧值,也就是初始值。
紧接着有出现了一次渲染,不过这次渲染并不是我们操作的原因,而是官方所说的会在后台会安排一次重新渲染,然后在这次重新渲染中,useDeferredValue将返回上次渲染传入的最新值,而我们上次渲染传给useDeferredValue的值是增加后的数字1,因此在后台的二次渲染中就返回了最新值1.
通过上面的基本解释,我们大概了解了useDeferredValue的运行机制,然而这样的机制有什么作用呢,单独看的话甚至还额外多了一次渲染,又有什么必要呢,我们下面用一个官方的案例解释它的作用。
实现输入框内容实时更新到列表功能
我们实现一个功能,当我们在输入框中内容时,将内容实时显示在下面的列表中,我们在列表中故意加入了一个大量的循环,来模拟列表存在大量计算,渲染缓慢的场景。
不使用useDeferredValue实现
Test组件
tsx
import React, { useState } from 'react'
import List from './list'
function Test() {
const [inputValue, setInputValue] = useState('')
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log('handleChange')
setInputValue(e.target.value)
}
return (
<div>
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="Search..."
/>
<List inputValue={inputValue} />
</div>
)
}
export default Test
List组件
tsx
import React, { memo } from 'react'
// 定义一个列表组件List
function List(props: { inputValue: string }) {
const { inputValue } = props
console.log('List render')
let k = 0
for (let i = 0; i <= 200000000; i += 1) {
k = i
}
return (
<ul>
<li>Cycle Times {k}Text: {inputValue}</li>
<li>Cycle Times {k}Text: {inputValue}</li>
<li>Cycle Times {k}Text: {inputValue}</li>
<li>Cycle Times {k}Text: {inputValue}</li>
<li>Cycle Times {k}Text: {inputValue}</li>
</ul>
)
}
export default memo(List)
我们这里就是很简单的,输入框输入内容,我们修改inputValue为最新值,并把inputValue传入list组件内进行显示,只不过list组件有个模拟渲染缓慢的循环。我们把list组件使用memo返回,使其变成一个记忆组件,只是现在每次props都会改变,暂时起不到作用。我们根据上述代码开始测试,我们在输入框快速输入字符,查看页面表现和控制台打印情况
根据页面表现我们可以看出,当我们快速输入时,页面有着明显卡顿,这是因为list组件的渲染非常缓慢,然而我们每次输入都会修改list组件的props值,也就意味着每次输入都会使list重新渲染,而react的渲染机制又是不可中断的,所以就会出现排队渲染的情况,只有等list组件上次渲染结束,test组件才能开始下次渲染,才能将最新的值显示在输入框内,这样会给用户很不好的操作体验。
根据控制台打印结果我们可以看出,每次渲染大概需要0.2秒的时间,记住这个时间,后续还会用到。
接下来我们使用useDeferredValue优化代码,再进行测试。
使用useDeferredValue实现
我们将Test组件做以下改造,将inputValue传入useDeferredValue,并返回一个deferredValue,然后我们将deferredValue传入List组件,并在List组件中打印出deferredValue的值
Test组件
tsx
import React, { useState, useDeferredValue } from 'react'
import List from './list'
function Test() {
const [inputValue, setInputValue] = useState('')
const deferredValue = useDeferredValue(inputValue)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log('handleChange')
setInputValue(e.target.value)
}
console.log('inputValue:', inputValue)
console.log('deferredValue:', deferredValue)
return (
<div>
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="Search..."
/>
<List inputValue={deferredValue} />
</div>
)
}
export default Test
List组件
tsx
import React, { memo } from 'react'
// 定义一个列表组件List
function List(props: { inputValue: string }) {
const { inputValue } = props
console.log('List render: ', inputValue)
let k = 0
for (let i = 0; i <= 300000000; i += 1) {
k = i
}
return (
<ul>
<li>Cycle Times {k}Text: {inputValue}</li>
<li>Cycle Times {k}Text: {inputValue}</li>
<li>Cycle Times {k}Text: {inputValue}</li>
<li>Cycle Times {k}Text: {inputValue}</li>
<li>Cycle Times {k}Text: {inputValue}</li>
</ul>
)
}
export default memo(List)
然后我们直接在输入框快速输入字符进行测试,观察页面表现
从页面表现我们可以看出,输入框显示最新输入内容变得很丝滑,不会那么卡顿,这是什么原因呢,我们通过过滤控制台数据逐个分析
过滤deferredValue
过滤List render
分析打印结果:
我们从打印结果可以看出,并不是每次输入新的内容时,deferredValue都会返回新的值,而是会隔一段时间返回一次,而list组件的渲染次数也刚好是deferredValue返回新的值的次数。这就充分解释了useDeferredValue作用就是不会立刻返回新的结果,会等到上一次返回的新结果处理完才会继续返回,我们还记得上面测试的按照List组件的渲染时间大概就是0.2秒,也就是说List组件所依赖的这个延迟返回的值,会等到list组件渲染完成后才会再返回新的结果。
而在useDeferredValue两次返回新结果之间,并不会影响父组件也就是Test组件的渲染,这样就避免了输入框内容不能快速展现的问题,也避免了多次重复渲染List组件产生的额外消耗。
useDeferredValue与防抖节流对比
我们从上面的结果和表现可以看出,useDeferredValue hook的作用非常类似我们之前做的防抖节流函数,那他们之前的区别,官方解释的很好,我这里就直接照搬过来。
防抖:是指在用户停止输入一段时间(例如一秒钟)之后再更新列表。
节流:是指每隔一段时间(例如最多每秒一次)更新列表。
虽然这些技术在某些情况下是有用的,但 useDeferredValue 更适合优化渲染,因为它与 React 自身深度集成,并且能够适应用户的设备。
与防抖或节流不同,useDeferredValue 不需要选择任何固定延迟时间。如果用户的设备很快(比如性能强劲的笔记本电脑),延迟的重渲染几乎会立即发生并且不会被察觉。如果用户的设备较慢,那么列表会相应地"滞后"于输入,滞后的程度与设备的速度有关。
此外,与防抖或节流不同,useDeferredValue 执行的延迟重新渲染默认是可中断的。这意味着,如果 React 正在重新渲染一个大型列表,但用户进行了另一次键盘输入,React 会放弃该重新渲染,先处理键盘输入,然后再次开始在后台渲染。相比之下,防抖和节流仍会产生不顺畅的体验,因为它们是阻*的:它们仅仅是将渲染阻塞键盘输入的时刻推迟了。
如果你要优化的工作不是在渲染期间发生的,那么防抖和节流仍然非常有用。例如,它们可以让你减少网络请求的次数。你也可以同时使用这些技术。
待解决的疑问
上面的案例有个地方有个问题,我至今没找到合理的解释,下面我详细描述一下,如果有大佬能够解释,可以在下面评论或者直接私信我,不胜感激。
我们把之前使用useDeferredValue之后打印的结果拿过来再分析一下
过滤deferredValue
过滤List render
我们从deferredValue的打印结果可以看出来,在deferredValue获取两个新的值之间,打印出来的结果是空,其实就是我们初始的时候传进去的inputValue,因为inputValue初始值是空。(这里如果想要看得更清晰,可以把inputValue的初始值设置为abc,那么在deferredValue两个新的值之间打印出来的就是abc)
但是我们根据官网的返回值介绍:在组件的初始渲染期间,返回的延迟值将与你提供的值相同。但是在组件更新时,React 将会先尝试使用旧值进行重新渲染(因此它将返回旧值),然后再在后台使用新值进行另一个重新渲染(这时它将返回更新后的值)。
那么deferredValue在两次返回值之间的值应该是上次的值,例如jnkjn与jnkjnkjn之间的值应该都是jnkjn才对,不然List组件渲染的情况也有问题,因为对于记忆组件,只有props的值发生改变就会重新渲染,那么在deferredValue的值从jnkjn变成空的时候,并没有渲染list,那说明react认为在这之间deferredValue的值并没有改变。
我对该情况的猜想是,在两个值之间的返回值,值得引用地址没有变化,只是内容变成了初始化的内容。对于返回值的引用地址,只有每次获取到最新值的时候才会改变。也就是说例如jnkjn到jnkjnkjn之间打印出来的空值,实际上和jnkjn的引用地址是相同的,只是内容不同。然后memo返回的记忆组件又是浅比较,指挥对比props的引用地址,而不会对比具体内容,所以才出现这个现象,这只是我个人猜测。
我将该问题提交到了react的issues中,该问题在Github上的issue。有大佬能够解释欢迎评论,如果官方给出答案我也会及时更新过来。