Nuxt4 + AI流式SSR:2026年大模型應用首屏載入從3秒到300ms的優化實戰

前端工程AI Agent 生產實務

Nuxt4 + AI流式SSR:2026年大模型應用首屏載入從3秒到300ms的優化實戰

你的AI對話應用首屏載入要3秒以上?使用者發一則訊息要盯著空白頁面等大模型吐完才能看到內容?SSR渲染的HTML包含了所有AI回覆但使用者等得懷疑人生?2026年,Nuxt4的流式SSR讓AI應用體驗徹底改觀——首屏300ms可見,流式輸出即時可見。


背景知識

傳統SSR在AI應用中的困境

維度 傳統SSR 流式SSR
渲染模式 等所有資料就緒後一次性渲染 資料就緒一部分就渲染一部分
首屏時間 等AI完整回應(3-30秒) 300ms出首屏骨架
使用者體驗 長時間白屏 逐步呈現內容
TTFB 極高(等AI回應) 極低(立即回傳HTML頭)
水合方式 全量水合 島嶼式/漸進水合
伺服端資源 長連線佔用 流式釋放

Nuxt4核心新特性

  • 流式渲染:renderToString支援AsyncIterable,可以邊渲染邊傳送
  • Server Components:.server.vue元件在伺服端渲染,不傳送JS到客戶端
  • Edge SSR:原生支援Cloudflare Workers / Vercel Edge / Deno Deploy
  • 混合渲染:按路由設定SSR/SSG/SWR策略

問題分析

AI應用SSR慢的根本原因

  1. 串列等待:SSR必須等AI API回傳完整回應後才能產生HTML
  2. 全量水合:客戶端重新執行所有元件邏輯,包括AI呼叫
  3. 阻塞渲染:一個慢元件阻塞整個頁面的渲染
  4. 無快取策略:AI回應不可快取,每次請求都重新呼叫

分步實作

步驟1:建立Nuxt4專案

npx nuxi@latest init ai-chat-app --template v4-compat
cd ai-chat-app
npm install
// nuxt.config.ts
export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4,
  },
  experimental: {
    componentIslands: true,
    viewTransition: true,
    renderJsonPayloads: true,
  },
  routeRules: {
    '/': { ssr: true },
    '/chat/**': { ssr: true },
    '/static/**': { ssr: false },
    '/api/ai/**': { cors: true },
  },
  nitro: {
    preset: 'cloudflare-pages',
    compressPublicAssets: true,
  },
})

步驟2:實作流式AI Server Component

<!-- components/ChatStream.server.vue -->
<script lang="ts" setup>
const props = defineProps<{
  messageId: string
  prompt: string
}>()

const stream = await aiStreamResponse(props.prompt)

async function* aiStreamResponse(prompt: string) {
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${useRuntimeConfig().public.aiApiKey}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages: [{ role: 'user', content: prompt }],
      stream: true,
    }),
  })

  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, { stream: true })
    const lines = chunk.split('\n').filter(line => line.startsWith('data: '))

    for (const line of lines) {
      const data = line.slice(6)
      if (data === '[DONE]') return
      try {
        const parsed = JSON.parse(data)
        const content = parsed.choices[0]?.delta?.content
        if (content) yield content
      } catch {}
    }
  }
}
</script>

<template>
  <div class="chat-stream">
    <div class="message-content">
      <template v-for="(segment, i) in stream" :key="i">
        <span v-html="renderMarkdown(segment)" />
      </template>
    </div>
  </div>
</template>

步驟3:聊天頁面實作

<!-- pages/chat/[id].vue -->
<script lang="ts" setup>
const route = useRoute()
const chatId = route.params.id as string

const { data: messages, refresh } = await useFetch(`/api/chat/${chatId}/messages`)

const newMessage = ref('')
const isStreaming = ref(false)

async function sendMessage() {
  if (!newMessage.value.trim() || isStreaming.value) return

  const prompt = newMessage.value
  newMessage.value = ''
  isStreaming.value = true

  await $fetch('/api/chat/send', {
    method: 'POST',
    body: { chatId, content: prompt },
  })

  await refresh()
  isStreaming.value = false
}
</script>

<template>
  <div class="chat-container">
    <div class="messages">
      <div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]">
        <div class="message-text">{{ msg.content }}</div>
      </div>
      <LazyChatStream v-if="isStreaming" :message-id="chatId" :prompt="newMessage" />
    </div>
    <div class="input-area">
      <textarea v-model="newMessage" @keydown.enter.exact.prevent="sendMessage" placeholder="輸入訊息..." />
      <button :disabled="isStreaming" @click="sendMessage">傳送</button>
    </div>
  </div>
