在追尋極致體驗的康庄大道上,React 玩出了花

  • 2019 年 12 月 1 日
  • 筆記

寫在前面

Suspense 之後,又將迎來useTransition

一.有 Suspense 還不夠嗎?

const OtherComponent = React.lazy(() => import('./OtherComponent'));    function MyComponent() {    return (      <div>        <Suspense fallback={<div>Loading...</div>}>          <OtherComponent />        </Suspense>      </div>    );  }

Suspense 子樹中只要存在還沒回來的 Lazy 組件,就走 fallback 指定的內容,相當於可以提升到任意祖先級的 loading。 Suspense 組件可以放在(組件樹中)Lazy 組件上方的任意位置,並且下方可以有多個 Lazy 組件。

單從 loading 場景來看,Suspense 提供了兩種能力:

  • 支援 loading 提升
  • 支援 loading 聚合

對於用戶體驗而言,有兩方面的好處:

  • 避免布局抖動(數據回來之後冒出來一塊內容)
  • 區別對待不同網路環境(數據返回快的話壓根不會出現 loading)

前者是 loading(或 skeleton)帶來的好處,而後者得益於 Concurrent Mode 下的間歇調度

P.S.關於 Suspense 的詳細資訊,見React Suspense——從程式碼拆分加個 loading 說起……

Suspense 提供的優雅靈活、人性化的 loading 似乎已經達到極致的開發體驗與用戶體驗了,然而,進一步探索發現,圍繞 loading 還有幾個問題:

  • 加個 loading,體驗就一定更好嗎?
  • 立即顯示 loading,有什麼不好?
  • 如何解決交互實時響應與 loading 的衝突?
  • 對於砍不掉的長 loading,用戶感知上還有辦法更快嗎?
  • 布局抖動真的不存在了嗎?如果列表中同時存在多個 loading 呢?

接下來,我們逐一探討這些問題

二.視覺上弱化 loading

加個 loading,體驗就一定更好嗎?

以典型的分頁列表為例,常見的交互過程可能是這樣的:

1.第一頁內容出現  2.點擊下一頁  3.第一頁內容消失,或者被半透明蒙層罩住  4.顯示loading  5.一段時間後loading消失  6.第二頁內容出現

其中最大的問題在於,loading 期間第一頁內容是不可用的(不可見,或被遮起來)。也就是說,loading 影響了頁面內容的完整性,以及應用的響應能力(responsiveness)

既然如此,乾脆把 loading 去掉:

1.第一頁內容出現  2.點擊下一頁  3.第一頁內容保持原狀  ...沒有任何交互回饋,幾秒後  4.第二頁內容出現

由於缺少即時交互回饋,用戶體驗更糟糕了。那麼,有沒有兩全其美的辦法,既能保證 loading 期間的響應性,又有類似於 loading 的交互體驗呢?

有。弱化 loading 的視覺效果:

  • 把全局 loading(或內容塊 loading)弱化成局部 loading:避免 loading 破壞內容完整性
  • 用置灰等方式暗示正在顯示的是舊內容:避免舊內容對用戶造成的困擾

例如,對於按鈕點擊的場景,可以簡單地將 loading 回饋加在按鈕上:

//...  render() {    const { isLoading } = this.state;      return (      <Page>        <Content style={{ color: isLoading ? "black" : "gray" }} />        <Button>{isLoading ? "Next" : "Loading..."}</Button>      </Page>    );  }

不僅保證了 loading 過程中用戶看到的仍然是完整的內容(雖然部分內容有些舊,但已經通過置灰暗示出來了),還能立即給出交互回饋

大多數時候,像上例這樣立即展示 loading 沒什麼問題,然而,在另一些場景下,迅速出現的 loading 卻不盡如人意

三.邏輯上延遲 loading

立即顯示 loading,有什麼不好?

假如 loading 非常快(只需要 100ms),用戶可能只感覺到了什麼東西忽閃而過……又一個糟糕的用戶體驗

當然,這樣的場景我們通常不加 loading,因為 loading 通常帶給用戶一種「慢」的心理預期,而給一個本就非常快的操作加上 loading,無疑會拉低用戶感知上的速度體驗,所以我們選擇不加

然而,如果有一個可能極快,也可能極慢的操作,loading 是加還是不加?

此時就要按需 loading,比如延後 loading 時機,200ms 後新內容還沒準備好才顯示 loading

React 考慮到了這種場景,於是有了useTransition

useTransition

Transition 特性以 Hooks API 形式提供:

const [startTransition, isPending] = React.useTransition({    timeoutMs: 3000  });

P.S.注意,Transition 特性依賴 Concurrent Mode,並且目前(2019/11/23)尚未正式推出(屬於實驗特性),具體 API 可能還會發生變化,僅供參考,試玩見Transitions

Transition Hook 的作用是告訴 React,延遲更新 State 也沒關係:

