尧图精选

Remix SPA 包指南:用标准 fetch 路由构建纯客户端渲染应用

🕒 发布时间:2026/9/11 22:30:36 📁 来源:尧图网络
Remix SPA 包指南用标准 fetch 路由构建纯客户端渲染应用【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remixremix-run/spa是 Remix 仓库中专用于客户端渲染Client-rendered应用路由的包它把标准的 fetch 路由器Request→Response连接到浏览器 UI 运行时同时对外隐藏掉用于把路由响应与 Remix 节点关联起来的响应载体response carrier。读完本文你将掌握如何用render()中间件在路由处理器中直接渲染RemixNode、如何用run()启动带初始 fallback 的 SPA 运行时、如何为所有路由统一包裹应用外壳以及重定向与表单编码在 SPA 路由中的底层处理逻辑。包定位fetch 路由 浏览器 UI 运行时的胶水层在 Remix 的架构里fetch-router 负责把Request分发给控制器与中间件render-middleware 提供请求作用域内的渲染器ui 提供 Remix 组件、frame 与浏览器运行时。spa 则站在三者之上为纯客户端渲染场景无服务端渲染、页面由浏览器端路由驱动提供开箱即用的一体化入口。这一点从 packages/spa/package.json 的依赖关系可以印证remix-run/spa直接依赖remix-run/fetch-router、remix-run/render-middleware与remix-run/ui三个 workspace 包它对外导出的全部 API 只有render、run以及若干类型见 packages/spa/src/index.tsAPI 面非常克制。从 packages/spa/src/lib/spa.ts 的源码结构看整个包由三个层次构成render()一个中间件工厂通过renderWith()在请求上下文中安装context.render()spaResponse来自remix-run/ui一个响应载体用WeakMap把无 body 的Response和可渲染的RemixNode关联起来run()一个运行时启动器把普通 fetch 路由器的fetch桥接到 UI 运行时的 frame 导航与初始渲染。核心特性README 明确定义了该包的四大特性每一条都能在源码中找到对应实现标准 fetch 路由Standard fetch routing——完整保留Request到Response的分发、重定向、状态码、响应头、中间件与取消语义。路由本身依然是普通 fetch 路由器其 actions、controllers、middleware 与 context 类型系统完全不变见 packages/spa/src/lib/spa.ts 中run仅要求路由器实现fetch(input, init): PromiseResponse这一最小契约。节点渲染中间件Node rendering middleware——通过context.render()渲染RemixNode值。render()返回的中间件类型为Middleware{ key: typeof Renderer; value: Render; property: render }见 packages/spa/src/lib/spa.ts与 fetch-router 的 context 系统完全兼容。浏览器运行时Browser runtime——通过路由器解析当前 URL 与后续 frame 导航。run()里resolveFrame用new URL(src, document.baseURI)解析目标地址再经router.fetch()获取响应packages/spa/src/lib/spa.ts。初始 fallbackInitial fallback——在首个路由加载期间显示一个可交互的 Remix 节点。app.ready()解析前fallback 会被替换进顶层 frame 并触发一次 reloadpackages/spa/src/lib/spa.ts。安装包名发布为remix在 npm 生态下直接安装npm i remix仓库内部通过 pnpm workspace 管理SPA 示例应用demos/spa的依赖即来自 workspace 内的remix包。基础用法核心用法分三步先在中间件和路由处理器使用context.render()之前安装render()中间件再把路由器传给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(h1Not Found/h1, { status: 404 }) }, }) router.map(routes, { actions: { home({ render }) { return render(h1Home/h1) }, about({ render }) { return render(h1About/h1) }, }, }) const app run(router, { fallback: pLoading…/p }) await app.ready()逐段拆解其中的关键点createRouter来自remix/router创建的依然是一个普通 fetch 路由器因此middleware、defaultHandler、router.map等既有能力全部可用。render()必须放在会用到context.render()的中间件与路由处理器之前它负责把渲染器安装进请求上下文。defaultHandler中的render(h1Not Found/h1, { status: 404 })展示了如何用渲染器返回带状态码的 SPA 响应——浏览器端会呈现 404 页面同时保留 HTTP 状态语义。run(router, { fallback })启动浏览器运行时fallback是初始路由加载期间显示的 Remix 节点。await app.ready()会等待客户端运行时启动且初始路由渲染完成后才解析。为什么普通路由能驱动 SPAREADME 特别强调The router remains an ordinary fetch router。这意味着你可以在 SPA 中继续使用 fetch-router 的全部能力例如在 demos/spa/app/main.tsx 中示例应用在render()之外又叠加了一个普通的日志中间件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 } const router createRouter({ middleware: [wrapRender, logSpaRequests], defaultHandler({ render }) { return render(NotFoundPage /, { status: 404 }) }, })路由处理器也和 SSR 场景一样通过router.map(routes, { actions: {...} })映射action 内直接解构render、request等上下文能力router.map(routes, { actions: { async home({ render, request }) { await sleep(700, request.signal) // 支持按 request.signal 取消 return render(HomePage /) }, 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} /) }, }, })URL 契约仍然集中定义在routes.ts中与路由处理器完全解耦demos/spa/app/routes.tsimport { get, post, route } from remix/routes export const routes route({ home: get(/), about: get(/about), greet: get(/greet), submitGreet: post(/greet), })用render()统一包裹路由内容应用外壳当所有路由需要共享一个应用外壳导航栏、布局容器等时给render()传入一个可感知请求的变换函数transform。该函数接收路由节点和当前请求上下文返回变换后的节点const router createRouter({ middleware: [ render((content, { url }) ( main>interface RenderTransform { (node: RemixNode, context: RequestContext): RemixNode }它拿到的是当前请求的上下文含url因此外壳可以按路径名做差异化渲染。demos/spa 正是这样做的——render((content, { url }) Layout url{url}{content}/Layout)用Layout组件包裹所有路由节点并在Layout内部监听 frame 的reloadStart/reloadComplete事件实现跨路径切换时显示 Loading、同路径刷新时不闪 Loading的精细体验见 demos/spa/app/components.tsx。变换与响应载体的关系render(transform)的实现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) }, ) }它基于remix-run/render-middleware的renderWith()实现——renderWith(createRenderer)为每个请求调用一次工厂函数创建渲染器并用context.set(Renderer, renderer, { property: render })把它安装到上下文的render属性上见 packages/render-middleware/src/lib/render.ts。因此 transform 闭包中捕获的context永远是当前请求的上下文不会被跨请求共享污染。启动运行时run()的内部原理run(router, options)是remix-run/ui中run()的 SPA 包装它做了三件关键的事packages/spa/src/lib/spa.ts禁用客户端入口水合loadModule()直接抛错SPA responses cannot hydrate client entries——SPA 响应只携带可渲染节点不存在可水合的客户端 entry自定义 frame 解析resolveFrame(src, options)用document.baseURI解析相对地址经followFrameRedirects()跟随重定向后调用spaResponse.finalize(response, redirectedTo)完成最终化fallback 与首屏app.ready()解析后若提供了fallback先app.frames.top.replace(fallback)把 fallback 换入顶层 frame再app.frames.top.reload()触发初始路由加载。响应载体spaResponse 与 WeakMaprun()依赖remix-run/ui的spaResponsepackages/ui/src/runtime/spa-response.ts。它的核心是一个模块级WeakMapResponse, SPAResponseDatatype SPAResponseData { node: RemixNode redirectedTo?: string } let spaResponses: WeakMapResponse, 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) if (redirectedTo undefined) { delete data.redirectedTo } else { data.redirectedTo redirectedTo } return response }, }这正是 README 所说的不暴露响应载体路由处理器看到的是一个带状态码/响应头的普通Response而真正要渲染的RemixNode被安全地挂在WeakMap上外部无法从响应对象本身窥探到它。两个值得注意的边界spaResponse.create()只能在浏览器环境调用服务端typeof document undefined会抛出TypeError。这一约束被 packages/spa/src/lib/spa-response.test.ts 的用例rejects use outside a browser environment直接验证finalize()会校验响应确实由create()创建否则抛TypeError(Expected a Remix SPA response)防止把普通响应误当作 SPA 响应渲染。重定向跟随同源跳转遵守 Fetch 语义followFrameRedirects()packages/spa/src/lib/spa.ts在浏览器端手动实现了 Fetch 标准的重定向语义只跟随状态码301 / 302 / 303 / 307 / 308的响应最多10 次const maxRedirects 10重定向目标必须是同源nextUrl.origin ! initialOrigin时抛TypeError(SPA routes cannot redirect to another origin)——SPA 不能跳到其他源遵守 Fetch 的 method 改写规则303且原方法非 GET/HEAD 时改写为 GET 并清空 body301/302且原方法为 POST 时同样改写为 GET避免浏览器的历史遗留行为每跳一次记录redirectedTo最终由spaResponse.finalize(response, redirectedTo)写入响应数据供 UI 运行时更新地址栏。对应的浏览器端测试在 packages/spa/src/lib/spa.test.browser.tsx路由先返回302 Location: ?spa-testredirected断言 router 收到两次GET请求且最终页面渲染出Redirected。表单提交按编码类型构造请求体SPA 场景下 frame reload 可能收到未经过表单导航的裸FormData因此run()在resolveFrame中调用getRequestBody()packages/spa/src/lib/spa.ts按encType手动编码text/plain按namevalue\r\n拼接且统一把换行规范化为\r\nnormalizeLineBreaks文件名取value.nameapplication/x-www-form-urlencoded用URLSearchParams编码其他默认multipart/form-data直接原样返回FormDataGET / HEAD 请求不携带 body。测试用例encodes text/plain form submissions with normalized line breakspackages/spa/src/lib/spa.test.browser.tsx验证了提交结果notefirst\r\nsecond\r\ncityParis\r\n与Content-Type: text/plain。运行与测试示例应用仓库提供了完整的 SPA 示例应用demos/spa由 Vite 驱动静态index.html持有文档外壳fallback 与路由节点渲染进其body。应用按职责拆分app/main.tsx负责配置并启动 SPAapp/routes.ts定义 URL 契约app/components.tsx承载 UIapp/utils.ts提供公共工具。# 启动开发服务器 pnpm -C demos/spa dev然后打开http://localhost:44100。示例覆盖了直接深链访问、客户端链接跳转、中止延迟路由request.signal、POST 表单提交以及 push/replace 历史行为等场景。# 端到端测试默认构建并预览生产版本可将测试文件中的 mode 改为 development 以针对 Vite 开发服务器运行 pnpm -C demos/spa test相关包与延伸阅读fetch-router——请求路由、控制器与中间件上下文SPA 路由的本质仍是它的Request → Response分发模型render-middleware——请求作用域渲染器render()与renderWith()的底层实现ui——Remix 组件、frame 与浏览器运行时run()、spaResponse、RemixNode均来源于此。此外还有两个外部标准值得了解Fetch 标准SPA 路由器所保留的请求/响应模型与 Navigation APIRemix UI 运行时使用的浏览器导航生命周期。本文所有实现细节均可直接在 packages/spa/src/lib/spa.ts 及其测试 packages/spa/src/lib/spa.test.browser.tsx 中查阅验证。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联 返回资讯列表 →