JavaScript 实现 HTTPS SSE 连接

JavaScript 实现 HTTPS SSE 连接

一、SSE 简介

SSE(Server-Sent Events,服务器推送事件)是一种基于 HTTP 的单向通信协议,允许服务器主动向客户端推送数据,适用于实时通知、消息提醒、状态同步等场景。与 WebSocket 双向通信不同,SSE 仅支持服务器到客户端的单向传输,开销更小,适配 HTTPS 协议更简洁。

本文实现的 SSE 客户端具备以下特性:HTTPS 适配、自动重连、事件监听、单例模式、错误处理、动态拼接用户参数,可直接集成到 Vue3/Vite 等前端项目中。

二、 SSE 客户端代码

核心文件:utils/sseClient.js,采用类封装,提供完整的连接、监听、断开、重连能力,支持自定义事件与参数动态拼接。

javascript 复制代码
import { getToken, getUserInfo } from '@/utils/tools';

class SSEClient {
    constructor() {
        this.source = null; // SSE 核心实例
        // 基础 URL(从环境变量读取,适配不同环境)
        this.baseUrl = import.meta.env.VITE_API_BASE_URL;
        this.path = '/sse/talent/adminConnect'; // SSE 接口路径
        this.url = `${this.baseUrl}${this.path}`;
        // 请求头(携带 Token 做权限校验,适配 HTTPS 鉴权),目前这种方式并不支持自定义headers
        this.headers = {
            'satoken': getToken(),
        };
        this.reconnectInterval = 30000; // 自动重连间隔(30秒,可配置)
        this.isConnected = false; // 连接状态标识
        this.isReconnecting = false; // 重连状态标识(避免重复重连)
        this.eventHandlers = new Map(); // 事件处理器映射表(支持多事件)
        this.maxReconnectTimes = 10; //最大重连次数(避免无限重连)
        this.reconnectCount = 0; // 当前重连次数
    }

    /**
     * 动态拼接 URL(携带用户ID与Token,适配权限校验)
     * 优化点:避免重复拼接相同参数,异常时返回基础URL
     */
    getUrlWithParams() {
        try {
            const userInfo = getUserInfo();
            // 无用户信息时返回基础URL
            if (!userInfo || !userInfo.userId) {
                return this.url;
            }
            const userId = userInfo.userId;
            // 拼接参数(含Token与用户ID,双重鉴权)
            const params = new URLSearchParams();
            params.append('userId', userId);
            // 拼接完整URL,避免重复参数
            const fullUrl = `${this.url}?${params.toString()}`;
            return fullUrl;
        } catch (error) {
            console.warn('SSE 拼接参数失败,使用基础URL:', error);
            return this.url;
        }
    }

    /**
     * 初始化 SSE 连接
     * @param {Object} options - 配置项
     * @param {boolean} options.withCredentials - 是否携带跨域凭证
     * @param {number} options.reconnectInterval - 自定义重连间隔
     */
    connect(options = {}) {
        // 避免重复连接或重连中重复触发
        if (this.isConnected || this.isReconnecting) {
            return;
        }

        // 合并配置项(支持自定义重连间隔)
        const withCredentials = options.withCredentials || false;
        this.reconnectInterval = options.reconnectInterval || this.reconnectInterval;

        // 先断开已有连接(防止残留连接)
        this.disconnect();
        // 更新最新URL(确保参数实时)
        this.url = this.getUrlWithParams();

        try {
            // SSE 初始化配置(适配 HTTPS 与跨域)
            const eventSourceInitDict = { withCredentials };
            this.source = new EventSource(this.url, eventSourceInitDict);

            // 监听连接成功事件
            this.source.onopen = () => {
                this.isConnected = true;
                this.isReconnecting = false;
                this.reconnectCount = 0; // 重置重连次数
                this.emit('open', { status: 'connected', url: this.url });
                console.log('SSE 连接成功:', this.url);
            };

            // 监听服务器推送的通用消息
            this.source.onmessage = (event) => {
                try {
                    // 尝试解析 JSON 格式数据(兼容字符串与JSON)
                    const data = JSON.parse(event.data);
                    this.emit('message', data);
                } catch (e) {
                    // 非JSON格式直接返回原始数据
                    this.emit('message', event.data);
                }
            };

            // 监听连接错误(核心:错误处理与自动重连)
            this.source.onerror = (error) => {
                this.isConnected = false;
                const errorMsg = `SSE 连接错误(状态码:${this.source.readyState})`;
                console.error(errorMsg, error);
                this.emit('error', { message: errorMsg, error, readyState: this.source.readyState });

                // 仅在连接关闭时触发重连(排除临时错误)
                if (this.source.readyState === EventSource.CLOSED && !this.isReconnecting) {
                    this.reconnectCount++;
                    // 超过最大重连次数停止重连
                    if (this.reconnectCount > this.maxReconnectTimes) {
                        this.isReconnecting = false;
                        this.emit('reconnect-failed', { maxTimes: this.maxReconnectTimes });
                        console.error(`SSE 重连失败(已超过最大次数 ${this.maxReconnectTimes})`);
                        return;
                    }

                    this.isReconnecting = true;
                    console.log(`SSE 准备重连(第 ${this.reconnectCount}/${this.maxReconnectTimes} 次),间隔 ${this.reconnectInterval}ms`);
                    // 延迟重连(避免频繁请求)
                    setTimeout(() => {
                        this.connect(options);
                    }, this.reconnectInterval);
                }
            };

            // 注册已绑定的自定义事件(避免重连后事件丢失)
            this.eventHandlers.forEach((handler, eventName) => {
                this.bindCustomEvent(eventName, handler);
            });

        } catch (e) {
            this.isReconnecting = false;
            const errorMsg = '创建 SSE 连接失败(初始化异常)';
            console.error(errorMsg, e);
            this.emit('error', { message: errorMsg, error: e });
        }
    }

