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)

相關文章
相關標籤/搜索