前言
目前 Concurrent 尚处于实验阶段,大部分文档还没有被翻译。我基于目前的官方文档,对 Concurrent 作一些介绍。
上一篇是关于React Suspense for Data的介绍。介绍了 Suspense for Data 模式和现有的数据请求方式的一些区别。大家阅读本篇文章前,可以先阅读下这篇文章。
为什么需要 Concurrent 模式?
通常我们在更新状态时,我们希望在屏幕立即看到状态的变化。但是在有些情况下,我们会希望屏幕上的状态更新延迟。比如,从一个页面切换到另一个页面时,另一个页面的代码或者数据还没有加载,会显示一个苍白的 loading 页,是令人沮丧的。在这种情况下,我们更愿意在上一个页面停留更长的时间。在以前的 React 中,实现会很困难。但是 Concurrent UI 模式的出现带来了新的可能。
为什么说 Concurrent 模式。带来了更好的交互体验。我们来举一个例子, 在github中点击进入下一层的文件夹,并不会出现loading页,而是将页面保持在前一个状态,当数据请求完成后,才会显示新的状态。其实过多的loading,可能会造成页面的闪烁,用户体验并不是很好。
useTransitions
在使用 Concurrent UI 模式出现前,在按下切换按钮后,当前页面的状态立即消失,并立即出现加载态。用户体验并不是很好。如果在请求数据响应时间较短的情况下,可以跳过中间的加载状态,直接显示下一个状态的页面,就好了。
使用 Concurrent UI 模式后,在请求数据响应时间较短的情况下(小于timeoutMs
),我们可以跳过中间加载态,直接显示新的状态页面。
React提供的新的内置Hook,useTransitions
,可以实现这种模式。useTransitions
会返回两个值,startTransition
以及isPending
startTransition
, 是一个函数。告诉React,React可以延迟某个状态的更新(延迟进入Suspense的挂起状态,保持目前的状态)。isPending
, 是一个布尔值,告诉我们状态是否正在过渡。
而useTransitions
的配置项。timeoutMs
,则告诉React,我们愿意为过渡等待的最大时间。
如果超过最大时间,则进入挂起状态,显示Suspense
的fallback
。但是如果过渡完成在超时前,则显示前一个状态,直到新状态过渡完成。
// 使用useTransition的例子
function Page () {
const [startTransition, isPending] = useTransition({
timeoutMs: 2000
});
const [resource, setResource] = useState(
initResource
);
const handleClickNext = () => {
startTransition(() => {
const nextId = getNextId()
setResource(http(nextId))
})
}
return (
<React.Fragment>
<button
disabled={isPending}
onClick={handleClickNext}
>Next Id</button>
<p>{isPending ? " Loading..." : null}</p>
<React.Suspense fallback={<h1>Loading...</h1>}>
<Figure resource={resource}/>
</React.Suspense>
</React.Fragment>
)
}
function App() {
return (
<div className="App">
<Page/>
</div>
)
}
新旧状态的组件是同时存在的
由于旧的状态组件出现在屏幕,所以我们知道它是存在的。对于新的状态组件,它也存在在某处。使用startTransition
对状态更新进行包装。状态更新将会发我们看不到的地方(类似平行宇宙的地方)。当新的状态准备完成,新旧状态会发生合并,渲染出现在屏幕上。
虽然新旧状态的组件是同时存在的,但是这不意味着它们是同时渲染的。计算机的并行计算,其实是在极短的时间内,切换到不同的任务进行计算。
Transitions 无处不在
function UserList ({ resource }) {
const list = resource.read();
return (
<ul>
{ list && list.map(item => <li key={item.id}>{item.name}</li>) }
</ul>
)
}
function App() {
const [resource, setResource] = useState(initResource);
const handleClickRefresh = () => {
setResource(http())
}
return (
<div className="App">
<button onClick={handleClickRefresh}>刷新</button>
<hr/>
<React.Suspense fallback={<h4>loading……</h4>}>
<UserList resource={resource}/>
</React.Suspense>
</div>
)
}
当我们浏览一个页面,并与之交互时,如果出现了不必要的loading,这种体验是不愉悦的。我们可以使用 useTransitions
将状态更新,包装在 startTransition
中。点击刷新,不会出现烦人的loading。
const initResource = http()
function List ({ resource }) {
const list = resource.read();
return (
<ul>
{ list && list.map(item => <li key={item.name}>{item.name}</li>) }
</ul>
)
}
function App() {
const [resource, setResource] = useState(initResource);
const [startTransition, isPending] = useTransition({
timeoutMs: 2500
});
const handleClickRefresh = () => {
startTransition(() => {
setResource(http())
})
}
return (
<div className="App">
<button onClick={handleClickRefresh}>刷新 { isPending && '加载中……' }</button>
<hr/>
<React.Suspense fallback={<h4>loading……</h4>}>
<List resource={resource}/>
</React.Suspense>
</div>
)
}
现在感觉好多了,点击刷新,不会出现苍白的loading页,数据正在内部加载,当数据准备好,它就会显示出来。
将 Transitions 应用到组件设计
Transitions 是很常见的,任何可以导致组件被挂起的组件都应该被 useTransition
包裹起来。下面是一个 Button
组件的例子。通过将 Transitions 融合到组件设计中,可以避免大量重复无用的代码。
// Button组件
function Button (props) {
const [startTransition, isPending] = useTransition({
timeoutMs: 3000
});
const { onClick, children } = props;
const handleClick = () => {
startTransition(() => {
onClick()
})
}
return (
<button onClick={handleClick}>
{ children } { isPending && '……s' }
</button>
)
}
// 在App文件中,不需要再次重复useTransition的逻辑
function App() {
const [resource1, setResource1] = useState(initResource);
const [resource2, setResource2] = useState(initResource);
const handleRefresh1 = () => {
setResource1(http())
}
const handleRefresh2 = () => {
setResource2(http())
}
return (
<div className="App">
<Button onClick={handleRefresh1}>刷新列表1</Button>
<br/>
<Button onClick={handleRefresh2}>刷新列表2</Button>
<hr/>
<React.Suspense fallback={<h4>loading……</h4>}>
<List resource={resource1}/>
</React.Suspense>
<hr/>
<React.Suspense fallback={<h4>loading……</h4>}>
<List resource={resource2}/>
</React.Suspense>
</div>
)
}
三个步骤
- Pending,待定态。
useTransitions
,会时页面保持在当前的状态,使页面依然是可交互的。当数据准备完毕,进入Skeleton态。如果数据超时,则回退到Receded态。 - Receded,退化态。当前页面数据消失,显示一个大大的loading页。
- Skeleton,骨架态。数据部分准备完毕,页面部分以及加载完成。
- Complete,完成态。页面加载完成。
默认情况下,我们的页面状态变化是 Receded -> Skeleton -> Complete
。但是 Receded
状态给用户的体验并不好,在使用 useTransitions
后。我们首选的页面状态变化是 Pending -> Skeleton -> Complete
。
将慢速组件包装在Suspense中
考虑下面这种情况,假设我们的 "用户列表" 的接口响应速度总是很慢(需要5s的时间才能返回),后端同学短时间也无法优化。它会拖慢我们整个页面进入 Skeleton
态的时间。让页面长期处于 Pending
态,迟迟不能进入下一个状态。(如下图所示)
function HomePage ({ resource }) {
return (
<React.Fragment>
{/* UserList列表加载过慢 */}
<UserList resource={resource}/>
<NewsList resource={resource}/>
</React.Fragment>
)
}
function LoginPage ({ onClick }) {
return (
<div>
<h1>首页</h1>
{/* Button组件使用了useTransitions进行封装 */}
<Button onClick={onClick}>下一页</Button>
</div>
)
}
function App() {
const [tab, setTab] = useState('login')
const [resource, setResource] = useState(null);
const handleClick = () => {
setResource(http())
setTab('home')
}
let page = null
if (tab === 'login') {
page = <LoginPage onClick={handleClick}/>
} else {
page = <HomePage resource={resource}/>
}
return (
<React.Suspense fallback={<h1>loading……</h1>}>
<React.Fragment>
{page}
</React.Fragment>
</React.Suspense>
)
}
我们可能会首先想到,修改 useTransitions
的 timeoutMs
, 但这样同样无济于事,因为回退到 Receded
同样是不好的体验。
有没有什么好的办法,可以优化呢?我们可以将慢速的组件,包裹在Suspense中,让其延迟加载,这样看起来好多了,页面不会长期停留在Pending
态。
function HomePage ({ resource }) {
return (
<React.Fragment>
{/* 使用Suspense对慢速组件进行包裹 */}
<React.Suspense fallback={<h4>用户列表加载中……</h4>}>
<UserList resource={resource}/>
</React.Suspense>
<NewsList resource={resource}/>
</React.Fragment>
)
}
100ms
试想一下。当我们已经在 Skeleton
态时(这是合并渲染的前提)。此时有两个响应将在短时间内依次返回,比如用户列表在 1200ms 后返回,新闻列表在 1300ms 后返回。两个Suspense
将会依次结束挂起的状态(两个Suspense
是嵌套的)。我们已经等待了 1200ms, 也不在乎多等待100ms,所以为了减少页面的重绘次数,提升性能。React会合并它们,一起渲染,而不是两个列表组件依次渲染。但是如果间隔大于100ms,还是会依次渲染。
小于等于100ms
function HomePage ({ resource }) {
return (
<React.Fragment>
{/* Title resource 在 1000ms后响应,页面进入 Skeleton 态,这是合并渲染的前提 */}
<Title resource={resource}/>
{/* News resource 在 1200ms后响应 */}
{/* NewsList 将等待 UserList 响应后一起渲染 */}
<React.Suspense fallback={<h4>加载信息……</h4>}>
<NewsList resource={resource}/>
{/* News resource 在 1300ms后响应 */}
<React.Suspense fallback={<h4>加载用户列表……</h4>}>
<UserList resource={resource}/>
</React.Suspense>
</React.Suspense>
</React.Fragment>
)
}
大于100ms
划分高优先级和低优先级的状态
不是所有的状态更新,都适合放在 useTransition
中。我们首先来看一下谷歌翻译的例子。
在谷歌翻译中。我们在左侧每输入一点内容,右侧都会给出翻译的结果。当左侧结束输入的时候,右侧会给出完整的翻译结果。我们来试着还原下这个效果。
Approach 1
在第一次输入的时候,Translation
组件被挂起,页面显示 Suspense
的 fallback
。这种效果是不理想的,我们应该在输入完成前,看到之前翻译的内容。
并且控制台会打印中,如下的警告
警告我们更新,更新应该分为多个部分。一部分更新需要及时的反馈到页面上,而另一部分更新应该包含在 Transition
function App () {
const [query, setQuery] = useState(initQuery)
const [resource, setResource] = useState(initResource)
const handleChange = (e) => {
const query = e.target.value
setQuery(query);
setResource(http(query))
}
return (
<div>
<input value={query} onChange={handleChange}/>
<React.Suspense fallback={<h1>翻译中…………</h1>}>
<Translation resource={resource} />
</React.Suspense>
</div>
)
}
Approach 2
如果我们把所有的状态更新,都包含在 Transition
中呢?问题更大了,页面的更新将会变得非常缓慢。input
中value的变化斗都非常的卡顿。
function App () {
const [query, setQuery] = useState(initQuery)
const [resource, setResource] = useState(initResource)
const [startTransition, isPending] = useTransition({
timeoutMs: 5000
})
const handleChange = (e) => {
startTransition(() => {
const query = e.target.value
setQuery(query)
setResource(http(query))
})
}
return (
<div>
<input value={query} onChange={handleChange}/>
<React.Suspense fallback={<h1>翻译中…………</h1>}>
<Translation resource={resource} />
</React.Suspense>
</div>
)
}
Approach 3
正确的做法是应该区分,高优先级状态的更新(setQuery
),以及低优先级的状态更新(setResource
)。setQuery
是立即发生的,setResource
则需要过渡。
function App () {
const [query, setQuery] = useState(initQuery)
const [resource, setResource] = useState(initResource)
const [startTransition, isPending] = useTransition({
timeoutMs: 5000
})
const handleChange = (e) => {
const query = e.target.value
setQuery(query)
startTransition(() => {
setResource(http(query))
})
}
return (
<div>
<input value={query} onChange={handleChange}/>
<React.Suspense fallback={<h1>翻译中…………</h1>}>
<Translation resource={resource} />
</React.Suspense>
</div>
)
}
🚀useTransition 正确使用的方法
useTransition
必须配合Suspense
一起使用。startTransition
中的操作可以触发Suspense
,才会让页面进入Pending
态。startTransition
,所触发的Suspense
,必须在startTransition
触发前挂载完成。(Suspense
必须提前包裹在startTransition
操作的外面)useTransition
中的状态更新,不应该包含高优先级的(需要及时更新的内容。)
SuspenseList
思考一个例子,我们在一个页面中会请求两个接口,一个文章的接口,一个文章的留言接口。两个接口响应时间是随机的。这就意味着,可能文章的留言接口已经返回了,但是文章的接口还没有返回。给用户的视觉体验并不好。
解决这种问题,有两种思路,第一种是将,文章的接口和文章的留言接口,都放在同一个 Suspense
中。但是如果文章的接口提前返回,我们没有理由去等待留言的接口返回后,然后再渲染页面。
<React.Suspense fallback={<h1>加载中……</h1>}>
<Article resource={resource}/>
<ArticleComments resource={resource}/>
</React.Suspense>
更好的办法是使用 SuspenseList
, SuspenseList
会控制 Suspense
子节点的显示顺序。
revealOrder="forwards"
当SuspenseList的revealOrder属性设置为forwards时,内容将会按照它们在VDOM树中的顺序显示,从前向后渲染,即使它们的数据以不同的顺序到达。(如果前面返回时间,大于后面的,它们会一起显示)
function App () {
return (
<React.Fragment>
<React.SuspenseList revealOrder="forwards">
<React.Suspense fallback={<h1>文章加载中……</h1>}>
<Article resource={resource}/>
</React.Suspense>
<React.Suspense fallback={<h1>留言加载中……</h1>}>
<ArticleComments resource={resource}/>
</React.Suspense>
</React.SuspenseList>
</React.Fragment>
)
}
revealOrder="backwards"
当设置为backwards时,内容将会按照它们在VDOM树中的顺序显示,从前向后渲染
revealOrder="together"
当设置为together时,内容会一起渲染。
另外一点是,SuspenseList是可以进行组合的。
结语
上面仅是作者自己的理解,如有错误请及时指出。