如何优化首次渲染时的前置请求?

chahua

插画来自谷歌搜索

背景

在业务中,我们经常会遇到这么一些情况,每个请求需要带一些前置信息,例如token用户id 等等,然而,这些信息也是需要异步请求得到的。

解决方案

我们一般会立马想到以下两种方案:

  1. ssr服务端渲染
  2. 前端请求拦截

ssr服务端渲染比较好理解,服务端去请求前置信息,然后把结果添加到返回的html或者url里,前端直接取就好了,本文不做过多介绍。主要看下第二种方案,前端如何去解决呢?

一般方案

相信聪明的读者看到这里,立马会想到:

简单~拦截发起请求的方法,在每个请求发起前,先去请求前置信息,如果请求到了,就把这些数据缓存下来,防止下次请求时再去请求这些信息。

我们以请求token的场景为例,写一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// fetch.js
let token = null;

const requestToken = () => {
return new Promise((resolve, reject) => {
if (token) {
return resolve(token);
}
setTimeout(() => {
token = 'this is token';
console.log('请求 token 成功一次');
resolve(token);
}, 300);
})
}

const request = async (args) => {
return new Promise(async (resolve, reject) => {
try {
const token = await requestToken();
setTimeout(() => {
resolve(`${args} 请求成功了,token是 ${token}`);
}, 300);
} catch (error) {
reject('请求失败了')
}
})
}

export const getUserInfo = () => request('getUserInfo');

export const getList = () => request('getList');

export const getDetail = () => request('getDetail');

可以看到,我们用 setTimeout模拟异步请求,封装了 requestTokenrequest 方法,并对外暴露3个请求函数,分别是 getUserInfogetListgetDetail

接下去在首次渲染的时候去并发这三个请求函数,在这三次并发请求完成后,再执行任意一个请求函数,验证下是否只请求了一次token。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// homepage.jsx
import React from 'react';
import { getUserInfo, getList, getDetail } from './fetch.js';

const Homepage = () => {
React.useEffect(() => {
Promise.all([
getUserInfo(),
getList(),
getDetail(),
]).then(res => {
console.log('并发请求成功', res);
}).catch(e => {
console.error('并发请求失败', e);
})

setTimeout(() => {
getUserInfo().then(res => {
console.log('等到上面的并发请求完成后再请求', res);
}).catch(e => {
console.error('第二次请求失败', e);
});
}, 2000);
}, [])

return (
<div>This is homepage.</div>
)
}

export default Homepage;

OK,接下来我们看下效果。

p1

p2

问题来了,requestToken请求了多次

可以看到,首次并发请求时,由于没有一个 requestToken 请求返回,所以 requestToken 发起了3次请求。后面一个 getUserInfo 执行时,由于token这时已经返回过结果了,所以没有再次发起请求

会导致什么问题?

  1. 在一次访问中,requestToken 重复请求是毫无意义的,浪费流量
  2. http2.0 以前,浏览器同时并发的请求数是有限制的,requestToken 占用了请求通道,势必会影响其他请求,降低用户体验

作为一个对性能有追求的前端,这是不能忍的。那如何去优化呢?

优化版

思路

我们封装一个高阶函数 fetchOnce,这个高阶函数的作用是包裹真正的请求函数,当该包裹函数调用时,将真正的请求函数push到请求队列中,然后依次去执行请求函数。如果请求成功了,就存储结果并返回,如果请求失败了,就继续请求,直到请求队列为空。

除此之外,还有几个细节需要考虑。

如何阻塞请求?

这个很简单,加一个锁就好了。当有请求在处理的时候,把锁锁上,后续的请求就不会继续请求了。当某个请求成功后或全部失败后,把锁重新打开。

当某个请求成功时或全部失败时,如何通知所有的请求函数正确处理结果呢?

一样用到队列,每次调用包裹函数时,因为返回的是一个promise,所以往promise队列push当前promise的resolve和reject方法,当某个请求成功时,就执行promise队列中所有promise的resolve方法;当全部请求失败时,就执行promise队列中所有promise的reject方法。

