本文首发于我的个人博客: https://teobler.com ,转载请注明出处

自从三大框架成型之后,各个框架都为提升开发者的开发效率作出了不少努力,但是看起来技术革新都到了一个瓶颈。除了 React 引入了一次函数式的思想,感觉已经没有当初从DOM时代到数据驱动时代的惊艳感了。于是 React 将精力放在了用户体验上,想让开发者在不过多耗费精力的情况下,用框架自身去提升用户体验。

于是在最近的几个版本中,React 引入了一个叫做 Concurrent Mode 的东西,同时还引入了 Suspense,旨在提升用户的访问体验,React 应用在慢慢变大以后会慢慢变得越来越卡,这次的新功能就是想在应用初期就解决这些问题。

虽然现在这些功能还处在实验阶段,React 团队并不建议在生产环境中使用,不过大部分功能已经完成了,而且他们已经用在了新的网站功能中,所以面对这样一个相对成熟的技术,其实我们还是可以来自己玩一下的,接下来我来带领大家看看这是一个什么样的东西吧。

Concurrent Mode

WHAT

那么这个 Concurrent Mode 是个啥呢?

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 包含了一系列新功能,这些新功能可以根据用户不同的设备性能和不同的网速进行不同的响应,以使得用户在不同的设备和网速的情况下拥有最好的访问体验。那么问题来了,它是怎么做到这一点的呢?

HOW

可停止的rendering (Interruptible Rendering)

在通常状况下,React 在 render 的时候是没有办法被打断的(这其中有创建新的 DOM 节点等等),rendering 的过程会一直占用 JS 线程,导致此时浏览器无法对用户的操作进行实时反馈,造成了一种整个页面很卡的感觉。

而在 Concurrent Mode 下,rendering 是可以被打断的,这意味着 React 可以让出主线程给浏览器用于更紧急的用户操作。

想象这样一个通用的场景:用户在一个输入框中检索一些信息,输入框中的文字改变后页面都将重新渲染以展示最新的结果,但是你会发现每一次输入都会卡顿,因为每一次重新渲染都将阻塞主线程,浏览器就将没有办法相应用户在输入框中的输入。当然现在通用的解决办法是用 debouncing 或者 throtting。但是这个方式存在一些问题,首先是页面没有办法实时反应用户的输入,用户会发现可能输入了好多个字符页面才刷新一次,不会实时更新;第二个问题是在性能比较差的设备上还是会出现卡顿的情况。

如果在页面正在 render 时用户输入了新的字符,React 可以暂停 render 让浏览器优先对用户的输入进行更新,然后 React 会在内存中渲染最新的页面,等到第一次 render 完成后再直接将最新的页面更新出来,保证用户能看到最新的页面。

这个过程有点像 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 在这样的写法下会多发一次莫名其妙的请求,目前还没有找到原因
      • 图中第一个第二个请求分别是请求的 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 部分。

欢迎关注我的公众号

