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和method:
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)允许:字符串、数字、布尔值、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 水合完成后 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 事件,不要为了重建平台已提供的行为而用它。
若某个事件在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(); })} 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(如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。对于纯客户端 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 就能增强专辑表单。处理器沿用表单已有的action、method与FormData,然后跟随 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 或内联错误 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 = 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。
小结:交互层的职责划分
本章建立了一条清晰的分层纪律:
- 渐进增强优先:先让无 JS 的服务端请求(表单 POST、锚点导航)完整可用;
- 最小水合边界:用
clientEntry只让交互部件进入浏览器,静态部分留在服务端; - 单一浏览器运行时:
run()水合当前文档,不充当第二个路由器; - setup scope 管理 UI 状态,业务规则放在普通 TypeScript 模型,通过
TypedEventTarget事件接回组件; - 信号驱动的取消:按“处理器级 → 任务级 → 组件级”选择最窄的 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),仅供参考