Concurrent UI Patterns

1,043 阅读11分钟

前言

目前 Concurrent 尚处于实验阶段,大部分文档还没有被翻译。我基于目前的官方文档,对 Concurrent 作一些介绍。

上一篇是关于React Suspense for Data的介绍。介绍了 Suspense for Data 模式和现有的数据请求方式的一些区别。大家阅读本篇文章前,可以先阅读下这篇文章。

为什么需要 Concurrent 模式?

通常我们在更新状态时,我们希望在屏幕立即看到状态的变化。但是在有些情况下,我们会希望屏幕上的状态更新延迟。比如,从一个页面切换到另一个页面时,另一个页面的代码或者数据还没有加载,会显示一个苍白的 loading 页,是令人沮丧的。在这种情况下,我们更愿意在上一个页面停留更长的时间。在以前的 React 中,实现会很困难。但是 Concurrent UI 模式的出现带来了新的可能。

为什么说 Concurrent 模式。带来了更好的交互体验。我们来举一个例子, 在github中点击进入下一层的文件夹,并不会出现loading页,而是将页面保持在前一个状态,当数据请求完成后,才会显示新的状态。其实过多的loading,可能会造成页面的闪烁,用户体验并不是很好。

github.gif

useTransitions

在使用 Concurrent UI 模式出现前,在按下切换按钮后,当前页面的状态立即消失,并立即出现加载态。用户体验并不是很好。如果在请求数据响应时间较短的情况下,可以跳过中间的加载状态,直接显示下一个状态的页面,就好了。

old.gif

使用 Concurrent UI 模式后,在请求数据响应时间较短的情况下(小于timeoutMs),我们可以跳过中间加载态,直接显示新的状态页面。

new.gif

React提供的新的内置Hook,useTransitions,可以实现这种模式。useTransitions会返回两个值,startTransition以及isPending

  • startTransition, 是一个函数。告诉React,React可以延迟某个状态的更新(延迟进入Suspense的挂起状态,保持目前的状态)。
  • isPending, 是一个布尔值,告诉我们状态是否正在过渡。

useTransitions的配置项。timeoutMs,则告诉React,我们愿意为过渡等待的最大时间。

如果超过最大时间,则进入挂起状态,显示Suspensefallback。但是如果过渡完成在超时前,则显示前一个状态,直到新状态过渡完成。

// 使用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 无处不在

old-list.gif


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。

new-list.gif


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>
  )
}

三个步骤

steps.png

  1. Pending,待定态。useTransitions,会时页面保持在当前的状态,使页面依然是可交互的。当数据准备完毕,进入Skeleton态。如果数据超时,则回退到Receded态。
  2. Receded,退化态。当前页面数据消失,显示一个大大的loading页。
  3. Skeleton,骨架态。数据部分准备完毕,页面部分以及加载完成。
  4. Complete,完成态。页面加载完成。

默认情况下,我们的页面状态变化是 Receded -> Skeleton -> Complete。但是 Receded 状态给用户的体验并不好,在使用 useTransitions后。我们首选的页面状态变化是 Pending -> Skeleton -> Complete

将慢速组件包装在Suspense中

考虑下面这种情况,假设我们的 "用户列表" 的接口响应速度总是很慢(需要5s的时间才能返回),后端同学短时间也无法优化。它会拖慢我们整个页面进入 Skeleton 态的时间。让页面长期处于 Pending 态,迟迟不能进入下一个状态。(如下图所示)

old.gif

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>
  )
}

我们可能会首先想到,修改 useTransitionstimeoutMs, 但这样同样无济于事,因为回退到 Receded同样是不好的体验。

有没有什么好的办法,可以优化呢?我们可以将慢速的组件,包裹在Suspense中,让其延迟加载,这样看起来好多了,页面不会长期停留在Pending态。

new.gif

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

<=100.gif

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

>100.gif

划分高优先级和低优先级的状态

不是所有的状态更新,都适合放在 useTransition 中。我们首先来看一下谷歌翻译的例子。

谷歌翻译.gif

在谷歌翻译中。我们在左侧每输入一点内容,右侧都会给出翻译的结果。当左侧结束输入的时候,右侧会给出完整的翻译结果。我们来试着还原下这个效果。

Approach 1

在第一次输入的时候,Translation组件被挂起,页面显示 Suspensefallback。这种效果是不理想的,我们应该在输入完成前,看到之前翻译的内容。

Approach1.gif

并且控制台会打印中,如下的警告

Warning.png

警告我们更新,更新应该分为多个部分。一部分更新需要及时的反馈到页面上,而另一部分更新应该包含在 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 正确使用的方法

  1. useTransition 必须配合 Suspense 一起使用。startTransition中的操作可以触发Suspense,才会让页面进入 Pending 态。
  2. startTransition,所触发的Suspense,必须在startTransition触发前挂载完成。(Suspense必须提前包裹在startTransition操作的外面)
  3. useTransition中的状态更新,不应该包含高优先级的(需要及时更新的内容。)

SuspenseList

思考一个例子,我们在一个页面中会请求两个接口,一个文章的接口,一个文章的留言接口。两个接口响应时间是随机的。这就意味着,可能文章的留言接口已经返回了,但是文章的接口还没有返回。给用户的视觉体验并不好。

SuspenseList1.gif

解决这种问题,有两种思路,第一种是将,文章的接口和文章的留言接口,都放在同一个 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是可以进行组合的。

结语

上面仅是作者自己的理解,如有错误请及时指出。

参考

Concurrent UI Patterns (Experimental)