实战:针对慢网的一些实践与探索

396 阅读11分钟

前言

相信大家在自己的项目实际应用中都会遇到慢网的场景。

在慢网环境中,应用的稳定性交互是用户体验的关键,所以我们需要从前端的角度提出来一些方式来尽可能的避免慢网对于接口数据的影响,尽可能满足实际的应用场景。

那么本文将从交互、接口等几个角度介绍几种应对慢网的方式,用于解决慢网交互不流畅、卡顿,且数据被错误覆盖等问题;

问题

慢网会遇到哪些问题

  • 交互
    • 交互卡顿、不流畅
  • 接口
    • 接口响应过慢,数据渲染不及时
    • 接口响应失败
    • 接口数据被错误覆盖
    • ......
  • 资源
    • 资源加载慢
    • 资源加载失败
    • ......

问题具体描述

交互不流畅

这个比较好理解,就是在某些场景下我们需要接口的数据去渲染页面,如果这时候网比较慢的话,一个接口的响应时长2-10秒,那么对于用户来说,就会感觉到非常卡顿,非常不友好,可以对比打游戏网慢卡顿的情况;

数据被错误覆盖

如gif图所示,多次发送请求后,我们希望的渲染顺序是1、2、3、4......按照顺序渲染,但是实际的情况却是无序的;

  • 这是由于每个接口的响应数据为异步后一个接口比前一个接口响应时间长导致的:

    • 快网情况下,接口响应时长很快几百毫秒一次且两个接口之间的响应时长差距不大,不会出现一个接口的总体响应时长大于上一个接口响应时长,导致最新的接口的响应数据被上一个接口的响应数据给覆盖
    • 慢网情况下,可能当前接口的响应时长为1秒,而上一个接口的响应时长为5秒,那么最后渲染的数据为上一个接口的数据;
屏幕录制2024-06-27 23.18 -middle-original.gif

这里我们模拟一下这个场景:

  1. 通过setTimeoutpromise去模拟请求接口;
  2. Math.round(Math.random() * 10000)去随机模拟的接口响应时长,为了观察更明显,我将响应时长范围延长到0-10秒,且将后面的小数去掉;
  3. 为了方便看出是哪个接口,我在全局声明了一个变量nnIndex来确定请求顺序;
import React, { useState } from "react";
import { Button } from "antd-mobile";

let nnIndex = 0; // 用于模拟接口请求的index

const Demo = () => {

  const [demoMsg, setDemoMsg] = useState<string>(''); // 接口返回的数据

  /** 模拟接口 */
  const fetchDataApi = (index: number) => {
    const timeRound = Math.round(Math.random() * 10000); // 响应时间在0-10s之间
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(`接口返回数据--api${index}, 响应时间${timeRound}ms`);
      }, timeRound);
    });
  }

  /** 模拟调用接口 */
  const fetchData = async (index: number) => {
    try {
      const res = await fetchDataApi(index);
      console.log('res: ', res);
      setDemoMsg(res);
    } catch (error) {
      console.log('error: ', error)
    }   
  }

  return <div>
      <Button color="success" onClick={() => fetchData(nnIndex++)}>点击模拟慢网请求</Button>
      <h1>{demoMsg}</h1>
  </div>
}

export default Demo;

解决方案

我们将从交互层、接口层等方面来解决这个问题;

1、防抖 + loading

我们可以考虑给请求接口的函数加一个防抖,让其在5秒(结合自己的实际情况)的点击只执行一次,且考虑到对用户友好,加个loading,让他知道他点了是有反应的:

屏幕录制2024-06-28 09.12.35.gif

代码实现

那么是怎么做的呢,代码如下:

import React, { useState } from "react";
import { Button } from "antd-mobile";
import { debounce } from "lodash-es";

let nnIndex = 0; // 用于模拟接口请求的index

const Demo = () => {

  const [demoMsg, setDemoMsg] = useState<string>(''); // 接口返回的数据
  const [loading, setLoading] = useState<boolean>(false);

  /** 模拟接口 */
  const fetchDataApi = (index: number) => {
    const timeRound = Math.round(Math.random() * 10000); // 响应时间在0-10s之间
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(`接口返回数据--api${index}, 响应时间${timeRound}ms`);
      }, timeRound);
    });
  }

  /** 模拟调用接口 */
  const fetchData = async (index: number) => {
    try {
      setLoading(true)
      const res = await fetchDataApi(index);
      console.log('res: ', res);
      setDemoMsg(res);
      setLoading(false)
    } catch (error) {
      console.log('error: ', error)
      setLoading(false)
    }   
  }

  return <div>
      <Button color="success" onClick={debounce(() => fetchData(nnIndex++), 5000, {'leading': true,'trailing': false})} loading={loading}>点击模拟慢网请求</Button>
      <h1>{demoMsg}</h1>
  </div>
}

