跳至主要內容
Skip to content

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' 或 AbortError

1.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()逐塊處理
SSEEventSource / Fetch伺服器推送
上傳進度XHRFetch 不原生支援

> **最佳實踐**:

  • 搜尋/自動完成一定要用 AbortController
  • 大檔案下載用串流追蹤進度
  • AI 串流用 ReadableStream 逐字顯示
  • React 元件卸載時記得取消請求

進階挑戰

  1. 實作一個可取消的 AI 對話元件,支援「停止生成」功能。
  2. 建立一個大檔案下載器,顯示進度條並支援暫停/繼續(使用 Range 請求)。
  3. 封裝一個統一的 Fetch 包裝器,整合超時、重試、取消功能。

延伸閱讀與資源