news 2026/9/10 13:21:58

@remix-run/spa 深入解析:用 `render()` 中间件与 `run()` 运行时搭建客户端渲染的 Remix 应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
@remix-run/spa 深入解析:用 `render()` 中间件与 `run()` 运行时搭建客户端渲染的 Remix 应用

@remix-run/spa 深入解析:用render()中间件与run()运行时搭建客户端渲染的 Remix 应用

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

@remix-run/spa是 Remix 仓库中负责"客户端渲染(SPA)路由"的包:它把标准的 fetch router 连接到浏览器的 UI 运行时,让路由处理器直接返回 Remix 节点(RemixNode),由浏览器端完成渲染。阅读本文后,你将掌握该包的render()渲染中间件与run(router, { fallback? })浏览器运行时的工作原理,理解它如何保留 fetch router 的Request → Response契约,并能在自己的客户端渲染应用中完整落地这套方案。

一、SPA 包定位:从 CHANGELOG 看它解决了什么问题

根据 packages/spa/CHANGELOG.md,该包在v0.1.0首次发布,核心内容一句话可以概括:

新增初始的@remix-run/spa包,提供render()中间件与run(router, { fallback? })浏览器运行时,用于客户端渲染的 Remix 应用。路由处理器使用context.render(),包本身保留路由器的RequestResponse契约,并隐藏 SPA 响应载体(response carrier)。

同时 v0.1.0 将依赖提升到了render-middleware@0.2.0ui@0.8.0,这意味着 SPA 包是建立在两套基础设施之上的:

  • @remix-run/render-middleware:提供请求作用域内的渲染器中间件(renderWith),让context.render()可以安装到任意 fetch router 的请求上下文上;
  • @remix-run/ui:提供 Remix 组件、frame(帧)与浏览器运行时(runspaResponse),负责真正把路由节点画到页面上。

从 packages/spa/package.json 可以看到包的元信息:名称@remix-run/spa、版本0.1.0、描述为 "Client-rendered application routing for Remix",运行时依赖正是上述两个包加上@remix-run/fetch-router

二、快速上手:三行核心代码跑起一个 SPA

2.1 安装与导入

包对外以remix/spa子路径导出。仓库的 packages/remix/src/spa.ts 是一个自动生成的转发文件,内容为:

export * from '@remix-run/spa'

因此在应用里可以统一从remix导入:

npm i remix
import { render, run } from 'remix/spa'

2.2 最小可运行示例

packages/spa/README.md 给出了完整的入门示例:先安装render()中间件(必须放在使用context.render()的中间件与路由处理器之前),再把 router 交给run()

import { createRouter } from 'remix/router' import { get, route } from 'remix/routes' import { render, run } from 'remix/spa' const routes = route({ home: get('/'), about: get('/about'), }) const router = createRouter({ middleware: [render()], defaultHandler({ render }) { return render(<h1>Not Found</h1>, { status: 404 }) }, }) router.map(routes, { actions: { home({ render }) { return render(<h1>Home</h1>) }, about({ render }) { return render(<h1>About</h1>) }, }, }) const app = run(router, { fallback: <p>Loading…</p> }) await app.ready()

这段代码的要点在于:router 仍然是一个普通的 fetch router,它的 actions、controllers、middleware 与 context 类型体系全部照常生效,只是路由处理器不再返回 HTML,而是通过context.render()返回一个携带 Remix 节点的响应。

2.3 用请求感知的 transform 包裹应用外壳

当每个路由都希望共享一个应用外壳(如导航栏 + 主内容区)时,可以给render()传一个"请求感知"的转换函数:

const router = createRouter({ middleware: [ render((content, { url }) => ( <main>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) }, ) }

3.1 底层机制:renderWithRenderer上下文键

render()复用的是@remix-run/render-middlewarerenderWith(见 packages/render-middleware/src/lib/render.ts)。renderWith会:

  1. 为每个请求调用传入的工厂函数,生成一个渲染器;
  2. 通过context.set(Renderer, renderer, { property: 'render' })把渲染器挂到请求上下文上,同时绑定为context.render属性;
  3. 因此任何中间件、路由处理器都能以context.render(node, init?)的形式调用它。

Renderer是一个用createContextKey创建的上下文键,SSR 场景下的render()render-ui.ts)与 SPA 场景下的render()共用同一套上下文键机制,只是生成响应的方式不同:SSR 渲染为 HTML 流,SPA 则生成spaResponse

3.2 响应载体(Response Carrier):spaResponse

SPA 路由处理器返回的"响应"本身没有任何响应体,真正的内容挂在 carrier 上。packages/ui/src/runtime/spa-response.ts 的实现显示,它用一个WeakMap<Response, SPAResponseData>把 Response 与{ node, redirectedTo? }关联起来:

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 }

两个值得注意的细节:

  • 仅限浏览器spaResponse.create()在非浏览器环境(没有document)会直接抛TypeError,这由 packages/spa/src/lib/spa-response.test.ts 的用例验证;
  • 无响应体new Response(null, init)只携带状态码与头信息,Remix 节点通过 WeakMap 关联,这正是 README 所说的"隐藏 SPA 响应载体"——路由处理器只看到一个普通的Response

