Orion's Studio.

自动批量请求

2020/03/31

先来个问题,假设有如下一颗元素树,React 各个元素的渲染顺序和 useEffect 的顺序是怎样的?

1
2
3
4
5
    A
/ \
B C
/ \ / \
D E F G

代码来了:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
function D() {
console.log('render D');
useEffect(() => {
console.log('useEffect D');
setTimeout(() => {
console.log('setTimeout D');
}, 0);
});

return <div>D</div>;
}

function E() {
console.log('render E');
useEffect(() => {
console.log('useEffect E');
setTimeout(() => {
console.log('setTimeout E');
}, 0);
});

return <div>E</div>;
}

function F() {
console.log('render F');
useEffect(() => {
console.log('useEffect F');
setTimeout(() => {
console.log('setTimeout F');
}, 0);
});

return <div>F</div>;
}

function G() {
console.log('render G');
useEffect(() => {
console.log('useEffect G');
setTimeout(() => {
console.log('setTimeout G');
}, 0);
});

return <div>G</div>;
}

function B() {
console.log('render B');
useEffect(() => {
console.log('useEffect B');
setTimeout(() => {
console.log('setTimeout B');
}, 0);
});

return (
<div>
B
<D />
<E />
</div>
);
}
function C() {
console.log('render C');
useEffect(() => {
console.log('useEffect C');
setTimeout(() => {
console.log('setTimeout C');
}, 0);
});

return (
<div>
C
<F />
<G />
</div>
);
}

function A() {
console.log('render A');
useEffect(() => {
console.log('useEffect A');
setTimeout(() => {
console.log('setTimeout A');
}, 0);
});

return (
<div>
A
<B />
<C />
</div>
);
}

打印的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
render A
render B
render D
render E
render C
render F
render G
useEffect D
useEffect E
useEffect B
useEffect F
useEffect G
useEffect C
useEffect A
setTimeout D
setTimeout E
setTimeout B
setTimeout F
setTimeout G
setTimeout C
setTimeout A

由此可知:

元素的渲染是先序遍历执行的,而 useEffect 是后序遍历执行的。

另外,在渲染全部完毕后,才开始 useEffect 的执行;而在 useEffect 全部执行完毕后,才开始 setTimeout 的执行。所以渲染和 useEffect 都是同步执行的,各自都在同一个 Event Loop 内。

都在同一个 Event Loop 内?关于这一点,我们能不能利用来做点文章呢?

我们最喜欢在 useEffect 做的是什么呢?

没错,发送初始化请求。

那也就是说我们在同一个 Event Loop 内,同步发送了多次异步请求,既然是同步执行的,那为什么不合并成同一个请求呢?

正巧云音乐的后台已经支持了 api/batch 的批量请求方式,能不能正好利用这个来进行优化?

对于 HTTP 1.1 而言,如果合并成一个请求,那么就可以省略掉相同的 HTTP 头部,比如冗杂的 Cookie,这样可以减少不必要的网络带宽。

而且,如果同时建立多个并行链接,会有 TCP 慢启动的效率问题。

说干就干,想要把一个 Event Loop 的请求合并,实际上就是一个 fetch-debounce 的功能,把它们都放入到 setTimeout 里延时到下一次 Event Loop 时一起执行就 ok 啦~

setTimeout 参数传 0 可以合并一个 Event Loop 内的请求,而传一个极小的数字也可以合并多个极短时间内的请求,这在分包打包时可以用到。但我们要追求尽可能快地发出请求,参数传 0 较好,甚至可以用微任务 Promise.resolve 来替换 setTimeout 进一步提速。

核心代码如下:

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
const fetchBatch = (function() {
let batchFlag = null; // 标记一次 eventLoop 上是否已存在其他请求
let paramsMap = {}; // 请求参数 Map,键名是 api
return function(
name = '', // api
params = {} // params 参数对象
) {
paramsMap[name] = params.data;
if (!batchFlag) {
batchFlag = Promise.resolve().then(() => {
fetchBase('/api/batch', {
data: paramsMap
})
.then(res => {
Object.keys(res).forEach(key => {
...
});
})
.catch(error => { });
// 清空变量
batchFlag = null;
paramsMap = {};
});
}
};
})();

这时候我们遇到一个问题,在各自的 useEffect 里发送请求时,需要返回一个 Promise, 多个 useEffect 就需要构造多个 Promise 返回给调用方。但是实际上我们只发送了一个真实的 Promise 网络请求,也就是要用这一个真实的 Promise 去改变多个 Promise 的状态。而 Promise 只能内部改变自己的状态,无法从外部主动改变状态,所以这里引用了 Qdefer 功能创建了可以延迟被改变状态的 Promise

