资讯动态

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

发布时间:2026/9/11 6:45:28 来源:尧图企业网站定制
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包装成浏览器端可渲染的Response、run(router, { fallback })如何驱动顶部 frame 导航运行时以及深链、客户端链接、延迟路由中止、POST 表单和 push/replace 历史行为在这套架构下的完整落地方式。一、SPA Demo 的设计意图demos/spa/README.md 开宗明义这是一个基于 Vite 的应用使用 Remix 作为客户端路由器同时保留 fetch router 正常的Request到Response契约。也就是说路由处理器写的仍然是一个接收Request、返回Response的 fetch 风格函数只是这个Response携带的不再是 HTML而是可供 UI 运行时直接渲染的 Remix 节点。整套架构由两个关键 API 支撑均来自remix/spa包render()中间件向路由上下文暴露context.render(node)把RemixNode包装成 SPA 运行时认识的Response并隐藏 UI 运行时内部使用的响应载体response carrierrun(router, { fallback })启动客户端运行时先渲染一个活的 Remix fallback例如 Loading 页随后由顶部 frame 导航运行时加载并渲染与当前 URL 对应的路由节点。Demo 的功能覆盖面刻意做得完整包含以下行为类型后文逐一展开直接深链deep link访问客户端链接导航中止被超越的延迟路由aborting delayed routesPOST 表单数据提交push/replace 历史行为差异。二、按职责划分的应用结构Demo 将应用按职责拆分成四个模块互不越界文件职责app/main.tsx配置并启动 SPA创建路由器、挂载中间件、映射路由、启动运行时app/routes.ts定义 URL 契约路径与方法app/components.tsxUI 层Layout、导航、各页面组件与样式app/utils.ts共享支持代码可中止的sleep同时静态的 index.html 拥有文档外壳document shellfallback 与路由节点都渲染进它的body。这是纯客户端模式的关键特征HTML 外壳与路由内容解耦外壳只负责挂载入口脚本内容完全由运行时动态渲染。!-- demos/spa/index.html -- !doctype html html langen head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / meta namedescription contentA client-only Remix router demo. / link relicon hrefdata:, / titleRemix SPA Demo/title style…/style /head body script typemodule src/app/main.tsx/script /body /htmlindex.html只做两件事提供全局基础样式字体、背景色、margin: 0并加载/app/main.tsx作为入口。随后的一切渲染都发生在body内部。三、render()把 RemixNode 变成 SPA 响应的中间件在 main.tsx 中render中间件通过回调形式安装回调会在每个请求的上下文里拿到content与url从而可以用一个 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 renderWithconst renderer extends AnyRenderer( createRenderer: RendererFactoryrenderer, ): 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: 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) // 记录或清理重定向目标后返回同一个 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/routes的route/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), })注意greet与submitGreet共享/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 namefriend /) }, 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 路由的典型能力模拟延迟home、about、submitGreet都先sleep(700, request.signal)再渲染把加载中 → 完成的状态变化暴露给 UI便于观察 top-frame 加载与取消行为读取表单submitGreet通过await request.formData()读取 POST 表单数据并在空值时回退到默认名friend回传状态通过 props 把isSubmission标记传给GreetingPage让组件能区分直接访问与表单提交后到达两种渲染场景。五、run()启动客户端运行时run()是remix/spa对remix/ui的run()的封装它实现了 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 }) }loadModule直接抛错SPA 响应不做客户端水合SPA responses cannot hydrate client entries从根源上切断了服务端渲染路径SPA 化的resolveFrame把每次 frame 解析都变成一次router.fetch(url, { method, body, signal })调用——这正是保留 fetch router 的 Request→Response 契约的落地位置拿到响应后交给spaResponse.finalize校验并记录重定向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 降为 GET301/302 对 POST 降为 GET并清空 body最终通过spaResponse.finalize(response, redirectedTo)把最终 URL 记录进响应数据供 frame 更新地址栏。5.3 表单编码与 abort 信号透传getRequestBody处理了手动 reload 携带 FormData的场景源码同样在 packages/spa/src/lib/spa.tstext/plain编码逐字段拼成namevalue\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组件风格每个组件接收HandleT参数返回一个渲染函数。例如NavLinkexport function NavLink(handle: HandleNavLinkProps) { return () ( a href{handle.props.href} aria-current{handle.props.current ? page : undefined} mix{navLinkStyle} {handle.props.children} /a ) }通过aria-currentpage标记当前激活项配合 CSS 里的[aria-currentpage]高亮当前导航。6.2 用 frame 事件驱动加载态Layout组件演示了如何监听顶部 frame 的加载事件来驱动 loading 状态。它在queueTask中注册reloadStart与reloadComplete监听并用目标路径与当前路径是否不同来判断是否需要显示 Loadinghandle.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-busy与rolestatusLoadingPage 使用让无障碍与 e2e 测试都能稳定感知加载态。GreetingPage还演示了另一种模式监听reloadComplete把表单提交的isPending状态复位并在on(submit, ...)里把按钮置为Submitting…禁用态。6.3 零运行时样式的 css()样式通过css({...})对象式 API 定义如appShellStyle、headerStyle、navLinkStyle与组件同文件共存无需单独的 CSS 文件也无需样式库依赖。七、延迟路由与中止sleep AbortSignalapp/utils.ts 提供了一个对AbortSignal敏感的sleepexport function sleep(milliseconds: number, signal: AbortSignal): Promisevoid { 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 methodPOST action{routes.submitGreet.href()}目标/greet是新 URL提交后产生一次push地址栏从/变为/greetGreetingPage 表单form methodPOST 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.jsondev: vite、build: vite build、preview: vite preview、test: remix test、typecheck: tsc --noEmit。依赖上只声明了remix: workspace:*类型层面需要types/dom-navigationNavigation API 类型与types/nodeNode 版本要求24.3.0。tsconfig.json中有两个值得注意的配置jsx: react-jsxjsxImportSource: remix/uiJSX 编译目标指向 Remix UI 运行时而非 Reactlib: [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/beforeAll与remix/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 提供入口→ 先出现rolestatus的 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/replaceawait 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… 禁用态无全局 loadingstatus 数量为 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 化的resolveFrame调router.fetch并处理重定向文档外壳服务端模板/布局静态index.html拥有 shell节点渲染进body路由与控制器与本文一致与本文一致router.mapactions仅返回类型不同路由映射方式与 SSR 一致、只有响应内容不同这一点正是这套设计最大的价值在保留既有 fetch router 心智模型的前提下获得纯前端的部署形态。十二、进一步探索若想深入这套机制的实现建议按以下路径阅读源码packages/spa/src/lib/spa.tsrender/run/followFrameRedirects/getRequestBody的完整实现packages/spa/src/index.tsremix/spa包的公开 API 面render、run、Render、RenderTransform、Router、RunOptions、Runtimepackages/ui/src/runtime/spa-response.tsSPA 响应载体WeakMap 代理与finalize校验packages/ui/src/runtime/run.ts客户端运行时基座AppRuntime、顶部 frame 创建、默认resolveFramepackages/ui/src/runtime/navigation.ts基于 Navigation API 的navigate与startNavigationListenerpush/replace、scroll 复位、WebKit 滚动同步packages/render-middleware/src/lib/render.tsrenderWith与Renderer上下文键packages/ui/src/spa.test.tsxspaResponse的单元测试创建、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),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价