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

3,005 阅读6分钟

chahua

插画来自谷歌搜索

背景

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

解决方案

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

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

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

一般方案

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

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

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

// 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。

// 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方法。

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

// 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 函数来试试效果

// 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 函数,让他随机成功或失败

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://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/6/9/172975019c182928~tplv-t2oaga2asx-image.image

失败二次

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/6/9/172975019c210edb~tplv-t2oaga2asx-image.image

并发三次全失败,并发后的那次成功

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/6/9/17297501c799a224~tplv-t2oaga2asx-image.image

全部失败

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/6/9/17297501c81aad95~tplv-t2oaga2asx-image.image

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

总结

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

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