    /**
     * 绑定自定义事件(抽离复用,适配重连场景)
     * @param {string} eventName - 自定义事件名
     * @param {Function} handler - 事件处理器
     */
    bindCustomEvent(eventName, handler) {
        // 排除系统内置事件(open/message/error)
        const systemEvents = ['open', 'message', 'error'];
        if (systemEvents.includes(eventName)) return;

        // 移除已有监听(避免重复绑定)
        if (this.source) {
            this.source.removeEventListener(eventName, handler);
            // 绑定新监听
            this.source.addEventListener(eventName, (event) => {
                try {
                    const data = JSON.parse(event.data);
                    handler(data);
                } catch (e) {
                    handler(event.data);
                }
            });
        }
    }

    /**
     * 注册事件监听(支持系统事件与自定义事件)
     * @param {string} eventName - 事件名
     * @param {Function} handler - 事件处理器
     */
    on(eventName, handler) {
        if (typeof handler !== 'function') {
            console.warn(`SSE 事件 ${eventName} 处理器必须是函数`);
            return;
        }
        // 存储事件处理器
        this.eventHandlers.set(eventName, handler);
        // 已连接状态下立即绑定事件
        if (this.source && this.isConnected) {
            this.bindCustomEvent(eventName, handler);
        }
    }

    /**
     * 触发事件(供内部调用,分发消息)
     * @param {string} eventName - 事件名
     * @param {any} data - 事件数据
     */
    emit(eventName, data) {
        const handler = this.eventHandlers.get(eventName);
        if (handler) {
            try {
                handler(data);
            } catch (e) {
                console.error(`SSE 触发事件 ${eventName} 失败:`, e);
            }
        }
    }

    /**
     * 移除事件监听
     * @param {string} eventName - 事件名
     */
    off(eventName) {
        this.eventHandlers.delete(eventName);
        if (this.source) {
            const handler = this.eventHandlers.get(eventName);
            if (handler) {
                this.source.removeEventListener(eventName, handler);
            }
        }
    }

    /**
     * 关闭 SSE 连接(重置状态,避免内存泄漏)
     * @param {boolean} isManual - 是否手动关闭(区别于异常断开)
     */
    disconnect(isManual = false) {
        if (this.source) {
            this.source.close();
            this.source = null;
        }
        // 重置状态
        this.isConnected = false;
        this.isReconnecting = false;
        if (isManual) {
            this.reconnectCount = 0; // 手动关闭重置重连次数
            this.emit('close', { isManual });
            console.log('SSE 手动关闭连接');
        }
    }

    /**
     * 强制重连(主动触发重连,重置重连次数)
     * @param {Object} options - 配置项
     */
    forceReconnect(options = {}) {
        this.reconnectCount = 0;
        this.isReconnecting = false;
        this.connect(options);
    }

    /**
     * 更新连接参数(如Token过期后刷新URL)
     */
    updateParams() {
        this.url = this.getUrlWithParams();
        // 已连接状态下重新连接(刷新参数)
        if (this.isConnected) {
            this.forceReconnect();
        }
    }
}

// 全局单例模式(确保全项目唯一 SSE 实例,避免多连接冲突)
export const sseClient = new SSEClient();

三、使用示例(Vue3/Vite 集成)

3.1 全局引入(main.js)

将 SSE 实例挂载到全局,便于全项目使用:

javascript 复制代码
import { createApp } from 'vue';
import App from './App.vue';
import { sseClient } from '@/utils/sseClient';

const app = createApp(App);
// 全局挂载 SSE 实例
app.config.globalProperties.$sse = sseClient;

app.mount('#app');

3.2 组件内使用(页面/组件中)

vue 复制代码
<script setup>
import { onMounted, onUnmounted } from 'vue';
import { sseClient } from '@/utils/sseClient';

onMounted(() => {
    // 1. 注册事件监听
    // 连接成功事件
    sseClient.on('open', (res) => {
        console.log('SSE 连接成功', res);
        // 可在这里处理连接成功后的逻辑(如更新UI状态)
    });

    // 通用消息事件(服务器推送的所有消息)
    sseClient.on('message', (data) => {
        console.log('收到 SSE 消息', data);
        // 处理业务逻辑(如实时通知、数据更新)
    });

    // 自定义事件(后端指定事件名,如"notice")
    sseClient.on('notice', (data) => {
        console.log('收到自定义通知', data);
        // 处理自定义消息(如系统通知、业务提醒)
    });

    // 错误事件
    sseClient.on('error', (err) => {
        console.error('SSE 异常', err);
        // 处理错误(如提示用户、降级处理)
    });

    // 重连失败事件
    sseClient.on('reconnect-failed', (res) => {
        console.error('SSE 重连失败', res);
        // 提示用户手动刷新页面
    });

    // 2. 启动 SSE 连接(配置跨域凭证)
    sseClient.connect({ withCredentials: true });
});

