ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Remix 交互性实战:从渐进增强到客户端水合、事件处理与取消机制

Remix 交互性实战:从渐进增强到客户端水合、事件处理与取消机制 Remix 交互性实战从渐进增强到客户端水合、事件处理与取消机制【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix导读本篇指南以 Remix 官方文档 Interactivity 章节 为核心脉络完整讲解如何在服务端渲染的组件上逐步添加浏览器行为标记水合边界、启动浏览器运行时、维护组件局部 UI 状态、挂载事件、取消过期的异步工作以及增强链接与表单。读完本文你将掌握clientEntry、run、createRoot、mix、on、ref、handle.update等核心 API 的用法理解“控制器仍拥有变更与错误响应、组件只负责 UI 状态”的边界划分原则并能独立实现带 pending 状态、乐观 UI、可取消搜索请求的交互组件。本文中的所有代码示例均取自仓库中的 Interactivity 章节 及其配套的 examples/05-interactivity 目录源码级证据可在 packages/ui/src/runtime 中查找。渐进增强先有可用请求再谈浏览器增强在 Start Here 章节中专辑页、编辑表单、校验、变更与重定向在加入浏览器 JavaScript 之前就已经全部正常工作。随后表单被标记为 client entry其提交按钮才能展示 pending 状态。这正是 Remix 交互模型的核心起点——渐进增强progressive enhancement。先渲染一个能描述服务器应收到何种请求的链接和表单。例如专辑编辑表单带有真实的action和methodimport type { Handle } from remix/ui; import { routes } from ../../../../routes.ts; interface Album { artist: string; id: string; title: string; year: number; } export function AlbumEditForm(handle: Handle{ album: Album }) { return () { let { album } handle.props; return ( form action{routes.albums.edit.action.href({ albumId: album.id })} methodpost label Title input defaultValue{album.title} nametitle required / /label label Artist input defaultValue{album.artist} nameartist required / /label label Year input defaultValue{album.year} nameyear required typenumber / /label button typesubmitSave album/button /form ); }; }无 JavaScript 时浏览器把FormDataPOST 到路由action 校验、更新专辑并返回重定向一切照常工作有 JavaScript 时事件处理器可以拦截这同一次提交加入 pending UI 或局部刷新页面关键不变式controller 仍然拥有变更mutation与所有错误响应浏览器代码只是消费请求结果。导航同理先渲染带有效href的锚点再让浏览器运行时在可用时增强同源导航。有些交互天生依赖 JavaScript如下文中的实时搜索。即便如此服务器仍应渲染有用的内容、导航与表单骨架并把可序列化数据和路由 URL 交给每个需要水合的组件而不是把整页迁入浏览器代码。Hydration 边界与 clientEntryclientEntry(...)标记需要在浏览器中运行的最小组件。在实现上它只是给组件函数附加$entry与$entryId元数据见 client-entries.tsimport { clientEntry } from remix/ui; import type { Handle } from remix/ui; import { routes } from ../../../../routes.ts; interface Album { artist: string; id: string; title: string; year: number; } export const AlbumEditForm clientEntry( import.meta.url, function AlbumEditForm(handle: Handle{ album: Album }) { return () { let { album } handle.props; return ( form action{routes.albums.edit.action.href({ albumId: album.id })} methodpost {/* fields */} /form ); }; }, );服务端它仍然像普通组件一样渲染浏览器端Remix 加载同一个组件模块并用服务端渲染页面携带的 props 启动它。Client-entry props 必须可序列化。SerializableValue类型见 client-entries.ts允许字符串、数字、布尔值、null、undefined、普通对象与数组以及 JSX 元素。不要传函数、类实例、带自定义原型的数据库记录或其他不透明运行时值。边界应围绕交互性 UI 划定而不是习惯性地包住整页。按钮下方的静态祖先与兄弟节点无需因按钮需要水合而跟着水合。把数据库、已认证用户、请求上下文留在服务端给 client entry 只传初始数据、路由 URL 和其他浏览器安全值。用 run() 启动浏览器运行时文档外壳加载app/actions/public/entry.ts该模块调用一次run()import { run } from remix/ui; let app run({ async loadModule(moduleUrl, exportName) { let module await import(moduleUrl); return module[exportName]; }, }); app.addEventListener(error, (event) { console.error(Component error:, event.error); }); await app.ready();loadModule为文档中发现的每个 client entry 动态导入命名导出。源码层面的run实现位于 run.ts它创建样式管理器、调度器与顶层 Frame启动导航监听并返回AppRuntime。返回的运行时带有三个生命周期方法方法作用app.ready()在初始 client entries 水合完成后 resolveapp.flush()同步应用挂起的组件更新主要用于测试与需要立即观察 DOM 的集成场景app.dispose()移除运行时的组件与监听器run()水合服务端渲染的页面它不是第二个应用路由器。浏览器的请求仍然交给拥有对应服务端行为的路由 action。当应用需要按路由拥有、可独立加载/重载的区域时Streaming UI with Frames 章节补充了可选的resolveFrame回调其默认实现是携带表单数据 fetch 帧源 HTML见 run.ts。用 createRoot 挂载纯客户端 UI当没有可水合的服务端渲染组件时——例如挂载到其他应用提供的容器中的命令式小部件——使用createRoot(container)import { createRoot } from remix/ui; import type { Handle } from remix/ui; function SupportWidget(_handle: Handle) { return () a href/supportContact support/a; } let container document.getElementById(support-widget); if (container) { let root createRoot(container); root.render(SupportWidget /); window.addEventListener(pagehide, () root.dispose(), { once: true }); }root 拥有该容器。root.render(node)调度一棵树root.flush()同步应用挂起工作root.dispose()移除树并执行清理。源码实现见 vdom.ts它还会为容器挂载错误转发监听并在容器存在服务端内容时采用“增量采纳”方式合并服务端样式。常规页面 UI 应该从服务端开始渲染并使用clientEntry(...)否则用户要等 JavaScript 下载并执行后才能看到服务器本来就能渲染的内容。状态、更新与渲染后任务组件局部 UI 状态放在setup scope中。在事件处理器里修改它然后调用handle.update()import { on } from remix/ui; import type { Handle } from remix/ui; function Counter(handle: Handle{ initialCount: number }) { let count handle.props.initialCount; return () ( button mix{on(click, () { count; handle.update(); })} typebutton Count: {count} /button ); }count存活于 setup scope因此每次渲染都能存活且不会成为组件 props 的一部分。配套的可运行示例见 examples/05-interactivity/basic-counter.tsx。在更新后操作 DOMawait handle.update()handle.update()调度工作并返回 promise。当下一步需要已更新的 DOM 时await 它import { on, ref } from remix/ui; import type { Handle } from remix/ui; function RenameButton(handle: Handle) { let editing false; let input: HTMLInputElement; return () ( div button mix{on(click, async () { editing true; await handle.update(); input.focus(); })} typebutton Rename /button {editing input aria-labelAlbum title mix{ref((node) (input node))} /} /div ); }该 promise 会 resolve 为一个AbortSignal但仅在更新后还要跨另一个异步边界继续工作时才需要捕获它。运行时无法在await handle.update()与下一条同步语句之间中止信号所以input.focus()无需信号检查。当后续还有异步工作时把信号传给该工作并在之后的await之后检查// Inside an async event handler: let signal await handle.update(); let response await fetch(href, { signal }); if (signal.aborted) return; status response.ok ? saved : error; handle.update();渲染后任务queueTaskhandle.queueTask(task)在下次更新后的 commit 阶段运行任务。当 DOM 测量或另一次更新必须属于那次 flush 时使用它// Inside an event handler after detailsSection has been assigned: showDetails true; handle.update(); handle.queueTask(() { detailsSection.scrollIntoView({ block: nearest }); });不要为了“让队列任务稍后注意到”而刻意造出额外的状态。应在拥有该工作的事件处理器里启动工作或把队列任务直接绑定到它观察的 prop 值上。key 与列表更新只要更新改变了列表key 就很重要。使用稳定 ID而不是排序后会改变的数组索引也不要使用渲染期间生成的随机值。匹配的 key 让 Remix 移动现有 DOM 与组件实例而不是把状态配错给错误的条目。示例见 examples/05-interactivity/keyed-list.tsx。Setup-scope 变量适合存放“菜单是否打开”“哪个字段获得焦点”“请求是否 pending”这类 UI 状态。业务规则与共享应用状态应放在组件外的普通 TypeScript 中由事件处理器调用——详见下文“Keep application logic outside components”小节。用 on() 挂载事件on(type, handler, capture?)为宿主元素挂载一个带类型的DOM 事件。事件的currentTarget会从宿主推断出来import { on } from remix/ui; import type { Handle } from remix/ui; function AlbumForm(_handle: Handle) { return () ( form mix{on(submit, (event) { event.preventDefault(); let formData new FormData(event.currentTarget); console.log(formData.get(title)); })} {/* fields */} /form ); }源码层面on是类型化包装见 on-mixin.ts保证 JSX 宿主上下文能根据事件类型推断event/currentTarget。优先使用原生元素与其原生事件按钮的click事件已覆盖指针、触摸、Enter 与 Space 激活表单的submit事件已覆盖提交按钮与字段内按 Enter只有交互本身确实需要时才使用底层 pointer / keyboard 事件不要为了重建平台已提供的行为而用它。若某个事件在window或document上启动临时监听器请给这些监听器各自的清理信号并在交互结束或组件断开时停止它们。只在增强路径将接管浏览器职责时才调用preventDefault()。如果事件处理器只是在普通表单提交前添加 pending 状态就应放行提交继续。受控与非受控输入非受控输入把当前值保存在 DOM 中defaultValue只提供初始值受控输入的当前值来自组件状态input 处理器把 DOM 值拷进状态并更新组件。import { on } from remix/ui; import type { Handle } from remix/ui; const initialTitle Thriller; function TitleInputs(handle: Handle) { let title initialTitle; return () ( div p label Uncontrolled input defaultValue{initialTitle} / /label /p p label Controlled{ } input value{title} mix{on(input, (event) { title event.currentTarget.value; handle.update(); })} / /label /p button mix{on(click, () { title initialTitle; handle.update(); })} typebutton Reset controlled input /button /div ); }输入时两个输入框都会变化点击按钮只会重置受控输入框其value来自title非受控输入框保留 DOM 值。可运行示例见 examples/05-interactivity/controlled-uncontrolled-inputs.tsx。用 mix 组合行为mixprop 把可复用行为与样式附加到宿主元素上。传单个 mixin或多个时传数组import button from remix/ui/button; import { attrs, css, on, ref } from remix/ui; import type { Handle } from remix/ui; const saveButtonStyle css({ minWidth: 8rem }); function SaveButton(_handle: Handle) { return () ( button mix{[ button({ tone: primary }), saveButtonStyle, attrs({ type: button }), ref((node) console.log(Mounted, node)), on(click, () console.log(Save album)), ]} Save /button ); }核心 mixin 各司其职Mixin作用on(...)挂载带类型的事件处理器ref(...)元素插入时执行 setup并提供清理信号attrs(...)提供默认属性但不覆盖显式的元素 propslink(...)添加客户端导航行为与链接语义css(...)应用静态生成的 CSS动画 mixin 使用同一 prop见 Animation 章节。频繁变化的值应放在普通 props如style、value、checked、ARIA 属性中而不是为每个状态变化重建静态 mixin。DOM 引用、全局事件与清理ref(...)接收插入的 DOM 节点和一个在该节点被移除时 abort 的信号。把观察器与元素自有监听器绑定到该信号import { ref } from remix/ui; import type { Handle } from remix/ui; function MeasuredPanel(handle: Handle) { let width 0; return () ( div mix{ref((node, signal) { let observer new ResizeObserver((entries) { let entry entries[0]; if (!entry) return; width Math.round(entry.contentRect.width); handle.update(); }); observer.observe(node); signal.addEventListener(abort, () observer.disconnect()); })} Panel width: {width}px /div ); }ref 回调在元素插入时运行而不是每次组件更新都运行。若元素是条件渲染的其 ref 信号的生命周期恰好匹配“仅当该元素消失时应停止的工作”。对于渲染树之外的事件目标window、document、媒体查询使用原生addEventListener()。在客户端 commit 之后调度浏览器专属 setup并传入handle.signal让监听器在组件断开时被移除import { clientEntry } from remix/ui; import type { Handle } from remix/ui; export const ViewportWidth clientEntry(import.meta.url, function ViewportWidth(handle: Handle) { let width: number | undefined; handle.queueTask(() { width window.innerWidth; window.addEventListener( resize, () { width window.innerWidth; handle.update(); }, { signal: handle.signal }, ); handle.update(); }); return () span{width undefined ? Measuring… : ${width}px}/span; });调度 setup 可以避免在服务端渲染期间读取window。对于纯客户端 rootsetup 本来就在浏览器中运行因此可以直接注册监听器。某些监听器的生命周期比组件更短。在这种工作开始时创建一个AbortController把它的 signal 传给addEventListener(...)工作结束时 abort 它同时从handle.signal再 abort 一次确保导航不会遗留临时监听器。异步工作与取消handle.signalRemix 为异步工作提供一个限定在其创建者生命周期内的 abort 信号信号何时 abort适用场景on(...)处理器的第二个参数同一处理器再次运行或其元素被移除最新事件应取代先前工作的请求队列任务的参数组件再次渲染或断开以 prop 为 key 的加载与渲染后 DOM 工作handle.signal组件断开组件生命周期的定时器与全局监听器把最窄的信号传进fetch()并在任何无法接受信号的 await 操作之后检查它。下面的搜索示例中服务器把routes.albums.search.href()作为searchHref传入路由返回短文本结果浏览器组件不硬编码端点import { clientEntry, on } from remix/ui; import type { Handle } from remix/ui; export const AlbumSearch clientEntry( import.meta.url, function AlbumSearch(handle: Handle{ searchHref: string }) { let error: string | undefined; let loading false; let resultText ; return () ( div input aria-labelSearch albums mix{on(input, async (event, signal) { let url new URL(handle.props.searchHref, window.location.href); url.searchParams.set(query, event.currentTarget.value); error undefined; loading true; handle.update(); try { let response await fetch(url, { signal }); if (!response.ok) { throw new Error(Search failed with status ${response.status}); } let nextResultText await response.text(); if (signal.aborted) return; resultText nextResultText; loading false; handle.update(); } catch (caught) { if (signal.aborted) return; error caught instanceof Error ? caught.message : Search failed; loading false; handle.update(); } })} typesearch / {error ? p rolealert{error}/p : null} {loading ? pSearching…/p : null} {resultText ? p{resultText}/p : null} /div ); }, );再次输入会 abort 前一个处理器信号及其 fetch。一个旧查询的慢响应不能覆盖更新的结果——这正是信号驱动的防竞态保障。把应用逻辑留在组件之外Setup-scope 变量让组件状态变得容易但这不等于组件应该成为每条规则的拥有者留在组件内描述当前 UI 的状态——面板是否打开、哪个元素应获得焦点、请求是否 pending放进普通 TypeScript应用数据与操作由组件调用。一个小模型能让这条边界具体化。下面的 cart 拥有自己的条目与数量变化import { TypedEventTarget } from remix/ui; export interface CartItem { id: string; quantity: number; } export class CartModel extends TypedEventTarget{ change: Event } { #items: CartItem[]; constructor(items: readonly CartItem[]) { super(); this.#items [...items]; } get itemCount(): number { return this.#items.reduce((total, item) total item.quantity, 0); } add(productId: string): void { let item this.#items.find((item) item.id productId); this.#items item ? this.#items.map((item) item.id productId ? { ...item, quantity: item.quantity 1 } : item, ) : [...this.#items, { id: productId, quantity: 1 }]; this.dispatchEvent(new Event(change)); } }组件从可序列化 props 创建模型、监听变化只保留详情面板的 open 状态import { clientEntry, on } from remix/ui; import type { Handle } from remix/ui; import { CartModel, type CartItem } from ./cart-model.ts; interface CartProps { initialItems: CartItem[]; product: { id: string; name: string }; } export const Cart clientEntry(import.meta.url, function Cart(handle: HandleCartProps) { let cart new CartModel(handle.props.initialItems); let detailsOpen false; cart.addEventListener(change, () handle.update(), { signal: handle.signal }); return () ( section button mix{on(click, () cart.add(handle.props.product.id))} typebutton Add {handle.props.product.name} /button button aria-expanded{detailsOpen} mix{on(click, () { detailsOpen !detailsOpen; handle.update(); })} typebutton Cart ({cart.itemCount}) /button {detailsOpen ? p{cart.itemCount} items in your cart./p : null} /section ); });cart.addEventListener()把模型变化连接到handle.update()handle.signal在组件断开时移除订阅。若多个组件共享同一模型应在它们最近的共享 client 边界创建它并通过组件 context 提供——不要把类实例穿过clientEntry(...)props因为那些 props 会被序列化。这种分离让每个测试更聚焦不带 DOM 测试 cart 规则用组件测试详情按钮与渲染出的条目数因为它们依赖组件与 DOM。客户端导航继续使用真实锚点。run()启动后运行时能通过浏览器的 Navigation API 增强同源导航跨域链接、下载与浏览器无法拦截的导航继续作为普通文档请求。import type { Handle } from remix/ui; import { routes } from ../routes.ts; function AlbumLink(handle: Handle{ albumId: string; title: string }) { return () ( a href{routes.albums.show.href({ albumId: handle.props.albumId })}{handle.props.title}/a ); }当导航从代码开始时使用navigate(href, options)把link(href, options)应用到任何应表现得像链接的 HTML 宿主上——例如整块表面都可点击的卡片import { link, navigate, on } from remix/ui; import type { Handle } from remix/ui; import { routes } from ../routes.ts; function AlbumNavigation(handle: Handle{ albumId: string; title: string }) { return () { let href routes.albums.show.href({ albumId: handle.props.albumId }); return ( div article mix{link(href)}{handle.props.title}/article button mix{on(click, () navigate(href, { history: replace }))} typebutton Replace current album /button /div ); }; }在非锚点宿主上link(...)会添加rolelink、在需要时让元素可键盘聚焦、处理 Enter 与鼠标/修饰键激活并用aria-disabled反映禁用宿主。标记合适时优先用锚点当让其他宿主表现得像链接是有意为之的 UI 设计时才用 mixin。原生锚点与表单还可以用与link(...)/navigate(...)选项对应的属性控制帧感知导航属性作用data-rmx-target指定要重载的帧名data-rmx-src提供该帧要 fetch 的 URL而href仍是浏览器的目的地data-rmx-historypush|replace控制导航如何更新历史包括覆盖表单默认行为data-rmx-reset-scrollfalse保留当前滚动位置data-rmx-document退出拦截让浏览器执行整页文档导航Streaming UI with Frames 展示了这些属性与命名帧的配合并说明表单何时可以直接经由帧解析器提交。用 fetch 与导航增强表单现在无需引入第二个变更 API 就能增强专辑表单。处理器沿用表单已有的action、method与FormData然后跟随 action 的重定向// Inside AlbumEditForms render function: form action{routes.albums.edit.action.href({ albumId: album.id })} methodpost mix{on(submit, async (event, signal) { let form event.currentTarget; event.preventDefault(); error undefined; pending true; handle.update(); try { let response await fetch(form.action, { body: new FormData(form), method: form.method, signal, }); if (signal.aborted) return; if (!response.ok) { error await response.text(); if (signal.aborted) return; pending false; handle.update(); return; } await navigate(response.url, { history: replace }); } catch (caught) { if (signal.aborted) return; error caught instanceof Error ? caught.message : Unable to save album; pending false; handle.update(); } })} {/* The same fields from the progressively enhanced form above. */} button disabled{pending} typesubmit {pending ? Saving… : Save album} /button {error ? p rolealert{error}/p : null} /formcontroller action 没有任何改变。未增强的提交依然收到它的重定向或错误响应增强路径消费该响应、非成功时在本地展示错误并用navigate(...)跟随控制器的重定向 URL。选择与 UI 匹配的同步边界场景做法文档导航本身就是正确结果正常提交现有表单页面需要在导航前展示 pending 或内联错误 UI用fetch()拦截浏览器端模型拥有数据且无需改动任何服务端渲染区域发送 JSON数据独立于当前页面变化轮询小型 JSON 端点Streaming UI with Frames 覆盖了“变更只重载一个路由拥有区域而不导航整个文档”的可选场景。Pending 与乐观 UI专辑表单把pending放在 setup scope因为渲染输出读取它。提交处理器在请求前设置它、请求结束后清除——这对禁用控件、pending 标签、进度指示器与防重复提交来说已经足够。乐观 UI在请求完成前就渲染预期结果。把乐观操作及其回滚策略放进拥有数据的模型组件渲染模型的当前状态同时把 pending 标签、打开的菜单、聚焦的控件这类 UI 关注点留在本地。乐观状态最适合可逆、可预测的变化切换、重排、添加已知条目。当服务器决定最终值、执行破坏性操作、或可能以用户必须先解决的错误拒绝请求时等待响应。若乐观请求失败模型可以恢复先前状态或采纳服务器响应组件渲染由此产生的错误或重试控件。何时创建自定义事件 mixin大多数应用代码应使用on(...) 原生事件。当多个组件需要由多个底层事件组合出的同一交互例如带时序或指针状态的手势时才创建事件 mixin。mixin 可以把状态放在 setup scope并为消费者派发一个语义化的、带类型的事件。下面的longPress()mixin 把长按指针转成app:longpress事件import { createMixin, on } from remix/ui; export const longPressType app:longpress as const; export class LongPressEvent extends Event { constructor() { super(longPressType, { bubbles: true }); } } declare global { interface HTMLElementEventMap { [longPressType]: LongPressEvent; } } export const longPress createMixinHTMLElement((handle) { let node: HTMLElement | undefined; let timeout: number | undefined; handle.addEventListener(insert, (event) { node event.node; }); handle.addEventListener(remove, cancel); function cancel() { window.clearTimeout(timeout); } return () ( handle.element mix{[ on(pointerdown, () { cancel(); timeout window.setTimeout(() { node?.dispatchEvent(new LongPressEvent()); }, 500); }), on(pointerup, cancel), on(pointercancel, cancel), on(pointerleave, cancel), ]} / ); });像任何其他 DOM 事件一样用on(...)消费这个语义化事件button mix{[ longPress(), on(longPressType, () { console.log(Long press); }), ]} Hold for options /button用命名空间限定自定义事件名以避免冲突一次性行为留在使用它的组件里。createMixin的实现与MixinHandle含insert/remove/commit等生命周期事件见 mixin.ts。小结交互层的职责划分本章建立了一条清晰的分层纪律渐进增强优先先让无 JS 的服务端请求表单 POST、锚点导航完整可用最小水合边界用clientEntry只让交互部件进入浏览器静态部分留在服务端单一浏览器运行时run()水合当前文档不充当第二个路由器setup scope 管理 UI 状态业务规则放在普通 TypeScript 模型通过TypedEventTarget事件接回组件信号驱动的取消按“处理器级 → 任务级 → 组件级”选择最窄的 abort 信号杜绝过期异步结果覆盖新状态。下一章将基于同一个服务端渲染器与浏览器运行时用Frame流式渲染并重载按路由拥有的 UI见 06-streaming-ui-with-frames.mdAnimation 章节随后把组件状态、mix组合、key 与取消机制应用到入场、退场、布局、spring 与 tween 动效上。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表