使用 Nitro + Vite 实现无框架 SSR:HTML 模板流式渲染实战指南
2026/9/15 14:19:32 网站建设 项目流程

使用 Nitro + Vite 实现无框架 SSR:HTML 模板流式渲染实战指南

【免费下载链接】nitroNext Generation Server Toolkit. Create web servers with everything you need and deploy them wherever you prefer.项目地址: https://gitcode.com/GitHub_Trending/ni/nitro

Nitro 与 Vite 深度集成后,可以不依赖任何前端框架,仅凭一个 HTML 模板、一个 SSR 入口和一个 API 路由,就完成服务端渲染(SSR)乃至逐词流式输出。本文以仓库中的 vite-ssr-html 示例 为蓝本,完整拆解<!--ssr-outlet-->占位符、Nitro Vite 插件配置、SSR 入口的数据获取与ReadableStream流式响应,并深入到 src/build/vite/prod.ts 等源码验证其底层替换机制,让你掌握一套可直接复制运行的"纯 HTML + SSR"技术方案。

示例概览:一个"名言卡片"应用

该示例实现了一个展示随机名言的小型页面:首次访问时由服务端渲染并流式输出一句名言,页面上的 "New Quote" 按钮则通过fetch("/quote")拉取新名言并替换显示。它的核心演示点在于:

  1. 通过 Nitro Vite 插件启用 SSR,无需任何 UI 框架;
  2. HTML 模板以<!--ssr-outlet-->注释标记服务端内容插入位置
  3. SSR 入口(server entry)抓取数据并返回ReadableStream,实现逐词流式刷新(每个单词间隔 50ms);
  4. API 路由提供服务端数据,页面与 SSR 共用同一个数据源。

仓库对该示例的一句概括(见 examples/vite-ssr-html/README.md):"This example renders an HTML template with server-side data and streams the response word by word. It demonstrates how to use Nitro's Vite SSR integration without a framework."

项目文件结构

示例位于仓库 examples/vite-ssr-html 目录,完整的文件组织如下:

examples/vite-ssr-html/ ├── app/ │ └── entry-server.ts # SSR 入口:抓取名言并以流式响应返回 ├── routes/ │ └── quote.ts # /quote API 路由:返回随机名言 ├── index.html # HTML 模板,含 <!--ssr-outlet--> 占位符 ├── package.json ├── tsconfig.json └── vite.config.ts # 引入 nitro() 与 tailwindcss() 两个 Vite 插件

其中app/entry-server.tsroutes/quote.ts的位置分别对应 Nitro 的 Server Entry(服务器入口) 与 文件系统路由(routing) 约定:routes/目录下的文件自动成为路由,app/entry-server.ts则是通过 Vite 环境约定的 SSR 入口。

逐个文件拆解

1. index.html:HTML 模板与<!--ssr-outlet-->

这是整个 SSR 方案的起点。模板使用 Tailwind CSS v4(通过<style>@import "tailwindcss";</style>引入),并在正文中放置了一个 HTML 注释<!--ssr-outlet-->,它就是服务端渲染内容的"插座":

