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

前端工程

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元件中使用onClickref等客戶端API,建置報錯。

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

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

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

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

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

現象:部署到Cloudflare Workers後報process is not definedBuffer 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