服务端渲染与部分预渲染

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:

app/status/page.tsxtsx
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 完成:

app/deployments/live/page.tsxtsx
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 个会话(优先淘汰断开连接时间最长的会话)。
text.config.tsts
export default defineConfig({  live: { maxSessions: 512, reconnectGraceMs: 60_000 },});

部分预渲染

text build 按配置的 viewport 尺寸将每个静态路由渲染一次,并将首帧作为一小段经典 <script> 内联到路由的 HTML 中。控制台会在任何模块加载之前绘制:

  1. 静态外壳。 包含所有同步渲染的内容。每个仍在等待的 <Suspense> 边界都会显示其后备内容,包括异步服务端组件(外壳渲染期间不会运行)、use() 和懒加载模块。外壳渲染期间,fetch() 不会完成,因此在 effect 中加载的数据也会保持加载状态。
  2. 动态空缺。 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)。

在 GitHub 上编辑此页