Wrap state update into startTransition to tell React it』s okay to delay it.

例如:

function App() {    const [resource, setResource] = useState(initialResource);    const [startTransition, isPending] = React.useTransition({      timeoutMs: 3000    });      return (<>      <button        disabled={isPending}        onClick={() => {          startTransition(() => {            const nextUserId = getNextId(resource.userId);            setResource(fetchProfileData(nextUserId));          });        }}      >        Next      </button>      {isPending ? " Loading..." : null}      <ProfilePage resource={resource} />    </>);  }    function ProfilePage({ resource }) {    return (<Suspense fallback={<h1>Loading profile...</h1>} >      <ProfileDetails resource={resource} />      <Suspense fallback={<h1>Loading posts...</h1>} >        <ProfileTimeline resource={resource} />      </Suspense>    </Suspense>);  }
  1. 點擊Next按鈕立即獲取ProfileData,接著isPending變成true,顯示Loading...
  2. 如果ProfileData在 3 秒內回來了,則(從正在顯示的舊ProfilePage切換到)顯示新ProfilePage內容
  3. 否則進入ProfilePage的 Suspense fallback,(舊ProfilePage消失)顯示Loading profile...

也就是說,startTransition把本該立即傳遞給ProfilePage的(尚未獲取到的)resource狀態值往後延了,並且最多延 3 秒,而這正是我們想要的按需 loading 能力timeoutMs毫秒內不 loading,超時才顯示 loading

所以,簡單來講,Transition 能夠 delay Suspense,即,Transition 能夠延遲 loading

按需 loading

從頁面內容狀態上看,Transition 引入了一種舊內容仍然可用的 Pending 狀態

各個狀態含義如下:

  • Receded(消失):當前頁內容消失,降級到 Suspense fallback
  • Skeleton(骨架):新頁已經出現,部分新內容可能仍在載入中
  • Pending(等待中):新內容正在路上,當前頁內容完整,仍然可交互

提出 Pending 的出發點是避免開倒車(隱藏已經存在的內容)

However, the Receded state is not very pleasant because it 「hides」 existing information. This is why React lets us opt into a different sequence (Pending → Skeleton → Complete) with useTransition.

簡單地加個 loading 對應的狀態變化是Receded → Skeleton → Complete(無論快慢,都顯示 loading),而有了 Transition 後,體驗最優的情況是Pending → Skeleton → Complete(很快,不需要 loading),差一點的是Pending → Receded → Skeleton → Complete(很慢,不 loading 不行)

所以,為了最優的體驗,應該縮短 Pending 時間,以儘快進入 Skeleton 狀態,一個小技巧是把慢的、以及不重要的組件用 Suspense 包起來:

Instead of making the transition shorter, we can 「disconnect」 the slow component from the transition by wrapping it into

最佳實踐

同時,得益於Hooks 細粒度邏輯復用方面的優勢,很容易把 Transition 的按需 loading 效果封裝成基礎組件,例如Button

