Remix UI Frames 实战指南:用 `<Frame>` 把服务器内容流式渲染进页面

发布时间:2026/9/11 6:16:33
Remix UI Frames 实战指南:用 `<Frame>` 把服务器内容流式渲染进页面 Remix UI Frames 实战指南用Frame把服务器内容流式渲染进页面【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remixFrame是 Remix UI 提供的核心组件它把服务器端渲染SSR的内容按需注入到当前页面中Frame 既可以在首屏 HTML 之后流式到达也可以嵌套在其他 Frame 内部、承载客户端入口client entry还能在客户端不整页跳转的情况下被单独重新加载。本文基于packages/ui/docs/frames.md结合仓库源码packages/ui/src/runtime/frame.ts、packages/ui/src/runtime/run.ts、demos/frames演示应用等展开读完你将掌握 Frame 的三种 Props、阻塞/非阻塞流式策略、服务端与客户端两侧的resolveFrame解析机制、handle.frame.reload()定向刷新、data-rmx-preserve-dom保留客户端 DOM以及基于data-rmx-*属性的链接与表单软导航。Frame 是什么一句话概括Frame src...声明一块内容占位区占位区的最终内容由服务器按需渲染再通过流stream或客户端 fetch 到达并就地更新到 DOM 中。它不同于iframe——Frame 内容直接合入当前文档参与同一棵 DOM 树并能与客户端入口、嵌套 Frame 协同工作。Frame 的能力清单在首屏 HTML 之后流式进入页面嵌套在其他 Frame 内部形成多级内容树内部可以包含客户端入口client entry并随 Frame 一起水合hydrate客户端可以通过handle.frame.reload()单独重载不需要整页导航命名 Frame 可被定向刷新data-rmx-target。从源码看Frame 的核心类型定义在 component.tsexport interface FrameProps { /** Optional frame name used for targeted navigation and lookups. */ name?: string /** Source URL used when the frame loads or reloads its content. */ src: string /** Fallback content to render while the frame is pending. */ fallback?: Renderable /** Event handlers invoked for events dispatched from the frame element. */ on?: Recordstring, (event: Event, signal: AbortSignal) void | Promisevoid }基本用法与 Props在任意组件树中引入Frameimport { Frame } from remix/ui function App() { return () ( div h1Dashboard/h1 Frame src/sidebar fallback{divLoading sidebar.../div} / Frame src/main-content / /div ) }三个 Props 各自职责src必填Frame 内容的来源 URL。服务端渲染时它会被resolveFrame用来抓取内容客户端重载时handle.frame.reload()会重新 fetch 这个地址。fallback可选Frame 加载期间展示的占位内容。它同时决定了流式行为——提供 fallback 时 Frame 以非阻塞方式流式加载首屏立即渲染 fallback真实内容稍后到达替换不提供 fallback 时 Frame 会阻塞渲染直到内容就绪。name可选注册 Frame 的名字之后客户端入口可以通过handle.frames.get(name)查到这个 Frame 的句柄并触发定向刷新。阻塞与非阻塞fallback 决定流式策略fallback的存在与否直接改变服务端输出的时序阻塞无 fallback服务器会等待 Frame 内容解析完成再发送首块 HTML。适用于必须首屏立即可见的内容例如关键头部Frame src/critical-header /非阻塞有 fallbackfallback 会先渲染进首块 HTML真实内容随后流式到达并替换 fallback。适用于可以渐进加载的内容Frame src/recommendations fallback{divLoading.../div} /这套语义在服务端流式实现里得到印证。packages/ui/src/server/README.md对renderToStream的行为描述如下无 fallback阻塞Frame 内容被 await 后才发送首块 HTML解析结果内联出现在响应中有 fallback非阻塞fallback 内联渲染进首块响应Frame 解析完成后真实内容以template元素的形式流式追加在响应末尾客户端运行时自动将其换入 DOM。这意味着首块响应永远是一份完整、可渲染的页面慢数据源不会阻塞首屏绘制。演示应用 demos/frames 中大量使用了带 fallback 的非阻塞写法例如 home.tsx 里的 Sidebar 与 Activity 两个 Frame。服务端解析 Frame 内容render()中间件与renderToStream()标准路径render()中间件在 Remix 应用中安装一次render()中间件即可它会通过当前 router 解析嵌套的、被定向的 Frame携带请求凭证credentials与顶层 Frame 状态跟随重定向并保留非成功响应的内容import { render } from remix/middleware/render import { createRouter } from remix/router let router createRouter({ middleware: [render()] }) router.get(/, (context) context.render(App /)) router.get(/recommendations, (context) context.render(Recommendations /))演示应用中 frames/controller.tsx 的每个 action 正是通过context.render(node)返回 Frame 内容——/frames/sidebar、/frames/activity等路由各自渲染一段 HTML 片段供页面中的Frame消费。自定义管线renderToStream({ resolveFrame })如果需要完全自定义渲染管线renderToStream的resolveFrame接受三类内容一段 HTML 字符串一个ReadableStreamUint8Array上述两者的 Promise。Frame 内容本身也是用renderToStream渲染的因此 Frame 可以继续包含 Frame 和客户端入口嵌套 Frame 的水合数据hydration data会自动合并进父响应。renderToStream的完整选项见 server/README.mdframeSrc为当前渲染播种 SSR Frame 状态服务端组件可在 SSR 期间读取handle.frame.src与handle.frames.top.srctopFrameSrc覆盖根 Frame 的 URLhandle.frames.top.src主要用在嵌套 Frame 的resolveFrame()内部再次调用renderToStream()的场景signal取消未完成的服务器渲染工作通常传入request.signalresolveFrame(src, target, context)遇到Frame时被调用返回 HTML 字符串、流或二者的 Promise。context.currentFrameSrc是包含该Frame的 Frame 的 URLcontext.topFrameSrc是外层文档 URLresolveClientEntry(entryId, component)解析水合客户端入口的公共模块 URL、导出名与可选预加载 hrefonError(error)渲染出错回调缺省时流会直接 reject。关键约定当服务器 Frame 响应本身也由renderToStream()渲染时要为该 Frame 的 URL 传入frameSrc并把resolveFrame()传来的topFrameSrc继续向下传递这样嵌套的 SSR 组件通过handle.frames.top.src看到的始终是外层文档 URL。这正是 component.ts 中handle.frames.top语义在服务端的落实frames.get(name)取命名 Frameframes.top恒为当前运行时树的根 Frame。客户端解析 Frame默认resolveFrame与自定义在客户端run()默认会按需 fetch Frame 源。内置解析器等价于见 run.ts 中的getRequestBody与defaultResolveFrameasync function resolveFrame(src, options) { let response await fetch(src, { body: getRequestBody(options), headers: { Accept: text/html }, method: options?.method, signal: options?.signal, }) if (!response.ok) { throw new Error(Failed to resolve frame: ${response.status} ${response.statusText}.trimEnd()) } return response } function getRequestBody(options) { let formData options?.formData if (!formData || options?.method?.toLowerCase() get) return if (options?.encType text/plain) { let body for (let [name, value] of formData) { name normalizeLineBreaks(name) value normalizeLineBreaks(typeof value string ? value : value.name) body ${name}${value}\r\n } return new Blob([body], { type: text/plain }) } if (options?.encType ! application/x-www-form-urlencoded) return formData let body new URLSearchParams() for (let [name, value] of formData) { body.append(name, typeof value string ? value : value.name) } return body } function normalizeLineBreaks(value) { return value.replace(/\r\n|\r|\n/g, \r\n) }这段逻辑用于待定pendingFrame 的初始水合、handle.frame.reload()调用、链接导航与表单导航。编码规则明确GET 表单值已经编码进src解析器直接拿 URL 即可非 GET 提交application/x-www-form-urlencoded用URLSearchParamstext/plain用 CRLF 分隔的文本multipart/form-data直接用FormData。自定义resolveFrame会在需要额外请求头、另一种 body 编码或不同响应策略时传入run()。自定义解析器能收到signal与target始终提供非 GET 表单提交额外提供formData、method、encType类型定义见 frame.ts 的ResolveFrameOptions。响应策略上有两个值得注意的点默认解析器拒绝非 OK 响应自定义解析器可以返回任意状态码的Response让 Remix UI 渲染其响应体。客户端解析器可以直接返回 Frame 内容也可以返回 fetch 到的Response——返回 Response 可以让 Remix 流式消费其 body。当顶层 Frame 导航中 fetch 跟随了重定向时最终响应 URL 会替换浏览器导航 URL成为顶层 Frame 的规范src其他 Frame 渲染响应时两个 URL 都不变。由于该函数定义了 Frame HTML 的信任边界只应从可信来源返回内容文档原文明确提示了这一点。服务端返回的Response在客户端被 frame-resolution.ts 的unwrapFrameResolution解包优先读取 SPA 响应数据否则取response.body流式或response.text()。重新加载 Framehandle.frame.reload()Frame 内部的客户端入口可以主动触发重载import { clientEntry, on, type Handle } from remix/ui export let RefreshButton clientEntry( /assets/refresh.js#RefreshButton, function RefreshButton(handle: Handle) { return () ( button mix{[ on(click, () { handle.frame.reload() }), ]} Refresh /button ) }, )这里handle.frame指向当前组件所属的最近 Framecomponent.ts中Handle.frame字段reload()返回一个 Promiseresolve 为AbortSignal可用于感知重载生命周期。命名 Frame 的协同刷新借助name注册与handle.frames.get(name)可以一次刷新多个相邻 FrameFrame namecart-summary src/cart-summary / Frame namecart-empty src/cart-empty / Frame src/cart-row /function CartRow(handle: Handle) { return () ( button mix{[ on(click, async () { await handle.frames.get(cart-summary)?.reload() await handle.frames.get(cart-empty)?.reload() await handle.frame.reload() }), ]} Save /button ) }当没有对应名字的 Frame 挂载时handle.frames.get(name)返回undefined因此用可选链?.reload()安全调用即可。刷新时的 DOM 协调过程Frame 重载时按以下步骤执行对应 frame.ts 中resolveAndRenderReload的实现路径通过客户端resolveFrame重新 fetch 该 Frame 的src解析新 HTML与当前 Frame 内容做 diffdiffNodes实现位于 diff-dom.ts匹配到的 DOM 节点原地更新新节点插入被移除的节点清理Frame 内的客户端入口接收服务器发来的新 props同时保留自身的本地组件状态。这意味着一个在重载 Frame 里的计数器重载后计数保持不变但能看到服务器发来的任何新 props。演示应用 demos/frames 的 reload-scope 页面app/actions/reload-scope.tsx专门演示了Frame 单独重载 vs 顶层重载的差异root-reload-client-entries.tsx则演示了顶层 Frame 的src更新与重载。保留客户端拥有的 DOMdata-rmx-preserve-dom当某个元素的实时 DOM 在 Frame 重载后应继续归客户端代码所有时给它加上data-rmx-preserve-domfunction SearchWidget() { return () ( pagefind-ui>function App() { return () ( div Frame src/outer fallback{divLoading outer.../div} / /div ) } // /outer response: function OuterFrame() { return () ( div h2Outer/h2 Frame src/inner fallback{divLoading inner.../div} / /div ) }嵌套 Frame独立流式加载外层 Frame 解析渲染时内层 Frame 可能仍在加载中。SSR 期间应保证handle.frame.src指向当前正在渲染的 Frame而handle.frames.top.src固定为外层文档 URL——在嵌套resolveFrame()处理器中使用renderToStream({ frameSrc, topFrameSrc })来维持这种区分。演示应用 frames/controller.tsx 给出了完整的嵌套实例activityaction 渲染的片段中又嵌入了activityDetailFrameactivityDetail里再嵌套timeFrame 并带一个水合的 CounterclientFrameExample与clientMountedOuter也都演示了服务器片段 嵌套非阻塞 Frame的组合。链接导航软导航与data-rmx-*属性调用run()后当前文档会被表示为handle.frames.top并启动一个 Navigation API 监听器。符合条件的同源锚点导航会通过 Frame 解析器重新加载顶层 Frame 的 HTML并协调进现有文档而不是执行整页文档导航。这种软导航行为即使页面没有显式渲染Frame也生效——详见 hydration.md 对run()的说明。链接上可用的控制属性实现见 navigation.tsdata-rmx-targetname只重载指定名字的 Framedata-rmx-src/frame覆盖解析进该 Frame 的 URL而href仍是导航目的地data-rmx-historypush|replace控制导航如何更新 historydata-rmx-reset-scrollfalse保留当前滚动位置data-rmx-document让该链接退化为普通文档导航。如果希望链接/表单保持文档导航、但仍水合客户端入口并使用显式 Frame可以在调用run()之前用自己的监听器取消内置的navigate事件行为window.navigation?.addEventListener(navigate, (e) event.stopImmediatePropagation())这会让 Remix 收不到任何导航事件包括data-rmx-target与命令式navigate()调用的事件显式重载如handle.frame.reload()不受影响。表单导航增强提交的编码与定向符合条件的同源表单提交走与链接相同的 Frame 导航路径。原生约束校验constraint validation与表单的submit事件会先执行因此无效表单永远不会到达resolveFrame。表单语义与链接基本一致提交默认重载handle.frames.topdata-rmx-targetname重载命名 Framedata-rmx-src/frame覆盖解析进该 Frame 的 URL而表单 action 仍是导航目的地data-rmx-historypush|replace覆盖导航对 history 的更新方式data-rmx-reset-scrollfalse保留滚动位置data-rmx-document让提交退化为普通文档导航提交者submitter的formmethod、formenctype、formtarget覆盖表单属性跨源提交、methoddialog、target_blank交给浏览器原生处理。GET 表单与链接行为一致浏览器把成功的控件拼进目标 URL解析器收到的src就是那个 URL没有额外提交元数据。非 GET 提交的编码策略同前面客户端解析器所述。历史记录history方面增强后的非 GET 提交若提交到当前 URL会替换当前历史条目而不是推入重复项提交到不同 URL 则推入新条目GET 提交值已体现在目标 URL 中同样推入新条目data-rmx-history可强制覆盖replace强制替换push强制推送非 GET 的FormData只用于当前 Frame 重载不会保留在历史中。一个经典例子这个表单在无 JS 时是普通文档 POSTrun()启动后则只重载命名 FrameFrame nameaccount src/account/edit / form action/account/edit methodpost contenteditable="false">【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考