<!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Nitro Quotes</title> <style> @import "tailwindcss"; </style> </head> <body class="min-h-screen flex items-center justify-center p-5 bg-gradient-to-br from-indigo-500 to-purple-600 font-sans" > <div class="max-w-xl w-full text-center text-white"> <div class="bg-white/10 backdrop-blur-md rounded-2xl p-10 shadow-xl border border-white/20"> <div id="quote" class="text-[clamp(1.2rem,4vw,1.8rem)] leading-relaxed mb-5 font-light opacity-70 transition-opacity duration-500" > <!--ssr-outlet--> </div> <div id="author" class="text-[clamp(1rem,3vw,1.2rem)] opacity-0 font-normal transition-opacity duration-500" ></div> <button id="refresh-btn" class="mt-5 bg-white/20 border border-white/30 text-white px-6 py-3 rounded-full cursor-pointer text-sm transition hover:bg-white/30 hover:-translate-y-0.5" onclick="fetchQuote()" > New Quote </button> </div> <div class="mt-8 text-sm opacity-60"> Powered by <a class="text-white no-underline border-b border-white/30 hover:border-white transition-colors" href="https://vitejs.dev/" >Vite</a > and <a class="text-white no-underline border-b border-white/30 hover:border-white transition-colors" href="https://github.com/nitrojs/nitro" >Nitro v3</a >. </div> </div> <script> const quoteElement = document.getElementById("quote"); const authorElement = document.getElementById("author"); const refreshBtn = document.getElementById("refresh-btn"); const baseQuoteClasses = "text-[clamp(1.2rem,4vw,1.8rem)] leading-relaxed mb-5 font-light transition-opacity duration-500"; const loadingQuoteClasses = baseQuoteClasses + " opacity-70"; const normalQuoteClasses = baseQuoteClasses + " opacity-100"; const errorQuoteClasses = baseQuoteClasses + " text-red-400 opacity-100 text-sm"; const baseAuthorClasses = "text-[clamp(1rem,3vw,1.2rem)] font-normal transition-opacity duration-500"; const hiddenAuthorClasses = baseAuthorClasses + " opacity-0"; const visibleAuthorClasses = baseAuthorClasses + " opacity-80"; async function fetchQuote() { try { quoteElement.textContent = "Loading..."; quoteElement.className = loadingQuoteClasses; authorElement.textContent = ""; authorElement.className = hiddenAuthorClasses; refreshBtn.style.display = "none"; const response = await fetch("/quote"); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const { text, author } = await response.json(); quoteElement.textContent = `"${text}"`; quoteElement.className = normalQuoteClasses; authorElement.textContent = `— ${author}`; authorElement.className = visibleAuthorClasses; } catch (error) { console.error("Error fetching quote:", error); quoteElement.textContent = "Failed to load quote. Please try again."; quoteElement.className = errorQuoteClasses; authorElement.textContent = ""; authorElement.className = hiddenAuthorClasses; } finally { refreshBtn.style.display = "inline-block"; } } </script> </body> </html>

页面包含三个关注点:

  • <!--ssr-outlet-->:服务端渲染结果将被插入到#quote这个<div>内(即首次访问时的名言内容);
  • 内联<script>:负责客户端"换一条名言"的交互逻辑,通过fetch("/quote")请求 API,并根据response.ok切换加载中、正常、错误三种视觉状态(对应三组 CSS class);
  • onclick="fetchQuote()":按钮直接绑定全局函数,属于无框架场景下最朴素的交互方式。

值得注意的是,该脚本同时服务于"SSR 填充后的初始状态"与"客户端刷新后的状态"——两条路径共用同一个数据来源/quote,这正是该示例想传达的"服务端与客户端共享 API"的设计思路。

2. vite.config.ts:接入 Nitro Vite 插件

Nitro 通过 Vite 插件的形式与 Vite 构建管线集成。示例中的配置非常简洁:

import { defineConfig } from "vite"; import { nitro } from "nitro/vite"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [ nitro({ serverDir: "./", }), tailwindcss(), ], });

关键点解析:

  • nitro()是 Nitro 提供的 Vite 插件(从nitro/vite导入),它负责把 Nitro 的 SSR、路由、构建能力接入 Vite 的开发服务器与生产构建;
  • serverDir: "./"表示把当前目录作为 Nitro 的服务器扫描目录——这解释了为什么routes/quote.tsapp/entry-server.ts能被自动识别(分别作为路由和 SSR 入口);
  • tailwindcss()是 Tailwind CSS v4 官方 Vite 插件,负责处理模板中的@import "tailwindcss";与 Utility class。

3. app/entry-server.ts:SSR 入口与逐词流式输出

这是整个 SSR 流程的"发动机"。它导出一个带fetch方法的对象,与 Server Entry 文档 中定义的 Web 兼容接口完全一致(fetch(request: Request): Response):

import { fetch } from "nitro"; export default { async fetch() { const quote = (await fetch("/quote").then((res) => res.json())) as { text: string; }; return tokenizedStream(quote.text, 50); }, }; function tokenizedStream(text: string, delay: number): ReadableStream<Uint8Array> { const tokens = text.split(" "); return new ReadableStream({ start(controller) { let index = 0; function push() { if (index < tokens.length) { const word = tokens[index++] + (index < tokens.length ? " " : ""); controller.enqueue(new TextEncoder().encode(word)); setTimeout(push, delay); } else { controller.close(); } } push(); }, }); }