按着这个思路,我们可以马上写出代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// fetchonce.js
// 高阶函数,参数是真实的请求函数
const fetchOnce = (fn) => {
// 请求函数队列
const fnQueue = [];
// promise队列
const promiseQueue = [];
// 错误队列,用于收集每一次的错误信息,当全部失败时都要返回
const errors = [];

// 用于缓存结果
let result;
// 请求锁
let lock = false;

// 消费promise队列
const dispatch = (isSuccess, value) => {
while(promiseQueue.length){
const p = promiseQueue.shift();
p[isSuccess ? 'resolve' : 'reject'](value);
}
}

// 返回包裹函数
return function(...args) {
return new Promise(async (resolve, reject) => {
// 当有结果时,直接resolve结果
if(result){
return resolve(result);
}
// 将真实的请求函数和当前的resolve reject塞入队列中
fnQueue.push(fn);
promiseQueue.push({resolve, reject});
// 如果锁住了,就不要继续往下执行了
if(lock){
return;
}
lock = true;
// 遍历请求函数队列,依次执行
for(let func of fnQueue){
try{
// 如果有一个成功了,清空请求队列和错误队列,缓存结果,消费promise,放开锁
const res = await func.apply(this, args);
fnQueue.length = 0;
errors.length = 0;
result = res;
dispatch(true, res);
lock = false;
}catch(e){
errors.push(e);
// 如果全部失败了,消费promise队列,清空请求队列和错误队列,放开锁
if(errors.length && errors.length === promiseQueue.length){
dispatch(false, [...errors]);
fnQueue.length = 0;
errors.length = 0;
lock = false;
}
}
}
})
}
}

接着我们用这个高阶函数包裹一下 requestToken,并修改一下 request 函数来试试效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// fetch.js
import fetchOnce from './fetchonce.js';

const requestTokenOnce = fetchOnce(requestToken);

const request = (args) => {
return new Promise(async (resolve, reject) => {
try {
const token = await requestTokenOnce();
setTimeout(() => {
resolve(`${args} 请求成功了,token是 ${token}`);
}, 300);
} catch (error) {
reject('请求失败了')
}
})
}

我们看一下页面中的打印结果

p3

结果跟预期想象的一样,requestToken 只真正请求了一次token,接下去我们模拟测试一下请求失败的场景。

我们修改一下 requestToken 函数,让他随机成功或失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const requestToken = () => {
return new Promise((resolve, reject) => {
if (token) {
return resolve(token);
}
const random = Math.random() * 10;
setTimeout(() => {
if (random > 5) {
token = 'this is token';
console.log('请求 token 成功一次');
resolve(token);
} else {
console.error('请求 token 失败一次');
reject('token 请求失败了');
}
}, 300);
})
}

我们刷新多次,看下有失败的情况下,token的请求情况

失败一次
https://user-gold-cdn.xitu.io/2020/6/9/172975019c182928?w=2232&h=194&f=jpeg&s=46578

失败二次
https://user-gold-cdn.xitu.io/2020/6/9/172975019c210edb?w=2252&h=238&f=jpeg&s=52003

并发三次全失败,并发后的那次成功
https://user-gold-cdn.xitu.io/2020/6/9/17297501c799a224?w=1124&h=282&f=jpeg&s=35039

全部失败
https://user-gold-cdn.xitu.io/2020/6/9/17297501c81aad95?w=542&h=276&f=jpeg&s=23304

OK,效果跟想象中的一样,用fetchOnce包裹后的一批并发请求中,有一个请求成功了,则大家都成功;全部失败了,此次请求才算失败。

总结

以上,我们就用队列实现了并发请求控制,从而解决了首次渲染时,前置请求会并发多次的问题,皆大欢喜~

完整的代码仓库可以查看这里 fetchOnce,喜欢的朋友可以留下你们的赞和star~

坚持原创技术分享,您的支持将鼓励我继续创作!