finalize(response, redirectedTo)则负责校验响应确实由spaResponse.create()创建(否则抛Expected a Remix SPA response),并记录重定向后的最终 URL(见 packages/ui/src/runtime/spa-response.ts)。

3.3 关键类型:RenderRenderTransform

packages/spa/src/lib/spa.ts 定义了配套类型:

interface Render { (node: RemixNode, init?: ResponseInit): Response } interface RenderTransform { (node: RemixNode, context: RequestContext): RemixNode }
  • Render:路由处理器里context.render的签名,init可携带状态码与头信息(例如{ status: 404 });
  • RenderTransformrender(transform)的入参,可以在节点渲染前基于当前请求上下文做包裹或替换。注意它拿到的是完整RequestContext,因此可以读取context.urlcontext.request等任何中间件写入的上下文数据。

四、run()浏览器运行时:把 URL 变成页面

4.1 最小路由器契约

run()只要求传入一个极简契约(packages/spa/src/lib/spa.ts):

interface Router { fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> }

也就是说,任何满足 fetch 签名的东西都能接入——真实的路由器、mock 对象,甚至测试里的mock.fn。浏览器运行时通过resolveFrame把当前 URL 与后续的同源导航全部交给这个router.fetch分发。

4.2 运行流程与fallback

run()的实现(packages/spa/src/lib/spa.ts)本质是@remix-run/uirun()的 SPA 适配包装:

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. resolveFrame:每个 frame 导航先基于document.baseURI解析出绝对 URL,再调用followFrameRedirects通过router.fetch取响应,最后spaResponse.finalize校验并记录重定向目标;
  2. fallback是"可交互的" Remix 节点app.ready()解析后,先用app.frames.top.replace(options.fallback)把占位 UI 放入顶层 frame,再reload()触发首次路由加载。之所以不叫 loading spinner 而叫 fallback,是因为它可以是带状态的组件——测试 packages/spa/src/lib/spa.test.browser.tsx 中有一个可点击计数的<Fallback>按钮,在首次路由尚未完成时依然可以交互(Loading: 0→ 点击 →Loading: 1);
  3. loadModule直接抛错:SPA 响应不经过客户端 entry 水合(hydrate),这与 SSR 场景形成鲜明对比;
  4. 返回的RuntimeOmit<AppRuntime, 'ready'>并重写ready(),保证"客户端运行时启动且初始路由已渲染"之后该 Promise 才 resolve。

RunOptions目前只有fallback?: RemixNode一个选项(packages/spa/src/lib/spa.ts)。

五、重定向语义:同源跟随与 Fetch 方法规范

followFrameRedirects(packages/spa/src/lib/spa.ts)是 SPA 路由里最容易出问题、也最值得展开的部分:

const redirectStatuses = new Set([301, 302, 303, 307, 308]) const maxRedirects = 10
  • 跟随 5 种重定向状态码301/302/303/307/308,其余状态直接返回;
  • 最多 10 次跳转:超过则抛TypeError('SPA route exceeded 10 redirects'),防止死循环;
  • 严格同源限制:重定向目标nextUrl.origin必须等于初始 origin,否则抛TypeError('SPA routes cannot redirect to another origin')
  • 遵循 Fetch 标准的方法改写
    • 303 See Other:非GET/HEAD一律改写为GET并清空 body;
    • 301/302+POST:改写为GET并清空 body;
    • 其余情况保持原方法与 body。

测试 packages/spa/src/lib/spa.test.browser.tsx 中验证了302 → 同源目标的场景:两次请求方法均为GET,最终渲染出Redirected页面。这套语义与 SSR 侧render-ui.tsfollowFrameRedirects(上限 20 次)保持一致的思路,只是 SPA 侧更简单——没有跨域 frame 的复杂头处理。

六、表单提交:请求体编码的三种形态

getRequestBody(packages/spa/src/lib/spa.ts)处理 frame 手动重载时携带的原始FormData,目的是"按表单声明的编码发送请求体,而不是一律 multipart":

  • text/plain:拼成name=value\r\n格式的 Blob,且对 name/value 都做了换行符归一化(\r\n | \r | \n\r\n,见normalizeLineBreaks);
  • application/x-www-form-urlencoded:转为URLSearchParams,文件类型字段取.name
  • 其他(默认):原样透传FormData(浏览器会自动编码为 multipart)。

测试用例验证了text/plain场景:表单encType="text/plain"提交后,请求头的Content-Typetext/plain,请求体为note=first\r\nsecond\r\ncity=Paris\r\n——textarea 内部的换行被正确归一化。

另外,GET/HEAD方法不携带请求体(直接return),符合浏览器表单语义。

七、仓库内的实战验证

7.1 浏览器端单元测试

packages/spa/src/lib/spa.test.browser.tsx 覆盖了核心行为:

测试用例验证点
render给普通 router 上下文注入请求感知渲染器context.render(node, { status: 201 })后,document.querySelector('h1')渲染出包含context.url.pathname的内容
render对 defaultHandler 可用未匹配路由经defaultHandler返回404并渲染Not Found
ready()前完成初始 URL 渲染初始 URL 携带的 query 参数出现在渲染结果中,fetch只调用 1 次
fallback 可交互首次路由挂起期间 fallback 按钮可点击、计数更新,路由完成后被替换
text/plain表单编码换行符归一化为\r\n,Content-Type 正确
同源重定向302 被跟随,方法按 Fetch 语义保持GET

7.2 端到端演示应用

demos/spa 是一个完整的 Vite + Remix 纯客户端演示应用,覆盖了直接深链、客户端链接导航、取消被替代的慢路由、POST 表单数据、push/replace 历史行为等场景。运行方式:

pnpm -C demos/spa dev # 打开 http://localhost:44100 pnpm -C demos/spa test # 端到端测试(默认对生产构建跑,可改为 development 模式)

其 demos/spa/app/main.tsx 展示了比 README 更完整的工程形态:

  • 组合中间件:[wrapRender, logSpaRequests],其中logSpaRequests是一个普通 fetch-router 中间件,演示了 SPA 场景下中间件体系完全复用;
  • 异步路由:home/about处理器里await sleep(700, request.signal),配合请求信号实现可取消的慢加载;
  • POST 表单:submitGreet读取request.formData()后返回带状态的GreetingPage
  • 错误监听:app.addEventListener('error', ...)捕获运行时错误。

路由定义在 demos/spa/app/routes.ts:get('/')get('/about')get('/greet')post('/greet')/greet同时挂 GET 与 POST,正好对应端到端测试中"提交新表单 push 历史记录、提交当前 URL replace 历史记录"的行为断言(见 demos/spa/app/app.test.e2e.ts)。

八、类型、导出与依赖全景

packages/spa/src/index.ts 对外导出:

render, run, type Render, type RenderTransform, type Router, type RunOptions, type Runtime

其中RouterRuntime是 SPA 特有的抽象:前者把"路由器"压缩到单个fetch方法,后者把 ui 运行时的ready语义改写为"初始路由已渲染"。依赖关系上,@remix-run/spa直接依赖fetch-router(路由与上下文)、render-middleware(渲染中间件)与ui(帧与浏览器运行时),三者缺一不可(见 packages/spa/package.json)。

九、总结:SPA 包的设计要点

回到 CHANGELOG 的原始表述,@remix-run/spav0.1.0 的全部设计可以归结为三条原则:

  1. 契约保留:router 始终是标准 fetch router,Request → Response的调度、重定向、状态码、头信息、中间件与取消语义一脉相承(README 的 Features 第一条即 "Standard fetch routing");
  2. 载体隐藏context.render()对路由处理器暴露的是普通Response,Remix 节点藏在spaResponse的 WeakMap carrier 中,由run()resolveFrame取出并渲染;
  3. 运行时接管run(router, { fallback })把当前 URL 与后续同源导航统一交给 router 分发,ready()等待首次渲染完成,fallback 则提供了可交互的首屏占位。

如果你要继续深入,推荐按依赖顺序阅读三个文件:packages/render-middleware/src/lib/render.ts(渲染中间件基础)、packages/ui/src/runtime/spa-response.ts(响应载体实现)、packages/spa/src/lib/spa.ts(SPA 包的renderrun全部源码)。

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 13:21:25

CSDN博客API签名机制详解:Java HMAC-SHA256实战实现

简介&#xff1a;本资源是一份面向Java中高级开发者的安全机制实践指南&#xff0c;聚焦CSDN平台API调用中关键的x-ca-nonce与x-ca-signature生成原理与工程实现&#xff0c;解决开发者在对接含签名认证的HTTP接口时常见的随机数生成、HMAC-SHA256签名构造、密钥安全使用等实际…

作者头像 李华
网站建设 2026/9/10 13:16:45

双向储能控制仿真:从功率级建模到PI整定与SOC估算

简介&#xff1a;基于Matlab和Simulink实现的双向储能控制仿真模型源码包&#xff0c;面向计算机、电子信息工程、数学等专业学生&#xff0c;可作为课程设计、期末大作业或毕业设计阶段的仿真建模与调试参考资料。资源共149个文件&#xff0c;压缩包体积仅4.66MB&#xff0c;主…

作者头像 李华
网站建设 2026/9/10 13:16:43

Python生成机器学习合成数据集的方法与实践

1. 项目背景与核心目标在数据科学和机器学习领域&#xff0c;构建高质量的合成数据集是算法开发和模型测试的关键环节。这个项目的核心任务是生成一个包含1000个样本的数据集&#xff0c;其中包含8个有效特征和3个冗余特征。这类数据集在以下场景中特别有用&#xff1a;机器学习…

作者头像 李华
网站建设 2026/9/10 13:15:11

Sway 光标主题完整指南:5 步换指针,动画与排错一次讲清

Sway 光标主题完整指南&#xff1a;5 步换指针&#xff0c;动画与排错一次讲清 【免费下载链接】sway i3-compatible Wayland compositor 项目地址: https://gitcode.com/GitHub_Trending/swa/sway 刚装好 Sway&#xff0c;光标是系统默认箭头&#xff0c;很难起眼。这份…

作者头像 李华