function Button({ children, onClick }) {    const [startTransition, isPending] = useTransition({      timeoutMs: 10000    });      function handleClick() {      startTransition(() => {        onClick();      });    }      const spinner = (      // ...    );      return (<>      <button onClick={handleClick} disabled={isPending}>        {children}      </button>      {isPending ? spinner : null}    </>);  }

這也是官方推薦的做法,由 UI 組件庫來考慮需要 useTransition 的場景,以減少冗餘程式碼:

Pretty much any button click or interaction that can lead to a component suspending needs to be wrapped in useTransition to avoid accidentally hiding something the user is interacting with. This can lead to a lot of repetitive code across components. This is why we generally recommend to bake useTransition into the design system components of your app.

四.解決交互實時響應與 loading 的衝突

如何解決交互實時響應與 loading 的衝突?

Transition 之所以能延遲 loading 顯示,是因為延遲了 State 更新。那麼對於無法延遲的 State 更新呢,比如輸入值:

function App() {    const [query, setQuery] = useState(initialQuery);      function handleChange(e) {      const value = e.target.value;      setQuery(value);    }      return (<>        <input value={query} onChange={handleChange} />        <Suspense fallback={<p>Loading...</p>}>          <Translation input={query} />        </Suspense>    </>);  }

這裡把input作為受控組件來用(通過onChange處理用戶輸入),因此必須立即將新value更新到 State 中,否則會出現輸入延遲,甚至錯亂

於是,衝突出現了,這種實時響應輸入的要求與 Transition 延遲 State 更新似乎沒辦法並存

官方提供的解決方案是把該狀態值冗餘一份,既然有衝突,乾脆分開各用各的:

function App() {    const [query, setQuery] = useState(initialQuery);    const [resource, setResource] = useState(initialResource);    const [startTransition, isPending] = useTransition({      timeoutMs: 5000    });      function handleChange(e) {      const value = e.target.value;        // Outside the transition (urgent)      setQuery(value);        startTransition(() => {        // Inside the transition (may be delayed)        setResource(fetchTranslation(value));      });    }      return (<>        <input value={query} onChange={handleChange} />        <Suspense fallback={<p>Loading...</p>}>          <Translation resource={resource} />        </Suspense>    </>);  }

雖然 React 的實踐經驗告訴我們能算則算,能共享就共享,不要冗餘狀態值,好處是能避免狀態更新時可能的遺漏:

This lets us avoid mistakes where we update one state but forget the other state.

而我們剛剛也確實冗餘了一個狀態值(queryresource),並不是要推翻實踐原則,而是說能夠對 State 區分優先順序:

  • 高優 State:不想其更新被 delay 的 State,比如輸入值
  • 低優 State:需要 delay 的狀態,比如 Transition 相關的

也就是說,有了 Transition 之後,State 有了優先順序

五.考慮犧牲 UI 一致性

對於砍不掉的長 loading,用戶感知上還有辦法更快嗎?

有。如果願意犧牲 UI 一致性的話

沒有聽錯,UI 一致性也並非不可撼動,必要時可以考慮犧牲 UI 一致性來換取感知上更好的體驗效果。雖然會出現「文不對題」的情況,但也可能要比顯示長達 10 秒甚至更久的 loading 要友好一些。同樣,我們能夠輔以置灰暗示等手段讓用戶意識到 UI 不一致的事實

為此,React 提供了 DeferredValue Hook

useDeferredValue

const deferredResource = React.useDeferredValue(resource, {    timeoutMs: 1000  });    // 用法  <ProfileTimeline    resource={deferredResource}    isStale={deferredResource !== resource} />

P.S.注意,目前(2019/11/23)尚未正式推出useDeferredValue具體 API 可能還會發生變化,僅供參考,試玩見Deferring a Value

與 Transition 機制類似,相當於延遲狀態更新,在新數據準備好之前,可以繼續沿用舊數據,如果 1 秒內新數據來了,(從舊內容切換到)顯示新內容,否則立即更新狀態,該 loading 就 loading

與 Transition 的區別在於,useDeferredValue是面向狀態值的,而 Transition 面向狀態更新操作,算是 API 及語義上的差異,機制上二者非常相像

六.徹底消除布局抖動

布局抖動真的不存在了嗎?如果列表中同時存在多個 loading 呢?

在多 loading 並存的場景下,難免出現 loading 先後順序不同造成的布局抖動。而視覺效果上,我們通常不希望原有的一塊東西被擠到一邊去(視覺上應該都是 append,而不要 insert)。要想徹底消除布局抖動,有兩種思路:

  • 所有列表項同時顯示:等待所有項都準備好了再顯示,但等待時間上去了
  • 控制列表項按其相對順序出現:能消除 insert,等待時間也不總是最壞

那麼,非同步內容出現(loading 消失)順序要如何控制?

React 又考慮到了,所以提供了SuspenseList來控制 Suspense 內容的渲染順序,保證列表中元素的顯示順序按相對位置來,避免內容被擠下去:

<SuspenseList> coordinates the 「reveal order」 of the closest <Suspense> nodes below it

SuspenseList

import { SuspenseList } from 'react';    function ProfilePage({ resource }) {    return (      <SuspenseList revealOrder="forwards">        <ProfileDetails resource={resource} />        <Suspense fallback={<h2>Loading posts...</h2>}>          <ProfileTimeline resource={resource} />        </Suspense>        <Suspense fallback={<h2>Loading fun facts...</h2>}>          <ProfileTrivia resource={resource} />        </Suspense>      </SuspenseList>    );  }

revealOrder="forwards"表示SuspenseList下的子級Suspense必須按照自上而下的順序出現,無論誰的數據先準備好,類似的值還有backwards(逆序出現)和together(同時出現)

另外,為了避免多個 loading 同時出現可能對用戶造成的體驗困擾,還提供了tail選項,具體見SuspenseList

P.S.注意,目前(2019/11/23)尚未正式推出SuspenseList具體 API 可能還會發生變化,僅供參考,試玩見SuspenseList

七.總結

如我們所見,在追尋極致體驗的康庄大道上,React 正越走越遠:

  • Suspense:支援優雅靈活、人性化的內容降級
  • useTransition:支援按需降級,只在確實很慢的情況才降級
  • useDeferredValue:支援犧牲 UI 一致性換取感知上更好的體驗效果
  • SuspenseList:支援控制一組降級效果的出現順序,以及並存數量

P.S.最簡單的降級策略就是 loading,其它的比如用快取值,甚至來一段廣告,開個小遊戲等都算降級

參考資料

  • Concurrent UI Patterns (Experimental)

聯繫ayqy

如果在文章中發現了什麼問題,請查看原文並留下評論,ayqy看到就會回復的(不建議直接回復公眾號,看不到的啦)