服务端渲染与部分预渲染
runtime = "server":在服务器上渲染路由并以帧流式传输;预渲染控制台外壳。
在服务器上渲染路由的 React 树,并将帧以流的形式传输到控制台。将每个静态路由的首帧预渲染到其 HTML 中。
两种运行时
每个页面都在以下两个位置之一渲染:
| 客户端(默认) | 服务端(runtime = "server") | |
|---|---|---|
| React 树运行位置 | 浏览器标签页 | text dev / text start / bun 适配器(Bun 或 Node) |
| 浏览器下载内容 | 完整应用:React、Yoga WASM、你的路由 | 帧播放器,gzip 压缩后约 21 kB |
| 数据访问 | 通过 fetch() 请求路由处理程序 | 直接访问:数据库、文件、node:*、密钥 |
| 异步组件 | 使用 use() 或 effect | 支持 async function 组件 |
| 按键延迟 | 同一任务内,同步执行 | 与服务器往返一次 |
| 可在静态托管服务上运行 | 是 | 否,需要 text start 或 bun dist/server.js |
两种运行时生成相同的帧。帧播放器使用相同的 CssConsoleDriver 编码单元格,并以相同的约 15 fps 节奏播放,因此服务端渲染的帧与客户端的 console.log 调用逐字节一致。
启用
从页面导出 runtime:
import { Suspense, type ReactNode } from "react";import os from "node:os";import { Box, Text } from "@textjs/app";export const runtime = "server";async function Load(): Promise<ReactNode> { const [one, five] = os.loadavg(); return <Text>{`load ${one.toFixed(2)} ${five.toFixed(2)}`}</Text>;}export default function StatusPage(): ReactNode { return ( <Box flexDirection="column"> <Text bold>Status</Text> <Suspense fallback={<Text color="gray">Reading host…</Text>}> <Load /> </Suspense> </Box> );}runtime 按页面读取,必须导出静态字符串(可以使用类型注解、as const 或重新导出,不能使用计算值)。布局、loading.tsx、error.tsx 和 not-found.tsx 的渲染位置与所属页面相同:客户端页面和服务端页面共用的布局会在两端运行,并发送到浏览器;仅包含服务端页面的路由段,其布局和边界则留在服务器上。Hook、焦点、useInput、<Link> 和 useRouter() 的行为与客户端相同。
运行时的选择
以下场景使用客户端运行时(默认):
- 输入密集型界面,每次按键都必须在同一任务内完成绘制,例如编辑器和筛选器。
- 部署到静态托管服务或 CDN 的应用。
- 支持离线使用的工具。
以下场景使用服务端运行时:
- 读取数据库、文件、队列或私有 API 的仪表盘和管理界面。凭据保留在服务器上。
- 包含大型依赖(SDK、解析器、语法表),且不希望这些依赖进入浏览器包的路由。
- 需要在慢速网络上快速加载的页面。浏览器下载 21 kB(gzip 压缩后)的播放器,无需下载完整应用。
你可以在同一个应用中混用运行时。在不同运行时之间导航时,目标 URL 会作为新文档加载。浏览器包用占位模块替代每个服务端页面,因此服务端模块及其导入的任何内容都不会发送到客户端。
将仅限服务端使用的模块命名为 *.server.ts。如果发送到浏览器的代码(客户端页面,或客户端页面使用的布局)导入了 *.server.* 模块或 node:* 内置模块,构建会失败,并指出导入方。
中间件保护服务端路由的方式与保护文档相同:它会在会话打开前、每次会话内导航(router.push、后退和前进)前针对目标路由运行,并在会话的 cookie 或 Authorization 发生变化时再次运行。当中间件需要重定向、拒绝或重写请求时,浏览器会改为将 URL 作为文档加载,由中间件响应该请求。
异步服务端组件
在服务端,任何 async function 组件都会挂起到最近的 <Suspense> 或 loading.tsx,直到其 promise 完成:
export const runtime = "server";async function Deployments({ team }: { team: string }) { const rows = await db.deployments.list({ team }); return <Table data={rows} columns={COLUMNS} />;}每个 promise 都按会话缓存,以组件和 props 为键,因此重试渲染会复用已经发出的请求。路由变化或调用 router.refresh() 时,缓存会清空。要在输入后重新访问数据,调用 router.refresh():
const router = useRouter();useInput((e) => e.input === "r" && router.refresh());传给异步组件的 props 应可序列化:字符串、数字、普通对象、数组、Date、URL、Map、Set 和元素。函数 props 不计入缓存键(传递数据,不要传递回调)。每个会话最多缓存 256 个 promise。你也可以使用 @textjs/app/server-components 中的 serverComponent(fn) 手动包装组件。
Hook(useState、useInput 等)无法在 async function 组件内使用。在其外层或下层渲染常规组件来处理交互。
工作原理
browser tab ──(GET /__textjs/live, server-sent events)──▶ frame deltasbrowser tab ──(POST /__textjs/live, JSON)──────────────▶ keys, paste, wheel, resize, focus, Back/Forward- 每个标签页一个会话。 每次页面加载都会打开独立会话:服务器上的一个 React 根、内存中的路由历史记录和一个单元格缓冲区。会话 ID 是 192 位随机值,浏览器仅将其保存在内存中。
- 单元格增量。 服务器仅发送相对于该连接收到的上一帧哈希值发生变化的行。每行以文本片段的形式发送,样式引用仅追加的样式表,宽字素、emoji 簇和半透明颜色均能精确保留。
- 背压。 帧间隔至少为 66 ms,每帧都与套接字上次接受的内容做差异比较。当慢速连接无法继续排出数据时,中间状态会被跳过,下一帧直接跳到最新状态。
- 重连。 如果流断开,播放器会通过
x-textjs-live请求头携带会话 ID 重连(绝不放在 URL 中),并从新的关键帧恢复同一会话,完整保留状态。遗漏的地址栏更新会重新应用。没有连接的会话会停止渲染,并在 30 秒后卸载;如果服务器已遗忘该会话,播放器会打开新会话。 - 历史记录。 后退和前进会让两端的历史记录移动相同数量的条目,服务端的
router.back()也会移动浏览器历史记录。 - 安全性。 两个端点都拒绝跨站请求(检查
Sec-Fetch-Site和Origin;仅在启用server.trustProxy时才采信X-Forwarded-Host)。输入 POST 请求需要 JSON 请求体和x-textjs-live请求头。每个事件都会按固定 schema 验证,导航目标必须是同源路径。只有app/清单中的页面才能渲染。会话绑定到打开时使用的 cookie。 - 限制。 每次 POST 最多包含 64 个事件,每个会话每秒约 240 个事件(超出则返回
429),请求体超时为 5 秒,每个客户端地址最多 32 个会话,每个进程最多maxSessions个会话(优先淘汰断开连接时间最长的会话)。
export default defineConfig({ live: { maxSessions: 512, reconnectGraceMs: 60_000 },});部分预渲染
text build 按配置的 viewport 尺寸将每个静态路由渲染一次,并将首帧作为一小段经典 <script> 内联到路由的 HTML 中。控制台会在任何模块加载之前绘制:
- 静态外壳。 包含所有同步渲染的内容。每个仍在等待的
<Suspense>边界都会显示其后备内容,包括异步服务端组件(外壳渲染期间不会运行)、use()和懒加载模块。外壳渲染期间,fetch()不会完成,因此在 effect 中加载的数据也会保持加载状态。 - 动态空缺。 JavaScript 启动后,客户端渲染器或帧播放器会以流的形式传输实际帧。该帧追加在外壳下方,水合帧自身的幕布会在同一次 DevTools 绘制中将外壳移出视野。外壳会记录绘制时间,因此首个水合帧会等到 DevTools 清空后的时间窗口结束,避免进入慢速路径。
加载期间,只有外壳会清空控制台。水合渲染器以追加模式(initialClear: false)启动,因此不会闪烁,且外壳只会在预渲染时对应的路径上绘制。动态路由([id]、force-dynamic)不渲染外壳。
✓ Prerendering console shells (5/5) · 1 server-rendered, frame player 56.5 kB (21.3 kB gzip)在 text.config.ts 中设置 ppr: false 可将其关闭。预渲染会在构建时运行组件,因此模块顶层代码不要使用仅限浏览器的全局变量。路由若在预渲染期间抛出异常、调用 redirect() / notFound() 或执行导航,则不会生成外壳(错误界面绝不会写入 HTML),构建会说明原因。
外壳按 viewport.width × viewport.height(默认 120 × 36)渲染,因为构建无法得知访问者的控制台尺寸;水合帧随后会调整为实际控制台尺寸。外壳是内联 <script>:如果内容安全策略禁止内联脚本,可通过哈希允许该脚本,或设置 ppr: false。
部署
text build 写入以下文件:
| 文件 | 用途 |
|---|---|
index.html, <route>/index.html | 客户端路由及其外壳。可在任何静态托管服务上运行。 |
assets/player-[hash].js | 帧播放器。 |
.text/server/live.mjs | 服务端路由、App Router 和控制台渲染器。 |
.text/pages/…, .text/live.html | 服务端路由文档。 |
.text/ansi/… | 供 curl 和其他终端客户端使用的首帧。 |
text start 和 bun 适配器提供上述所有文件。服务端路由需要长期运行的进程:使用 static、vercel 和 cloudflare 适配器会导致构建失败(TX030)。