慢网?网慢?听我一言,都解决相信大家在自己的项目实际应用中都会遇到慢网的场景。 在慢网环境中,应用的稳定性和交互是用户体
前言
相信大家在自己的项目实际应用中都会遇到慢网
的场景。
在慢网
环境中,应用的稳定性
和交互
是用户体验的关键,所以我们需要从前端
的角度提出来一些方式来尽可能的避免
慢网对于接口数据
的影响,尽可能满足实际的应用场景。
那么我先给大家提出三个
问题,大家先行思考,再逐步解决:
- 什么是慢网?
- 慢网会造成哪些问题?
- 如何尽量避免慢网的影响?
问题
- 交互
-
- 交互卡顿、不流畅
- 接口
-
- 接口响应过慢,数据渲染不及时
- 接口响应失败
- 接口数据被错误覆盖
- ......
- 资源
-
- 资源加载慢
- 资源加载失败
- ......
模拟
- 通过
setTimeout
和promise
去模拟请求接口; - 用
Math.round(Math.random() * 10000)
去随机模拟的接口响应时长,为了观察更明显,将响应时长范围延长到0-10秒,且将后面的小数去掉; - 为了方便看出是哪个接口,在全局声明了一个变量
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;
慢网
chatGPT 对与慢网的定义
网络速度的快慢通常根据特定使用场景
的需求来定义。
以下是一些常见的网络速度分类及其对应的体验:
1. 慢网
- 下载速度: 小于 5 Mbps
- 上传速度: 小于 1 Mbps
- 体验:
-
- 网页浏览: 网页加载较慢,可能需要几秒钟到几十秒。
- 视频流媒体: 低画质(如360p),缓冲频繁,观看体验较差。
- 在线游戏: 高延迟,可能会影响游戏体验,尤其是对延迟敏感的游戏。
- 文件下载: 大文件下载时间较长,可能需要几分钟到几小时。
2. 普通网
- 下载速度: 5 Mbps - 25 Mbps
- 上传速度: 1 Mbps - 5 Mbps
- 体验:
-
- 网页浏览: 网页加载速度正常,通常几秒钟内完成。
- 视频流媒体: 中等画质(如720p),偶尔缓冲。
- 在线游戏: 延迟较低,基本流畅。
- 文件下载: 大文件下载时间在几分钟内。
3. 快网
- 下载速度: 25 Mbps - 100 Mbps
- 上传速度: 5 Mbps - 25 Mbps
- 体验:
-
- 网页浏览: 即时加载。
- 视频流媒体: 高清画质(如1080p),几乎无缓冲。
- 在线游戏: 延迟极低,非常流畅。
- 文件下载: 大文件下载时间很短,通常在几分钟内完成。
4. 超快网
- 下载速度: 大于 100 Mbps
- 上传速度: 大于 25 Mbps
- 体验:
-
- 网页浏览: 即时加载。
- 视频流媒体: 超高清画质(如4K),无缓冲。
- 在线游戏: 延迟极低,非常流畅。
- 文件下载: 大文件几乎瞬间下载完成。
常见应用场景的需求
- 基础网页浏览和邮件: 至少 1-5 Mbps。
- 标准清晰度视频流媒体(如Netflix的480p) : 至少 3-5 Mbps。
- 高清(HD)视频流媒体(如Netflix的1080p) : 至少 5-10 Mbps。
- 超高清(4K)视频流媒体: 至少 25 Mbps。
- 多人在线游戏: 至少 3-6 Mbps 下载和 1 Mbps 上传。
测试慢网
- Download(下载速度):指从服务器到客户端的数据传输速度,通常以 Mbps(兆比特每秒)或 Kbps(千比特每秒)为单位。例如,3G 网络的下载速度通常比 4G 网络慢很多。
- Upload(上传速度):指从客户端到服务器的数据传输速度,通常也以 Mbps 或 Kbps 为单位。上传速度对一些应用(如视频通话、上传文件等)非常重要。
- Latency(延迟):指数据包从客户端发送到服务器并返回的时间延迟,通常以毫秒(ms)为单位。高延迟会导致明显的网络延迟,影响用户体验。例如,卫星互联网的延迟通常比光纤连接高很多。
- Packet Loss(数据包丢失):指在网络传输过程中丢失的数据包百分比。数据包丢失可能导致连接不稳定、数据传输中断或质量下降,尤其在实时应用(如视频通话或在线游戏)中。
- Packet Queue Length(数据包队列长度):指在网络传输过程中等待传输的数据包数量。较长的队列长度可能导致延迟增加,因为数据包需要在队列中等待传输。
- Packet Reordering(数据包重新排序)是指在计算机网络中,数据包到达的顺序与发送时的顺序不同。
实践
1、防抖+loading
<Button color="success"
onClick={debounce(() => fetchData(nnIndex++), 5000, {'leading': true,'trailing': false})}
loading={loading}
>
点击模拟慢网请求
</Button>
2、对同一个接口串行
使用场景
轮询,需要对每一次轮询
的数据都做处理
思路
- 全局添加一个
标志位
flag,用于保存当前的接口是否
已经处于请求状态; - 在请求的时候先
判断
当前标志位,若还在请求
状态,则不请求下一接口; - 当请求接口请求成功,或者出现
异常
时,更新
标志位;
let flag = false; // 全局标志位
/** 模拟调用接口 */
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
值; - 检查是否存在
重复请求
,若存在,则取消已发的请求; - 取消请求有两种方式:
AbortController
和CancelToken
;
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); // 移除该请求key
...其它逻辑
},
(error: any) => {
if(!axios.isCancel(error)) {
removeRequest(config); // 移除该请求key
}
if(axios.isCancel(error)) { // 针对取消异常做处理
console.log('error111: ', error);
}
...其它逻辑
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;
qid?: string;
}
/**
* @param count 重新加载图片的次数
*/
export const useImgReload = (config: IUseImgReload) => {
const { count, imgQuerySelector = 'img', qid } = 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;
}, 1500);
} else {
// 错误次数超过限制,添加点击加载图片能力
img.style.fontSize = '12px';
img.alt = '图片加载失败, 点击重新加载';
img.onclick = () => {
img.src = img.src;
};
}
}
});
}, 0);
}, [qid]);
}
6、网络异常自动刷新页面
使用场景
对于mqtt
网慢情况下连接失败,mqtt多次自动重连
不上的场景,减少用户自己手动刷新页面这一步,自动重新刷新页面
;
思路
- 在
监听异常
的地方自动调用刷新机制 - 使用
document.cookie
存储自动刷新次数,并设置cookie的失效时间Max-Age
- 当自动刷新
超过
次数时,上报埋点
clientSelf.on('error', (err: Error) => {
Toast.show('页面出了点问题,请稍后刷新页面!');
console.error('Connection error: ', err);
mqttErrorLog(err)
setConnectStatus('error')
errorToReload('error')
});
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 = 6; // 设置最大错误次数
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 {
// 如果错误次数达到最大错误次数,停止刷新页面,埋点上报
mqttMaxErrorLog(type)
}
}, 1000)
}
7、用小图判断网速,弹出网速提示
使用场景
用于一些判断
网速的场景
思路
- 创建一个
Image
对象,用于加载指定的图片。 - img.src设置为传入的url,并在其后加上一个
随机
的时间戳_t
,以确保浏览器不会缓存该图片,每次都会发起新的请求 - 记录开始加载图片的时间
- 当图片加载成功时,计算加载所花费的总时间
costTime
。 - 计算下载速度
speed
,即文件大小除以加载时间,并转换为每秒下载的kb数。 - 使用
resolve
将结果传递给Promise的成功处理函数,返回一个包含速度
和耗时
的对象 - 如果加载图片时
发生错误
,则直接将错误信息传递给Promise
的失败处理函数
const testDownloadSpeed = ({ url = "https://m.xiwang.com/resource/-AoxAKO9BfzlhTcwuXiso-1700028922889.png", 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
})
}
......
未完待续🫣
转载自:https://juejin.cn/post/7403576996394074148