export default Demo;

2、对同一个接口串行

防抖加loading已经可以解决很大部分的场景了,但是在某些场景下,就是需要在短时间大量``请求接口,且要求返回的接口数据按顺序渲染

那么此时我们就可以考虑另外一种方式:串行

接口串行指多个接口请求按照顺序依次执行,每个接口请求的执行依赖于上一个接口请求的结果;

使用场景

轮询,需要对每一次轮询的数据都做处理

注意事项

  • 但是此方法有注意的地方:
    • 当网特别慢的情况下,会造成阻塞可能不能及时拿到最新的数据;
    • 当其中的某一个接口长时间请求然后最后超时失败,会非常影响用户体验;

思路

  • 全局添加一个标志位flag,用于保存当前的接口是否已经处于请求状态;
  • 在请求的时候先判断当前标志位,若还在请求状态,则不请求下一接口;
  • 当请求接口请求成功,或者出现异常时,更新标志位;

代码实现

代码如下:

let flag = false; // 用于模拟接口请求的flag

/** 模拟调用接口 */
const fetchData = async (index: number) => {
    if(flag) return; // 如果正在请求中,则不再请求
    try {
      flag = true;
      const res = await fetchDataApi(index);
      flag = false;
      console.log('res: ', res);
      setDemoMsg(res);
    } catch (error) {
      flag = false;
      console.log('error: ', error)
    }   
}

3、取消重复请求取消

还有一种方式,就是当多次快速点击的时候把上一次的请求给取消掉,永远走最新的请求,这样既节省了性能,又保证了每次交互中,都能拿到最新的数据;

使用场景

不能使用防抖等技巧,在某一种情况下确实需要短时间多次请求数据

注意事项

但是也有需要注意的几点:

  • 当接口多次请求时,虽然可以取消上一次的接口请求,但是可能会出现,服务器已经接收到这个接口请求了,但客户端却取消了
  • 上述情况对于修改服务器数据的接口尤其需要注意,因为一旦出现上述的情况,就会出现服务端的数据和客户端的数据不一样的情况,这时候就需要后端对该接口请求做幂等处理
  • 对于只拿服务器数据的接口就比较友好,因为多次请求,不会更改服务器的数据;
  • 当接口取消时,fetch() promise 将会抛出 DOMException 类型的 Error(名称为 AbortError),可以针对自己的需求,对这个异常做特殊处理,防止埋点上报异常,导致自己查埋点时,异常超标;

思路

那么如何实现呢,步骤如下:

  • 做一个统一的封装,用给接口传参的形式,去判断该接口是否需要取消重复请求;
  • 考虑用一个map来存储多次请求,每发一次请求,将前面的请求都给取消掉;
  • 存储请求的位置在请求拦截器,取消请求的位置在响应拦截器
  • map存储多次请求的时候,我们需要对每个请求生成一个唯一的key值;
  • 检查是否存在重复请求,若存在,则取消已发的请求;
  • 取消请求有两种方式: AbortControllerCancelToken(已被弃用);

代码实现

AbortController

abortController 接口表示一个控制器对象,允许你根据需要中止一个或多个 Web 请求;

主要使用它提供的一个构造函数AbortController()、一个实例属性AbortController.signal、一个实例方法AbortController.abort()

1、在调用接口的地方给接口传参,判断该接口是否需要走这个配置:

export function api(data) {
  return http({
    url: `自己的url`,
    method: "post",
    data,
    cancelDuplicateRequests: true, // true为是,false为不是
  });
}

2、将map管理逻辑、生成请求唯一标识、检查是否存在重复请求、取消请求统一封装到一个文件,方便管理:

  • 这里在生成请求唯一标识的时候,我们理解同一个请求方法+url+参数一致,即可理解为同一个请求,当然,也可以自行配置,采取适合自己的方案;
  • 用于将当前请求信息添加到请求对象中,需要先判断一下,请求对象中是否已经添加过signal,若是未添加则添加;
  • 键值对里的键存储的是请求的唯一标识,值则为该请求所对应的AbortController实例;
  • 我们使用map的key的唯一的特性,去判断该请求是否已经存在map中,若存在,则取消该请求;
import { AxiosRequestConfig } from "axios";

/** 获取请求唯一标识 */
export const getRequestIdentify = (config: AxiosRequestConfig) => {
    const { method, url, params = {}, data = {} } = config || {};
    return [method, url, JSON.stringify(params), JSON.stringify(data)].join("&");
}