分三步理解这段代码:

  1. 内部请求 APIimport { fetch } from "nitro"提供的是 Nitro 的内部 fetch,它绕过真实网络直接调用应用自身的路由处理器。这里用它请求/quote并解析出{ text },避免在服务端硬编码名言数据,也让 SSR 与后续客户端刷新保持同一数据源;
  2. 流式响应tokenizedStream(quote.text, 50)把名言文本按空格切分为单词数组,用标准的 WebReadableStream逐词写入:controller.enqueue(new TextEncoder().encode(word))每 50ms 推送一个单词(单词之间补一个空格),全部推完后controller.close()
  3. 无框架 SSR 的范式:SSR 入口只需返回一个Response(或在这里直接返回ReadableStream),Nitro 会把它插入 HTML 模板的<!--ssr-outlet-->位置。由于流是异步逐块产生的,浏览器会看到文字"打字机式"地逐词出现——这正是流式渲染(Streaming SSR)的魅力。

4. routes/quote.ts:API 路由与模块级缓存

routes/quote.ts是文件系统路由,自动映射到GET /quote

const QUOTES_URL = "https://github.com/JamesFT/Database-Quotes-JSON/raw/refs/heads/master/quotes.json"; let _quotes: Promise<unknown> | undefined; function getQuotes() { return (_quotes ??= fetch(QUOTES_URL).then((res) => res.json())) as Promise< { quoteText: string; quoteAuthor: string }[] >; } export default async function quotesHandler() { const quotes = await getQuotes(); const randomQuote = quotes[Math.floor(Math.random() * quotes.length)]; return Response.json({ text: randomQuote.quoteText, author: randomQuote.quoteAuthor, }); }

三个要点:

  • 模块级缓存let _quotes位于模块顶层,??=确保名言数据集只远程拉取一次,之后所有请求复用同一份Promise(并发的第一次请求也会共享),避免每次请求都产生外部网络开销;
  • 标准 Web Response:处理器直接返回Response.json(...),与 Nitro 基于 Web 标准的运行时保持一致(详见 src/runtime/vite.ts 中fetchViteEnvPromise.resolve(viteEnv.fetch(toRequest(input, init)))调用链);
  • 随机选取:每次请求从数组中随机挑一条名言,返回{ text, author }结构,同时服务于 SSR 入口与浏览器端fetchQuote()

5. package.json 与 tsconfig.json

