React Concurrent Mode 之 Suspense 實踐

本文首發於個人我的博客: https://teobler.com ,轉載請註明出處

自從三大框架成型以後,各個框架都爲提高開發者的開發效率做出了很多努力,可是看起來技術革新都到了一個瓶頸。除了 React 引入了一次函數式的思想,感受已經沒有當初從DOM時代到數據驅動時代的驚豔感了。因而 React 將精力放在了用戶體驗上,想讓開發者在不過多耗費精力的狀況下,用框架自身去提高用戶體驗。html

因而在最近的幾個版本中,React 引入了一個叫作 Concurrent Mode 的東西,同時還引入了 Suspense,旨在提高用戶的訪問體驗,React 應用在慢慢變大之後會慢慢變得愈來愈卡,此次的新功能就是想在應用初期就解決這些問題。react

雖然如今這些功能還處在實驗階段,React 團隊並不建議在生產環境中使用,不過大部分功能已經完成了,並且他們已經用在了新的網站功能中,因此面對這樣一個相對成熟的技術,其實咱們仍是能夠來本身玩一下的,接下來我來帶領你們看看這是一個什麼樣的東西吧。ios

Concurrent Mode

WHAT

那麼這個 Concurrent Mode 是個啥呢?git

Concurrent Mode is a set of new features that help React apps stay responsive and gracefully adjust to the user’s device capabilities and network speed.

在官網的解釋中, Concurrent Mode 包含了一系列新功能,這些新功能能夠根據用戶不一樣的設備性能和不一樣的網速進行不一樣的響應,以使得用戶在不一樣的設備和網速的狀況下擁有最好的訪問體驗。那麼問題來了,它是怎麼作到這一點的呢?github

HOW

可中止的rendering (Interruptible Rendering)

在一般情況下,React 在 render 的時候是沒有辦法被打斷的(這其中有建立新的 DOM 節點等等),rendering 的過程會一直佔用 JS 線程,致使此時瀏覽器沒法對用戶的操做進行實時反饋,形成了一種整個頁面很卡的感受。typescript

而在 Concurrent Mode 下,rendering 是能夠被打斷的,這意味着 React 可讓出主線程給瀏覽器用於更緊急的用戶操做。npm

想象這樣一個通用的場景:用戶在一個輸入框中檢索一些信息,輸入框中的文字改變後頁面都將從新渲染以展現最新的結果,可是你會發現每一次輸入都會卡頓,由於每一次從新渲染都將阻塞主線程,瀏覽器就將沒有辦法相應用戶在輸入框中的輸入。固然如今通用的解決辦法是用 debouncing 或者 throtting。可是這個方式存在一些問題,首先是頁面沒有辦法實時反應用戶的輸入,用戶會發現可能輸入了好多個字符頁面才刷新一次,不會實時更新;第二個問題是在性能比較差的設備上仍是會出現卡頓的狀況。axios

若是在頁面正在 render 時用戶輸入了新的字符,React 能夠暫停 render 讓瀏覽器優先對用戶的輸入進行更新,而後 React 會在內存中渲染最新的頁面,等到第一次 render 完成後再直接將最新的頁面更新出來,保證用戶能看到最新的頁面。segmentfault

image-20200516110545540

這個過程有點像 git 的多分支,主分支是用戶可以看到的而且是能夠被暫停的,React 會新起一個分支來作最新的渲染,當主分支的渲染完成後就將新的分支合併過來,獲得最新的視圖。後端

可選的加載順序(Intentional Loading Sequences)

在頁面跳轉的時候,爲了提高用戶體驗,咱們每每會在新的頁面中加上 skeleton,這是爲了防止要渲染的數據尚未拿到,用戶看到一個空白的頁面。

Concurrent Mode 中,咱們可讓 React 在第一個頁面多停留一會,此時 React 會在內存中用拿到的數據渲染新的頁面,等頁面渲染完成後再直接跳轉到一個已經完成了的頁面上,這個行爲要更加符合用戶直覺。並且須要說明的是,在第一個頁面等待的時間裏,用戶的任何操做都是能夠被捕捉到的,也就是說在等待時間內並不會 block 用戶的任何操做。

總結

總結一下就是,新的 Concurrent Mode 可讓 React 同時在不一樣的狀態下進行並行處理,在並行狀態結束後又將全部改變合併起來。這個功能主要聚焦在兩點上:

  • 對於 CPU 來講(好比建立 DOM 節點),這樣的並行意味着優先級更高的更新能夠打斷 rendering
  • 對於 IO 來講(好比從服務端拿數據),這樣的並行意味着 React 能夠將先拿到的一部分數據用於在內存中構建 DOM,所有構建完成後在進行一次性的渲染,同時不影響當前頁面

而對於開發者來講,React 的使用方式並無太大的變化,你之前怎麼寫的 React,未來仍是怎麼寫,不會讓開發者有斷層的感覺。下面咱們能夠經過幾個例子來看看具體怎麼使用。

Suspense

開始前的準備

