Fetch 進階:AbortController 與 Streaming
Fetch API 本身沒有內建取消和進度追蹤功能,但透過 AbortController 和 ReadableStream,我們可以實現這些進階功能。
一、 AbortController:取消請求
1.1 基本用法
javascript
// 建立控制器
const controller = new AbortController();
const signal = controller.signal;
// 發送可取消的請求
fetch("/api/data", { signal })
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => {
if (error.name === "AbortError") {
console.log("請求已取消");
} else {
console.error("請求失敗:", error);
}
});
// 取消請求
controller.abort();1.2 AbortController API
javascript
const controller = new AbortController();
// 取得 signal
const signal = controller.signal;
// 檢查是否已取消
console.log(signal.aborted); // false
// 監聽取消事件
signal.addEventListener("abort", () => {
console.log("已取消");
});
// 取消(可帶原因)
controller.abort();
controller.abort("User cancelled"); // 自訂原因
// 取消後
console.log(signal.aborted); // true
console.log(signal.reason); // 'User cancelled' 或 AbortError1.3 實用場景
1. 超時控制
javascript
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeout);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
}
}2. 競態條件處理(Race Condition)
javascript
let currentController = null;
async function search(query) {
// 取消前一個請求
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal,
});
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
console.log("舊請求已取消");
return null;
}
throw error;
}
}
// 快速輸入時,只有最後一個請求會完成
search("a");
search("ab");
search("abc"); // 只有這個會完成3. React useEffect 清理
javascript
function SearchComponent({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
}
fetchData();
// 清理函數
return () => controller.abort();
}, [query]);
return <ResultList results={results} />;
}1.4 AbortSignal.timeout()
現代瀏覽器支援更簡潔的超時寫法:
javascript
// 5 秒超時
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(5000),
});1.5 組合多個 Signal
javascript
// 任一條件觸發都取消
const controller = new AbortController();
const combinedSignal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(5000),
]);
fetch("/api/data", { signal: combinedSignal });
// 可以手動取消,也會在 5 秒後自動取消二、 ReadableStream:串流處理
2.1 什麼是串流?
傳統 Fetch 需要等待整個回應後才能處理:
javascript
const response = await fetch("/api/large-data");
const data = await response.json(); // 等待全部下載串流允許邊下載邊處理:
2.2 讀取串流
javascript
async function readStream(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log("串流結束");
break;
}
// value 是 Uint8Array
const chunk = decoder.decode(value, { stream: true });
result += chunk;
console.log("收到 chunk:", chunk.length, "bytes");
}
return result;
}2.3 下載進度追蹤
javascript
async function downloadWithProgress(url, onProgress) {
const response = await fetch(url);
const contentLength = response.headers.get("Content-Length");
const total = parseInt(contentLength, 10);
const reader = response.body.getReader();
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (total) {
const progress = (received / total) * 100;
onProgress(progress, received, total);
}
}
// 合併所有 chunks
const blob = new Blob(chunks);
return blob;
}
// 使用
downloadWithProgress("/files/large.zip", (percent, received, total) => {
console.log(`${percent.toFixed(1)}% (${received}/${total})`);
});2.4 串流 JSON(Line-delimited JSON)
處理 NDJSON 格式(每行一個 JSON):
javascript
async function* streamJSON(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按行分割
const lines = buffer.split("\n");
buffer = lines.pop(); // 保留不完整的最後一行
for (const line of lines) {
if (line.trim()) {
yield JSON.parse(line);
}
}
}
// 處理最後一行
if (buffer.trim()) {
yield JSON.parse(buffer);
}
}
// 使用
for await (const item of streamJSON("/api/stream")) {
console.log("收到:", item);
}三、 Server-Sent Events (SSE)
3.1 使用 EventSource
javascript
const eventSource = new EventSource("/api/events");
eventSource.onmessage = (event) => {
console.log("收到:", event.data);
};
eventSource.onerror = (error) => {
console.error("SSE 錯誤:", error);
};
// 關閉連線
eventSource.close();3.2 使用 Fetch 讀取 SSE
更靈活的方式:
javascript
async function readSSE(url, onEvent) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE 事件以 \n\n 分隔
const events = buffer.split("\n\n");
buffer = events.pop();
for (const eventText of events) {
const event = parseSSE(eventText);
if (event) onEvent(event);
}
}
}
function parseSSE(text) {
const lines = text.split("\n");
const event = {};
for (const line of lines) {
if (line.startsWith("data: ")) {
event.data = line.slice(6);
} else if (line.startsWith("event: ")) {
event.type = line.slice(7);
} else if (line.startsWith("id: ")) {
event.id = line.slice(4);
}
}
return event.data ? event : null;
}四、 AI 串流回應
現代 AI API 常用的串流模式:
4.1 OpenAI 風格串流
javascript
async function streamChat(prompt, onChunk) {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, stream: true }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 處理 data: 前綴
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
try {
const json = JSON.parse(data);
const content = json.choices?.[0]?.delta?.content;
if (content) onChunk(content);
} catch {}
}
}
}
}
// 使用
let fullResponse = "";
await streamChat("你好", (chunk) => {
fullResponse += chunk;
console.log(chunk); // 逐字輸出
});4.2 帶取消功能的 AI 串流
javascript
function createStreamingChat() {
let controller = null;
return {
async send(prompt, onChunk) {
// 取消前一個請求
this.abort();
controller = new AbortController();
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, stream: true }),
signal: controller.signal,
});
// ... 串流處理邏輯
} catch (error) {
if (error.name === "AbortError") {
console.log("串流已停止");
} else {
throw error;
}
}
},
abort() {
if (controller) {
controller.abort();
controller = null;
}
},
};
}
const chat = createStreamingChat();
chat.send("寫一首詩", console.log);
// 停止生成
chat.abort();五、 上傳進度
Fetch 無法直接追蹤上傳進度,但可以用 XHR 或 ReadableStream:
5.1 使用 XHR
javascript
function uploadWithProgress(url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
onProgress((e.loaded / e.total) * 100);
}
});
xhr.addEventListener("load", () => resolve(xhr.response));
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
xhr.open("POST", url);
xhr.send(file);
});
}5.2 使用壓縮串流
javascript
// 實驗性 API
const stream = new ReadableStream({
start(controller) {
const reader = file.stream().getReader();
let uploaded = 0;
function push() {
reader.read().then(({ done, value }) => {
if (done) {
controller.close();
return;
}
uploaded += value.length;
console.log("上傳進度:", (uploaded / file.size) * 100);
controller.enqueue(value);
push();
});
}
push();
},
});
fetch("/api/upload", {
method: "POST",
body: stream,
duplex: "half", // 需要此選項
});總結
| 功能 | API | 說明 |
|---|---|---|
| 取消請求 | AbortController | 任何時候可取消 |
| 超時 | AbortSignal.timeout() | 指定毫秒數 |
| 下載進度 | ReadableStream | 讀取 response.body |
| 串流處理 | reader.read() | 逐塊處理 |
| SSE | EventSource / Fetch | 伺服器推送 |
| 上傳進度 | XHR | Fetch 不原生支援 |
> **最佳實踐**:
- 搜尋/自動完成一定要用 AbortController
- 大檔案下載用串流追蹤進度
- AI 串流用 ReadableStream 逐字顯示
- React 元件卸載時記得取消請求
進階挑戰
- 實作一個可取消的 AI 對話元件,支援「停止生成」功能。
- 建立一個大檔案下載器,顯示進度條並支援暫停/繼續(使用 Range 請求)。
- 封裝一個統一的 Fetch 包裝器,整合超時、重試、取消功能。