{ "type": "module", "scripts": { "build": "vite build", "dev": "vite dev", "preview": "vite preview" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", "nitro": "latest", "tailwindcss": "^4.2.2", "vite": "latest" } }
{ "extends": "nitro/tsconfig" }
  • "type": "module"使项目采用 ESM,SSR 入口与路由均使用import/export语法;
  • 三个脚本dev/build/preview完全由 Vite 驱动,Nitro 作为 Vite 插件参与其中,不需要单独调用 Nitro CLI;
  • tsconfig.json直接继承nitro/tsconfig,获得 Nitro 预设的路径别名(如nitro模块、#nitro/...虚拟模块)与类型配置。

工作原理:从请求到流式 HTML

<!--ssr-outlet-->的替换机制(源码级验证)

Nitro 在开发与生产两种模式下,以不同方式处理<!--ssr-outlet-->

  • 开发模式:模板文件在每次请求时从磁盘读取,Nitro 将<!--ssr-outlet-->替换为对 SSR 服务的调用。见 src/build/vite/dev.ts 第 249 行附近的"<!--ssr-outlet-->"处理逻辑,同时 Vite 的transformIndexHtml钩子会注入 HMR 客户端脚本;
  • 生产构建:Vite 先完成对index.html的构建(解析脚本、CSS 与资源),随后 Nitro 读取构建产物并把占位符替换为模板表达式,写入临时文件作为最终 renderer 模板。见 src/build/vite/prod.ts:
const html = await readFile(outputPath, "utf8").then((r) => r.replace("<!--ssr-outlet-->", `{{{ fetchViteEnv("ssr", $REQUEST) || "" }}}`) );

fetchViteEnv("ssr", $REQUEST)调用在 src/runtime/vite.ts 中实现:它根据环境名"ssr"#nitro/virtual/vite-services中取出对应的 Vite 服务,再把$REQUEST转成标准Request交给该服务的fetch执行——即调用你的app/entry-server.ts。返回的流式内容最终被嵌入{{{ }}}(rendu 模板的原始输出语法)进入响应。

路由匹配优先级

SSR 入口在 Nitro 的路由体系中属于兜底处理器。根据 Server Entry 文档 与 Renderer 文档 的说明,请求的匹配顺序为:

  1. 具体路由优先:routes/quote.ts命中/quote
  2. 未命中的请求进入 SSR 入口(server entry);
  3. SSR 入口返回undefined时才继续交给 renderer(index.html)。

在本示例中,/quote永远由 API 路由处理(返回 JSON),而页面根路径/走 SSR 入口(返回流式 HTML)。这也解释了为什么浏览器端fetch("/quote")拿到的永远是纯 JSON,而首次页面加载拿到的是流式渲染的完整 HTML。

数据流全景

首次访问 / └─ SSR 入口 fetch("/quote")(内部 fetch,无真实网络) └─ routes/quote.ts → 读取缓存名言集 → 随机一条 └─ tokenizedStream(text, 50) → ReadableStream 逐词推送 └─ 通过 fetchViteEnv("ssr", $REQUEST) 嵌入 <!--ssr-outlet--> └─ 浏览器逐词渲染名言 点击 "New Quote" └─ fetch("/quote")(浏览器真实网络请求) └─ 同一 routes/quote.ts → 随机名言 JSON └─ 前端更新 #quote / #author DOM

运行方式

在 examples/vite-ssr-html 目录下执行:

npm install # 安装 nitro / vite / tailwindcss 等依赖 npm run dev # 启动 Vite 开发服务器(Nitro 插件同时生效)

开发模式下可直接在浏览器观察:首次加载页面时名言以打字机效果逐词出现;点击 "New Quote" 后按钮短暂隐藏、文案进入加载态,随后替换为新名言。生产验证则使用:

npm run build # Vite 构建 + Nitro 产出服务器包 npm run preview # 本地预览生产构建产物

延伸阅读与扩展方向

  • 理解 SSR 入口的本质app/entry-server.ts本质上就是 Server Entry 文档 描述的 Web 兼容处理器(fetch(request) → Response),因此它可以被替换为任意实现了该接口的框架(如 H3、Hono、Elysia),这正是 Nitro 挂载其他框架的方式;
  • 自定义 renderer 模板<!--ssr-outlet-->只是 renderer 的一种用法。你可以通过renderer配置项指定模板路径、静态/动态处理,甚至编写自定义渲染处理器,详见 Renderer 文档;
  • 仓库内同类示例对照:仓库 examples 目录下还有大量 Vite SSR 集成示例,例如使用 React 的 vite-ssr-react、使用 Vue Router 的 vite-ssr-vue-router、使用 Preact 的 vite-ssr-preact 等,它们与本文的"无框架"版本共享同一套nitro()插件与 SSR 入口机制,可作为从零搭建到框架选型的过渡参考;
  • 相关单元测试:仓库 test/vite 目录下的测试(如 app.test.ts、server-entry.test.ts)覆盖了 Vite 集成与 SSR 入口的行为,是验证上述机制的自动化佐证。

小结

本文以 vite-ssr-html 示例 为主线,完整走通了"纯 HTML 模板 + Nitro Vite 插件 + SSR 入口 + API 路由"的无框架服务端渲染链路。核心要点可归纳为:

  1. nitro({ serverDir: "./" })一个插件调用即可启用 SSR;
  2. <!--ssr-outlet-->是服务端内容的注入点,开发/生产模式下分别由 src/build/vite/dev.ts 与 src/build/vite/prod.ts 负责替换;
  3. SSR 入口返回ReadableStream即可实现流式输出,配合模块级缓存的路由处理器,可以构建出响应迅速、数据同源的 SSR 页面。

这套方案特别适合需要 SSR 与流式体验、但又不想引入完整前端框架的场景,也是理解 Nitro 更复杂 SSR 集成的理想起点。

【免费下载链接】nitroNext Generation Server Toolkit. Create web servers with everything you need and deploy them wherever you prefer.项目地址: https://gitcode.com/GitHub_Trending/ni/nitro

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询