/** 键值对存储当前请求信息 */
const pendingRequestMap = new Map();

/** 删除对应的key值 */
export const deletePendingRequestMap = (config: AxiosRequestConfig) => {
    const requestKey = getRequestIdentify(config);
    pendingRequestMap.delete(requestKey);
}

/** 用于把当前的请求信息添加到请求对象中 */
export const addRequest = (config: AxiosRequestConfig) => {
    const requestKey = getRequestIdentify(config);
    if(!config.signal) {
        const controller  = new AbortController();
        config.signal = controller.signal;
        if(!pendingRequestMap.has(requestKey)) {
            pendingRequestMap.set(requestKey, controller);
        }
    }
}

/** 检查是否存在重复请求,若存在则取消已发的请求 */
export const removeRequest = (config: AxiosRequestConfig) => {
    const requestKey = getRequestIdentify(config);
    if(pendingRequestMap.has(requestKey)) {
        const controller = pendingRequestMap.get(requestKey);
        controller.abort();
        deletePendingRequestMap(config);
    }
}

3、在请求和响应拦截器中调用这些功能函数:

请求拦截器:

service.interceptors.request.use(
  (config) => {
    const { cancelDuplicateRequests = false } = config;

    if(cancelDuplicateRequests) {
      removeRequest(config); // 取消重复请求
      addRequest(config); // 添加请求信息
    }
    
    ...别的逻辑
    
    return config;
  },
  
  (error) => {
    return Promise.reject(error);
  }
);

响应拦截器:

service.interceptors.response.use(
  (response) => {
    const { config } = response;
    removeRequest(config); // 取消重复请求
    
    ...其它逻辑
  },
  (error: any) => {
    if(axios.isCancel(error)) { // 针对取消异常做处理
      console.log('error111: ', error);
    }
    
    if(!axios.isCancel(error)) {
         removeRequest(config); // 取消重复请求
    }
    
    ...其它逻辑
    return Promise.reject(error);
  }
);

CancelToken

Axios 的 cancel token API 是基于被撤销 cancelable promises proposal

此 API 从 v0.22.0 开始已被弃用,不应在新项目中使用。

更改关键代码:

/** 用于把当前的请求信息添加到请求对象中 */
export const addRequest = (config: AxiosRequestConfig) => {
  const requestKey = getRequestIdentify(config);
  if(!config.cancelToken) { // 区别
      const CancelToken = axios.CancelToken; // 区别
      const source = CancelToken.source(); // 区别
      config.cancelToken = source.token; // 区别
      if(!pendingRequestMap.has(requestKey)) {
          pendingRequestMap.set(requestKey, source); // 区别
      }
  }
}

/** 检查是否存在重复请求,若存在则取消已发的请求 */
export const removeRequest = (config: AxiosRequestConfig) => {
  const requestKey = getRequestIdentify(config);
  if(pendingRequestMap.has(requestKey)) {
      const source = pendingRequestMap.get(requestKey); // 区别
      source.cancel(); // 区别
      deletePendingRequestMap(config);
  }
}

4、接口重试机制

使用场景

由于网络或者服务等原因,导致接口超时,或产生未知错误,降低用户刷新页面的操作成本,自动重新请求接口;

思路

利用第三方依赖axios-retry,给所有接口,或者以接口为颗粒度去进行配置,让其在对应的错误下,进行对应次数的重新请求;

全局设置

axiosRetry(service, {//传入axios实例
  retries: 3,              // 设置自动发送请求次数
  retryDelay: (retryCount) => {
    return retryCount * 1500;      // 重复请求延迟(毫秒)
  },
  shouldResetTimeout: true,       //  重置超时时间
  retryCondition: (error) => {
    //true为打开自动发送请求,false为关闭自动发送请求
    if (error.message.includes('timeout') || error.message.includes("status code")) {
      return true;
    } else {
      return false;
    };
  }
});

单个接口设置

export function api(data) {
  return http({
    url: `自己的url`,
    method: "post",
    data,
    isNeedRetry: true, // 是否需要重试,否则不需要重试
  });
}

axiosRetry(service, {//传入axios实例
  retries: 3,              // 设置自动发送请求次数
  retryDelay: (retryCount) => {
    return retryCount * 1500;      // 重复请求延迟(毫秒)
  },
  shouldResetTimeout: true,       //  重置超时时间
  retryCondition: (error) => {
    const { config,  message} = error; 
    const { isNeedRetry } = config;
    if(!isNeedRetry) return false; // 该接口不需要重试
    //true为打开自动发送请求,false为关闭自动发送请求
    if (message.includes('timeout') || message.includes("status code")) {
      return true;
    } else {
      return false;
    };
  }
});

