先来个问题,假设有如下一颗元素树,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 ; let paramsMap = {}; return function ( name = '' , 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
只能内部改变自己的状态,无法从外部主动改变状态,所以这里引用了 Q
的 defer
功能创建了可以延迟被改变状态的 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 ; let paramsMap = {}; let successMap = {}; let errorMap = {}; let count = 0 ; return function ( name = '' , params = {}, success = noop, error = noop ) { paramsMap[name] = deepStringify(params.data); successMap[name] = params.success || success; errorMap[name] = params.error || error; count += 1 ; if (!batchFlag) { batchFlag = Promise .resolve().then(() => { const locaSuccessMap = successMap; const localErrorMap = errorMap; if (count > 1 ) { fetchBase('/api/batch' , { data: paramsMap }) .then(res => { 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% 左右的页面渲染时间。
最后,敬谢 @李权威。本文主要都来源于他的意见和思考。