Concurrent UI Patterns

前言

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

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

为何须要 Concurrent 模式?

一般咱们在更新状态时,咱们但愿在屏幕当即看到状态的变化。可是在有些状况下,咱们会但愿屏幕上的状态更新延迟。好比,从一个页面切换到另外一个页面时,另外一个页面的代码或者数据尚未加载,会显示一个苍白的 loading 页,是使人沮丧的。在这种状况下,咱们更愿意在上一个页面停留更长的时间。在之前的 React 中,实现会很困难。可是 Concurrent UI 模式的出现带来了新的可能。git

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

github.gif

useTransitions

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

old.gif

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

new.gif

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

  • 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对状态更新进行包装。状态更新将会发咱们看不到的地方(相似平行宇宙的地方)。当新的状态准备完成,新旧状态会发生合并,渲染出如今屏幕上。spa

虽然新旧状态的组件是同时存在的,可是这不意味着它们是同时渲染的。计算机的并行计算,实际上是在极短的时间内,切换到不一样的任务进行计算。

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)

相关文章
相关标签/搜索