本文首发于个人我的博客: 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 部分。
欢迎关注个人公众号