伺服器端渲染與部分預先渲染

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() 的行為與用戶端相同。

如何選擇執行環境

以下情況使用用戶端執行環境(預設):

  • 需要大量輸入、每次按鍵都必須在同一個工作內繪製的 UI,例如編輯器和篩選器。
  • 部署至靜態主機或 CDN 的應用程式。
  • 可離線使用的工具。

以下情況使用伺服器執行環境:

  • 讀取資料庫、檔案、佇列或私有 API 的儀表板與管理介面。憑證留在伺服器上。
  • 含有大型相依套件(SDK、剖析器、語法表),且不希望將其納入瀏覽器套件的路由。
  • 需要在慢速網路上快速載入的頁面。瀏覽器下載 21 kB(gzip)的播放器,而非完整應用程式。

你可以在同一個應用程式中混用執行環境。在兩者之間導覽時,會將目標網址載入為新文件。瀏覽器套件會以佔位模組取代每個伺服器頁面,因此伺服器模組及其匯入的所有內容都不會傳送至用戶端。

將僅供伺服器使用的模組命名為 *.server.ts。若傳送至瀏覽器的程式碼(用戶端頁面,或用戶端頁面使用的版面配置)匯入 *.server.* 模組或 node:* 內建模組,建置就會失敗,並指出匯入來源。

中介軟體保護伺服器路由的方式與保護文件相同:在工作階段開啟前、每次工作階段內導覽(router.push、上一頁和下一頁)之前,都會針對目標路由執行,且每當工作階段的 cookie 或 Authorization 變更時會再次執行。當中介軟體要重新導向、拒絕或改寫時,瀏覽器會改將該網址載入為文件,並由中介軟體回應該請求。

非同步伺服器元件

在伺服器上,任何 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 重新連線(絕不放在網址中),並從新的關鍵影格恢復同一個工作階段,完整保留狀態。遺漏的網址列更新會重新套用。沒有連線的工作階段會停止渲染,並在 30 秒後卸載;若伺服器已忘記該工作階段,播放器會開啟新的工作階段。
  • 歷史記錄。 上一頁和下一頁會讓兩份歷史記錄移動相同的項目數,而伺服器上的 router.back() 也會移動瀏覽器的歷史記錄。
  • 安全性。 兩個端點都會拒絕跨網站請求(Sec-Fetch-Site 和 Origin;X-Forwarded-Host 只有在設定 server.trustProxy 時才會採用)。輸入 POST 請求需要 JSON 本文與 x-textjs-live 標頭。每個事件都會依固定結構描述驗證,且導覽目標必須是你來源下的路徑。只有 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 啟動時,用戶端渲染器或影格播放器會串流傳送實際影格。該影格會附加至外殼下方,而 hydration 影格本身的遮幕會在同一次 DevTools 繪製中將外殼移出可視範圍。外殼會記錄繪製時間,因此第一個 hydration 影格會等到 DevTools 清除後的時間窗口結束,避免走入較慢的處理路徑。

載入期間,只有外殼會清除主控台。執行 hydration 的渲染器會以附加模式(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)渲染,因為建置時無法得知訪客的主控台尺寸;之後 hydration 影格會調整為實際主控台尺寸。外殼是內嵌的 <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 編輯此頁