你最近有没有试着理解一下 React 中的数据请求的最新进展?我尝试了。在这个过程中我几乎失去了理智。无穷无尽的数据管理库的混乱,是否采用 GraphQL ,最近社区有关 React 18 和 useEffect 的争议,useEffect 是邪恶的,因为它导致了 waterfalls , suspense 被认为是破局之道,但它现在还是实验性的。是先渲染后请求,还是先请求后渲染,又或是边请求边渲染,甚至迷惑了那些布道者。这到底是怎么回事?为什么我突然需要一个博士学位来发起一个简单的 GET 请求?
(按:waterfalls 指的是 network waterfall 。由于先渲染父组件,父组件中发起请求,获得数据后渲染子组件,子组件发起请求...,在网络不好的情况下,这样的渲染会非常的慢。后文有详细的解释)
现在在 React 中请求数据的正确姿势是什么呢? 接着看下去吧。
不同类型的请求 Types of data fetching
一般来说,请求可以被分为两类:初识数据请求 和 按需数据请求 。
按需数据是在用户与页面交互之后获取的数据,目的是更新用户的体验。所有各种各样的自动完成、动态表单和搜索体验都属于这一类。在 React 中,此数据的获取通常在回调中触发。
初始数据是您打开页面时希望立即看到的数据。它是我们在组件出现在屏幕上之前需要获取的数据。我们需要尽快向用户展示一些有意义的体验。在 React 中,像这样获取数据通常发生在 useEffect (或 class 组件的 Component DidMount )中。
有趣的是,尽管这些概念看起来完全不同,但是核心原理和基本模式是完全相同的。但是对于大多数人来说,最初的数据获取通常是最关键的。在这个阶段,你的应用程序给人的第一印象就是“慢得要命”或者“快得要命”。这就是为什么在本文的剩余部分中,我将只关注初始数据请求以及如何在考虑性能的情况下正确地进行数据请求。
我真的需要一个外部库来发请求吗? Do I really need an external library to fetch data in 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)如何处理?如果我想从屏幕上删除组件,该怎么办?我应该取消这个请求吗?那内存泄漏呢?等等等等。阿布拉莫夫解释得很好,强烈推荐阅读更多细节。
这些问题是没有一个是特定于 React 的,它们是发请求时会遇到的普遍问题。为了解决这些问题,要么重新造轮子,要么导入现有库。
有些库,比如 Axios,会抽象出一些通用问题,比如取消请求,但是不会基于 React 特有的 API 。其他的库,比如 swr,几乎可以处理所有事情,包括缓存。但本质上,技术选型在这里并不重要。世界上没有任何库可以单独提高应用程序的性能。他们只是让一些事情变得更容易,而让一些事情变得更难。为了编写高性能的应用程序,您最终还是需要了解这些技术背后的基本原理。
什么样的 React 应用 看上去 高性能? What is a “performant” React app?
在进入具体的模式和代码示例之前,让我们先讨论一下应用程序的“性能”是什么。如何判断一个应用程序是否“高效”?我们用一个相对简单的组件: 你只需要测量渲染它需要多长时间,数字越小,组件的“性能”(即更快)就越好。
如图,我们设计一个简单的 issue 收集 app ,它的左边是侧边栏,右上角是内容区,包括标题和描述,右下角是评论区。
我们假设这个 app 以三种不同的方式实现:
- 显示加载状态,然后等所有数据都加载完毕,然后一次呈现所有内容。(这大概需要3 秒时间)
- 先展示侧边栏,并保持加载状态,直到主要部分中数据加载完毕。侧边栏显示需要 1 秒,app 的其余部分需要 3 秒,总共需要 4 秒钟
- 显示加载状态 —— 2 秒后,显示内容区 —— 再过 1 秒后,显示侧边栏 —— 再过 2 秒后显示评论区。全部内容展现需要 5 秒钟。
上面哪个 app 看上去性能更好呢?
...
…
...
…
...
答案当然出乎你的意料,他们都不是最好的。
第一个应用程序加载只需3秒钟——是所有应用程序中最快的。从纯数字的角度来看,这是一个明显的赢家。但是它在3秒内不会向用户显示任何东西——这是最长的一次。
第二个应用程序在 1 秒钟内在屏幕上(侧边栏)加载一些东西。从尽可能快地展示一些东西的角度来看,这是一个明显的赢家。但是它用于展示主要部分的时间是最长的。
第三个应用程序首先加载问题信息。从首先展示应用程序主要部分的角度来看,它是一个明显的赢家。大部分语言是从左到右的,所以我们阅读时自然会从左上角看到右下角。我们通常都是这么读的。这个应用程序违反了它,它使体验最“垃圾”,更不用说它是所有demo 中装载时间最长的。明白了吗?
应用看起来是否高效取决于您试图向用户传达的信息。把自己想象成一个讲故事的人,这个应用程序就是你的故事。这个故事最重要的部分是什么?第二个是什么?你的故事流畅吗?您甚至可以分段讲述它,或者您希望您的用户立即看到完整的故事,而不需要任何中间步骤?
只有当你知道你的故事应该是什么样子的时候,才是组装应用程序并尽可能快地优化故事的时候。真正的力量不是来自各种轮子,GraphQL 或 suspense ,而是来自了解:
- 什么时候可以开始获取数据?
- 在数据获取的过程中,我们可以做什么?
- 当数据获取完成时,我们应该做什么?
并了解一些技术,使你能够控制数据获取请求的所有三个阶段。
但是在进入实际技术之前,我们需要了解两个更基本的东西: 对 React 生命周期和浏览器请求限制。
React 生命周期和数据请求 React lifecycle and data fetching
在规划请求策略时,需要知道最重要的事情是触发 React 组件的生命周期的时间。看看这个代码:
const Child = () => {
useEffect(() => {
// do something here, like fetching data for the Child
}, []);
return <div>Some child</div>
};
const Parent = () => {
// set loading to true initially
const [isLoading, setIsLoading] = useState(true);
if (isLoading) return 'loading';
return <Child />;
}
父组件基于状态决定是否呈现子组件。子组件中 useEffect 里的函数会被调用吗?答案很直观——它不会。只有在父组件 的 isLoadingstate 更改为 false 之后,才会在 Child 组件中触发渲染和所有其他效果。
那么,如果父组件中代码变成这样呢?
const Parent = () => {
// set loading to true initially
const [isLoading, setIsLoading] = useState(true);
// child is now here! before return
const child = <Child />;
if (isLoading) return 'loading';
return child;
}
功能还是一样的,但是这次的 < Child/> 元素在 if 条件之前。这次会触发在子组件中的useEffect 吗?现在的答案不那么直观了,我看到很多人会犹豫。答案仍然是一样的——不,不会的。
当我们写 const Child = < Child/> 时,我们不 “呈现(render)” Child 组件。< Child/> 只不过是创建将来元素描述的函数的语法糖。只有当这个描述最终出现在实际的可见呈现树(即父组件的 return 语句)中时,才会呈现它。在那之前,它只是作为一个巨大的物体闲置在那里,什么也不做。
如果您希望更详细地了解它的工作原理和代码示例,以及所有可能的边界情况,那么本文可能会让您感兴趣:The mystery of React Element, children, parents and re-renders
当然,关于 React 生命周期还有更多的事情需要了解: 所有这些被触发的顺序,绘制之前或之后被触发的事情,什么放慢了什么以及如何放慢,使用 LayoutEffect 钩子等等。但这些只有当你已经完美地编排了所有事情,在一个非常复杂的大型应用程序中争取几毫秒的时间,才变得重要。所以这是另一篇文章的主题,否则这篇文章会变成一本书。
浏览器限制和数据请求 Browser limitations and data fetching
看到这你可能会想: 天哪,这太复杂了。我们就不能同时发起所有的请求,然后把数据放到全局存储中,接着在可用的时候使用它?为什么还要为任何事情的生命周期和编排而烦恼呢?
我能理解。如果应用程序是简单的,只有几个请求,我们当然可以这么做。但是在大型应用程序中,可能会有几十个数据获取请求,这种策略会适得其反。我甚至不是在谈论服务器负载和服务器是否能够处理请求。我们假设服务器可以。问题是我们的浏览器不能!
你是否知道,浏览器对于同一主机并行处理多少请求是有限制的?假设服务器是 HTTP1(它仍然是互联网的70%) ,这个数字并不大。在 Chrome 浏览器中只能同时发起6个请求。同时提出6个请求!如果您同时触发更多,那么所有其他用户将不得不排队等待第一个可用的“插槽”。
在一个大型应用程序中,6个初始数据获取请求几乎不可能。上文中非常简单的 issue 收集 app 已经有 3 个请求,我们甚至还没有实现任何有价值的东西。想象一下,如果你只是添加一个有点慢的分析请求,在应用程序开始的时候什么都不做,最终会减慢整个体验,你的产品经理会多生气。
const App = () => {
// I extracted fetching and useEffect into a hook
const { data } = useData('/fetch-some-data');
if (!data) return 'loading...';
return <div>I'm an app</div>
}
假设那里的获取请求非常快,只需要约50ms。但是如果我在这个应用程序之前添加6个需要10秒钟的请求,而不需要等待或解析它们,整个应用程序的载入就会花费这10秒钟(当然是在 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
}
在这里查看,只要删除其中一个无用的获取是如何产生不同效果的。
请求瀑布: 它们如何出现 Requests waterfalls: how they appear
最后,是时候做一些严肃的编码了!现在,我们已经有了所有组件,并知道如何将它们组合在一起,是时候编写我们的 issue 收集 app 的故事了。让我们从本文开始就实现这些示例,看看会遇到哪些问题。
让我们首先对组件进行布局,然后发请求。
const App = () => {
return (
<>
<Sidebar />
<Issue />
</>
)
}
const Sidebar = () => {
return // some sidebar links
}
const Issue = () => {
return <>
// some issue data
<Comments />
</>
}
const Comments = () => {
return // some issue comments
}
然后开始发请求。让我们首先将实际的请求和 useEffect 以及状态管理提取到一个漂亮的钩子函数中,以简化示例:
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 };
};
接着,我可能会自然而然地想将各自的请求放到各自的组件当中。当然,在我们等待的时候会显示加载状态!
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 => <div>{comment.title}</div>)
}
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 (
<div>
<h3>{data.title}</h3>
<p>{data.description}</p>
<Comments />
</div>
)
}
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 (
<>
<Sidebar data={data} />
<Issue />
</>
)
}
搞定!查看这里实际实现。你注意到它有多慢了吗?比我们上面的所有例子都慢!
我们在这里实现了一个经典的请求瀑布。还记得生命周期部分吗?只有实际返回的组件才会被挂载、呈现,因此,将触发 useEffect 和其中的数据获取。在我们的示例中,每个组件在等待数据时返回“装入”状态。只有当数据被加载时,它们才会切换到呈现树中的下一个组件,它会触发自己的数据获取,返回“加载”状态,然后循环重复自己。
当你需要尽快展示应用程序时,这样的瀑布可不太妙。可能有一些方法来处理这些问题(但不是悬念,关于这个稍后再说)。
如何解决请求瀑布 How to solve requests waterfall
解决方案 Promise.all solution Promise.all
第一个也是最简单的解决方案是将所有这些数据获取请求放在呈现树中尽可能高的位置。在我们的例子中,它是我们的根组件应用程序。但这里有一个问题: 你不能只是“移动”它们,然后就不管了。我们不能就这么做:
useEffect(async () => {
const sidebar = await fetch('/get-sidebar');
const issue = await fetch('/get-issue');
const comments = await fetch('/get-comments');
}, [])
这只是另一个瀑布,只在单个组件中共存: 我们获取边栏数据,等待它,然后获取问题,等待,获取注释,等待。所有数据可用于渲染的时间将是所有等待时间的总和: 1秒 + 2秒 + 3秒 = 6秒。相反,我们需要同时发射它们,以便它们被并行发送。这样我们等待他们的时间不会超过他们中最长的一个: 3秒。50% 的性能提升!
其中一个方法就是使用 Promise.all:
useEffect(async () => {
const [sidebar, issue, comments] = await Promise.all([
fetch('/get-sidebar'),
fetch('/get-issue'),
fetch('/get-comments')
])
}, [])
然后将它们保存为父组件中的状态,并将它们作为 props 传递给子组件:
const useAllData = () => {
const [sidebar, setSidebar] = useState();
const [comments, setComments] = useState();
const [issue, setIssue] = useState();
useEffect(() => {
const dataFetch = async () => {
// waiting for allthethings in parallel
const result = (
await Promise.all([
fetch(sidebarUrl),
fetch(issueUrl),
fetch(commentsUrl)
])
).map((r) => r.json());
// and waiting a bit more - fetch API is cumbersome
const [sidebarResult, issueResult, commentsResult] = await Promise.all(
result
);
// when the data is ready, save it to state
setSidebar(sidebarResult);
setIssue(issueResult);
setComments(commentsResult);
};
dataFetch();
}, []);
return { sidebar, comments, issue };
};
const App = () => {
// all the fetches were triggered in parallel
const { sidebar, comments, issue } = useAllData()
// show loading state while waiting for all the data
if (!sidebar || !comments || !issue) return 'loading';
// render the actual app here and pass data from state to children
return (
<>
<Sidebar data={state.sidebar} />
<Issue comments={state.comments} issue={state.issue} />
</>
)
}
这就是上文中小测试开始时的第一个应用程序。
平行 Promise 解决方案 Parallel promises solution
但如果我们不想等所有请求全部响应再显示呢?我们的评论区是页面中最慢和最不重要的部分,当我们等待它们的时候阻止侧边栏的渲染是没有意义的。我是否可以并行地激发所有请求,但是独立地等待它们?
当然!我们只需要将这些请求从 async/await 语法转换为适当的老式 promise,然后在回调中保存数据:
fetch('/get-sidebar').then(data => data.json()).then(data => setSidebar(data));
fetch('/get-issue').then(data => data.json()).then(data => setIssue(data));
fetch('/get-comments').then(data => data.json()).then(data => setComments(data));
现在,每个获取请求都并行激发,但是都是独立解析的。现在,在应用程序的渲染中,我们可以做一些很酷的事情,比如一旦请求响应就立即渲染侧边栏和内容区。
onst App = () => {
const { sidebar, issue, comments } = useAllData();
// show loading state while waiting for sidebar
if (!sidebar) return 'loading';
// render sidebar as soon as its data is available
// but show loading state instead of issue and comments while we're waiting for them
return (
<>
<Sidebar data={sidebar} />
<!-- render local loading state for issue here if its data not available -->
<!-- inside Issue component we'd have to render 'loading' for empty comments as well -->
{issue ? <Issue comments={comments} issue={issue} /> : 'loading''}
</>
)
}
在这里,我们在侧边栏、内容区和评论区组件的数据可用时立即呈现它们——与初始瀑布的行为完全相同。但是,由于我们并行激发了这些请求,所以总等待时间将从 6 秒降低到仅仅 3 秒。我们不仅大大提高了应用程序的性能,而且保持其行为完好无损!
这里需要注意的一点是,在这个解决方案中,我们将独立地触发三次状态更改,这将导致父组件的三次重新呈现。考虑到它发生在应用程序的顶部,像这样不必要的重新渲染可能会导致一半的应用程序不必要地重新渲染。对性能的影响实际上取决于组件的顺序以及它们的大小,但是要记住这一点。关于如何处理重新渲染的一个有用的指南在这里:React re-renders guide: everything, all at once
Data provider解决方案 Data providers to abstract away fetching
像上面的例子一样提升数据加载,虽然对性能有好处,但对应用程序架构和代码可读性来说是很糟糕的。突然之间,我们不再需要将数据获取请求和它们的组件很好地放在同一个地方,而是拥有一个巨大的组件来获取所有东西,以及贯穿整个应用程序的大量 props。
有一个简单的解决方案: 我们可以将 “data provider” 的概念引入到应用程序中。这里的“数据提供者”只是一个抽象的数据获取,它使我们能够在应用程序的一个地方获取数据,并在另一个地方访问这些数据,绕过中间的所有组件。本质上类似于每个请求一个迷你缓存层。在 React 中,那就是 context API。
const Context = React.createContext();
export const CommentsDataProvider = ({ children }) => {
const [comments, setComments] = useState();
useEffect(async () => {
fetch('/get-comments').then(data => data.json()).then(data => setComments(data));
}, [])
return (
<Context.Provider value={comments}>
{children}
</Context.Provider>
)
}
export const useComments = () => useContext(commentsContext);
我们三个请求的逻辑完全一样。然后,我们的大型应用程序组件就变成了这样一个简单的东西:
const App = () => {
const sidebar = useSidebar();
const issue = useIssue();
// show loading state while waiting for sidebar
if (!sidebar) return 'loading';
// no more props drilling for any of those
return (
<>
<Sidebar />
{issue ? <Issue /> : 'loading''}
</>
)
}
我们用三个data provider 包装 App 组件,并在并行安装后立即发出获取请求:
export const VeryRootApp = () => {
return (
<SidebarDataProvider>
<IssueDataProvider>
<CommentsDataProvider>
<App />
</CommentsDataProvider>
</IssueDataProvider>
</SidebarDataProvider>
)
}
在组件层面消费这些信息
const Comments = () => {
// Look! No props drilling!
const comments = useComments();
}
如果您不是 Context 的忠实粉丝——不用担心,完全相同的概念将适用于您选择的任何状态管理解决方案。如果你想尝试一下 Context,看看这篇文章: How to write performant React apps with Context序,它有一些与 Context 相关的性能模式。
如果我在 React 之前获取数据呢? What if I fetch data before React?
学习瀑布搏击的最后一招。了解这一点非常重要,这样你就可以在 CR 的时候阻止你的同事使用这招。我想说的是: 这是一件非常危险的事情,明智地使用它。
让我们回顾一下我们实现第一个瀑布的时候的 评论区组件,它自己获取数据(我将 getData hook 移到了组件中)。
const Comments = () => {
const [data, setData] = useState();
useEffect(() => {
const dataFetch = async () => {
const data = await (await fetch('/get-comments')).json();
setData(data);
};
dataFetch();
}, [url]);
if (!data) return 'loading';
return data.map(comment => <div>{comment.title}</div>)
}
特别注意那里的第六行。什么是fetch(’/get-comments’) ?它只不过是一个Promise,我们等待在我们的使用效果。在这种情况下,它不依赖于 React 的任何东西——没有props 、state 或 useEffect。那么,如果我只是把它移动到最顶端,甚至在我声明 Comments 组件之前,会发生什么呢?
const commentsPromise = fetch('/get-comments');
const Comments = () => {
useEffect(() => {
const dataFetch = async () => {
// just await the variable here
const data = await (await commentsPromise).json();
setState(data);
};
dataFetch();
}, [url]);
}
非常奇妙的事情: 我们的数据请求基本上“逃脱”了所有的 React 生命周期,并且一旦 javascript 被加载到页面上,就会被激发,在任何 useEffect 被调用之前。甚至在根节点App 组件中的第一个请求被调用之前。它将被激活,javascript 将转移到其他事情上去处理,数据将静静地呆在那里,直到有人真正resolve 它。
还记得我们最初拍的瀑布照片吗?
它将会变为
从技术上讲,我们可以将所有的 promise 移到组件之外,这样就可以解决瀑布问题,而不必处理提取或数据提供者的问题。
那我们为什么没有呢? 为什么这不是一个非常普遍的模式?
放松。还记得浏览器限制章节吗?只有6个并行请求,下一个将排队。像这样的接口会立即开始请求,并且完全无法控制。在你的应用程序中,一个组件可以获取大量的数据,并且在极少数情况下使用“传统的”瀑布方法进行渲染,在实际渲染之前,它不会打扰任何人。但是通过这种 hack 行为,它有可能拖延初识渲染的关键几秒。如果有人想搞清楚为什么这个组件将如何拖慢整个应用程序的运行速度,那 debug 得多痛苦。
对于这种模式,我只能想到两种“合法”用例: 在路由器级别预取一些关键资源和在延迟加载的组件中预取数据。
在第一种情况下,您实际上需要尽快获取数据,并且确切地知道数据是关键的并且立即需要。而延迟加载组件的 javascript 只有在它们最终出现在呈现树中时才会被下载和执行,所以也没必要用这种方式。
如果我使用外部库来获取数据会怎样? What if I use libraries for data fetching?
到目前为止,在所有代码示例中,我一直只使用native fetch。这是有目的的: 我想在 React 中向您展示基本的数据获取模式,它们是独立于库的。不管您正在使用或想要使用哪个库,React 生命周期内外的瀑布、获取数据的原则都是相同的。
像 Axios 这样独立于 React 的库只是抽象出处理实际获取的复杂性,仅此而已。我可以在示例中用 axios.get 替换所有的提取,结果将保持不变。
无论是 Axios 还是 swr ,在底层他们都是使用的 useEffet 或类似的方法,通过 state 来更新数据,触发重渲染。
总结
终于讲完了。希望你不会在睡梦中看到瀑布(我现在肯定看到了!).最后是全文的小总结:
- 你不需要外部库来获取 React 中的数据,但是它们很有帮助
- 性能是主观的,总是取决于用户体验
- 可用的浏览器资源有限,只有6个并行请求
- useEffect 本身不会引起瀑布,它们只是组合和加载状态的自然结果
祝你请求愉快,下次见 🐈⬛