在React开发时,不只是重新渲染会影响性能。如果获取主要数据需要花费两秒钟的时间,那么即便有即时的重新渲染也帮不了你。又或者,如果页面在获取数据时显得非常 "杂乱无章",所有的用户界面元素都在动,加载指示器也转个不停,这会让你的用户看得头疼不已。在前端开发的领域,获取数据是非常麻烦的。
那么,在React中获取数据的最佳实践是什么?在这一章,将会学到:
- 前端获取数据的方法。
- 我们可以是仅仅使用
fetch
获取数据吗? - 性能好的应用意味着什么?
- 在获取数据时,浏览器的限制是什么?
- 什么是请求瀑布流,以及它如何出现?
- 解决瀑布流问题的 一些方案。
获取数据的方式
概要性地说,在现代前端世界,有两种获取数据的方式:初始数据获取 与 按需数据获取 是 发生在用户在页面进行交互后。而在React中,这类数据获取,通常是在回调函数中触发的。
初始 数据获取,是发生在你打开一个页面,看到页面内容时。这是一项我们需要进口展示给用户的内容。在React中国呢,如果没有使用SSR,这类数据获取通常发生在useEffect
中(如果是class组件,则是在componentDidMount
)。
虽然这两个概念看着不一样,但其核心原则和基本模式是一样的。
开发React项目时,是否需要借助外部库来获取数据?
答案是,看情况而定。如果你仅仅是需要获取一些数据,不需要存储它,那只要在useEffect
里使用fetch
即可。
js
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
setData(data);
};
dataFetch();
}, []);
return <>...</>;
};
但是,一旦你的开发场景不再仅仅要求获取数据时,你就会遇到一系列难搞的问题了。如何进行错误处理?如果多个组件想获取同一个组件的数据该如何?我是否应该缓存数据?缓存多久?遇到竞态条件该怎么办?遇到内存泄露该怎么办?等等...
这些问题在开发React以外的项目时,也会遇到。这是获取数据时的普遍问题。要处理这类问题,无非是重新造轮子,或者借用现有的库。
像axios这样的库,封装了数据获取的基本概念,但并没有针对React进行特殊化处理。而像swr库,可以替我们做数据缓存,提升工作效率。但本质上而言,这些技术的选型并不重要,因为它们不过是以性能为代价,提升了开发者的效率罢了。为了开发出高性能的应用,你需要理解数据获取的基本原理,以及数据编排的模式与技术。
什么是性能好的应用?
在进入具体的模式设计和代码示例前,我们要搞明白什么是"性能好的应用"?其实也很简单,我们需要衡量多久能渲染出一个组件。而这个时间越少,性能就越好。
但是如果这个组件涉及到了异步操作,而且在大型应用中,衡量起来就不是这么简单了。
想象一下,我们正在为一个问题跟踪器实现一个问题查看界面。该界面左侧会有侧边栏导航,包含一系列链接;中间是主要的问题信息,比如问题标题、描述、负责人等;下方则是一个包含评论的部分。
而这个组件可以通过三种方法实现:
- 展示加载状态,直到所有数据都加载完,之后一次性完渲染页面所有内容。总计用时3秒。
- 先显示加载状态,直到侧边栏数据首先加载完成,然后渲染侧边栏,接着继续保持加载状态,直至主体部分的数据也加载完毕。侧边栏的显示大约需要 1 秒钟,应用程序其余部分的显示大约需要 3 秒钟。总体而言,整个过程大约需要 4 秒钟。
- 先显示加载状态,直到主要的问题数据加载完成,然后渲染该数据,同时对于侧边栏和评论部分继续保持加载状态。当侧边栏数据加载完成后,渲染侧边栏,此时评论部分仍处于加载状态。主体部分大约在 2 秒后显示,在那之后侧边栏大约 1 秒后显示,评论部分还需要大约 2 秒才能显示出来。总体来说,整个界面显示出来大约需要 5 秒钟。
代码示例: advanced-react.com/examples/14...
代码示例: advanced-react.com/examples/14...
代码示例: advanced-react.com/examples/14...
这三个方案中,哪一个性能最好呢?
答案是,看具体情况。
第一个方案,就纯加载时间而言,它是用时最少的。但是在前三秒,用户什么都看不到。
第二个方案,只用了一秒就看到了侧边栏,似乎这个方案不错。但是它用了最长的时间才能看到页面的主要内容。
第三个方案,虽然最先展示了页面的主要内容,但是它的展示顺序,又与业界展示顺序相悖。。。
这总是取决于你试图向用户传达的信息。把你自己想象成一个讲故事的人,而应用程序就是你的故事。这个故事中最重要的部分是什么?第二重要的部分又是什么?你的故事有连贯性吗?你是可以将它分成若干部分来讲述,还是希望用户能立刻完整地看到整个故事,而没有任何中间步骤呢?
React的生命周期与数据获取
当你要调用接口请求数据时,你首先要搞清楚React的生命周期是如何被触发的。来看看这一段代码:
js
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 />;
};
在这段代码中,Parent
组件会基于状态,条件式地渲染Child
组件。那么Child
组件的useEffect
会立即调用吗?但是,否。Child
组件的useEffect
只有在Parent
组件的isLoading
为false时,才会触发Child
的渲染及其钩子函数。
如果我把Parent
组件修改成这样,会如何:
js
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;
};
这段代码所实现的功能和之前是一样的:如果isLoading
为false
,展示 Child
组件;如果isLoading
为true
,展示'loading'字符串。但是,现在Child
是先于if代码执行,此时的useEffect
会被执行吗?虽然答案很反直接,但还是不会。
当我们写const child = <Child />
时,我们并没有渲染Child
组件。<Child />
不过是生成其描述对象的函数的语法糖罢了。只有当这个描述最终出现在实际可见的渲染树中时,它才会被渲染 ------ 也就是说,从组件中返回时才会被渲染。在那之前,它就只是作为一个对象闲置在那里,什么也不做。
React的生命周期还有很多知识点:生命周期被触发的顺序,绘图前触发了什么钩子,绘图后触发了什么钩子,哪些钩子拖延了渲染,关于useLayoutEffect
等等。但所有这些在很久之后才会显得重要,当你已经完美地编排好了一切,并且现在正处于一个非常庞大、复杂的应用程序中,为争取哪怕几毫秒的性能提升而努力的时候。
浏览器的限制与数据获取
也许你会想,哎妈呀,这太复杂了。我们不能一次发起多个数据获取请求,把数据放进全局仓库,在有需要时使用这些数据,不就行了吗?干嘛老是为React生命周期和数据编排头疼?
我可以理解你的苦恼。如果这个应用比较简单,这样是可行的。但在大型应用中,我们有大量的数据获取请求要发起,这个策略是不行的。不行的原因有很多,除了要考虑服务器是否能承载这个请求频次外,浏览器本身对请求频次也有限制!!!
你知道浏览器限制了一次最多能发送几个数据获取请求吗?假设服务器使用的是 HTTP/1 协议(目前互联网中仍有 70% 的服务器使用该协议),这个数字其实并没有那么大。在谷歌浏览器(Chrome)中,这个数字仅仅是 6。也就是说只能同时进行 6 个并行请求!如果你同时发起更多的请求,那么其余所有请求都将不得不进入队列,等待第一个可用的时机。
而且对于一个大型应用程序来说,在初始数据获取时进行 6 个请求并非不合理。我们这个非常简单的 "问题追踪器" 就已经有三个请求了,而我们甚至还没有实现任何有实际价值的功能呢。想象一下,如果你在应用程序一开始就添加一个有点慢的分析请求,而这个请求实际上在一开始什么作用都没有起到,却最终导致整个应用体验变慢,你会得到多少愤怒的目光。
让我们看看代码吧:
js
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>;
};
假设那里的获取请求超级快,仅耗时约 50 毫秒。如果在那个应用程序之前我仅仅添加六个耗时 10 秒的请求,并且不等待它们完成或对它们进行处理,那么整个应用程序的加载就会耗费这 10 秒钟(当然,是在谷歌浏览器中)。
js
// 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
}