Remix 纯客户端 SPA 模式:demos/spa 的架构、运行与端到端验证
2026/9/11 6:45:26 网站建设 项目流程

Remix 纯客户端 SPA 模式:demos/spa 的架构、运行与端到端验证

【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix

导读

本文以 demos/spa 为例,深入剖析如何在纯前端场景下把 Remix 当作**客户端专用路由器(client-only router)**使用:整个应用运行在浏览器里,不依赖任何服务端渲染或水合,却完整保留 fetch router 的Request → Response契约。读完本文,你将掌握render()中间件如何把RemixNode包装成浏览器端可渲染的Responserun(router, { fallback })如何驱动顶部 frame 导航运行时,以及深链、客户端链接、延迟路由中止、POST 表单和 push/replace 历史行为在这套架构下的完整落地方式。

一、SPA Demo 的设计意图

demos/spa/README.md 开宗明义:这是一个基于 Vite 的应用,使用 Remix 作为客户端路由器,同时保留 fetch router 正常的RequestResponse契约。也就是说,路由处理器写的仍然是一个接收Request、返回Response的 fetch 风格函数,只是这个Response携带的不再是 HTML,而是可供 UI 运行时直接渲染的 Remix 节点。

整套架构由两个关键 API 支撑(均来自remix/spa包):

  • render()中间件:向路由上下文暴露context.render(node),把RemixNode包装成 SPA 运行时认识的Response,并隐藏 UI 运行时内部使用的"响应载体"(response carrier);
  • run(router, { fallback }):启动客户端运行时,先渲染一个"活的" Remix fallback(例如 Loading 页),随后由顶部 frame 导航运行时加载并渲染与当前 URL 对应的路由节点。

Demo 的功能覆盖面刻意做得完整,包含以下行为类型(后文逐一展开):

  1. 直接深链(deep link)访问;
  2. 客户端链接导航;
  3. 中止被超越的延迟路由(aborting delayed routes);
  4. POST 表单数据提交;
  5. push/replace 历史行为差异。

二、按职责划分的应用结构

Demo 将应用按职责拆分成四个模块,互不越界:

