资讯详情

Remix UI 客户端水合(Hydration)完全指南:clientEntry 与 run 实战

📅 2026/9/10 17:03:13 | 华诺云谱 👁 阅读
Remix UI 客户端水合(Hydration)完全指南:clientEntry 与 run 实战
Remix UI 客户端水合Hydration完全指南clientEntry 与 run 实战【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix导读本文围绕 Remix UIpackages/ui的水合机制展开讲解如何通过clientEntry把服务端渲染SSR生成的 HTML 在浏览器端唤醒为可交互组件并用run()启动客户端运行时完成就地水合与软导航。读完本文你将掌握客户端入口的定义格式、序列化约束、run()的完整选项与appAPI并结合仓库源码client-entries.ts、run.ts、stream.ts理解水合的底层原理。水合Hydration是 Remix UI 让服务端渲染的 HTML 在客户端变得可交互的核心机制你在组件树中把特定组件标记为客户端入口client entry客户端运行时在页面中定位这些入口、按需加载其代码模块并在原 DOM 位置上就地水合。只有被标记的组件才会被水合页面其余部分始终保持为静态 HTML——这正是全栈框架The fully-stacked web framework实现渐进增强的基础。本文档为 packages/ui/docs/hydration.md 的深入展开读者可配合 Frames 与 Getting Started 一起阅读。一、什么是客户端入口client entry水合的关键设计是按需激活服务端渲染阶段所有组件都产出 HTML但只有标记过的组件才向浏览器发送对应的 JavaScript 模块并在加载后水合。在 client-entries.ts 中clientEntry()的实现非常朴素它只是给组件函数附加两个元数据字段——$entry: true与$entryId模块标识然后原样返回该函数export function clientEntry(entryId: string, component: any): any { if (!entryId) { throw new Error(clientEntry() requires an entry ID) } component.$entry true component.$entryId entryId return component }配套的类型守卫isEntry()通过检查$entry true判断某组件是否入口组件服务端渲染器 stream.ts 正是用它来决定是否走入口输出分支buildSegment中isEntry(type)时调用buildEntrySegment。对应的测试用例见 client-entry.test.tsx其中验证了$entry、$entryId的写入以及空 entryId 抛错的行为。1.1 entryId 的格式moduleUrl#ExportNameclientEntry的第一个参数是客户端加载该组件时要使用的模块 URL 与导出名格式为moduleUrl#ExportName。如果省略#后的导出名则回退使用组件函数自身的名字。这个回退逻辑由服务端 stream.ts 中的resolveDefaultClientEntry实现function resolveDefaultClientEntry(entryId: string, component: EntryComponent): ResolvedClientEntry { let fallbackExportName component.name || let hashIndex entryId.lastIndexOf(#) if (hashIndex -1 fallbackExportName) { return { exportName: fallbackExportName, href: entryId } } if (hashIndex ! -1) { let exportName entryId.slice(hashIndex 1) || fallbackExportName if (exportName) { return { exportName, href: entryId.slice(0, hashIndex) } } } throw new Error( clientEntry() requires either an export name in the entry ID (e.g., /js/module.js#ComponentName), a named component function, or a resolveClientEntry hook that resolves one. ..., ) }注意这里要求导出名或具名函数至少存在一个否则会直接抛错——这是编写入口组件时容易踩的坑建议总是显式写#ExportName。仓库中的真实示例cart-button.tsx使用import.meta.url作为模块 URL让模块自身的地址即入口地址组件名CartButton作为导出名回退export const CartButton clientEntry( import.meta.url, function CartButton(handle: Handle{ inCart: boolean; id: string | number; slug: string }) { // ... }, )二、服务端如何产出可水合的 HTML在服务端clientEntry组件和普通组件一样渲染。区别在于输出会被两层结构包裹注释标记入口组件渲染结果被!-- rmx:h:instanceId --与!-- /rmx:h --一对注释包围客户端靠这对标记定位水合边界数据脚本组件的 props 与模块元信息被收集进一个script typeapplication/json idrmx-data标签随文档一起发送。对应源码在 stream.ts 的buildEntrySegmentfunction buildEntrySegment(type, props, context, frameState): Segment { let instanceId randomId(h) let rendered buildComponentSegment(type, props, context, instanceId, frameState) let replacer createHydrationPropsReplacer(context, frameState) context.unresolvedHydrationData.set(instanceId, { entryId: type.$entryId, component: type, props: JSON.parse(JSON.stringify(props, replacer)), }) let start staticSeg(!-- rmx:h:${instanceId} --) let end staticSeg(!-- /rmx:h --) return compositeSeg([start, rendered, end]) }其中createHydrationPropsReplacer负责把 props 中的 JSX 元素序列化为描述符$rmx形状、把Frame元素序列化为帧描述符$rmxFrame形状保证客户端能原样复活这些结构。所有入口的序列化数据汇总后由buildRmxDataScript输出为script typeapplication/json idrmx-data{...}/script数据格式为{ h: { hydrationId: { moduleUrl, exportName, props } }, f: { frameId: { status, name, src } } }——h记录每个水合入口的模块地址、导出名与 propsf记录帧元信息。finalizeHtml会把这个脚本注入到/body之前无完整文档骨架时追加在末尾。在 hydration.components.test.tsx 中可以直接看到这两处产物的断言expect(html).toContain(!-- rmx:h:)与expect(html).toContain(!-- /rmx:h --)且测试覆盖了嵌套水合边界、返回null与 Fragment 的组件等边界情况。2.1 模块预加载preload若resolveClientEntry返回了preloads数组服务端会为每个预加载地址生成link relmodulepreload标签createModulePreloadTag并注入文档head。这使浏览器在水合前就开始拉取入口模块及其依赖进一步压缩HTML 可见 → 组件可交互的时间窗口。三、启动客户端run()浏览器端通过run()启动 Remix 组件运行时。它扫描文档中的客户端入口标记、加载对应模块并逐个水合import { run } from remix/ui let app run({ async loadModule(moduleUrl, exportName) { let mod await import(moduleUrl) return mod[exportName] }, }) await app.ready()入口模块即上面assets/entry.tsx这类文件通常在页面 HTML 中以script async typemodule src/assets/entry.js /的形式引入参见 getting-started.md。run()的底层行为可追溯至 run.ts它创建样式管理器与调度器用createFrame(document, ...)把当前文档本身表示为顶层帧app.frames.top并调用startNavigationListener监听 Navigation API 事件。此后符合条件的同源链接与表单会走软导航soft navigationRemix 通过帧解析器frame resolver拉取目标 HTML就地协调进现有文档而不是整页刷新——即使页面只用了clientEntry()、没有渲染任何显式Frame这一行为也生效。3.1 run 选项选项必填说明loadModule(moduleUrl, exportName)是页面中每发现一个客户端入口都会调用它需返回该组件函数。通常用动态import()实现见 run.ts 中LoadModule类型定义resolveFrame(src, options)否覆盖默认的fetch()解析器。在Frame需要加载/重载内容、以及链接或表单执行帧导航时被调用resolveFrame的options可能包含signal导航取消时中止本次加载的中止信号target目标帧名非 GET 表单提交时额外提供formData、method、encTypeGET 表单的值已经编码进src不再单独传递。默认解析器defaultResolveFrame请求 HTMLAccept: text/html并按编码方式构造请求体application/x-www-form-urlencoded用URLSearchParamstext/plain用 CRLF 分隔的namevalue文本multipart/form-data直接用FormData非 2xx 响应会抛错。完整实现见 run.ts 的getRequestBody与defaultResolveFrame。需要自定义请求头、请求体编码或响应策略时传入resolveFrame即可。请求编码、定位与退出的详细规则见 Frames表单导航。3.2 app 属性run()返回的app具备以下成员对应 run.ts 中AppRuntime类型app.ready()返回一个 Promise在所有初始客户端入口水合完成后 resolveapp.flush()同步冲刷所有待处理的组件更新app.frames.top应用最顶层的帧句柄即当前文档app.frames.get(name)按名称返回帧句柄不存在时返回undefinedapp.dispose()拆除所有已水合组件并清理运行时中止导航监听、释放顶层帧、销毁样式管理器。3.3 错误监听app同时是一个EventTarget实际是TypedEventTarget事件表见 run.ts 的AppRuntimeEventMap可以监听任何已水合组件抛出的错误app.addEventListener(error, (event) { console.error(Component error:, event.error) })底层用createComponentErrorEvent包装错误并派发run()内部对topFrame.ready()的失败也会先派发错误事件再向上抛出。四、什么数据会被序列化客户端入口的 props 会被序列化为 JSON。支持的类型对应 client-entries.ts 中SerializableValue联合类型字符串、数字、布尔值、null、undefined由上述值组成的普通对象与数组JSX 元素序列化为描述符客户端复活props 中的Frame元素序列化为帧描述符客户端重建帧边界。不支持的类型函数、类实例及其他不可序列化的值不能作为 props 传给客户端入口。这一点有类型层面的保障——clientEntry的泛型约束为props extends SerializableProps在 client-entry.test.tsx 的类型测试中用ts-expect-error验证了函数 prop 会被 TypeScript 拒绝。五、水合的工作原理文档 hydration.md 给出了四个步骤结合源码可以对应到具体实现服务端渲染入口组件并包裹注释标记buildEntrySegment用!-- rmx:h:id --/!-- /rmx:h --包裹渲染结果见 stream.ts收集 props 与模块元信息到rmx-data脚本resolveClientEntries把每个hydrationId映射为{ moduleUrl, exportName, props }最终由buildRmxDataScript输出script typeapplication/json idrmx-data序列化数据中所有会被转义为\u003cescapeScriptJson避免提前闭合 script 标签客户端run解析数据、发现标记、逐个调用loadModule见 frame.ts 中mergeRmxDataFromDocument/scheduleHydrationInContainer等逻辑运行时在帧区域内扫描注释标记并调度水合模块加载完成后组件对照现有 DOM 水合匹配的元素就地采用adopt in place不匹配的做补丁patch修正。DOM 协调基于 diff-dom.ts 的差异算法。5.1 由这套机制带来的特性无白屏闪烁页面在模块加载完成前就已完整渲染一旦模块就绪立即可交互按需分发 JS只有被标记的组件才发送 JavaScript静态内容始终保持静态首屏体积被严格收敛入口可出现在任意位置包括帧Frame内部每个帧独立水合自己的入口data-rmx-preserve-dom的边界语义位于该属性元素内部的客户端入口可以在首次启动时正常水合但之后的帧重载不会再通过该保留宿主修补新的服务端渲染子节点或 props。详见 Frames保留客户端拥有的 DOM。六、与 Frames 的联动软导航run()启动时把当前文档表示为顶层帧并开始监听 Navigation API 事件这使得水合与帧导航无缝衔接符合条件的同源链接点击会重载顶层帧的 HTML 并协调进现有文档data-rmx-target、data-rmx-src、data-rmx-history、data-rmx-reset-scroll、data-rmx-document控制具体行为符合条件的表单提交走同样的帧导航路径原生约束校验失败的表单永远不会到达resolveFrame若想保留水合与显式帧、但让链接/表单退化为普通文档导航可在调用run()前自行取消内置navigate事件window.navigation?.addEventListener(navigate, (e) event.stopImmediatePropagation())文档级导航的完整效果与逐项退出方式见 Frames链接导航。真实示例reload-time.tsx展示了水合组件与帧生命周期的协作——监听reloadStart/reloadComplete事件切换按钮的Refreshing…态export const ReloadTime clientEntry(import.meta.url, function ReloadTime(handle: Handle) { let pending false handle.frame.addEventListener(reloadStart, () { pending true; handle.update() }, { signal: handle.signal }) handle.frame.addEventListener(reloadComplete, () { pending false; handle.update() }, { signal: handle.signal }) return () ( button typebutton mix{[reloadButtonStyle, on(click, () { void handle.frame.reload() })]} style{pending ? { background: rgba(255,255,255,0.04) } : undefined} {pending ? Refreshing… : Refresh} /button ) })七、实战一个完整的计数/购物车入口把以上知识点串成一个最小可运行闭环与 getting-started.md 的工程结构一致服务端页面路由import { render } from remix/middleware/render import { createRouter } from remix/router import { Frame } from remix/ui import { Counter } from ./assets/counter.tsx function App() { return () ( html head titleMy App/title script async typemodule src/assets/entry.js / /head body h1Hello/h1 Counter initialCount{0} labelClicks / Frame src/sidebar fallback{divLoading.../div} / /body /html ) } let router createRouter({ middleware: [render()] }) router.get(/, (context) context.render(App /)) router.get(/sidebar, (context) context.render(navSidebar/nav))客户端入口组件assets/counter.tsximport { clientEntry, on, type Handle } from remix/ui export let Counter clientEntry( /assets/counter.js#Counter, function Counter(handle: Handle{ initialCount?: number; label: string }) { let count handle.props.initialCount ?? 0 return () ( div span{handle.props.label}: {count}/span button mix{[ on(click, () { count handle.update() }), ]} /button /div ) }, )客户端引导模块assets/entry.tsximport { run } from remix/ui let app run({ async loadModule(moduleUrl, exportName) { let mod await import(moduleUrl) return mod[exportName] }, }) await app.ready()服务端渲染后Counter的位置会以!-- rmx:h:... --标记包裹rmx-data脚本中记录/assets/counter.js#Counter与{ initialCount: 0, label: Clicks }浏览器端 entry 模块执行run()后动态import(/assets/counter.js)取出Counter导出对照既有 DOM 完成水合点击按钮即可通过handle.update()触发局部重渲染。八、相关文档与进一步阅读服务端渲染ServerrenderToString与renderToStream其中resolveClientEntry选项负责把入口 ID 解析为公开模块 URL、导出名与预加载列表生产环境常结合资产服务器assetServer.getHref(entryId)/getPreloads(entryId)使用Frames流式局部服务端 UI、帧重载、保留客户端 DOM、链接/表单导航的完整规则Components组件模型与生命周期Getting Started从零搭建一个包含水合入口的服务端渲染应用核心实现client-entries.ts、run.ts、frame.ts、stream.ts测试参考hydration.components.test.tsx、client-entry.test.tsx。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。