核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const batchFetch = (function() {
...
return function(...) {
...
batchPromises[name] = Q.defer();
if (!batchFetchTimer) {
batchFetchTimer = setTimeout(() => {
fetchBase()
.then(res => {
Object.keys(res).forEach(key => {
...
batchPromises[key].resolve(resp.data);
});
})
.catch(() => {})
}, 0);
}
return batchPromises[name].promise;
};
})();

这样,对于外部来说,发送请求的使用方式还是 fetch('/api/xxx', { data }) 与原先无差异。

但是在真正发请求后,又遇到了一个后台解析的问题,后台对嵌套的对象需要对每一层都做一次 stringify,而不单单只是最外层做一次,可以用以下的代码实现递归 stringify

1
2
3
4
5
6
7
8
9
const deepStringify = obj =>
Object.keys(obj)
.map(key => {
const target = obj[key];
return typeof target === 'object'
? { [key]: JSON.stringify(deepStringify(target)) }
: { [key]: target };
})
.reduce((prev, cur) => ({ ...prev, ...cur }), {});

至此为止,我们已经实现了自动批量发送请求的功能。

然而,对于 HTTP 2.0,已有多路复用、头部压缩等优化,合并请求已无法带来性能上的提升。

这时候我们又灵光一现,在合并请求的基础上,能不能也合并页面渲染呢?

因为多个请求的回调是统一执行的,其实我们可以放入 unstable_batchedUpdates 中批量 setState,这样,不就也能减少原先由多个异步请求而带来的渲染次数,从而优化页面性能了吗?

然而,我们难以把异步的 Promise 回调中统一放入 unstable_batchedUpdates 中处理,而unstable_batchedUpdates 也没有对外暴露是否可以继续合并的标记值,也没法去设置它。

这时候只能采用一个古朴而有效的解决方案:

就是放弃 Promise 的请求方式,而是把成功回调和错误回调都作为参数传递。最后是完整的代码:

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
63
64
65
66
67
68
69
70
71
const fetchBatch = (function() {
let batchFlag = null; // 标记一次 eventLoop 上是否已存在其他请求
let paramsMap = {}; // 请求参数 Map,键名是 api
let successMap = {}; // 成功回调 Map,键名是 api
let errorMap = {}; // 错误回调 Map,键名是 api
let count = 0; // 统计一次 eventLoop 上的请求次数
return function(
name = '', // api
// params 参数对象,格式为 { data: {}, success: () => {}, error: () => {} },
// data 为参数,success 为成功回调,error 为错误回调,
// 优先从 params 中取回调
params = {},
success = noop, // 成功回调的另一种传参方式
error = noop // 错误回调的另一种传参方式
) {
paramsMap[name] = deepStringify(params.data); // 深度 stringify 对象
successMap[name] = params.success || success;
errorMap[name] = params.error || error;
count += 1;

if (!batchFlag) {
batchFlag = Promise.resolve().then(() => {
// 因为得清空变量,所以需要临时保存一下
const locaSuccessMap = successMap;
const localErrorMap = errorMap;
// 当数量大于 1 才启用 batch 请求,等于 1 时正常请求发送
if (count > 1) {
fetchBase('/api/batch', {
data: paramsMap
})
.then(res => {
// 用 unstable_batchedUpdates 统一更新,减少渲染次数
unstable_batchedUpdates(() => {
Object.keys(res).forEach(key => {
const resp = res[key];
if (resp.code !== 200) {
const error = new Error(
resp.message ||
'系统繁忙,请稍后再试'
);
error.code = resp.code;
if (localErrorMap[key]) {
localErrorMap[key](error);
}
}
if (locaSuccessMap[key]) {
locaSuccessMap[key](resp.data);
}
});
});
})
.catch(error => {
Object.keys(localErrorMap).forEach(key => {
if (localErrorMap[key]) {
localErrorMap[key](error);
}
});
});
} else {
fetchBaseReturnData(name, params).then(success, error);
}
// 清空变量
batchFlag = null;
paramsMap = {};
successMap = {};
errorMap = {};
count = 0;
});
}
};
})();

本来还想做一次性能分析的对比,但是囿于时间关系,直接说结论,大概能减少 10% 左右的页面渲染时间。

最后,敬谢 @李权威。本文主要都来源于他的意见和思考。

CATALOG