前端高频刷新、SSE/XHR请求管理与性能优化实战(笔记)

背景与现象

在前端开发中,数据刷新和实时推送(如 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.readerthis.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 请求堆积等问题,首先检查是否有高频刷新或重复请求,及时优化刷新逻辑,保障前端性能和用户体验。

相关推荐
安心不心安36 分钟前
React hooks——useReducer
前端·javascript·react.js
像风一样自由202038 分钟前
原生前端JavaScript/CSS与现代框架(Vue、React)的联系与区别(详细版)
前端·javascript·css
啃火龙果的兔子39 分钟前
react19+nextjs+antd切换主题颜色
前端·javascript·react.js
_pengliang1 小时前
小程序按住说话
开发语言·javascript·小程序
布兰妮甜1 小时前
创建游戏或互动体验:从概念到实现的完整指南
javascript·游戏开发·游戏ai·互动体验·用户输入处理
paid槮1 小时前
HTML5如何创建容器
前端·html·html5
小飞悟1 小时前
一打开文章就弹登录框?我忍不了了!
前端·设计模式
烛阴1 小时前
Python模块热重载黑科技:告别重启,代码更新如丝般顺滑!
前端·python
吉吉612 小时前
Xss-labs攻关1-8
前端·xss
拾光拾趣录2 小时前
HTML行内元素与块级元素
前端·css·html