前端通過fetch模擬調(diào)用SSE接口的實(shí)現(xiàn)方案
需求背景
團(tuán)隊(duì)要在系統(tǒng)集成AI大模型,需要實(shí)現(xiàn)類似于市面上的AI問答的聊天窗口。
技術(shù)調(diào)研
由于大模型接口是SSE接口,我們先了解一下SEE接口的特性。
SSE(Server-Sent Events)是一種基于HTTP的單向服務(wù)端推送技術(shù),適用于實(shí)時(shí)數(shù)據(jù)更新場景(如新聞推送、股票行情)。以下是其核心特性:
1. 單向通信
- 方向:僅服務(wù)端→客戶端單向推送,客戶端無法通過SSE通道發(fā)送數(shù)據(jù)(需配合其他API如
fetch)。 - 對比WebSocket:WebSocket是雙向通信,SSE更輕量且無需額外協(xié)議升級。
2. 基于HTTP協(xié)議
- 兼容性:直接復(fù)用HTTP協(xié)議,無需像WebSocket那樣升級協(xié)議(
Upgrade: websocket)。 - 默認(rèn)行為:
- 使用簡單GET請求建立連接。
- 響應(yīng)頭需包含
Content-Type: text/event-stream。 - 自動處理連接重連(客戶端默認(rèn)重試機(jī)制)。
3. 事件流格式
- 數(shù)據(jù)格式:每條消息以
data:開頭,以\n\n結(jié)尾,例如:data: {"time": "2025-09-02T00:00:00"}\n\n - 多字段支持:
event:自定義事件類型(如event: update\n)。id:消息ID,用于斷線重連時(shí)定位。retry:指定重連延遲(毫秒)。
4. 自動重連
- 機(jī)制:連接中斷后,瀏覽器自動嘗試重新連接(默認(rèn)間隔約3秒)。
- 控制:可通過
retry字段或監(jiān)聽onerror事件自定義重試邏輯。
5. 適用場景
- 推薦場景:實(shí)時(shí)通知、日志流、進(jìn)度更新等服務(wù)端主導(dǎo)推送的需求。
- 不適用場景:需要客戶端頻繁交互(如在線游戲、聊天室)。
6. 限制
- 協(xié)議限制:僅支持文本數(shù)據(jù)(二進(jìn)制需編碼為Base64)。
- 瀏覽器限制:
- 最大并發(fā)連接數(shù)(同一域名下通常6個(gè),HTTP/2可復(fù)用)。
- 部分老舊瀏覽器(如IE)不支持。
現(xiàn)有方案
針對SSE接口瀏覽器其實(shí)給我提供了一個(gè)簡單高效的api(EventSource),他是一個(gè)構(gòu)造函數(shù),其實(shí)例會對HTTP服務(wù)器開啟一個(gè)持久化的連接,以 text/event-stream 格式發(fā)送事件,此連接會一直保持開啟直到通過調(diào)用 EventSource.close()關(guān)閉,使用示例:
const eventSource = new EventSource('你的SSE接口地址');
eventSource.onmessage = (event) => {
console.log('接收到數(shù)據(jù):', event.data);
// 對響應(yīng)數(shù)據(jù)進(jìn)行業(yè)務(wù)處理
...業(yè)務(wù)代碼
};
eventSource.onerror = (error) => {
console.error('SSE連接錯(cuò)誤:', error);
// 可選:重連邏輯
};上面這種方案簡單高效,但是他只能發(fā)送get請求,并且請求參數(shù)不能手動直接添加到URL上,必須通過手動構(gòu)造URL字符串來實(shí)現(xiàn)。
const params = new URLSearchParams({ key1: 'value1', key2: 'value2' });
const url = `/api/v1/sse?${params.toString()}`;
const eventSource = new EventSource(url);如果想了解更多有關(guān) EventSource Web api 信息的,可以研讀下面這篇文檔,EventSource:https://developer.mozilla.org/zh-CN/docs/Web/API/EventSource
問題來了
由于系統(tǒng)需要集成的SSE接口是post請求,所以上面的方案不適用,經(jīng)過調(diào)研有兩種實(shí)現(xiàn)方案:
1.使用fetch+ReadableStream實(shí)現(xiàn) SSE
由于fetch 可以通過流式響應(yīng)逐步讀取數(shù)據(jù),模擬SSE的持續(xù)特性。(強(qiáng)烈推薦)
async function fetchSSE(url, options = {}) {
const response = await fetch(url, {
...options,
// 請求頭可根據(jù)接口文檔自行調(diào)整
headers: {
'Accept': 'text/event-stream', // 聲明需要SSE格式
...options.headers,
},
});
if (!response.ok) Promise.reject(new Error(response.status));
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// 解析SSE格式的數(shù)據(jù)(如 "data: ...\n\n")
chunk.split('\n\n').forEach(event => {
if (event.trim()) {
const data = event.replace(/^data: /, '').trim();
// 持續(xù)解析數(shù)據(jù)進(jìn)行業(yè)務(wù)處理
...業(yè)務(wù)代碼
}
});
}
}
// 調(diào)用示例
fetchSSE('url', {
method: 'POST',
headers: { 'Authorization': 'Bearer xxx' },
body: JSON.stringify({ key: 'value' }),
}).catch(error => {
// 錯(cuò)誤處理
})2. 使用XMLHttpRequest(傳統(tǒng)Ajax)模擬SSE
這種方式實(shí)現(xiàn)需要后端分塊傳輸,然后去監(jiān)聽progress事件,無法真正實(shí)現(xiàn)流式解析,不推薦使用,案例代碼:
function xhrSSE(url) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.setRequestHeader('Accept', 'text/event-stream');
xhr.onprogress = function() {
// 增量獲取響應(yīng)文本(需后端支持分塊傳輸)
const newData = xhr.responseText.substring(lastIndex);
lastIndex = xhr.responseText.length;
// 業(yè)務(wù)處理
...業(yè)務(wù)代碼
};
xhr.send();
}總結(jié)
上面三種方式都能實(shí)現(xiàn)SSE接口的調(diào)用和響應(yīng)解析,如果是get請求推薦使用瀏覽器提供的api(EventSource),如果是post請求則推薦使用fetch;開發(fā)過程中遇到的業(yè)務(wù)場景,記錄一下,希望能夠幫助到遇到同樣問題的小伙伴。
附:fetch 模擬 sse 請求方式封裝
// streamUtils.ts
/**
* SSE 請求配置選項(xiàng)
* @template T - 期望解析的數(shù)據(jù)類型 (默認(rèn)為 string)
*/
type SSEOptions<T> = {
/**
* 請求目標(biāo) URL (必需)
* @example "/api/chat-stream"
*/
url: string;
/**
* fetch 請求初始化配置
* @default { method: 'GET' }
* @example {
* method: "POST",
* headers: { "Authorization": "Bearer token" },
* body: JSON.stringify({ prompt: "Hello" })
* }
*/
requestInit?: RequestInit;
/**
* 自定義事件塊解析器
* @param eventChunk - 原始事件字符串 (包含 "data:" 等前綴)
* @returns 解析后的數(shù)據(jù)對象或 null (表示無效數(shù)據(jù))
* @default defaultParser
*/
parser?: (eventChunk: string) => T | null;
/**
* 數(shù)據(jù)到達(dá)回調(diào) (必需)
* @param data - 解析后的數(shù)據(jù)對象
*/
onOpen: (data: String) => void;
onData: (data: T) => void;
/**
* 錯(cuò)誤處理回調(diào)
* @param error - 遇到的錯(cuò)誤對象
*/
onError?: (error: Error) => void;
/**
* 流接收完成回調(diào)
*/
onComplete?: () => void;
};
/**
* SSE 流處理核心方法
* @template T - 期望解析的數(shù)據(jù)類型
* @param options - 配置選項(xiàng)
* @returns 包含中止方法的對象 { abort: () => void }
*
* @example 基本使用
* const { abort } = fetchStream({
* url: "/api/stream",
* onData: data => console.log(data),
* onError: err => console.error(err)
* });
*
* @example 帶自定義解析器
* fetchStream({
* parser: chunk => ({ msg: chunk.trim() }),
* // ...其他配置
* });
*/
export function fetchStream<T = string>(options: SSEOptions<T>) {
// 創(chuàng)建中止控制器用于中斷請求
const controller = new AbortController();
// 文本解碼器用于處理二進(jìn)制流
const decoder = new TextDecoder();
// 保存不完整的事件塊 (跨 chunk 的場景)
let partialChunk = '';
// 解構(gòu)配置參數(shù)并設(shè)置默認(rèn)值
const { parser = defaultParser, onOpen, onData, onError, onComplete } = options;
onOpen && onOpen('會話開始')
/**
* 處理流數(shù)據(jù)的內(nèi)部方法
* @param response - fetch 返回的響應(yīng)對象
*/
async function handleStream(response: Response) {
try {
// 獲取可讀流讀取器
const reader = response.body?.getReader();
if (!reader) throw new Error('Failed to get stream reader');
// 持續(xù)讀取數(shù)據(jù)流
while (true) {
const { done, value } = await reader.read();
if (done) break; // 流讀取結(jié)束
// 解碼當(dāng)前 chunk 并拼接之前未完成的數(shù)據(jù)
const chunk = decoder.decode(value, { stream: true });
// 使用通用行結(jié)束符分割事件塊 (兼容不同系統(tǒng))
// 注意:SSE 規(guī)范要求用 \n\n 分割,但某些服務(wù)可能使用 \r\n\r\n
const events = (partialChunk + chunk).split(/\r\n\r\n|\n\n/);
// 保存未完成的事件塊供下次處理
partialChunk = events.pop() || '';
// 處理每個(gè)完整的事件塊
for (const eventChunk of events) {
const data = parser(eventChunk);
if (data !== null) {
onData(data); // 觸發(fā)數(shù)據(jù)回調(diào)
}
}
}
// 處理剩余數(shù)據(jù) (最后一個(gè)事件塊)
if (partialChunk) {
const data = parser(partialChunk);
if (data !== null) onData(data);
}
onComplete?.(); // 觸發(fā)完成回調(diào)
} catch (error) {
// 忽略主動中斷產(chǎn)生的錯(cuò)誤
if (error instanceof Error && error.name !== 'AbortError') {
onError?.(error);
}
}
}
// 發(fā)起 fetch 請求
fetch(options.url, {
...options.requestInit, // 用戶自定義配置
signal: controller.signal, // 綁定中止信號
headers: {
Accept: 'text/event-stream', // 確保接收 SSE 流
...options.requestInit?.headers, // 合并用戶自定義 headers
},
})
.then(handleStream)
.catch((error) => {
// 處理初始請求錯(cuò)誤 (如網(wǎng)絡(luò)問題)
if (error instanceof Error && error.name !== 'AbortError') {
onError?.(error);
}
});
// 返回中止方法供外部調(diào)用
return {
/** 中止當(dāng)前請求 */
abort: () => controller.abort(),
};
}
/**
* 默認(rèn) SSE 解析器
* @param eventChunk - 原始事件字符串
* @returns 解析后的數(shù)據(jù)對象或 null
*
* @example 輸入示例
* "data: Hello\ndata: World\n\n"
*
* @example 輸出結(jié)果
* "Hello\nWorld" (自動合并多個(gè) data 行)
*/
function defaultParser<T = string>(eventChunk: string): T | null {
try {
let content = '';
// 逐行處理事件內(nèi)容
for (const line of eventChunk.split('\n')) {
// 僅處理 data 字段 (忽略 event/id/retry 等)
if (line.startsWith('data:')) {
content += line.slice(5).trim() + '\n'; // 保留換行結(jié)構(gòu)
}
}
content = content.trim(); // 去除首尾空白
// 空內(nèi)容返回 null
if (!content) return null;
// 嘗試解析為 JSON,失敗則返回原始字符串
try {
return JSON.parse(content) as T;
} catch {
return content as unknown as T;
}
} catch (e) {
console.error('SSE parsing error:', e);
return null;
}
}
調(diào)用方法
let abortController: (() => void) | null = null; // 中止控制器
// 發(fā)起SSE請求
const { abort } = fetchStream({
url: `/ai/chat/memory`,
requestInit: {
method: "POST",
body: formData
},
onOpen(data) {
// 開始
},
onData(data) {
// 有返回?cái)?shù)據(jù)了
},
onError(err) {
console.log("會話失敗");
error.value = `請求失敗: ${err.message}`;
},
onComplete() {
console.log("會話結(jié)束");
},
});
abortController = abort; // 保存中止函數(shù)
};// 組件卸載時(shí)中止請求
onUnmounted(() => {
abortController?.();
});到此這篇關(guān)于前端通過fetch模擬調(diào)用SSE接口的文章就介紹到這了,更多相關(guān)前端模擬調(diào)用SSE接口內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
js實(shí)現(xiàn)網(wǎng)頁隨機(jī)驗(yàn)證碼
這篇文章主要為大家詳細(xì)介紹了js實(shí)現(xiàn)網(wǎng)頁隨機(jī)驗(yàn)證碼,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-10-10
JavaScript實(shí)現(xiàn)網(wǎng)頁頭部進(jìn)度條刷新
這篇文章主要介紹了JavaScript實(shí)現(xiàn)網(wǎng)頁頭部進(jìn)度條刷新實(shí)例代碼,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2017-04-04
JS自動跳轉(zhuǎn)手機(jī)移動網(wǎng)頁的實(shí)現(xiàn)方法
本文主要介紹了JS自動跳轉(zhuǎn)手機(jī)移動網(wǎng)頁的實(shí)現(xiàn)方法,可以通過檢查 navigator.userAgent 屬性來識別用戶代理字符串中包含的設(shè)備信息,下面就詳細(xì)的來介紹一下具體用法,感興趣的可以了解一下2024-03-03
深入理解Javascript動態(tài)方法調(diào)用與參數(shù)修改的問題
這篇文章主要是對Javascript動態(tài)方法調(diào)用與參數(shù)修改的問題進(jìn)行了詳細(xì)的分析介紹,需要的朋友可以過來參考下,希望對大家有所幫助2013-12-12
你可能不知道的JavaScript的new Function()方法
JavaScript的精神領(lǐng)袖Douglas Crockford曾說過JavaScript是程序員唯一不需要學(xué)習(xí)就能直接使用的語言. 在編程中確實(shí)是如此2014-04-04
前端異常監(jiān)控之如何捕獲并上報(bào)JS錯(cuò)誤與白屏詳解
在前端開發(fā)中,錯(cuò)誤處理是提升用戶體驗(yàn)的關(guān)鍵環(huán)節(jié),當(dāng)用戶在使用應(yīng)用時(shí)遇到錯(cuò)誤,一個(gè)友好的提示能有效降低用戶的挫敗感,這篇文章主要介紹了前端異常監(jiān)控之如何捕獲并上報(bào)JS錯(cuò)誤與白屏的相關(guān)資料,需要的朋友可以參考下2026-02-02

