本文首發於個人我的博客: https://teobler.com ,轉載請註明出處
自從三大框架成型以後,各個框架都爲提高開發者的開發效率做出了很多努力,可是看起來技術革新都到了一個瓶頸。除了 React 引入了一次函數式的思想,感受已經沒有當初從DOM時代到數據驅動時代的驚豔感了。因而 React 將精力放在了用戶體驗上,想讓開發者在不過多耗費精力的狀況下,用框架自身去提高用戶體驗。html
因而在最近的幾個版本中,React 引入了一個叫作 Concurrent Mode
的東西,同時還引入了 Suspense
,旨在提高用戶的訪問體驗,React 應用在慢慢變大之後會慢慢變得愈來愈卡,此次的新功能就是想在應用初期就解決這些問題。react
雖然如今這些功能還處在實驗階段,React 團隊並不建議在生產環境中使用,不過大部分功能已經完成了,並且他們已經用在了新的網站功能中,因此面對這樣一個相對成熟的技術,其實咱們仍是能夠來本身玩一下的,接下來我來帶領你們看看這是一個什麼樣的東西吧。ios
那麼這個 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
在一般情況下,React 在 render 的時候是沒有辦法被打斷的(這其中有建立新的 DOM 節點等等),rendering 的過程會一直佔用 JS 線程,致使此時瀏覽器沒法對用戶的操做進行實時反饋,形成了一種整個頁面很卡的感受。typescript
而在 Concurrent Mode
下,rendering 是能夠被打斷的,這意味着 React 可讓出主線程給瀏覽器用於更緊急的用戶操做。npm
想象這樣一個通用的場景:用戶在一個輸入框中檢索一些信息,輸入框中的文字改變後頁面都將從新渲染以展現最新的結果,可是你會發現每一次輸入都會卡頓,由於每一次從新渲染都將阻塞主線程,瀏覽器就將沒有辦法相應用戶在輸入框中的輸入。固然如今通用的解決辦法是用 debouncing
或者 throtting
。可是這個方式存在一些問題,首先是頁面沒有辦法實時反應用戶的輸入,用戶會發現可能輸入了好多個字符頁面才刷新一次,不會實時更新;第二個問題是在性能比較差的設備上仍是會出現卡頓的狀況。axios
若是在頁面正在 render 時用戶輸入了新的字符,React 能夠暫停 render 讓瀏覽器優先對用戶的輸入進行更新,而後 React 會在內存中渲染最新的頁面,等到第一次 render 完成後再直接將最新的頁面更新出來,保證用戶能看到最新的頁面。segmentfault
這個過程有點像 git 的多分支,主分支是用戶可以看到的而且是能夠被暫停的,React 會新起一個分支來作最新的渲染,當主分支的渲染完成後就將新的分支合併過來,獲得最新的視圖。後端
在頁面跳轉的時候,爲了提高用戶體驗,咱們每每會在新的頁面中加上 skeleton
,這是爲了防止要渲染的數據尚未拿到,用戶看到一個空白的頁面。
在 Concurrent Mode
中,咱們可讓 React 在第一個頁面多停留一會,此時 React 會在內存中用拿到的數據渲染新的頁面,等頁面渲染完成後再直接跳轉到一個已經完成了的頁面上,這個行爲要更加符合用戶直覺。並且須要說明的是,在第一個頁面等待的時間裏,用戶的任何操做都是能夠被捕捉到的,也就是說在等待時間內並不會 block 用戶的任何操做。
總結一下就是,新的 Concurrent Mode
可讓 React 同時在不一樣的狀態下進行並行處理,在並行狀態結束後又將全部改變合併起來。這個功能主要聚焦在兩點上:
而對於開發者來講,React 的使用方式並無太大的變化,你之前怎麼寫的 React,未來仍是怎麼寫,不會讓開發者有斷層的感覺。下面咱們能夠經過幾個例子來看看具體怎麼使用。
與以前的功能不一樣的是,Concurrent Mode
須要開發者手動開啓(只是使用 Suspense
貌似不用開啓,可是我爲了下一篇文章的代碼,如今就先開啓了)。爲了方(tou)便(lan),咱們用 cra 建立一個新的項目,爲了使用 Concurrent Mode
咱們須要作以下修改:
npm install react@experimental react-dom@experimental
爲了正常使用 TypeScript 在 react-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
Suspense
的實現尚未完成,可是已經有一個 pr 了本文中全部的代碼均可以在個人 github repo 裏找到,建議時間充裕的同窗 clone 一份和文章一同食用效果更佳。
React 團隊在 16.6 中加入了一個新的組件 Suspense
,Suspense
與其說是一個組件,更多的能夠說是一種機制。這個組件能夠在子節點渲染的時候進行」掛起「,渲染一個你設定好的等待圖標之類的組件,等子節點渲染完成後再顯示子節點。在 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 組件(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 的時間(若是我理解沒錯的話)
Suspense
將 Profile
組件和 ProfileTimeline
組件包起來,而後就可以在拿到相對應的數據(user 和 postList)以後渲染相對應的組件Suspense
將這個組件包起來,不然就會報錯「,因此這裏我將整個 PageProfile
包了起來。而這個時候就算我用兩個 Suspense
將 Profile
組件和 ProfileTimeline
組件包起來也沒辦法實現兩條加載信息,只會顯示最外層的 loading,也就沒有辦法實現 render-as-you-fetch
swr 在這樣的寫法下會多發一次莫名其妙的請求,目前尚未找到緣由
Suspense
模式下避免 waterfall,因此兩個請求會依次發出去,等待時間是總和,不過翻看github已經有 pr 在解決這個問題了,目前來看處於codereview的階段爲了解決上面的問題,我換了一種寫法:
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 更新下一個版本後再進行實驗。
除了上面介紹過的之外, 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 部分。
歡迎關注個人公衆號