</template>

步驟4:API路由實作流式回應

// server/api/chat/stream.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const prompt = query.prompt as string

  setResponseHeader(event, 'content-type', 'text/event-stream')
  setResponseHeader(event, 'cache-control', 'no-cache')
  setResponseHeader(event, 'connection', 'keep-alive')

  const stream = await callAIStream(prompt)

  return sendStream(event, stream)
})

async function callAIStream(prompt: string): Promise<ReadableStream> {
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.AI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages: [{ role: 'user', content: prompt }],
      stream: true,
    }),
  })

  return new ReadableStream({
    async start(controller) {
      const reader = response.body!.getReader()
      const decoder = new TextDecoder()

      while (true) {
        const { done, value } = await reader.read()
        if (done) {
          controller.close()
          break
        }
        controller.enqueue(value)
      }
    },
  })
}

步驟5:Edge SSR部署設定

// nuxt.config.ts - Edge設定
export default defineNuxtConfig({
  nitro: {
    preset: 'cloudflare-pages',
    cloudflarePages: {
      routes: {
        exclude: ['/assets/*', '/_nuxt/*'],
      },
    },
  },
  experimental: {
    asyncContext: true,
  },
})
# 建置並部署到Cloudflare Pages
npm run build
npx wrangler pages deploy .output/public

完整程式碼:生產級AI聊天應用

// composables/useAIChat.ts
export function useAIChat(chatId: string) {
  const config = useRuntimeConfig()
  const messages = ref<ChatMessage[]>([])
  const isStreaming = ref(false)
  const currentStreamContent = ref('')

  async function loadMessages() {
    const { data } = await useFetch<ChatMessage[]>(`/api/chat/${chatId}/messages`)
    if (data.value) messages.value = data.value
  }

  async function sendMessage(content: string) {
    if (isStreaming.value) return

    messages.value.push({
      id: crypto.randomUUID(),
      role: 'user',
      content,
      createdAt: new Date().toISOString(),
    })

    isStreaming.value = true
    currentStreamContent.value = ''

    try {
      const response = await fetch(`/api/chat/stream?prompt=${encodeURIComponent(content)}`, {
        headers: { 'Accept': 'text/event-stream' },
      })

      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, { stream: true })
        const lines = chunk.split('\n').filter(l => l.startsWith('data: '))

        for (const line of lines) {
          const data = line.slice(6)
          if (data === '[DONE]') continue
          try {
            const parsed = JSON.parse(data)
            const delta = parsed.choices[0]?.delta?.content || ''
            currentStreamContent.value += delta
          } catch {}
        }
      }

      messages.value.push({
        id: crypto.randomUUID(),
        role: 'assistant',
        content: currentStreamContent.value,
        createdAt: new Date().toISOString(),
      })
    } catch (error) {
      console.error('Stream error:', error)
    } finally {
      isStreaming.value = false
      currentStreamContent.value = ''
    }
  }

  return { messages, isStreaming, currentStreamContent, loadMessages, sendMessage }
}

interface ChatMessage {
  id: string
  role: 'user' | 'assistant' | 'system'
  content: string
  createdAt: string
}
// server/api/chat/[id]/messages.get.ts
import { kv } from '~/server/utils/kv'

export default defineEventHandler(async (event) => {
  const chatId = getRouterParam(event, 'id')!
  const cached = await kv.get(`chat:${chatId}:messages`)
  if (cached) return cached

  const messages = await db.chatMessage.findMany({
    where: { chatId },
    orderBy: { createdAt: 'asc' },
  })

  await kv.set(`chat:${chatId}:messages`, messages, { ttl: 60 })
  return messages
})
// server/middleware/cache.ts
export default defineEventHandler(async (event) => {
  if (event.path.startsWith('/api/chat/stream')) return

  const cached = await getResponseCache(event)
  if (cached) {
    return cached
  }
})

async function getResponseCache(event: H3Event) {
  const key = `cache:${event.path}`
  return await kv.get(key)
}

避坑指南

坑1:Server Component中使用了客戶端API

現象:.server.vue元件中使用onClick、ref等客戶端API,建置報錯。

解決:Server Component只能執行在伺服端,不能使用任何客戶端API。需要互動的部分提取為獨立的客戶端元件,透過<ClientOnly>或島嶼元件包裹。

坑2:流式渲染時水合不匹配

現象:主控台報Hydration mismatch警告,流式內容與伺服端渲染不一致。

