第三章:打字機特效實作:深入解析 AI Streaming API 的前端處理

如果你想開發一款具備 AI 功能的商業級 SaaS,一個會「一個字一個字慢慢吐出來」的打字機特效 (Streaming UI) 是絕對必備的。如果讓使用者對著白畫面發呆 10 秒才一口氣看到完整回答,他們只會覺得你的網站又卡又慢,以為伺服器當機了。

本章將帶你手把手解析,如何在前端接收並渲染由 OpenAI 或 Anthropic API 傳回來的 Streaming 資料串流。

什麼是 ReadableStream?

在傳統的 API 請求中,我們習慣用 `await fetch(...)` 拿到回應後,直接 `await res.json()` 把整包資料轉成 JavaScript 的 Object 物件。 但在 Streaming 模式下,這招行不通。伺服器不會一次給你整包 JSON,它會像擠牙膏一樣,一小塊一小塊 (Chunk) 地把文字塞給你。

在現代瀏覽器的 JavaScript 中,我們處理這種資料的介面叫做 `ReadableStream`

實戰:打造前端打字機 Hook

為了讓邏輯更乾淨,我們不應該把這些複雜的解碼邏輯塞在 UI 裡面。我們來寫一個名為 `useAiChat` 的 Custom Hook。 這個 Hook 的任務是:發送使用者的訊息給後端 API,然後負責接收源源不絕的文字流,並把文字串接起來。

```typescript import { useState } from "react"

export function useAiChat() { const [currentMessage, setCurrentMessage] = useState("") const [isLoading, setIsLoading] = useState(false)

const sendMessage = async (userText: string) => { setIsLoading(true) setCurrentMessage("") // 清空目前的訊息準備接收新回覆

try {
  // 呼叫我們自己寫的 Next.js Route Handler (後端)
  const response = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message: userText })
  })

  if (!response.ok) throw new Error("API 請求失敗")
  if (!response.body) throw new Error("無效的資料串流")

  // 取得 ReadableStream 的閱讀器
  const reader = response.body.getReader()
  
  // 將傳過來的位元組 (Bytes) 解碼成人類看得懂的文字 (UTF-8)
  const decoder = new TextDecoder("utf-8")
  let done = false

  // 核心迴圈:只要還沒結束,就一直讀取新的文字區塊
  while (!done) {
    const { value, done: readerDone } = await reader.read()
    done = readerDone
    
    if (value) {
      // stream: true 告訴 decoder,這個位元組可能是不完整的,先不要報錯,等下一包湊齊再解碼
      const chunkString = decoder.decode(value, { stream: true })
      
      // 把新進來的文字片段,拼接到目前的字串尾端
      setCurrentMessage((prev) => prev + chunkString)
    }
  }
} catch (error) {
  console.error("Streaming 發生錯誤:", error)
  setCurrentMessage("抱歉,連線發生錯誤。")
} finally {
  setIsLoading(false)
}

}

return { currentMessage, sendMessage, isLoading } } ```

這段看似簡單的 while 迴圈,其實解決了非常深奧的前端問題。它讓我們的網頁能像水管一樣,源源不絕地接收字元。

在 UI 中使用這個 Hook

有了上述強大的 Hook 幫我們扛下複雜的位元組解碼邏輯後,我們在 UI 元件裡的寫法就會變得超級簡單乾淨:

```tsx "use client" import { useState } from "react" import { useAiChat } from "./useAiChat"

export default function AiChatBox() { const [input, setInput] = useState("") const { currentMessage, sendMessage, isLoading } = useAiChat()

const handleSubmit = () => { if (!input.trim()) return sendMessage(input) setInput("") // 清空輸入框 }

return (

  {/* 這裡是 AI 回覆區 */}
  <div className="min-h-[150px] mb-4 p-4 bg-gray-50 rounded-lg text-gray-800 whitespace-pre-wrap leading-relaxed shadow-inner">
    {currentMessage || (isLoading ? "思考中..." : "請在下方輸入問題開始對話。")}
    
    {/* 小彩蛋:加上一個閃爍的游標,讓打字機感覺更逼真 */}
    {isLoading && <span className="inline-block w-2 h-4 ml-1 bg-blue-500 animate-pulse" />}
  </div>

  {/* 這裡是輸入區 */}
  <div className="flex gap-2">
    <input 
      value={input}
      onChange={(e) => setInput(e.target.value)}
      onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
      className="flex-1 p-3 border rounded-xl focus:outline-blue-500 focus:ring-2 focus:ring-blue-200 transition-all"
      placeholder="問我任何事..."
    />
    <button 
      onClick={handleSubmit}
      disabled={isLoading}
      className="px-6 py-3 bg-blue-600 text-white font-bold rounded-xl hover:bg-blue-700 disabled:opacity-50 transition-all"
    >
      送出
    </button>
  </div>
</div>

) } ```

為什麼要用 `whitespace-pre-wrap`?

在渲染 AI 回應的 `

` 中,我們加了一個 Tailwind 的 class 叫做 `whitespace-pre-wrap`。這是一個非常容易被忽略的 UI 細節!

AI 回傳的文章常常帶有換行符號 (`\n`)。在 HTML 的預設行為中,純文字裡面的 `\n` 會被視為一個普通的空白,導致排版全部擠成一團。 加上 `whitespace-pre-wrap` 可以強制瀏覽器「尊重文字字串裡面的換行符號」,完美還原 AI 生成的段落排版與程式碼縮排。

AI 生態系的超級作弊碼:Vercel AI SDK

如果你覺得自己刻 `ReadableStream` 和 `while` 迴圈還是太麻煩了,業界還有一個大絕招。 Next.js 的母公司 Vercel 推出了一套專門處理 AI 對話的開源庫:Vercel AI SDK (`npm i ai`)。

它把我們上面寫的 `useAiChat` 封裝成了極致精簡的 `useChat` Hook,不但自帶 Streaming,連你的發問歷史紀錄、重新生成、停止生成功能都一併做好了。在真實的商業專案中,我們絕大部分都會直接依賴這個強大的 SDK 來加速開發。 但了解底層的 ReadableStream 原理,會讓你在遇到千奇百怪的 Bug 時,具備無人能及的除錯能力!

解鎖完整教學內容

本章為付費內容。加入專案即可解鎖超過 5000 字的深度解析,包含 10 個以上神級 Prompt 與真實 Source Code 範例!