與以前的功能不一樣的是,Concurrent Mode 須要開發者手動開啓(只是使用 Suspense 貌似不用開啓,可是我爲了下一篇文章的代碼,如今就先開啓了)。爲了方(tou)便(lan),咱們用 cra 建立一個新的項目,爲了使用 Concurrent Mode 咱們須要作以下修改:

  • 刪除項目中的react版本,該用實驗版 npm install react@experimental react-dom@experimental
  • 爲了正常使用 TypeScriptreact-app-env.d.ts 文件中加入實驗版 React 的 type 引用

    /// <reference types="react-dom/experimental" />
    /// <reference types="react/experimental" />
  • index.tsx 中開啓 Concurrent Mode

    ReactDOM.unstable_createRoot(
      document.getElementById("root") as HTMLElement
    ).render(<App />);
  • Suspense 中若是你須要在拿後端數據時」掛起「你的組件,你須要一個按照 React 要求實現的 "Promise Wrapper",在這裏我選擇的是 swr

    • 從後面的結果來看,目前 swr 對於 Suspense 的實現尚未完成,可是已經有一個 pr 了
本文中全部的代碼均可以在個人 github repo 裏找到,建議時間充裕的同窗 clone 一份和文章一同食用效果更佳。

Data Fetching

React 團隊在 16.6 中加入了一個新的組件 SuspenseSuspense 與其說是一個組件,更多的能夠說是一種機制。這個組件能夠在子節點渲染的時候進行」掛起「,渲染一個你設定好的等待圖標之類的組件,等子節點渲染完成後再顯示子節點。在 Concurrent Mode 以前,該組件一般用來做懶加載:

const ProfilePage = React.lazy(() => import('./ProfilePage')); // Lazy-loaded

// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>

在最新的 React 版本中,如今 Suspense 組件能夠用來」掛起「任何東西,好比能夠在從服務端拿數據的時候」掛起「某個組件,好比一個圖片,好比一段腳本等等。咱們在這裏僅僅以從後端拿數據爲例。

在傳統的數據獲取中,每每是組件先 render 而後再去後端獲取數據,拿到數據後從新 render 一遍組件,將數據渲染到組件中(Fetch-on-render)。可是這樣就會有一些問題,最明顯就就是觸發瀑布流 -- 第一個組件 render 觸發一次網絡請求,完了之後 render 第二個組件又觸發一次網絡請求,可是其實這兩個請求能夠併發處理。

而後可能有人爲了不這種狀況的出現就會來一些技巧,好比我在 render 這兩個組件以前先發兩次請求,等兩次請求都完了我再用數據去 render 組件(Fetch-then-render)。這樣的確會解決瀑布流的問題,可是引入了一個新的問題 -- 若是第一個請求須要 2s 而第二個請求只須要 500ms,那第二個請求就算已經拿到了數據,也必須等第一個請求完成後才能 render 組件。

Suspense 解決了這個問題,它採用了 render-as-you-fetch 的方式。Suspense 會先發起請求,在請求發出的幾乎同一時刻就開始組件的渲染,並不會等待請求結果的返回。此時組件會拿到一個特殊的數據結構而不是一個 Promise,而這個數據結構由你選擇的 "Promise wrapper" 庫(在上文提到過,在個人例子裏我用的是 swr)來提供。因爲所需數據尚未準備好,React 會將此組件」掛起「並暫時跳過,繼續渲染其餘組件,其餘組件完成渲染後 React 會渲染離」掛起「組件最近的 Suspense 組件的 fallback。以後等某一個請求成功後,就會繼續從新渲染相對應的組件。

在個人例子中我嘗試用 Suspense + swr + axios 來實現。

在 parent 中 fetch data

在第一個版本中我嘗試在 parent 組件(PageProfile)中先 fetch data,而後在子組件中渲染:

const App: React.FC = () => (
  <Suspense fallback={<h1>Loading...</h1>}>
    <PageProfile />
  </Suspense>
);

export default App;
export const PageProfile: React.FC = () => {
  const [id, setId] = useState(0);
  const { user, postList } = useData(id);

  return (
    <>
      <button onClick={() => setId(id + 1)}>next</button>
      <Profile user={user} />
      <ProfileTimeline postList={postList} />
    </>
  );
};
export const useData = (id: number) => {
  const { data: user } = useRequest(
    { baseURL: BASE_API, url: `/api/fake-user/${id}`, method: "get" },
    {
      suspense: true,
    },
  );
  const { data: postList } = useRequest(
    { baseURL: BASE_API, url: `/api/fake-list/${id}`, method: "get" },
    {
      suspense: true,
    },
  );

  return {
    user,
    postList,
  };
};
import useSWR, { ConfigInterface, responseInterface } from "swr";
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";

export type GetRequest = AxiosRequestConfig | null;

interface Return<Data, Error>
  extends Pick<responseInterface<AxiosResponse<Data>, AxiosError<Error>>, "isValidating" | "revalidate" | "error"> {
  data: Data | undefined;
  response: AxiosResponse<Data> | undefined;
}

export interface Config<Data = unknown, Error = unknown>
  extends Omit<ConfigInterface<AxiosResponse<Data>, AxiosError<Error>>, "initialData"> {}

