提示: 本文不介绍并发更新原理,只介绍用法和用处。
react并发更新指的是让组件的render
流程从同步的变成异步可中断的,即使是单次render
时间过长,也不会一直占着主线程阻塞渲染。
截止目前的版本,react依然默认使用同步更新模式,但提供了并发特性供开发者使用:
- useTransition
- useDeferredValue
useTransition
useTransition
可以将一个更新转为低优先级更新,使其可以被打断,不阻塞UI。
返回值为数组:
- 第一项是标识是否完成更新的状态。
- 第二项是一个函数
startTransition
,将含有状态更新的同步函数传入startTransition
,这次的状态更新就是低优先级的。
useTransition
一般用于视图切换,如下所示
tsx
function TabContainer() {
const [tab, setTab] = useState('short');
function selectTab(nextTab) {
setTab(nextTab);
}
return (
<>
<TabButton isActive={tab === 'short'} onClick={() => selectTab('short')}>
短列表
</TabButton>
<TabButton isActive={tab === 'long'} onClick={() => selectTab('long')}>
长列表
</TabButton>
<hr />
{tab === 'short' && <ShortTab />}
{tab === 'long' && <LongTab />}
</>
);
}
function ShortTab() {
return <p>ShortTab页签内容</p>;
}
const LongTab = memo(function LongTab() {
let items = [];
for (let i = 0; i < 500; i++) {
items.push(<SlowCpn key={i} index={i} />);
}
return <ul>{items}</ul>;
});
function SlowCpn({ index }) {
let startTime = performance.now();
while (performance.now() - startTime < 3) {}
return <li>第 {index + 1} 项</li>;
}
点击长列表后页面渲染被阻塞,表现为 hover 效果失效。看一下 Performance 面板能发现 click 之后触发的更新是一个很长的执行任务,耗时的就是 LongTab 组件的渲染。
使用useTransition
能很简单的解决这个问题:
tsx
function TabContainer() {
const [tab, setTab] = useState('short');
const [isPending, startTransition] = useTransition();
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<TabButton isActive={tab === 'short'} onClick={() => selectTab('short')}>
短列表
</TabButton>
<TabButton isActive={tab === 'long'} onClick={() => selectTab('long')}>
长列表
</TabButton>
<hr />
{tab === 'short' && <ShortTab />}
{tab === 'long' && <LongTab />}
{isPending && '长列表加载中...'}
</>
);
}
用startTransition
包裹状态更新函数,可以用isPending
给一个加载的提示。
hover 效果正常,页面渲染没有被阻塞,再看一下 Performance,原本的长任务被切分为很多个短任务
这就是useTransition
最常用的场景------视图切换。
非 hook 用法
直接从 react 导入startTransition
startTransition 必须传入包含状态更新的同步函数
startTransition
传入的函数中,必须也只有同步调用状态更新才会有用,它的具体作用就是将同步执行函数时发生的所有状态更新标记为并发更新。
useTransition
实现的大致代码如下所示,只是在执行传入的函数时将一个全局变量设置为特定的值,比如 1,这样 react 在渲染流程可以根据这个全局变量确定使用同步更新还是并发更新。
ts
function useTransition(): [boolean, (callback: () => void) => void] {
const [isPending, setIsPending] = useState(false);
const start = startTransition.bind(null, setIsPending);
return [isPending, start];
}
function startTransition(setPending: Dispatch<boolean>, callback: () => void) {
// 设置isPending为true
setPending(true);
// 保存原先的currentBatchConfig.transition
const prevTransition = currentBatchConfig.transition;
// 讲currentBatchConfig.transition设置为1,react在渲染时看到此值是1便会选择并发更新
currentBatchConfig.transition = 1;
callback();
// 设置isPending为false
setPending(false);
// 回调结束,恢复currentBatchConfig.transition的值
currentBatchConfig.transition = prevTransition;
}
知道大致原理就能明白,如果回调是一个异步函数,执行时那个全局变量已经恢复原本的值;如果回调不包含非 react 状态更新,这将毫无意义,startTransition
只能将状态更新转为并发,其他逻辑代码都是正常执行。
不能用于文本输入
文本输入是强制性的高优先级更新,如随着文本输入更新状态,并触发耗时组件的渲染导致页面卡顿,用useTransition
是解决不了的。但是可以用useDeferredValue
,下文会提到。
tsx
const [text, setText] = useState('')
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<ExpensiveList text={text} />
</>
)
const Expensivelist = ({ text }) => {
let items = [];
for (let i = 0; i < 200; i++) {
items.push(<Item key={i} text={text} />);
}
return <ul className="items">{items}</ul>;
};
function Item({ text }) {
let startTime = performance.now();
while (performance.now() - startTime < 2) {}
return <li className="item">Text: {text}</li>;
}
useDeferredValue
useDeferredValue
提供一个 state 的延迟版本,从而推迟更新中的某一部分。
返回值为一个延迟版的状态:
- 在组件挂载期间,返回值将与传入的值相同;
- 在组件更新期间,react 将首先尝试使用旧值重新渲染,所以返回值和上一次的旧值相同。然后在后台使用新接收的值重新渲染(低优先级更新),渲染完成后将返回更新后的值。
概念总是很抽象,来看一个例子,我们把上面文本输入的代码改成这样:
tsx
const [text, setText] = useState('')
const deferredText = useDeferredValue(text)
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<ExpensiveList text={deferredText} />
</>
)
const Expensivelist = memo(({ text }) => {
let items = [];
for (let i = 0; i < 200; i++) {
items.push(<Item key={i} text={text} />);
}
return <ul className="items">{items}</ul>;
});
function Item({ text }) {
let startTime = performance.now();
while (performance.now() - startTime < 2) {}
return <li className="item">Text: {text}</li>;
}
区别在于:
- 使用
useDeferredValue
返回一个延迟版的 text,把这个延迟版的 text 传给耗时组件。 - ExpensiveList 使用
memo
包裹。
为什么不卡顿了呢?再来一段简易代码说明一下useDeferredValue
的大致原理,注意这不是useDeferredValue
实际的实现方式:
scss
function useDeferredValue(value) {
const [state, setState] useState(value);
useEffect(() => {
startTransition(() => {
setState(value);
})
}, [value])
return state
}
可以看出前面概念所说的在后台使用新值重新渲染是一个低优先级更新,这也意味着,如果在后台使用新值更新时 value 再次改变,它将打断那次更新。
在这个例子中,useDeferredValue
不会使 ExpensiveList 渲染的速度更快。但是它告诉 react 这是一个可以被中断的低优先级更新,这样它就不会阻塞输入,保持页面的交互性。也就是说,这个列表会落后于 输入然后尽快赶上,内部的调度性质(饥饿算法)也不会让 ExpensiveList 无限被中断。
需要 ExpensiveList 使用memo
,是因为useDeferredValue
本质作用是提供一个延迟版的状态,它本身并不会优化ExpensiveList
的渲染。我们仅仅是利用这个特性,结合memo
的特性,react 就能够快速重新渲染父组件,但传给 ExpensiveList 的依然是延迟的旧值,所以 ExpensiveList 可以跳过重新渲染(它的props没有改变),没有memo
,它无论如何都必须重新渲染,无法达到优化的目的。
和防抖节流有什么区别
听起来useDeferredValue
做的事情和防抖节流有点像,有什么区别呢?
防抖节流最尴尬的点是需要确定一个固定的延迟。
- 防抖:在更新组件之前会等待用户停止输入(例如500毫秒)。
- 节流:每隔一段时间更新一次列表(例如最多每500毫秒一次)。
如果用户的设备速度很快,不用防抖节流也不会卡顿,但我们刻意的让他发生卡顿了。如果用户的设备速度很慢,设置的延迟可能不够,依然会导致文本框被卡着不能输入,因为渲染本身还是阻塞渲染。
相比之下,useDeferredValue
更适合优化渲染,体现在两点:
- 与 react 高度集成:不需要确定固定的延迟,react 每次都会尽快执行渲染,但当再次输入时,react将中断渲染,处理输入事件,然后再次开始在后台渲染。
- 适应用户的设备,组件被渲染成功的速度和用户设备高度适应。设备好渲染快,设备差渲染慢。
对并发更新的误解
我们把目光放在前面例子中的 ExpensiveChild 上,这里多加一种写法:
tsx
// 写法1
const Expensivelist = memo(({ text }) => {
let items = [];
for (let i = 0; i < 200; i++) {
items.push(<Item key={i} text={text} />);
}
return <ul className="items">{items}</ul>;
});
function Item({ text }) {
let startTime = performance.now();
while (performance.now() - startTime < 2) {}
return <li className="item">Text: {text}</li>;
}
// 写法2
const ExpensiveChild = ({ text }) => {
let startTime = performance.now();
while (performance.now() - startTime < 500) {}
return <ul className="items">{text}</ul>;
};
有什么区别呢?
- 写法 1 昂贵的主要原因是渲染的子组件 Item 过多,每个子组件也稍微有一点耗时。
- 写法 2 昂贵的主要原因是 ExpensiveChild 本身耗时过长。
并发更新的特点是存在不同优先级的更新,高优先级可以打断低优先级更新,但要注意,可以打断的是 render 阶段,不能打断函数组件的函数运行。
在打断 render 流程之前,这个耗时的函数可能已经在执行了,那这个时候 react 不能打断函数组件的运行,只有等它执行完之后才能打断。
如果对写法 2 用并发更新优化,虽然也会有一点优化效果,但并不好,见下图输入 5 次有两次卡顿发生,这和输入速度、设备性能等有关。
这就说明只依靠并发更新并不可取,我们往往需要结合其他方法。
Suspense
接下来抛开并发特性在性能方面的意义,简单介绍一下并发特性结合Suspense
能做的事情。
Suspense
支持在客户端延迟加载代码,在子树未完成渲染时会显示fallback
回退,直到子项完成加载。
tsx
function SuspenseTest() {
const [text, setText] = useState('');
return (
<>
<input onChange={e => setText(e.target.value)} />
<Suspense fallback={<h1>加载中,请稍后</h1>}>
<SearchRes query={text} />
</Suspense>
</>
);
}
但我们往往可能不希望每次输入查询时都显示回退,可以先显示旧值,等查询成功之后再显示新值,比如 react 官网的查询。
如果Suspense
正在显示子树的内容,但随后子树进行网络查询,则会将fallback
再次显示,除非网络请求的动作是由并发更新引起的(startTransition
或useDerredValue
)。
tsx
export default function SuspenseTest() {
const [text, setText] = useState('');
const deferredText = useDeferredValue(text);
return (
<>
<input onChange={e => setText(e.target.value)} />
<Suspense fallback={<h1>加载中,请稍后</h1>}>
<SearchRes query={deferredText} />
</Suspense>
</>
);
}