React Concurrent Mode 之 Suspense 实践相关推荐

  1. 如何在 React 18中 利用Suspense 实现 服务端渲染(SSR)

    概述 React 18 将包括对 其服务器端渲染 (SSR) 性能的架构做了改进.这些改进带来了实质性的效果,是几年来其团队工作的结晶.大多数的改进点都是在幕后进行的,但您需要了解一些选择加入机制,尤 ...

  2. React Native在Android当中实践(五)——常见问题

    React Native在Android当中实践(一)--背景介绍 React Native在Android当中实践(二)--搭建开发环境 React Native在Android当中实践(三)--集 ...

  3. React Native在Android当中实践(一)——背景介绍

    React Native在Android当中实践(一)--背景介绍 React Native在Android当中实践(二)--搭建开发环境 React Native在Android当中实践(三)--集 ...

  4. 【前端】react and redux教程学习实践,浅显易懂的实践学习方法。

    前言 前几天,我在博文[前端]一步一步使用webpack+react+scss脚手架重构项目 中搭建了一个react开发环境.然而在实际的开发过程中,或者是在对源码的理解中,感受到react中用的最多 ...

  5. 前段react技术架构图_基于 React 的可视化编辑平台实践

    前言 前段时间发在朋友圈的一句话:各种自主搭建的平台,想起好多年各种DIY博客,行业门户网站,本质不变,变的是实现的手段了. 正文从这开始-- 本文主要介绍了基于 React 构建可视化编辑平台的实践 ...

  6. SignalR在React/Go技术栈的实践

    哼哧哼哧半年,优化改进了一个运维开发web平台. 本文记录SignalR在react/golang 技术栈的生产小实践. 01 背景 有个前后端分离的运维开发web平台, 后端会间隔5分钟同步一次数据 ...

  7. hooks组件封装 react_react-hooks amp; context 编写可复用react组件的一种实践

    在新context API出来的时候,就已经有人提出可以用context进行状态管理.react-hooks的更新,使得这一想法的实现更为方便.以一个例子组件进行分析. 前置条件:阅读过react官方 ...

  8. React 16.x折腾记 - (7) 基于React+Antd封装聊天记录(用到React的memo,lazy, Suspense这些)

    前言 在重构的路上,总能写点什么东西出来 , 这组件并不复杂,放出来的总觉得有点用处 一方面当做笔记,一方面可以给有需要的人; 有兴趣的小伙伴可以瞅瞅. 效果图 实现的功能 渲染支持图片,文字,图文 ...

  9. react如何控制全局loading_ReactJS实践(一)—— FrozenUI React化之Loading组件

    在前面我们通过四篇文章入门了React的大部分主要API,现在则开始进入实践环节. 实践系列的开篇打算拿我司的FrozenUI来试验,将其部分UI组件进行React化,作为第一篇实践文章,将以较简单的 ...

  10. React组件设计模式与最佳实践

    React设计思想 UI=f(data)XUI = f(data)XUI=f(data)X 在React中,界面完全由数据驱动 在React中,一切都是组件 用户界面就是组件 export defau ...

最新文章

  1. 博图读取温度的指令_1200读取温度巡检仪 16路
  2. bartender外部表不是预期格式_三张表轻松搞定项目计划
  3. 不同的寻址方式的应用——将每行的单词都变成大写
  4. [Json] C#ConvertJson|List转成Json|对象|集合|DataSet|DataTable|DataReader转成Json (转载)...
  5. python md5加密字符串_Python使用MD5加密字符串示例
  6. 【游戏开发实战】Unity实现类似GitHub地球射线的效果(LineRenderer | 贝塞尔曲线)
  7. UCSD ECE225A Syllabus
  8. xy坐标转换经纬度C语言,(转载)经纬度与西安80,北京54的坐标系转换(C# 实现)...
  9. 万恶的错误代码0xc000000e
  10. 阿里高频面试题:如何快速判断元素是不是在集合里?
  11. 使用Mono Cecil对MSIL进行注入
  12. here-document at line y delimited by end-of-file
  13. 抖音App四神算法分析
  14. Android Studio 2.3 打包apk
  15. vue传值给子页面html,vue.js如何父传子?
  16. Justep X5 Studio
  17. oracle查询导致 gc等待,如何诊断Oracle RAC系统中的等待事件gc cr multi block request?...
  18. 无代码表格数据库——一个企业数字化新物种
  19. 事后诸葛亮--Alpha版本总结
  20. Layui的用途——用户登录界面为案例(源码)

热门文章

  1. vue-element换肤所有主题色和基础色均可自主配置
  2. 修真院教学模式四大体系之开发流程
  3. mysql 基础 打油诗
  4. 图灵奖得主、《龙书》作者最新力作:抽象、算法与编译器
  5. 2019年培养工作室主力计划——第1次任务
  6. 猫哥教你写爬虫 049--完结撒花
  7. 网易云音乐Eason Chen 歌词词云
  8. 2.4G蓝牙耳机等穿戴蓝牙设备贴片天线方案 CA-C01
  9. 充分利用计算机研究GIS,关于地理信息系统GIS发展探究
  10. 企业与个人必备安全测试工具