背景与现象
在前端开发中,数据刷新和实时推送(如 SSE)是常见需求。但如果刷新操作被高频触发(如用户疯狂点击刷新按钮,或用极短的定时器反复刷新),会导致页面卡死、浏览器资源耗尽,出现大量 pending 请求,甚至 net::ERR_INSUFFICIENT_RESOURCES
错误。本文结合实际项目案例,系统梳理相关知识点与最佳实践。
1. 高频刷新导致页面卡死的原理
现象
- 用户疯狂点击刷新按钮或用
setInterval
以极短间隔(如10ms)反复刷新数据。 - 页面变得极其卡顿,Network 面板出现大量 pending 的 XHR 和 SSE 请求,甚至报资源耗尽错误。
原因
- 浏览器并发请求数有限(通常每域名6~10个),超出后新请求会 pending。
- 高频刷新会在极短时间内发起大量请求,远超浏览器和服务器的处理能力。
- XHR(如 axios、fetch)一旦发出,除非用 AbortController/CancelToken,否则无法主动中断。
- SSE(Server-Sent Events)虽然可以中断,但高频创建和销毁流对象,仍会造成资源堆积。
- 高频刷新本身是反模式,正常业务场景下刷新频率应为1秒及以上。
2. 单例与多实例的区别(以 SSE 管理为例)
MyStream.js
js
import AbortController from 'abort-controller';
import InvalidToken from '@common/utils/InvalidToken'
import axios from 'axios';
import { Message } from 'element-ui';
import _ from 'lodash';
class MyStream {
constructor() {
this._key = '';
this.req = null;
this.reader = null;
this.controller = null;
this.signal = null;
}
async getStream() {
// 使用参数顺序:url, methods, data, headers, fData, fEnd
const _arguments = arguments;
return new Promise((resolve, reject) => {
// 检查 token
if (!this.checkToken(_arguments, resolve, reject)) {
return;
}
// 执行原有的 stream 请求逻辑
this.executeStreamRequest(..._arguments, resolve, reject);
});
}
checkToken(_arguments, resolve, reject) {
// 登录接口不需要检查 token
let url = _arguments[0];
if (url === '/login' || url.indexOf('/XXX/XXX/login') !== -1) {
return true;
}
const access_token = localStorage.getItem('access_token');
const refresh_token = localStorage.getItem('refresh_token');
if (!access_token) {
// 返回到登录
return false;
}
if (access_token) {
const status = InvalidToken(access_token, refresh_token);
if (status === 'update') {
// 需要更新 token
return this.handleTokenRefresh(_arguments, resolve, reject);
} else if (status === 'expire') {
Message({
message: '当前会话已过期',
type: 'warning',
});
// 返回到登录
return false;
}
}
return true;
}
async handleTokenRefresh(_arguments, resolve, reject) {
try {
let refresh_token = localStorage.getItem('refresh_token');
if (!refresh_token || refresh_token === 'undefined') {
refresh_token = localStorage.getItem('access_token');
}
const response = await axios({
url: `XXXXXX/refresh-token`,
method: 'post',
headers: {
// 一些请求头
},
data: {},
});
// 更新 token
// 一些其他代码...
const { access_token, refresh_token: new_refresh_token } = response.data.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', new_refresh_token);
// 一些其他代码...
return true;
} catch (error) {
// 返回到登录
return false;
}
}
async executeStreamRequest(url, methods, data, headers, fData, fEnd, resolve, reject) {
const method = methods ? methods.toUpperCase() : 'GET';
const headerData = {
// 一些请求头
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...headers,
};
const body = data ? JSON.stringify({ ...data }) : '';
const fetchConfig = {
method,
headers: headerData,
};
if(method !== 'GET' && method !== 'HEAD') {
fetchConfig.body = body;
}
try {
this.controller = new AbortController();
this.signal = this.controller.signal;
const response = await fetch(url, {...fetchConfig, signal: this.signal});
const { body: responseBody = {} } = response;
if(responseBody.pipeThrough) {
this.reader = responseBody.pipeThrough(new TextDecoderStream()).getReader();
}
window.streamReader = this.reader;
let jsonAccumulator = '';
while (true) {
if (!this.reader) break;
const { value, done } = await this.reader.read();
if (done) break;
if (value.startsWith('{')) {
await this.reader.cancel();
throw new Error(value);
}
jsonAccumulator += value;
const chunks = jsonAccumulator.split('\n').map(chunk => chunk.trim()).filter(Boolean);
const dataIsIntegrity = this.judgingDataIntegrity(chunks[chunks.length -1]);
// 一些其他代码...
}
resolve();
} catch (error) {
reject(error);
}
}
// 拉断fetch请求
interruptStream() {
this?.controller?.abort();
// this.abortStream()
}
abortStream() {
console.log('断开 SSE')
if (this.reader) this.reader.cancel();
}
// 判断数据完整性
judgingDataIntegrity(str){
const defaultDataType = ['data: [START]', 'data: [PING]', 'data: [DONE]'];
if(defaultDataType.indexOf(str) > -1) {
return true;
}
return this.strIsJSON(str)
}
/**
* @desc 判断字符串是否为JSON格式
* @param {String} str 需要判断的字符串
* @returns {BOOlean} [返回布尔值]
*/
strIsJSON(str) {
if (typeof str === 'string') {
try {
var obj = JSON.parse(str.replace(/^(data:)/ig, ''));
if (typeof obj === 'object' && obj) {
return true;
} else {
return false;
}
} catch (e) {
return false;
}
}
return false;
}
}
// export default new MyStream(); // 单例模式
export default MyStream;
sse.js
js
import MyStream from './MyStream.js';
export default {
data(){
return {
sse: null,
};
},
created(){
this.sse = new MyStream();
// this.sse = MyStream;
},
beforeDestroy() {
this.closeSse();
},
methods: {
closeSse(){
if(this.sse) {
this.sse.abortStream();
this.sse = null;
}
},
},
}
错误做法:单例模式
js
// 错误:所有地方都用同一个实例,流对象和控制器会被覆盖
export default new MyStream();
- 多次并发 getStream 会互相覆盖
this.reader
、this.controller
,但老的 fetch/流并不会被真正中断。 - 导致大量 SSE 请求无法被正确关闭,pending 请求堆积。
正确做法:每次 new 实例
js
// 推荐:每次 new 一个实例,独立管理流和控制器
export default MyStream; // 不要 new
// 使用时
this.eventSource = new MyStream();
this.eventSource.getStream(...);
- 每次刷新时,先关闭上一个 SSE 连接,再新建新的连接,避免资源泄漏。
3. XHR/SSE 请求的正确中断方式
XHR 请求
- fetch 支持 AbortController 主动中断请求。
- axios 支持 CancelToken。
- 但一般只要刷新频率合理,不需要主动中断。
SSE 请求
- 通过保存每个 SSE 流的 controller/reader,调用 abort/cancel 方法中断。
- 需确保每次只管理自己的流对象,避免单例覆盖。
- 虽然每次都 new MyStream 并 abort 上一个,但 abort 不是同步的,极短时间内还是会有很多 SSE 请求 pending。
4. 防抖与节流:防止重复触发
防抖(debounce)
- 一段时间内只触发最后一次操作,适合输入框搜索等场景。
节流(throttle)
- 一段时间内最多触发一次,适合按钮点击、滚动监听等场景。
示例:
js
methods: {
throttledGetList: _.throttle(function(query, type) {
this.getList(query, type);
}, 1000), // 1秒内最多触发一次
}
- 按钮和定时器都调用
throttledGetList
,防止高频触发。
5. 浏览器资源限制与 pending 请求
- 浏览器对同一域名的并发请求数有限,超出后新请求会 pending。
- 高频请求会导致 pending 请求堆积,最终页面卡死,甚至报
net::ERR_INSUFFICIENT_RESOURCES
。 - 解决办法是限制请求频率 ,合理管理连接 ,避免资源泄漏。
6. 实战代码优化建议
SSE 管理优化:
js
import MyStream from './MyStream.js';
openSSE() {
this.closeSSE();
this.eventSource = new MyStream();
this.eventSource.getStream(...);
}
closeSSE() {
if (this.eventSource) {
this.eventSource.abortStream();
this.eventSource = null;
}
}
防抖/节流刷新:
js
methods: {
throttledGetList: _.throttle(function(query, type) {
this.getList(query, type);
}, 1000),
}
定时刷新建议:
js
startInterval() {
if (this.intervalId) {
this.stopInterval();
}
this.intervalId = setInterval(() => {
this.getList('', 'refresh');
}, 1000); // 1秒
}
7. 总结与最佳实践
- 高频刷新会导致前端页面卡死、请求堆积、资源耗尽。
- 正确做法是限制刷新频率 ,加防抖/节流 ,合理管理 SSE/XHR 连接。
- 任何情况下都不建议毫秒级高频刷新,正常业务刷新间隔应≥1秒。
- 遇到页面卡死、pending 请求堆积等问题,首先检查是否有高频刷新或重复请求,及时优化刷新逻辑,保障前端性能和用户体验。