解決:流式渲染的內容使用<ClientOnly>包裹,或使用useAsyncData的lazy: true選項避免阻塞水合。確保客戶端和伺服端使用相同的資料來源。

坑3:Edge Runtime不支援Node.js API

現象:部署到Cloudflare Workers後報process is not defined或Buffer is not defined。

解決:Edge Runtime不包含Node.js API。使用import { H3Event } from 'h3'替代Node.js的IncomingMessage,AI API呼叫使用原生fetch,避免使用axios等Node.js依賴。

坑4:SSE連線在CDN層被緩衝

現象:流式輸出在使用者端一次性出現,不是逐字顯示。

解決:確保CDN設定了X-Accel-Buffering: no回應標頭,Cloudflare預設支援SSE透傳。Vercel需要在next.config.js中設定experimental: { streaming: true }。

坑5:大量併發AI請求導致伺服端記憶體溢出

現象:高併發時Node.js程序記憶體持續增長直到OOM。

解決:實作請求佇列和併發限制,使用AbortController設定逾時,及時清理完成的流式連線。


報錯排查

序號 報錯資訊 原因 解決方法
1 Hydration mismatch 伺服端和客戶端渲染內容不一致 使用ClientOnly包裹動態內容,檢查資料一致性
2 process is not defined Edge Runtime不支援Node.js API 使用Web標準API替代,新增nitro polyfill
3 Server Component cannot use client APIs .server.vue中使用了ref/onClick 提取互動部分為客戶端元件
4 fetch is not a function 伺服端fetch未設定 確保Node.js 18+或設定nitro.nodeCompat
5 ReadableStream is not supported 執行時不支援串流 升級Node.js 18+或使用polyfill
6 CORS error on SSE 跨域SSE請求被攔截 設定routeRules的cors選項
7 KV storage not available Edge環境無持久化儲存 使用Cloudflare KV或Vercel KV
8 Maximum call stack exceeded 遞迴元件渲染溢出 檢查元件巢狀,限制遞迴深度
9 429 Too Many Requests AI API速率限制 實作請求佇列和退避重試
10 Worker exceeded CPU time limit Edge函式CPU時間超限 減少伺服端計算,流式處理AI回應

進階優化

1. 島嶼架構減少JS體積

<!-- components/AIChat.island.vue -->
<script lang="ts" setup>
defineOptions({
  island: true,
})
</script>

島嶼元件只在伺服端渲染,客戶端不下載對應JS,顯著減少水合開銷。

2. SWR快取AI回應

const { data } = await useFetch('/api/chat/messages', {
  key: `chat-${chatId}`,
  getCachedData(key, nuxtApp) {
    const cached = nuxtApp.payload.data[key]
    if (!cached) return null
    const expirationDate = new Date(cached.fetchedAt)
    expirationDate.setMinutes(expirationDate.getMinutes() + 5)
    if (expirationDate < new Date()) return null
    return cached
  },
})

3. 預渲染骨架屏

<!-- components/ChatSkeleton.vue -->
<template>
  <div class="chat-skeleton animate-pulse">
    <div class="h-4 bg-gray-200 rounded w-3/4 mb-2" />
    <div class="h-4 bg-gray-200 rounded w-1/2 mb-2" />
    <div class="h-4 bg-gray-200 rounded w-5/6" />
  </div>
</template>

4. 漸進水合策略

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    componentIslands: {
      selectiveHydration: true,
    },
  },
})

對比分析

維度 Nuxt3 SSR Nuxt4 流式SSR Next.js App Router Remix
流式渲染 需手動實作 原生支援 原生支援 defer支援
Server Components 無 原生支援 RSC 無
Edge SSR 實驗性 原生支援 原生支援 需適配
島嶼架構 實驗性 穩定 無 無
AI流式整合 需手動 內建composable Vercel AI SDK 需手動
水合策略 全量 漸進/島嶼 Selective 全量
學習曲線 中 中 高 中
生態成熟度 成熟 2026成熟 成熟 中等

總結展望

總結:Nuxt4的流式SSR為AI應用帶來了質的飛躍——從3秒白屏到300ms首屏可見。核心優化策略:Server Components減少JS體積、流式渲染即時輸出AI回應、Edge SSR降低延遲、島嶼架構按需水合。建議新AI應用直接使用Nuxt4,存量Nuxt3專案可漸進升級,重點改造AI互動頁面為流式渲染模式。


線上工具推薦

#Nuxt4#AI#SSR#流式渲染#Server Components#性能优化#大模型#Vue