5、图片失败自动加载+点击重新加载

使用场景

由于网络问题,图片资源加载报错

思路

  • 写一个自定义的hook
  • 通过CSS选择器选中对应dom里的所有图片资源
  • 且给加载失败的图片添加自定义属性errCount
  • 默认图片加载失败的话,点击加载
  • 给一个自动重新加载图片的次数,次数达到上限,只能点击加载
import { useEffect } from "react";

interface IUseImgReload {
  count?: number;
  imgQuerySelector?: string;
}

/**
 * @param count 重新加载图片的次数
 */
export const useImgReload = (config: IUseImgReload) => {
  const { count, imgQuerySelector = 'img' } = config || {}
  useEffect(() => {
    setTimeout(() => {
      const images = document.querySelectorAll(imgQuerySelector);
      if (images.length === 0) return;
      images.forEach((img) => {

        img.onerror = () => {
          img.dataset.errCount = img.dataset.errCount ? `${parseInt(img.dataset.errCount) + 1}` : '1';
          if (parseInt(img.dataset.errCount) <= count) {
            setTimeout(() => {
              // 重新加载图片
              img.src = img.src;
            }, 2000);
          } else {
            // 错误次数超过限制,添加点击加载图片能力
            img.style.fontSize = '18px';
            img.alt = '图片加载失败, 点击重新加载';
            img.onclick = () => {
              img.src = img.src;
            };
          }
        }
      });
    }, 0);
  }, []);
}

6、网络异常自动刷新页面

使用场景

对于网慢情况下请求失败,减少用户自己手动刷新页面这一步,自动重新刷新页面;

思路

  • 在监听异常的地方自动调用刷新机制
  • 使用document.cookie存储自动刷新次数,并设置cookie的失效时间Max-Age
  • 当自动刷新超过次数时,上报埋点
const getErrorCount = () => {
  const matches = document.cookie.match(/errorCount=(\d+)/);
  return matches ? Number(matches[1]) : 0;
};

const setErrorCount = (count: number) => {
  const maxAge = 3 * 60 * 60; // 3 hours in seconds
  document.cookie = `errorCount=${count}; Max-Age=${maxAge}; path=/`;
};

const MAX_ERROR_COUNT = 3; // 设置最大错误次数
let reloadTimeout: string | number | NodeJS.Timeout | null | undefined = null; // 用于存储 setTimeout 返回的 timeout ID

const errorToReload = (type: string) => {
  if (reloadTimeout !== null) {
    // 如果已经存在一个待执行的刷新操作,取消它
    clearTimeout(reloadTimeout);
  }
  // 设置一个新的刷新操作,在用户确认后 1 秒执行
  reloadTimeout = setTimeout(() => {
    let errorCount = getErrorCount();
    errorCount += 1;
    setErrorCount(errorCount);
    
    if (errorCount <= MAX_ERROR_COUNT) {
      // 如果错误次数小于最大错误次数,刷新页面
      window.location.reload();
    } else {
      // 如果错误次数达到最大错误次数,停止刷新页面,埋点上报
      上报埋点......
    }
  }, 1000)
}

7、用小图判断网速,弹出网速提示

使用场景

用于一些判断网速的场景

思路

  • 创建一个Image对象,用于加载指定的图片。
  • img.src设置为传入的url,并在其后加上一个随机的时间戳_t,以确保浏览器不会缓存该图片,每次都会发起新的请求
  • 记录开始加载图片的时间
  • 当图片加载成功时,计算加载所花费的总时间costTime
  • 计算下载速度speed,即文件大小除以加载时间,并转换为每秒下载的kb数。
  • 使用resolve将结果传递给Promise的成功处理函数,返回一个包含速度和耗时的对象
  • 如果加载图片时发生错误,则直接将错误信息传递给Promise的失败处理函数
const testDownloadSpeed = ({ url, size }) => {
    return new Promise((resolve, reject) => {
      const img = new Image()
      img.src = `${url}?_t=${Math.random()}` // 加个时间戳以避免浏览器只发起一次请求
      const startTime = new Date()
  
      img.onload = function () {
        const fileSize = size // 单位是 kb
        const endTime = new Date()
        const costTime = endTime - startTime
        const speed = fileSize / (endTime - startTime) * 1000 // 单位是 kb/s
        console.log('speed: ', speed);
        console.log('costTime: ', costTime);

        resolve({ speed, costTime })
      }
  
      img.onerror = reject
    })
  }

总结

以上方案各有优劣,请根据自己的实际场景进行选择;