Remix 交互性实战:从渐进增强到客户端水合、事件处理与取消机制
2026/9/10 16:53:54 网站建设 项目流程

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

【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix

导读

本篇指南以 Remix 官方文档 Interactivity 章节 为核心脉络,完整讲解如何在服务端渲染的组件上逐步添加浏览器行为:标记水合边界、启动浏览器运行时、维护组件局部 UI 状态、挂载事件、取消过期的异步工作,以及增强链接与表单。读完本文,你将掌握clientEntryruncreateRootmixonrefhandle.update等核心 API 的用法,理解“控制器仍拥有变更与错误响应、组件只负责 UI 状态”的边界划分原则,并能独立实现带 pending 状态、乐观 UI、可取消搜索请求的交互组件。

本文中的所有代码示例均取自仓库中的 Interactivity 章节 及其配套的 examples/05-interactivity 目录;源码级证据可在 packages/ui/src/runtime 中查找。

渐进增强:先有可用请求,再谈浏览器增强

在 Start Here 章节中,专辑页、编辑表单、校验、变更与重定向在加入浏览器 JavaScript 之前就已经全部正常工作。随后表单被标记为 client entry,其提交按钮才能展示 pending 状态。这正是 Remix 交互模型的核心起点——渐进增强(progressive enhancement)

先渲染一个能描述服务器应收到何种请求的链接和表单。例如专辑编辑表单带有真实的actionmethod

import 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 })} method="post"> <label> Title <input defaultValue={album.title} name="title" required /> </label> <label> Artist <input defaultValue={album.artist} name="artist" required /> </label> <label> Year <input defaultValue={album.year} name="year" required type="number" /> </label> <button type="submit">Save album</button> </form> ); }; }
  • 无 JavaScript 时:浏览器把FormDataPOST 到路由,action 校验、更新专辑并返回重定向,一切照常工作;
  • 有 JavaScript 时:事件处理器可以拦截这同一次提交,加入 pending UI 或局部刷新页面;
  • 关键不变式:controller 仍然拥有变更(mutation)与所有错误响应,浏览器代码只是消费请求结果。

导航同理:先渲染带有效href的锚点,再让浏览器运行时在可用时增强同源导航。

有些交互天生依赖 JavaScript(如下文中的实时搜索)。即便如此,服务器仍应渲染有用的内容、导航与表单骨架,并把可序列化数据和路由 URL 交给每个需要水合的组件,而不是把整页迁入浏览器代码。

Hydration 边界与 clientEntry

clientEntry(...)标记需要在浏览器中运行的最小组件。在实现上,它只是给组件函数附加$entry$entryId元数据(见 client-entries.ts):

import { 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 })} method="post"> {/* fields */} </form> ); }; }, );
  • 服务端:它仍然像普通组件一样渲染;
  • 浏览器端:Remix 加载同一个组件模块,并用服务端渲染页面携带的 props 启动它。

Client-entry props 必须可序列化SerializableValue类型(见 client-entries.ts)允许:字符串、数字、布尔值、nullundefined、普通对象与数组,以及 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 水合完成后 resolve
app.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="/support">Contact 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(); })} type="button" > Count: {count} </button> ); }

count存活于 setup scope,因此每次渲染都能存活,且不会成为组件 props 的一部分。配套的可运行示例见 examples/05-interactivity/basic-counter.tsx。

在更新后操作 DOM:await 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(); })} type="button" > Rename </button> {editing && <input aria-label="Album 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();

渲染后任务:queueTask

handle.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 事件,不要为了重建平台已提供的行为而用它。

若某个事件在windowdocument上启动临时监听器,请给这些监听器各自的清理信号,并在交互结束或组件断开时停止它们。

只在增强路径将接管浏览器职责时才调用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(); })} type="button" > 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(...)提供默认属性,但不覆盖显式的元素 props
link(...)添加客户端导航行为与链接语义
css(...)应用静态生成的 CSS

