您现在的位置是:网站首页> 编程资料编程资料
React 数据获取与性能优化详解_React_
2023-05-24
335人已围观
简介 React 数据获取与性能优化详解_React_
引言
如果你尝试过对 React 中的数据获取 (data fetching) 进行一些思考,就会发现它涉及了众多内容:数据管理类库层出不穷,是否要拥抱 GraphQL;useEffect 是引起瀑布流的祸首,救世主 Suspence 尚处于实验阶段;fetch-on-render、 fetch-then-render 和 render-as-you-fetch 这些模式不由得让人上头。那么问题来了,在 React 中获取数据的 “正确方式” 是什么?本文将给予解答。
数据获取的分类
一般来说,在现代前端中,我们可以将 “数据获取” 大致分为两类:初始数据获取 (initial data fetching) 和按需数据获取 (data fetching on demand)。
按需数据获取,是用户和页面发生交互后再请求数据,以便提高页面的交互体验。所有的自动填充、动态表单和内容搜索都属于这一类数据。在 React 中,通常是在事件的回调函数中请求这些数据。
初始数据获取,是在打开页面时我们期望立刻能看到数据,我们要在组件出现在屏幕之前拿到这些数据。这些内容对于用户体验比较重要,需要尽快展示出来。在 React 中,通常是在 useEffect (或者 componentDidMount) 中来发起这类数据请求。
有趣的是,虽然它们在概念上看起来完全不同,但获取数据的核心原则和基本模式是完全相同的。对于大部分人来说,初始数据获取通常是至关重要的。在这一阶段,你的应用程序给用户留下第一印象,要么是 “慢如黄牛” 要么是 “快如闪电”。正是因为这个原因,本文将用大量的篇幅来介绍初始数据获取,以及如何在保证性能的前提下正确的实现数据获取。
React 获取数据与类库支持
首先要回答一个问题,在 React 中要用第三方类库来获取数据吗?是,也不是。这要取决于我们的具体场景,如果我们只是简单的请求一次数据,那么就不需要第三方库支持。在 useEffect 中直接使用 fetch 即可:
const Component = () => { const [data, setData] = useState(); useEffect(() => { // fetch data const dataFetch = async () => { const data = await ( await fetch( "https://run.mocky.io/v3/b3bcb9d2-d8e9-43c5-bfb7-0062c85be6f9" ) ).json(); // set state when the data received setState(data); }; dataFetch(); }, []); return <>...> } 但是当我们的场景变得复杂时,就会面临一些棘手的问题。错误处理要怎么实现?如何处理多个组件从同一个接口获取数据?这些数据是否要缓存?缓存时间是多久?竞态问题 (race conditions) 要如何处理?如果要从屏幕上删除组件,那该怎么办?应该取消这次请求吗?内存泄漏又要怎么解决?问题诸如此类。
上面提出的问题并不只是针对于 React,这些是网络请求中数据获取的常见问题。解决这些问题(还有更多)只有两条路:要么重新发明轮子编写大量代码来解决这些问题,要么依靠一些已经存在的成熟类库。
这些类库,比如 axios,将对一些功能进行抽象和封装,如请求取消等,但是并不提供针对 React 的 API。其他类库,比如 swr,将为我们处理了几乎所有的事情,包括缓存。但本质上,技术的选择在这里并不重要。世界尚不存在这样的类库,仅通过自身就能提高应用程序的性能。它们只是让一些事情变得更容易,同时也让另外一些事情变得更困难。为了编写高性能的应用程序,我们始终需要了解数据获取的基础知识和数据编排的模式以及其他相关的技术。
React 应用的性能
在介绍具体模式和代码示例之前,让我们先讨论一下应用的 “性能” 到底是什么。你如何确定这个应用的 “性能” 是否良好呢?对于一个简单的组件来说,相对比较直观的:只需要测量渲染的耗时即可。数字越小,组件 “性能” 越好(速度更快)。数据获取属于典型的异步操作,在大型应用中从用户体验角度来看性能,就不是那么直观了。
假设我们正在开发一个用来追踪 issue 的应用。页面的左侧是一个 Sidebar 侧边栏,展示了一个链接列表;中间部分是主内容区,它的上半部分是用于展示 issue 的详情区,比如标题、描述等;issue 详情区的下方是评论区。

假如这个应用程序用下面三种不同的方式来实现:
- 展示一个 loading 状态,直至所有数据加载完毕,然后一次性渲染出所有数据。大约花费了 3s。
- 展示一个 loading 状态,侧边栏的数据加载完成后,渲染出侧边栏,然后继续保持 loading 状态,直到中间的内容区域的数据加载完成。侧边栏的需要 1s 的时间完成渲染,其他部分需要 3s 的时间。加到一起,大约花费了 4s。
- 展示一个 loading 状态,加载完主内容区的 issue 并渲染它,保持 loading 状态并加载侧边栏和评论的数据。侧边栏完成数据请求和渲染之后,继续为评论数据的加载保持 loading 状态。issue 的加载和渲染需要 2s,sidebar 在它之后需要 1s,评论需要额外的 2s 完成渲染。共计花费了 5s。
那么这个页面用哪种方案来实现的性能会更高呢?你是怎么认为的呢?当然,这个答案很棘手,还是要依据具体情况而定:
第一种实现方案总共花费 3s,是所有实现方案中最快的。单纯从数字的角度来看,毫无疑问它是胜出的。但是,在 3s 的时间里,它没有为用户呈现任何内容,这段白屏也是所有实现方案中最长的。
第二种实现方案只用了 1s 就在页面上显示出了一部分内容(Sidebar)。从尽可能快地展示内容的角度来看,它无疑是胜出的。但是,它是所有方案中主内容区耗时最长的。
第三种实现方案中,首先完成了主内容区的 issue 加载。从主内容区的加载速度来看,它是胜出的。但是,在从左到右的语言中,信息的自然流动方向是从左上到右下。这也是我们通常的阅读方式。这个页面违反了这个这个规则,这带来了最糟糕的用户体验。除此之外,它的加载时间是最长的。
对于方案的选择,通常取决于我们要向用户传递什么样的信息。把自己当作一个讲故事的人,而页面就是我们要讲述的故事。这个故事中最重要的部分是什么呢?次重要的部分又是什么呢?故事的情节是否连贯呢?你是想拆解成不同的章节讲述呢,还是立刻让用户看到故事的全貌呢?
只有当你对故事的样子有所了解,才是将故事整合在一起,并尽可能快地优化故事的时候。同理,应用的性能优化也是如此。而让我们解决问题的不是各种类库,Graphql 或 Suspense,而是下面的知识:
- 开始数据获取的合适的时机是什么?
- 在数据获取正在进行时,我们能做些什么?
- 在数据获取完成之后,我们应该做些什么?
以及使用一些技术手段,让我们在这三个阶段中控制数据请求。但在开始介绍这些技术之前,我们需要了解两个更基础的内容:React 生命周期和浏览器资源,以及其对我们目标的影响。
React 生命周期与数据获取
在我们设计数据请求的方案时,需要特别注意 React 生命周期被触发的时机。比如下面的代码:
const Child = () => { useEffect(() => { // do something here, like fetching data for the Child }, []); return Some child }; const Parent = () => { // set loading to true initially const [isLoading, setIsLoading] = useState(true); if (isLoading) return 'loading'; return ; } 在 Parent 组件中,Child 组件能否渲染取决于 state 的值。在 Child 组件中,useEffect 里的数据请求会被触发吗?很明显不会被触发。只有 Parent 组件中的 isLoading 被置为 false 时,Child 组件才会被渲染并且触发数据请求。那么再看下面的这段代码:
const Parent = () => { // set loading to true initially const [isLoading, setIsLoading] = useState(true); // child is now here! before return const child = ; if (isLoading) return 'loading'; return child; } 功能基本完全一致:当 isLoading 被置为 false 时 展示 Child,如果为 true 则展示 loading 状态。不同的是,把 元素放在了 if 条件的前面。这样改变之后,Child 组件中的 useEffect 会被触发吗?答案不是那么明显,我看到过很多人在这里纠结。答案同样是不能触发请求。
尽管我们写下了 const child = 这样的代码,但是这句代码并不会渲染组件。 只不过是一种语法糖,在函数中用来描述将要创建的元素。只有这种描述信息在实际可见的渲染树中,它才会被渲染 -- 比如在组件中作为返回值。在这之前,它什么都不做,安安静静地待在那里。
当然,还有许多关于 React 生命周期的事情需要了解:生命周期触发的顺序是怎样的,绘制之前和之后会触发哪些内容,是什么减慢了什么以及如何触发的,LayoutEffect 钩子怎样使用等等。但是,当你已经很好地协调了所有事情,在一个复杂的应用中挣扎了几秒之后,所有这些就会变得密切相关了。在这里就不展开讨论这个问题了,否则这篇文章会变成一本书。
浏览器限制和数据获取
也许你会有这样的想法:这太复杂了,难道我们就不能尽快发出所有请求,并且将数据放入某个全局存储,然后在可用时使用它?为什么还要为生命周期和请求编排而烦恼呢?
是的,如果这个应用程序很简单,并且只需要很少的请求,我们确实可以这样做。但在大型应用程序中,我们可能有几十个数据请求,这种实现方案很可能适得其反。甚至忽视了服务器的负载能否可以处理。假设服务器可以,问题是我们的浏览器却不能!
你知道吗,浏览器对相同 host 可以处理的并行请求数是有限制的。假设服务器是 HTTP1(仍占互联网的70%),那么这个数字并没有那么大。在 Chrome 中,最多只能有 6 个并行请求!如果你同时发起更多请求,剩下的所有请求都必须排队,等待可以发送请求的时机。
在一个大型应用程序中,有 6 个以上的初始数据获取请求并非不合理。在我们上面提到的非常简单的 “追踪 issue 应用” 示例中已经有 3 个请求了,我们甚至还没有实现任何有价值的东西。
想象一下,如果你只是添加了一个稍微慢一些的用于数据分析的请求,在应用的最开始它几乎什么都不做,最终它却减慢整个体验,你会不会整个人都不好了。
下面是一个比较简单代码:
const App = () => { // I extracted fetching and useEffect into a hook const { data } = useData('/fetch-some-data'); if (!data) return 'loading...'; return I'm an app } 假如在 App 中的数据请求非常快,只用了 50ms。如果在 App 之前再加 6 个请求,每个请求耗时都是 10s,那么整个 App 的加载时间也要花费 10s (当然这里是在 Chrome 浏览器中运行)。
// no waiting, no resolving, just fetch and drop it fetch('https://some-url.com/url1'); fetch('https://some-url.com/url2'); fetch('https://some-url.com/url3'); fetch('https://some-url.com/url4'); fetch('https://some-url.com/url5'); fetch('https://some-url.com/url6'); const App = () => { ... same app code } 假如我们删除其中某个请求,那么请求的时间就会降低很多。
出现请求瀑布流的原因
最后,是时候认真编码了!现在,我们已经拥有了所有需要的技术,并知道它们是如何组合在一起的,是时候开始编写我们的 Issue 追踪应用了。让我们来完成这个示例,看看如何讲述这个故事。
让我们首先完成组件布局,然后再进行数据获取。我们先实现应用的 App 组件,它将渲染 Sidebar 和 Issue,Issue 中渲染评论 Comments。
const App = () => { return ( <> > ) } const Sidebar = () => { return // some sidebar links } const Issue = () => { return <> // some issue data > } const Comments = () => { return // some issue comments } 现在来实现获取数据功能,首先将 fetch、useEffect 和状态管理封装到一个自定义 hook 中,代码如下:
export const useData = (url) => { const [state, setState] = useState(); useEffect(() => { const dataFetch = async () => { const data = await (await fetch(url)).json(); setState(data); }; dataFetch(); }, [url]); return { data: state }; }; 然后,我们将在更大的组件中请求数据:在 Issue 组件中请求 issue 数据,在 Comments 组件中请求评论列表。当然,还要在等待请求结果的过程中展示 loading 状态:
const Comments = () => { // fetch is triggered in useEffect there, as normal const { data } = useData('/get-comments'); // show loading state while waiting for the data if (!data) return 'loading'; // rendering comments now that we have access to them! return data.map(comment =>{comment.title}) } 在 Issue 中完成同样的代码,并且在 loading 结束之后,渲染 Comments 组件:
const Issue = () => { // fetch is triggered in useEffect there, as normal const { data } = useData('/get-issue'); // show loading state while waiting for the data if (!data) return 'loading'; // render actual issue now that the data is here! return ( {data.title}
{data.description}
) } App 的代码如下:
const App = () => { // fetch is triggered in useEffect there, as normal const { data } = useData('/get-sidebar'); // show loading state while waiting for the data if (!data) return 'loading'; return ( <> > ) } 当运行这段示例代码,你会发现执行起来很慢。我们这里所实现的是一个比较经典的请求瀑布流。还记得上面提到的 React 生命周期吗?组件只有作为返回值被返回时,才会被挂载和渲染,然后再去执行组件内部的 useEffect 和 数据请求。在这个实现方案中,各个组件在等待请求结果时,都返回的是 loading 状态。只有数据加载完成后,子组件才开始在组件树中渲染。然后子组件开始请求数据,展示 loading 状态,重复着和父组件一样的过程。

当我们想要尽快的展示应用页面时,像这样的瀑布流请求并不是一个好的解决方案。幸运的是,还有其他的方法来处理这个问题。
解决请求瀑布流的方案
Promise.all 方案
最简单的解决方案是,将这些请求尽可能的放在组件树的最顶层。在我们的示例中,这个最外层
相关内容
- element表单使用校验之校验失效问题详解_vue.js_
- react-dnd API拖拽工具详细用法示例_React_
- React DnD如何处理拖拽详解_React_
- uniapp获取底部安全距离以及状态栏高度等_javascript技巧_
- uniapp封装axios的详细过程(大可不必那么麻烦)_javascript技巧_
- 无UI 组件Headless框架逻辑原理用法示例详解_JavaScript_
- vue实现移动端适方案的完整步骤_vue.js_
- vue3使用element-plus中el-table组件报错关键字'emitsOptions'与'insertBefore'分析_vue.js_
- Vue3中使用vant的踩坑实战日记_vue.js_
- Vue 运行高德地图官方样例,设置class无效的解决_vue.js_