文件职责
app/main.tsx配置并启动 SPA:创建路由器、挂载中间件、映射路由、启动运行时
app/routes.ts定义 URL 契约(路径与方法)
app/components.tsxUI 层:Layout、导航、各页面组件与样式
app/utils.ts共享支持代码(可中止的sleep

同时,静态的 index.html 拥有文档外壳(document shell),fallback 与路由节点都渲染进它的<body>。这是纯客户端模式的关键特征:HTML 外壳与路由内容解耦,外壳只负责挂载入口脚本,内容完全由运行时动态渲染。

<!-- demos/spa/index.html --> <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="description" content="A client-only Remix router demo." /> <link rel="icon" href="data:," /> <title>Remix SPA Demo</title> <style>…</style> </head> <body> <script type="module" src="/app/main.tsx"></script> </body> </html>

index.html只做两件事:提供全局基础样式(字体、背景色、margin: 0)并加载/app/main.tsx作为入口。随后的一切渲染都发生在body内部。

三、render():把 RemixNode 变成 SPA 响应的中间件

在 main.tsx 中,render中间件通过回调形式安装,回调会在每个请求的上下文里拿到contenturl,从而可以用一个 Layout 组件包裹所有 SPA 路由

// demos/spa/app/main.tsx import { createRouter, type Middleware } from 'remix/router' import { render, run } from 'remix/spa' // ... const wrapRender = render((content, { url }) => <Layout url={url}>{content}</Layout>)

3.1 底层实现:renderWith + spaResponse

从源码看,render()本质上是renderWith的封装。packages/spa/src/lib/spa.ts 中:

export function render(transform?: RenderTransform): RenderMiddleware { return renderWith( (context) => function render(node: RemixNode, init?: ResponseInit): Response { return spaResponse.create(transform ? transform(node, context) : node, init) }, ) }

renderWith来自@remix-run/render-middleware,它的核心作用是把渲染器挂到请求上下文上,并同时暴露为context.render

// packages/render-middleware/src/lib/render.ts export function renderWith<const renderer extends AnyRenderer>( createRenderer: RendererFactory<renderer>, ): Middleware<{ key: typeof Renderer; value: renderer; property: 'render' }> { return (context, next) => { context.set(Renderer, createRenderer(context), { property: 'render' }) return next() } }

因此路由处理器里拿到的render参数,本质上是这个上下文渲染器;它接收RemixNode和可选ResponseInit(状态码、状态文本、响应头),返回一个 SPA 运行时可识别的Response

3.2 响应载体:WeakMap 代理机制

render()返回的 Response 之所以"无 body 却能携带节点",是因为 packages/ui/src/runtime/spa-response.ts 用WeakMap把节点与 Response 关联起来:

let spaResponses: WeakMap<Response, SPAResponseData> | undefined export const spaResponse = { create(node: RemixNode, init?: ResponseInit): Response { if (typeof document === 'undefined') { throw new TypeError('spaResponse.create() can only be used in a browser') } let response = new Response(null, init) let responses = (spaResponses ??= new WeakMap()) responses.set(response, { node }) return response }, finalize(response: Response, redirectedTo?: string): Response { let data = getSpaResponseData(response) if (!data) throw new TypeError('Expected a Remix SPA response') // 记录或清理重定向目标后返回同一个 response return response }, }

值得注意的细节:

  • create()明确限制只能在浏览器环境调用(typeof document === 'undefined'时抛TypeError),这从底层印证了 SPA 模式是纯客户端的;
  • Response 本身是new Response(null, init),即无 body,节点数据通过 WeakMap 传递,随 Response 的 GC 而回收,不会泄漏;
  • finalize()会校验该 Response 确实由spaResponse.create()创建,否则抛出Expected a Remix SPA response(对应 packages/ui/src/spa.test.tsx 中的测试用例)。

3.3 与 fetch router 的正常中间件协同

render中间件与普通中间件完全兼容,main.tsx 中同时演示了一个计时日志中间件:

const logSpaRequests: Middleware = async ({ request }, next) => { let url = new URL(request.url) let start = performance.now() console.log(`[SPA] → ${request.method} ${url.pathname}${url.search}`) let response = await next() let duration = Math.round(performance.now() - start) console.log(`[SPA] ← ${response.status} ${request.method} ${url.pathname} (${duration} ms)`) return response }

中间件按数组顺序在createRouter中注册:middleware: [wrapRender, logSpaRequests],因此每个请求都会先经过 SPA 渲染器初始化,再进入日志计时。

3.4 defaultHandler 与 404

路由器还配置了defaultHandler,当没有路由匹配时返回 404 页面:

const router = createRouter({ middleware: [wrapRender, logSpaRequests], defaultHandler({ render }) { return render(<NotFoundPage />, { status: 404 }) }, })

这里{ status: 404 }正是render(node, init)第二个参数ResponseInit的用法,说明状态码随 Response 一起被 SPA 运行时消费。

四、路由映射:URL 契约与控制器

4.1 声明式路由表

app/routes.ts 用remix/routesroute/get/post声明 URL 契约:

import { get, post, route } from 'remix/routes' export const routes = route({ home: get('/'), about: get('/about'), greet: get('/greet'), submitGreet: post('/greet'), })

注意greetsubmitGreet共享/greet路径但方法不同(GET / POST),这体现的是与 SSR 场景完全一致的"方法级路由"思想。

4.2 控制器与render

路由处理器通过router.map(routes, { actions: { ... } })映射,写法与 SSR 一致,差异仅在返回的是 SPA 节点而非 HTML:

// demos/spa/app/main.tsx router.map(routes, { actions: { async home({ render, request }) { await sleep(700, request.signal) return render(<HomePage />) }, async about({ render, request }) { await sleep(700, request.signal) return render(<AboutPage />) }, greet({ render }) { return render(<GreetingPage name="friend" />) }, async submitGreet({ render, request }) { let formData = await request.formData() let value = formData.get('name') let name = typeof value === 'string' && value.trim() !== '' ? value.trim() : 'friend' await sleep(700, request.signal) return render(<GreetingPage isSubmission name={name} />) }, }, })

这段代码同时展示了三个 SPA 路由的典型能力:

  • 模拟延迟homeaboutsubmitGreet都先sleep(700, request.signal)再渲染,把"加载中 → 完成"的状态变化暴露给 UI,便于观察 top-frame 加载与取消行为;
  • 读取表单submitGreet通过await request.formData()读取 POST 表单数据,并在空值时回退到默认名'friend'
  • 回传状态:通过 props 把isSubmission标记传给GreetingPage,让组件能区分"直接访问"与"表单提交后到达"两种渲染场景。

五、run():启动客户端运行时

run()remix/sparemix/uirun()的封装,它实现了 SPA 感知的resolveFrame,并负责 fallback 渲染与首次 top-frame 重载:

// demos/spa/app/main.tsx const app = run(router, { fallback: <LoadingPage /> }) app.addEventListener('error', (event) => { console.error('Remix SPA failed:', event.error) }) await app.ready()

5.1 启动流程

从 packages/spa/src/lib/spa.ts 的run实现看,启动流程分三步:

export function run(router: Router, options: RunOptions = {}): Runtime { let app = runRuntime({ loadModule() { throw new Error('SPA responses cannot hydrate client entries') }, async resolveFrame(src, options) { let url = new URL(src, document.baseURI) let { response, redirectedTo } = await followFrameRedirects(router, url, { method: options?.method, body: getRequestBody(options), signal: options?.signal, }) return spaResponse.finalize(response, redirectedTo) }, }) let readyPromise = app.ready().then(async () => { if (options.fallback !== undefined) { await app.frames.top.replace(options.fallback) } await app.frames.top.reload() }) return Object.assign(app, { ready: () => readyPromise }) }
  1. loadModule直接抛错:SPA 响应不做客户端水合(SPA responses cannot hydrate client entries),从根源上切断了服务端渲染路径;
  2. SPA 化的resolveFrame:把每次 frame 解析都变成一次router.fetch(url, { method, body, signal })调用——这正是"保留 fetch router 的 Request→Response 契约"的落地位置;拿到响应后交给spaResponse.finalize校验并记录重定向;
  3. fallback 与首次渲染app.ready()完成后,先把fallback替换进顶部 frame,再触发一次reload()让当前 URL 对应的路由真正渲染。换句话说,fallback 是"活"的 Remix 节点,不是静态占位 HTML。

5.2 重定向处理

followFrameRedirects实现了浏览器风格的重定向语义(源码位于 packages/spa/src/lib/spa.ts):

  • 识别 301 / 302 / 303 / 307 / 308;
  • 最多跟随 10 次(maxRedirects = 10),超限抛TypeError
  • 禁止跨 origin 重定向SPA routes cannot redirect to another origin);
  • 按规范降级方法:303 对非 GET/HEAD 降为 GET;301/302 对 POST 降为 GET,并清空 body;
  • 最终通过spaResponse.finalize(response, redirectedTo)把最终 URL 记录进响应数据,供 frame 更新地址栏。

5.3 表单编码与 abort 信号透传

getRequestBody处理了"手动 reload 携带 FormData"的场景(源码同样在 packages/spa/src/lib/spa.ts):

  • text/plain编码:逐字段拼成name=value\r\n的 Blob,并做换行符规范化;
  • application/x-www-form-urlencoded:转成URLSearchParams(文件字段取文件名);
  • 其它编码(含 multipart):直接透传原始FormData

同时options.signal被原样传给router.fetch,这正是延迟路由能够被中止的机制基础(见第七节)。

六、UI 层:Layout、导航与样式

6.1 组件风格

app/components.tsx 采用 Remix UI 的Handle组件风格:每个组件接收Handle<T>参数,返回一个渲染函数。例如NavLink

export function NavLink(handle: Handle<NavLinkProps>) { return () => ( <a href={handle.props.href} aria-current={handle.props.current ? 'page' : undefined} mix={navLinkStyle} > {handle.props.children} </a> ) }

通过aria-current="page"标记当前激活项,配合 CSS 里的&[aria-current="page"]高亮当前导航。

6.2 用 frame 事件驱动加载态

Layout组件演示了如何监听顶部 frame 的加载事件来驱动 loading 状态。它在queueTask中注册reloadStartreloadComplete监听,并用"目标路径与当前路径是否不同"来判断是否需要显示 Loading:

handle.queueTask(() => { handle.frame.addEventListener( 'reloadStart', () => { showLoading = new URL(handle.frame.src).pathname !== handle.props.url.pathname void handle.update() }, { signal: handle.signal }, ) handle.frame.addEventListener( 'reloadComplete', () => { if (!showLoading) return showLoading = false void handle.update() }, { signal: handle.signal }, ) })

渲染时根据showLoading切换<main aria-busy={showLoading}>的内容:加载中显示<LoadingPage />,否则显示路由内容。aria-busyrole="status"(LoadingPage 使用)让无障碍与 e2e 测试都能稳定感知加载态。

GreetingPage还演示了另一种模式:监听reloadComplete把表单提交的isPending状态复位,并在on('submit', ...)里把按钮置为Submitting…禁用态。

6.3 零运行时样式的 css()

样式通过css({...})对象式 API 定义(如appShellStyleheaderStylenavLinkStyle),与组件同文件共存,无需单独的 CSS 文件,也无需样式库依赖。

七、延迟路由与中止:sleep + AbortSignal

app/utils.ts 提供了一个对AbortSignal敏感的sleep

export function sleep(milliseconds: number, signal: AbortSignal): Promise<void> { return new Promise((resolve, reject) => { if (signal.aborted) { reject(signal.reason) return } let timeout = setTimeout(() => { signal.removeEventListener('abort', handleAbort) resolve() }, milliseconds) function handleAbort() { clearTimeout(timeout) reject(signal.reason) } signal.addEventListener('abort', handleAbort, { once: true }) }) }
  • 如果调用时 signal 已中止,立即以signal.reasonreject;
  • 否则设置定时器,并注册一次性abort监听;中止时清除定时器并 reject。

它把request.signal(来自resolveFrame透传)接到路由的延迟逻辑上:当用户快速连续导航时,被超越的旧路由请求会被 abort,其挂起的sleep随之 reject,从而避免过期响应覆盖新页面。这正是 README 所述"aborting delayed routes"的底层协作机制。

八、push / replace 历史行为

Demo 特意通过两个表单演示历史记录语义的差异(README 明确列出"push/replace history behavior"):

  • 首页表单<form method="POST" action={routes.submitGreet.href()}>目标/greet新 URL,提交后产生一次push,地址栏从/变为/greet
  • GreetingPage 表单<form method="POST" action={routes.submitGreet.href()}>目标就是当前 URL/greet,提交后执行replace,替换当前历史条目而不新增。

组件文案对此有明确说明:"The first submission from home pushes a new entry"(从首页的第一次提交 push 一个新条目);同时 README 与测试都验证了一个关键事实:历史条目不保留表单数据,因此 back/forward 回访该 URL 时以 GET 重新请求,isSubmission为假,页面按普通 GET 渲染。

九、运行方式

9.1 开发模式

pnpm -C demos/spa dev

然后打开http://localhost:44100。端口由 vite.config.ts 统一配置:

export default defineConfig({ server: { port: 44100 }, preview: { port: 44100 }, })

开发与预览共用 44100 端口。入口脚本来自 package.json:"dev": "vite""build": "vite build""preview": "vite preview""test": "remix test""typecheck": "tsc --noEmit"。依赖上只声明了remix: workspace:*,类型层面需要@types/dom-navigation(Navigation API 类型)与@types/node,Node 版本要求>=24.3.0

tsconfig.json中有两个值得注意的配置:

  • "jsx": "react-jsx"+"jsxImportSource": "remix/ui":JSX 编译目标指向 Remix UI 运行时而非 React;
  • "lib": ["ES2024", "DOM", "DOM.Iterable"]"types": ["vite/client"]:按纯浏览器 SPA 的目标环境声明。

9.2 生产构建与预览

pnpm -C demos/spa build # vite build,产物由 Vite 输出 pnpm -C demos/spa preview # vite preview,端口同样 44100

纯客户端模式下所有路由都是前端路由,生产环境由 Vite 的 history fallback 保证深链可访问(即访问/about返回index.html,再由运行时解析渲染)。

十、端到端测试与行为验证

运行端到端测试:

pnpm -C demos/spa test

测试文件 app/app.test.e2e.ts 的默认配置是mode: 'production':先build()再用preview()起服务。mode改成'development',同一套用例会改为针对 Vite 开发服务器运行:

const mode: 'development' | 'production' = 'production' async function createViteTestServer() { // ... if (mode === 'development') { vite = await createServer(options) await vite.listen() } else { vite = await preview(options) } // ... }

生产模式下beforeAll还会先行执行build({ root, logLevel: 'silent' })。测试使用remix/test提供的describe/it/beforeAllremix/assert断言,通过 Playwright 驱动浏览器。三组用例与 README 声明的功能覆盖一一对应:

10.1 直接深链

await page.goto('/about') await page.getByRole('status').waitFor() // LoadingPage 出现 await page.getByRole('heading', { name: 'URLs in, rendered UI out' }).waitFor() assert.equal(new URL(page.url()).pathname, '/about') assert.equal(await page.getByRole('link', { name: 'About' }).getAttribute('aria-current'), 'page')

验证:直接访问/about深链(由 Vite history fallback 提供入口)→ 先出现role="status"的 Loading → 渲染出 About 页 → URL 保持/about→ 导航高亮正确。这正是 README 中"direct deep link"的测试背书。

10.2 客户端链接导航与取消

await page.goto('/') await page.getByRole('heading', { name: 'A client-only Remix app' }).waitFor() void page.getByRole('link', { name: 'About' }).click() await page.getByRole('status').waitFor() await page.getByRole('link', { name: 'Home' }).click() // 在 About 加载完成前跳走 await page.getByRole('heading', { name: 'A client-only Remix app' }).waitFor() assert.equal(new URL(page.url()).pathname, '/')

点击 About 后不等它加载完就点 Home,最终稳定停在 Home。这个用例验证的是"cancels a superseded load"——被超越的 About 请求通过 abort 机制被取消,不会覆盖最终页面。

10.3 表单提交与 push/replace

await page.getByLabel('What should we call you?').fill('Ada') await page.getByRole('button', { name: 'Submit' }).click() await page.getByRole('heading', { name: 'Hello, Ada!' }).waitFor() assert.equal(new URL(page.url()).pathname, '/greet') // push 到新 URL await page.getByLabel('Try another name').fill('Grace') await page.getByRole('button', { name: 'Submit again' }).click() // Submitting… 禁用态,无全局 loading(status 数量为 0),标题仍是 Hello, Ada! await page.getByRole('heading', { name: 'Hello, Grace!' }).waitFor() await page.goBack() await page.getByRole('heading', { name: 'A client-only Remix app' }).waitFor() assert.equal(new URL(page.url()).pathname, '/')

这条链路完整覆盖 README 所述行为:首页提交 push 出/greet;在 GreetingPage 上再次提交因为目标是当前 URL 而 replace(可观察到按钮Submitting…的局部 pending 而非整页 loading);随后goBack()回到首页——说明历史条目确实以 GET 形态回访,且表单数据未残留。

十一、SPA 模式与 SSR 模式的差异小结

从源码可以梳理出这套"纯客户端 Remix"与常规 SSR 场景的几个本质差异:

维度SSR 场景SPA 场景(demos/spa)
渲染位置服务端产出 HTML,客户端水合完全在浏览器内渲染(spaResponse.create()强制浏览器环境)
响应内容HTML 文档通过 WeakMap 携带RemixNode的无 bodyResponse
入口能力支持clientEntry()水合loadModule直接抛错,不做水合
frame 解析默认fetch(src)取 HTMLSPA 化的resolveFramerouter.fetch并处理重定向
文档外壳服务端模板/布局静态index.html拥有 shell,节点渲染进body
路由与控制器与本文一致与本文一致(router.map+actions),仅返回类型不同

"路由映射方式与 SSR 一致、只有响应内容不同"这一点,正是这套设计最大的价值:在保留既有 fetch router 心智模型的前提下,获得纯前端的部署形态

十二、进一步探索

若想深入这套机制的实现,建议按以下路径阅读源码:

  • packages/spa/src/lib/spa.ts:render/run/followFrameRedirects/getRequestBody的完整实现;
  • packages/spa/src/index.ts:remix/spa包的公开 API 面(renderrunRenderRenderTransformRouterRunOptionsRuntime);
  • packages/ui/src/runtime/spa-response.ts:SPA 响应载体(WeakMap 代理)与finalize校验;
  • packages/ui/src/runtime/run.ts:客户端运行时基座AppRuntime、顶部 frame 创建、默认resolveFrame
  • packages/ui/src/runtime/navigation.ts:基于 Navigation API 的navigatestartNavigationListener(push/replace、scroll 复位、WebKit 滚动同步);
  • packages/render-middleware/src/lib/render.ts:renderWithRenderer上下文键;
  • packages/ui/src/spa.test.tsx:spaResponse的单元测试(创建、finalize、非法响应拒绝)。

结合 demos/spa/README.md、app/main.tsx 与 app/app.test.e2e.ts 对照阅读,即可完整掌握从 API 用法到底层运行的每一环。

【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix

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

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

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

立即咨询