export const useRequest = <Data = unknown, Error = unknown>(
  requestConfig: GetRequest,
  { ...config }: Config<Data, Error> = {},
): Return<Data, Error> => {
  const { data: response, error, isValidating, revalidate } = useSWR<AxiosResponse<Data>, AxiosError<Error>>(
    requestConfig && JSON.stringify(requestConfig),
    () => axios(requestConfig!),
    {
      ...config,
    },
  );

  return {
    data: response && response.data,
    response,
    error,
    isValidating,
    revalidate,
  };
};

可是不知道是否是個人寫法有問題,有幾個問題我死活沒弄明白:

  • 爲了實現 render-as-you-fetch,文檔中有提到過能夠儘量早的 fetch data,從而可讓 render 和 fetch 並行而且縮短拿到 data 的時間(若是我理解沒錯的話)

    • 個人想法是先在 parent 組件中 fetch data,而後用兩個 SuspenseProfile 組件和 ProfileTimeline 組件包起來,而後就可以在拿到相對應的數據(user 和 postList)以後渲染相對應的組件
    • 可是在使用的過程當中我發現 」在哪一個組件中 fetch data,就必須用 Suspense 將這個組件包起來,不然就會報錯「,因此這裏我將整個 PageProfile 包了起來。而這個時候就算我用兩個 SuspenseProfile 組件和 ProfileTimeline 組件包起來也沒辦法實現兩條加載信息,只會顯示最外層的 loading,也就沒有辦法實現 render-as-you-fetch
    • swr 在這樣的寫法下會多發一次莫名其妙的請求,目前尚未找到緣由

      • image-20200515111513161
      • 圖中第一個第二個請求分別是請求的 user 和 postList 數據,可是在完了以後又請求了一次 user
  • swr 目前尚未實如今 Suspense 模式下避免 waterfall,因此兩個請求會依次發出去,等待時間是總和,不過翻看github已經有 pr 在解決這個問題了,目前來看處於codereview的階段

在當前組件中 fetch data

爲了解決上面的問題,我換了一種寫法:

const App: React.FC = () => {
  const [id, setId] = useState(0);

  return (
    <>
      <button onClick={() => setId(id + 1)}>next</button>
      <Suspense fallback={<h1>Loading profile...</h1>}>
        <Profile id={id} />
        <Suspense fallback={<h1>Loading posts...</h1>}>
          <ProfileTimeline id={id} />
        </Suspense>
      </Suspense>
    </>
  );
};

export default App
export const Profile: React.FC<{ id: number }> = ({ id }) => {
  const { data: user } = useRequest({ baseURL: BASE_API, url: `/api/fake-user/${id}`, method: "get" }, { suspense: true });

  return <h1>{user.name}</h1>;
};
export const ProfileTimeline: React.FC<{ id: number }> = ({ id }) => {
  const { data: postList } = useRequest(
    { baseURL: BASE_API, url: `/api/fake-list/${id}`, method: "get" },
    { suspense: true },
  );

  return (
    <ul>
      {postList.data.map((listData: { id: number; text: string }) => (
        <li key={listData.id}>{listData.text}</li>
      ))}
    </ul>
  );
};

此時我將對應的請求放在子組件內,這樣的寫法無論是兩個組件 loading 的狀態,仍是網絡請求都是正常的了,可是按照個人理解這樣的寫法是不符合 React 的初衷的,在文檔中 React 提倡在頂層(好比上層組件)先 kick off 網絡請求,而後先無論結果,開始組件的渲染:

const resource = fetchProfileData();

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

可是目前這種寫法很明顯是開始渲染子組件了才發的網絡請求。關於這個問題我會等 swr merge完最新的 pr 更新下一個版本後再進行實驗。

one more thing

除了上面介紹過的之外, Suspense 還給開發者帶來了另一個好處 -- 你不用再寫 race condition 了。

在以前的請求方式中,首次渲染組件的時候你是拿不到任何數據的,此時你須要寫一個相似於這樣的判斷:

if (requestStage !== RequestStage.SUCCESS) return null;

而在同一個項目中經不一樣人的手還會有這樣的判斷:

if (requestStage === RequestStage.START) return null;

而若是請求掛了,你還得這樣:

return requestStage === RequestStage.FAILED ? (
  <SomeComponentYouWantToShow />
) : (
  <YourComponent />
);

這堆東西就是一堆模板代碼,有些時候還容易腦抽就忘加了,在 Suspense 下,你不再用寫這些東西了,數據沒拿到會直接渲染 Suspense 的 fallback,至於請求錯誤,在外層加一個 error boundary 就好了,這裏就不過多展開了,詳見文檔

總的來講 Suspense 的初衷是好的,能夠提高用戶體驗,可能如今各個工具包括 React 自己還處於實驗階段,還多多少少會有一些問題,接下來我會嘗試去找找怎麼解決這些問題再回來更新。下一篇我會接着躺坑 UI 部分。

歡迎關注個人公衆號

相關文章
相關標籤/搜索