动画 mixin 使用同一 prop,见 Animation 章节。频繁变化的值应放在普通 props(如stylevaluechecked、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 信号的生命周期恰好匹配“仅当该元素消失时应停止的工作”。

对于渲染树之外的事件目标(windowdocument、媒体查询),使用原生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。对于纯客户端 root,setup 本来就在浏览器中运行,因此可以直接注册监听器。

某些监听器的生命周期比组件更短。在这种工作开始时创建一个AbortController,把它的 signal 传给addEventListener(...),工作结束时 abort 它;同时从handle.signal再 abort 一次,确保导航不会遗留临时监听器。

异步工作与取消:handle.signal

Remix 为异步工作提供一个限定在其创建者生命周期内的 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-label="Search 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(); } })} type="search" /> {error ? <p role="alert">{error}</p> : null} {loading ? <p>Searching…</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: Handle<CartProps>) { 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))} type="button"> Add {handle.props.product.name} </button> <button aria-expanded={detailsOpen} mix={on("click", () => { detailsOpen = !detailsOpen; handle.update(); })} type="button" > 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" }))} type="button"> Replace current album </button> </div> ); }; }

在非锚点宿主上,link(...)会添加role="link"、在需要时让元素可键盘聚焦、处理 Enter 与鼠标/修饰键激活,并用aria-disabled反映禁用宿主。标记合适时优先用锚点;当让其他宿主表现得像链接是有意为之的 UI 设计时才用 mixin。

原生锚点与表单还可以用与link(...)/navigate(...)选项对应的属性控制帧感知导航:

属性作用
data-rmx-target指定要重载的帧名
data-rmx-src提供该帧要 fetch 的 URL,而href仍是浏览器的目的地
data-rmx-history="push|replace"控制导航如何更新历史,包括覆盖表单默认行为
data-rmx-reset-scroll="false"保留当前滚动位置
data-rmx-document退出拦截,让浏览器执行整页文档导航

Streaming UI with Frames 展示了这些属性与命名帧的配合,并说明表单何时可以直接经由帧解析器提交。

用 fetch 与导航增强表单

现在无需引入第二个变更 API 就能增强专辑表单。处理器沿用表单已有的actionmethodFormData,然后跟随 action 的重定向:

// Inside AlbumEditForm's render function: <form action={routes.albums.edit.action.href({ albumId: album.id })} method="post" 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} type="submit"> {pending ? "Saving…" : "Save album"} </button> {error ? <p role="alert">{error}</p> : null} </form>

controller action 没有任何改变。未增强的提交依然收到它的重定向或错误响应;增强路径消费该响应、非成功时在本地展示错误,并用navigate(...)跟随控制器的重定向 URL。

选择与 UI 匹配的同步边界:

场景做法
文档导航本身就是正确结果正常提交现有表单
页面需要在导航前展示 pending 或内联错误 UIfetch()拦截
浏览器端模型拥有数据,且无需改动任何服务端渲染区域发送 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 = createMixin<HTMLElement>((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。

小结:交互层的职责划分

本章建立了一条清晰的分层纪律:

  1. 渐进增强优先:先让无 JS 的服务端请求(表单 POST、锚点导航)完整可用;
  2. 最小水合边界:用clientEntry只让交互部件进入浏览器,静态部分留在服务端;
  3. 单一浏览器运行时run()水合当前文档,不充当第二个路由器;
  4. setup scope 管理 UI 状态,业务规则放在普通 TypeScript 模型,通过TypedEventTarget事件接回组件;
  5. 信号驱动的取消:按“处理器级 → 任务级 → 组件级”选择最窄的 abort 信号,杜绝过期异步结果覆盖新状态。

下一章将基于同一个服务端渲染器与浏览器运行时,用<Frame>流式渲染并重载按路由拥有的 UI(见 06-streaming-ui-with-frames.md);Animation 章节随后把组件状态、mix组合、key 与取消机制应用到入场、退场、布局、spring 与 tween 动效上。

【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询