onUnmounted(() => {
    // 组件卸载时移除事件监听、关闭连接(避免内存泄漏)
    sseClient.off('open');
    sseClient.off('message');
    sseClient.off('notice');
    sseClient.off('error');
    sseClient.off('reconnect-failed');
    sseClient.disconnect(true); // 手动关闭连接
});
</script>

3.3

  • 可以用pinia全局管理并调用,配合路由守卫也可以全局使用

3.4 特殊场景处理

  1. Token 过期刷新 :Token 刷新后调用 sseClient.updateParams(),自动更新URL并重新连接。

  2. 用户切换 :切换用户时先调用 sseClient.disconnect(true) 关闭连接,再调用 connect() 重新连接。

  3. 手动触发重连 :需要主动重连时调用 sseClient.forceReconnect()

四、HTTPS 适配注意事项

  • SSE 基于 HTTP/HTTPS 协议,HTTPS 环境下需确保后端 SSE 接口配置正确的 SSL 证书,避免证书无效导致连接失败。

  • 请求头鉴权:HTTPS 场景下建议将 Token 放在 URL 参数或请求头中(本文采用 URL 参数,适配部分后端不支持 SSE 自定义请求头的场景)。

  • 跨域配置:若前端与后端跨域,需后端配置 CORS 允许 EventSource 请求,同时前端开启 withCredentials: true 携带跨域凭证。

  • 连接超时:SSE 无内置超时机制,可通过后端设置心跳消息(如每30秒推送一次空消息),前端监听心跳判断连接状态。

五、后端配合要求

  1. 响应头配置:后端需返回正确的 SSE 响应头 Content-Type: text/event-stream,同时设置 Cache-Control: no-cache 禁止缓存。

  2. 消息格式:后端推送消息需遵循 SSE 格式规范,如 data: { "msg": "hello" }\n\n(注意结尾双换行)。

  3. 自定义事件:后端推送自定义事件时需指定事件名,如 event: notice\ndata: { "msg": "通知" }\n\n

  4. 连接保持:后端需保持 SSE 连接长驻,避免主动断开,同时处理客户端断开后的资源释放。

六、常见问题排查

  1. 连接失败(403 无权限):检查 Token 是否有效、用户ID是否正确,后端是否对 SSE 接口做了权限校验。

  2. 重连频繁触发 :排查后端是否主动断开连接、网络是否不稳定,可调整 reconnectInterval 重连间隔。

  3. 消息无法解析:检查后端推送消息格式是否符合 SSE 规范,是否为合法 JSON(非JSON格式会走字符串解析分支)。

  4. 跨域错误:协调后端配置 CORS,允许前端域名访问,同时前端开启 withCredentials 配置。

  5. 内存泄漏:组件卸载时必须移除事件监听并关闭连接,避免残留实例导致内存泄漏。

七、拓展方向

  1. 添加日志记录:集成日志工具,记录 SSE 连接状态、消息内容、错误信息,便于线上问题排查。

  2. 心跳检测:前端定时发送心跳请求(或后端定时推送心跳),实现连接状态精准判断。

  3. 多实例支持:修改单例模式为工厂模式,支持同时创建多个 SSE 实例,适配多接口推送场景。

  4. TypeScript 适配:添加类型定义,提升代码提示与类型安全,适配 TS 项目。

  5. 失败重试策略:支持指数退避重连(重连间隔逐渐延长),减少后端压力。

相关推荐
Marshmallowc2 小时前
React 刷新页面 Token 消失?深度解析 Redux + LocalStorage 数据持久化方案与 Hook 避坑指南
javascript·react·数据持久化·redux·前端工程化
Dreamy smile2 小时前
vue3 vite pinia实现动态路由,菜单权限,按钮权限
前端·javascript·vue.js
tqs_123452 小时前
Spring 框架中的 IoC (控制反转) 和 AOP (面向切面编程) 及其应用
java·开发语言·log4j
比昨天多敲两行2 小时前
C++ 类和对象(中)
开发语言·c++
hzb666662 小时前
basectf2024
开发语言·python·sql·学习·安全·web安全·php
superman超哥3 小时前
序列化性能优化:从微秒到纳秒的极致追求
开发语言·rust·开发工具·编程语言·rust序列化性能优化·rust序列化
Henry Zhu1233 小时前
Qt Model/View架构详解(一):基础理论
开发语言·qt
Swift社区3 小时前
Java 实战 - 字符编码问题解决方案
java·开发语言
灰灰勇闯IT3 小时前
【Flutter for OpenHarmony--Dart 入门日记】第3篇:基础数据类型全解析——String、数字与布尔值
android·java·开发语言