
Remix 框架入门全解从 CLI 创建应用到路由、页面渲染、表单与 Hydrate 的完整实战【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix本文以 Remix 官方指南的 Start Here 章节为主线带你建立理解 Remix 的核心心智模型它是什么、六大设计原则是什么如何用remixCLI 一键创建只依赖单一运行时包的全栈应用并通过一个数字唱片商店的完整示例亲手定义路由、渲染服务端页面、处理表单提交以及 Hydrate 第一个浏览器端组件。读完本篇你将能够独立搭建并扩展一个符合 Remix 架构规范的 Web 应用并知道每个文件在请求流中承担的职责。一、什么是 Remix定位与心智模型Remix 是一个用于构建 Web 应用的全栈 TypeScript 框架。根据官方文档01-start-here.md的定义它提供类型化路由与中间件typed routing and middlewareUI 组件模型支持服务端渲染SSR与浏览器 Hydration、导航、样式与事件原语数据校验与持久化、认证与会话资产编译asset compilation与测试工具链。一个关键事实是对大多数 Web 应用而言remix可以是package.json中唯一的运行时依赖。从仓库的 CLI 模板 template/package.json 可以看到这一点——依赖区只有一个remix: workspace: ^发布到 npm 后即remix: ^3.0.0其余均为 devDependencies。Web API 把这些部分串联起来路由接收一个 WebRequest返回一个 WebResponse响应可以包含由 Remix 组件渲染的 HTML组件在浏览器中 Hydrate 后即可处理交互。同一份类型化路由定义既用于匹配入站请求也用于为链接、重定向和表单生成 URL。这些 API 通过remix包的子路径导入subpath imports整体提供其底层包也可以独立使用——仓库中 packages/ 目录下的每个包如remix-run/route-pattern、remix-run/node-fetch-server、remix-run/form-data-middleware就是这些底层实现而 packages/remix/src 下每个子路径模块如form-data-middleware.ts、node-fetch-server.ts、ui.ts都只是对相应底层包的自动生成的 re-export。Remix 的设计源自六条核心原则Agent-First Development智能体优先Remix 针对 LLM 优化其源码、文档、工具与抽象并提供在产品内使用模型的原语。Build on Web APIs构建于 Web API 之上基于Request、Response、URL、FormData、headers、cookies 与 JavaScript 构建共享的平台 API 减少了全栈之间的上下文切换并保持应用可移植。Religiously Runtime严格运行时Remix API 不依赖打包器、类型生成或静态分析路由、中间件、控制器和测试都应作为普通运行时代码执行。Avoid Dependencies避免依赖谨慎选择依赖将其封装在自己的边界之后并在需要长期掌控时朝聚焦的自研包演进。Demand Composition强调组合包是单一职责、可替换、独立可用的应用变化时可以增删模块而紧耦合的模块则放一起。Distribute Cohesively凝聚式分发通过一个remix包和一条文档路径分发这些可组合部件让生态易于学习。理解 Remix 最快的方式是跨栈构建一个完整功能定义路由、返回服务端渲染页面、处理表单提交、Hydrate 一个组件。接下来就按这条路径走一遍。二、快速上手创建并运行一个 Remix 应用2.1 使用 CLI 创建项目最快的起点是remixCLI它会创建一个小型全栈应用Node 服务器、类型化路由、渲染中间件、服务端渲染首页、源码直出的资产source-served assets以及一个已 Hydrate 的组件。首次运行npx remixnext new my-remix-app然后安装项目依赖cd my-remix-app npm i打开package.json你会看到只有一个运行时依赖remix。这个单一包同时承担服务器适配器、路由、中间件、UI、资产与客户端入口{ name: my-remix-app, private: true, type: module, dependencies: { remix: ^3.0.0 } }最后启动开发服务器npm run dev服务器启动时会打印本地 URL开发服务器默认监听http://localhost:44100。对照仓库源码可以印证这些细节模板的dev脚本是NODE_ENVdevelopment node --watch --import remix/node-tsx server.ts见 template/package.json即通过remix/node-tsx让 Node 直接运行 TypeScript 源码——这正是Religiously Runtime原则的体现没有构建步骤、没有打包器。注意模板声明了engines.node 24.3.0适用前提是需要较新的 Node 运行时。默认端口 44100 来自 template/server.ts 中process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 44100的取值逻辑生产启动则对应start脚本NODE_ENVproduction node --import remix/node-tsx server.ts。模板还内置了 HMRnpm run hmr走 template/hmr.ts与测试脚本NODE_ENVtest node --import remix/node-tsx --test。新应用还包含一个 Agent Skill以及一个首页页面上有可以复制进智能编码环境的提示词prompts便于你自主探索。准备好之后回到这里我们开始逐文件导览。三、项目导览代码住在哪里典型的 Remix 应用会把请求流在文件结构中显式化。一个 HTTP 请求进入server.ts交给app/router.ts经过中间件与app/routes.ts中的路由定义匹配由app/actions/中的路由 action 处理最终返回一个 WebResponse。这个响应通常是 Remix 组件生成的 HTML但同一个 action 同样可以返回重定向、JSON、文件、流、错误或任何其他 Web 响应。这套流向让所有代码都有明确的归属路由定义放routes.ts中间件、路由与控制器的连接放router.ts路由特定行为放actions/共享视图与布局放ui/。浏览器可达的源码放在其最窄属主旁边的public/目录中使服务端/浏览器边界直接体现在文件结构里。my-remix-app/ ├── server.ts # 本运行时的请求入口 └── app/ ├── routes.ts # 路由定义、路由图与 URL 助手 ├── router.ts # 中间件、路由与控制器映射 ├── middleware/ # 请求中间件与上下文提供者 ├── actions/ # 控制器、路由 action 与路由局部 UI │ ├── document.tsx # 应用文档外壳 │ └── public/ │ └── entry.ts # 启动浏览器运行时 ├── ui/ # 共享视图、布局与 Remix 组件 └── assets.ts # 浏览器模块服务器配置与 entry hrefs下面通过构建一个小功能来逐一深入这些文件。唯一不碰的是server.ts简化版如下import * as http from node:http; import { createRequestListener } from remix/node-fetch-server; import { router } from ./app/router.ts; const requestListener createRequestListener(async (request) { return await router.fetch(request); }); const server http.createServer(requestListener); server.listen(44100, () { console.log(Server listening on http://localhost:44100); });server.ts是运行时入口在 Node 示例中它创建 HTTP 服务器把每个 Node 请求适配成 WebRequest再交给router.fetch(request)。从源码结构看真实模板 template/server.ts 在简化版之上多做了几件事createRequestListener(async (request) { ... })的回调里包了一层 try/catch非中止类错误会console.error后返回 500Internal Server Error并注册了SIGINT/SIGTERM优雅关闭server.closeAllConnections()以及可选的remix/node-hmr就绪信号。createRequestListener本身定义在 packages/node-fetch-server/src并有专门的测试覆盖packages/node-fetch-server/src/lib/request-listener.test.ts验证了它把 Node 的http事件适配为 fetch 标准Request/Response的语义。关于运行时适配器、createRequestListener、中间件顺序与类型化请求上下文的深入内容参见官方 Request Handling 章节。四、定义你的第一个路由在 Remix 中路由定义与处理请求的 action 是分离的app/routes.ts定义应用接受哪些 URL 和 HTTP 方法app/actions/中的控制器决定匹配后返回哪个Response。把 URL 契约独立出来让控制器、链接、表单、重定向和测试共享同一个类型化事实来源同时避免 URL 生成与请求处理相互耦合。先定义一个用于在数字唱片商店中查看不同专辑的路由// app/routes.ts import { get, route } from remix/routes; export const routes route({ assets: get(/assets/*path), home: /, albums: { show: get(/albums/:albumId), }, });这个路由定义做了很多事把albums定义为路由组route groupshow是代码中引用的路由名用get()收窄show路由使其仅匹配GET请求定义 URL 模式/albums/:albumId因此/albums/thriller这样的 pathname 可以匹配把:albumId作为路径变量成为 action 中可读取为params.albumId的路由参数提供类型化的 href 构造器routes.albums.show.href({ albumId: thriller })。除了:albumId这类路径变量路由模式还支持通配符、可选组、search 约束、主机名乃至完整 origin。这些能力由底层包remix-run/route-patternpackages/route-pattern实现从源码结构看match.ts、href.ts、specificity.ts分别负责请求匹配、URL 生成与路由优先级测试中还有针对:param、*wildcard、可选段等模式的完整契约见 packages/route-pattern/bench 与包内测试。现在开始组织代码为 albums 路由创建app/actions/下的目录与控制器文件mkdir app/actions/albums touch app/actions/albums/controller.tsx控制器一开始很小只处理目前的showaction。注意把routes.albums传给createController时类型系统要求你提供带show函数的actions而show接收类型安全的params因此可以保证:albumId路径变量对应的albumId必然存在。// app/actions/albums/controller.tsx import { createController } from remix/router; import { routes } from ../../routes.ts; export default createController(routes.albums, { actions: { show(context) { return new Response(Album: ${context.params.albumId}); }, }, });最后把 albums 路由图映射到app/router.ts中的路由器// app/router.ts import { createRouter, type RouterContext } from remix/router; import { render } from remix/middleware/render; import { staticFiles } from remix/middleware/static; import controller from ./actions/controller.tsx; import albumsController from ./actions/albums/controller.tsx; import { routes } from ./routes.ts; // ... router.map(routes, controller); router.map(routes.albums, albumsController);目前showaction 返回一个普通 WebResponse。在浏览器打开http://localhost:44100/albums/thriller你会看到这段文本响应Album: thrillerRouting and Controllers 章节会更深入地讲解路由图route maps与路由构建器route builders。五、构建你的第一个页面让专辑页面返回 Remix 组件渲染的 HTML把它变得更有趣。Remix UI 使用 JSX 配合两阶段组件模型组件是一个接收handle并返回另一个渲染 JSX 函数的函数。外层函数是 setup 阶段——在组件创建时执行一次在那里声明的普通 JavaScript 变量可以保存局部状态返回的内层函数是 render 阶段——首次渲染以及之后的每次更新都会执行。function MyComponent(handle) { // Setup 阶段本组件创建时执行一次。 // handle 让组件访问 props 与其他 Remix UI API。 return () { // Render 阶段首次渲染与之后每次更新都会执行。 // 从这里返回 JSX。 return divHello, Remix/div; }; }这里先点到为止组件模型的完整语义参见官方 Rendering UI 章节。现在为专辑页创建一个简单 UI复用别处已定义的Document组件来提供应用外壳touch app/actions/albums/show-page.tsx// app/actions/albums/show-page.tsx import type { Handle } from remix/ui; import { Document } from ../document.tsx; export function AlbumPage(handle: Handle{ id: string }) { return () { return ( Document titleAlbum — Albums main h1{handle.props.id}/h1 /main /Document ); }; }要使用这个组件需要在 action 中渲染并返回响应。路由依旧返回 WebResponse但context.render(...)是从组件树而非字符串创建该响应。这里先继续把匹配到的albumId传入使页面保持显示上一节的路由参数。context.render背后是remix/middleware/render提供的渲染中间件对应仓库中的 packages/render-middlewareStreaming UI with Frames 章节会深入讲解该渲染器。// app/actions/albums/controller.tsx import { createController } from remix/router; import { routes } from ../../routes.ts; import { AlbumPage } from ./show-page.tsx; export default createController(routes.albums, { actions: { show(context) { return context.render(AlbumPage id{context.params.albumId} /); }, }, });5.1 用内存数据库读取数据本走查用一个小型内存数据库真实数据库的设置见官方 Data and Validation 章节touch app/actions/albums/data.ts// app/actions/albums/data.ts export type Album { artist: string; id: string; title: string; year: number; }; let albums: Album[] [ { artist: Michael Jackson, id: thriller, title: Thriller, year: 1983, }, ]; export async function getAlbum(albumId: string) { return albums.find((album) album.id albumId); } export async function updateAlbum(albumId: string, values: PartialAlbum) { let album albums.find((album) album.id albumId); if (album undefined) { return undefined; } // 模拟网络延迟 await new Promise((resolve) setTimeout(resolve, 1000)); Object.assign(album, values); return album; }注意updateAlbum内置了 1 秒的模拟延迟——这是为后面 Hydrate 部分体感上需要等待的体验埋的伏笔。现在可以用路由参数加载对应专辑不存在时返回404存在时把专辑数据作为 props 传给组件// app/actions/albums/controller.tsx import { createController } from remix/router; import { routes } from ../../routes.ts; import { getAlbum } from ./data.ts; import { AlbumPage } from ./show-page.tsx; export default createController(routes.albums, { actions: { async show(context) { let album await getAlbum(context.params.albumId); if (album undefined) { return new Response(Album not found, { status: 404 }); } return context.render(AlbumPage album{album} /); }, }, });再微调AlbumPage组件以展示专辑数据// app/actions/albums/show-page.tsx import type { Handle } from remix/ui; import type { Album } from ./data.ts; import { Document } from ../document.tsx; export function AlbumPage(handle: Handle{ album: Album }) { return () { let { album } handle.props; return ( Document title{${album.title} — Albums} main p {album.artist} · {album.year} /p h1{album.title}/h1 /main /Document ); }; }现在页面显示专辑标题、艺术家与年份。再用css函数与mixprop 做居中和间距// app/actions/albums/show-page.tsx import type { Handle } from remix/ui; import { css } from remix/ui; import type { Album } from ./data.ts; import { Document } from ../document.tsx; export function AlbumPage(handle: Handle{ album: Album }) { return () { let { album } handle.props; return ( Document title{${album.title} — Albums} main mix{css({ maxWidth: 44rem, margin: 0 auto, padding: 4rem 1.5rem, })} p mix{css({ color: #666 })} {album.artist} · {album.year} /p h1{album.title}/h1 /main /Document ); }; }css与mix的完整能力类名生成、嵌套选择器如 input等由 packages/ui 的风格系统提供Rendering UI 章节有专门讲解。再次打开http://localhost:44100/albums/thriller同一个 URL 现在返回专辑页面渲染结果为 Michael Jackson · 1983 / Thriller。六、构建你的第一个表单 action现在可以检索并展示专辑了下一步是添加一个表单和一个可更新专辑的POSTaction。6.1 用form()同时生成 GET 与 POST 路由回到routes.ts使用form()路由助手。它会在同一个 URL 上创建两条路由用于显示表单的GET路由以及用于处理提交的POST路由。// app/routes.ts import { form, get, route } from remix/routes; export const routes route({ assets: get(/assets/*path), home: /, albums: { show: get(/albums/:albumId), edit: form(/albums/:albumId/edit), }, });具体地form(/albums/:albumId/edit)会创建routes.albums.edit.index对应GET /albums/:albumId/editroutes.albums.edit.action对应POST /albums/:albumId/edit。因为albums.edit是一个嵌套路由图为它建立edit/目录内含控制器与页面模块mkdir app/actions/albums/edit touch app/actions/albums/edit/controller.tsx app/actions/albums/edit/page.tsx先搭好控制器骨架// app/actions/albums/edit/controller.tsx import { createController } from remix/router; import { routes } from ../../../routes.ts; export default createController(routes.albums.edit, { actions: { async index() { return new Response(); }, async action() { return new Response(); }, }, });同样把它映射进路由器// app/router.ts import { createRouter, type RouterContext } from remix/router; import { render } from remix/middleware/render; import { staticFiles } from remix/middleware/static; import controller from ./actions/controller.tsx; import albumsController from ./actions/albums/controller.tsx; import albumsEditController from ./actions/albums/edit/controller.tsx; import { routes } from ./routes.ts; // ... router.map(routes, controller); router.map(routes.albums, albumsController); router.map(routes.albums.edit, albumsEditController);6.2 表单页面创建一个展示现有数据、并向 edit action 提交 POST 请求的简单表单// app/actions/albums/edit/page.tsx import { css } from remix/ui; import type { Handle } from remix/ui; import { routes } from ../../../routes.ts; import { Document } from ../../document.tsx; import type { Album } from ../data.ts; export function AlbumEditPage(handle: Handle{ album: Album }) { return () { let { album } handle.props; return ( Document title{Edit ${album.title} — Albums} main mix{css({ padding: 1rem })} h1Edit {album.title}/h1 form action{routes.albums.edit.action.href({ albumId: album.id })} methodpost mix{css({ display: grid, gap: 0.75rem, maxWidth: fit-content, input: { marginLeft: 0.5rem, }, })} label Title input nametitle defaultValue{album.title} required / /label label Artist input nameartist defaultValue{album.artist} required / /label label Year input nameyear defaultValue{album.year} required typenumber / /label button typesubmitSave album/button /form /main /Document ); }; }注意action{routes.albums.edit.action.href({ albumId: album.id })}——表单提交地址直接来自类型化路由定义这是URL 契约单一事实来源的直接收益。把组件接到控制器的indexaction 上// app/actions/albums/edit/controller.tsx import { createController } from remix/router; import { routes } from ../../../routes.ts; import { getAlbum } from ../data.ts; import { AlbumEditPage } from ./page.tsx; export default createController(routes.albums.edit, { actions: { async index(context) { let album await getAlbum(context.params.albumId); if (album undefined) { return new Response(Album not found, { status: 404 }); } return context.render(AlbumEditPage album{album} /); }, async action() { return new Response(Not implemented, { status: 501 }); }, }, });打开http://localhost:44100/albums/thriller/edit路由返回填好专辑数据的 HTML 表单。6.3 解析并校验 POST 数据在处理POST请求之前先在app/router.ts中加入form-data中间件。它会一次性解析浏览器表单请求体并把FormData提供给路由 action// app/router.ts import { createRouter, type RouterContext } from remix/router; import { formData } from remix/middleware/form-data; import { staticFiles } from remix/middleware/static; // ... export const router createRouter({ middleware: [staticFiles(./public, { index: false }), formData(), render()], }); export type AppContext RouterContexttypeof router; declare module remix/router { interface RouterTypes { context: AppContext; } } // ...从源码结构看这里有两个值得注意的细节中间件按数组顺序执行staticFiles → formData → render的顺序意味着表单解析发生在渲染之前context.formData因此可用formData中间件的实现位于 packages/form-data-middlewaredeclare module remix/router中的模块增强module augmentation是类型化上下文的关键通过RouterContexttypeof router推导AppContext并声明进RouterTypes之后所有 action 的context参数都自动携带中间件注入的类型化字段。模板 template/app/router.ts 采用了等价的MiddlewareContext写法。接下来用remix/data-schema/form-data把原始FormData转换为类型化值再更新专辑。remix/data-schema是 Remix 内置的数据校验/解析库实现见 packages/data-schema// app/actions/albums/edit/controller.tsx import { createController } from remix/router; import * as s from remix/data-schema; import * as f from remix/data-schema/form-data; import * as coerce from remix/data-schema/coerce; import { redirect } from remix/response/redirect; import { routes } from ../../../routes.ts; import { getAlbum, updateAlbum } from ../data.ts; import { AlbumEditPage } from ./page.tsx; const albumFormSchema f.object({ artist: f.field(s.string()), title: f.field(s.string()), year: f.field(coerce.number()), }); export default createController(routes.albums.edit, { actions: { async index(context) { let album await getAlbum(context.params.albumId); if (album undefined) { return new Response(Album not found, { status: 404 }); } return context.render(AlbumEditPage album{album} /); }, async action({ formData, params }) { let result s.parseSafe(albumFormSchema, formData); if (!result.success) { return new Response(Invalid album data, { status: 400 }); } let album await updateAlbum(params.albumId, result.value); if (album undefined) { return new Response(Album not found, { status: 404 }); } return redirect(routes.albums.show.href({ albumId: album.id }), 303); }, }, });action路由 action 会校验解析后的 WebFormData对非法输入或缺失专辑返回显式响应400 / 404并在更新成功后redirect回专辑页303 See Other。几个要点f.field(s.string())与f.field(coerce.number())分别表达必填字符串字段和需要字符串转数字的字段表单输入永远是字符串coerce.number()负责把1982这类值转为numbers.parseSafe返回{ success, value } | { success: false, ... }的可判别结果校验失败时显式返回 400而不是让错误隐式发生redirect(routes.albums.show.href({ albumId: album.id }), 303)再次体现用路由定义生成 URL的模式重定向目标与表单 action 地址都来自同一份类型化事实来源。Data and Validation 章节会展示如何把字段级校验错误渲染回表单。此时提交表单例如把年份从 1983 改为 1982你会看到输入被修改、点击提交后经历约 1 秒的模拟延迟然后被重定向回修正后的专辑页。七、添加你的第一个 Hydrate 组件你可能注意到保存专辑要等一会儿——正是updateAlbum里那 1 秒模拟延迟模拟用户在慢网络或远端服务器下的体验。这不算糟糕但可以更好。到目前为止我们没有用过任何浏览器 JavaScript一切都是服务端 JavaScript组件也在服务端渲染并以 HTML 流式输出。如果想向用户提示提交处理中就需要引入一点浏览器端 JavaScript。在 Remix 中最简便的方式是用clientEntry(...)包裹组件组件仍在服务端渲染但到达浏览器后 Remix 可以 Hydrate 它、响应用户交互、在不等待下一次服务端响应的情况下更新组件。7.1 建立浏览器模块边界先为表单创建路由局部的浏览器模块mkdir -p app/actions/albums/edit/public touch app/actions/albums/edit/public/album-edit-form.tsx同级的public/目录是显式的浏览器源码边界模板的资产服务器允许app/**/public/**下的源码Remix 因此可以把这个组件及其局部依赖变成浏览器模块。这些局部依赖要放在同一个public/目录内app/routes.ts被单独允许用于类型安全链接。模板还会加载app/actions/public/entry.ts由它启动 Remix UI 客户端运行时。这套无打包器资产方案的深入讲解见官方 Files and Assets 章节。现在把表单抽成独立组件// app/actions/albums/edit/public/album-edit-form.tsx import { css } 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 function AlbumEditForm(handle: Handle{ album: Album }) { return () { let { album } handle.props; return ( form action{routes.albums.edit.action.href({ albumId: album.id })} methodpost mix{css({ display: grid, gap: 0.75rem, maxWidth: fit-content, input: { marginLeft: 0.5rem, }, })} label Title input nametitle defaultValue{album.title} required / /label label Artist input nameartist defaultValue{album.artist} required / /label label Year input nameyear defaultValue{album.year} required typenumber / /label button typesubmitSave album/button /form ); }; }更新编辑页以渲染这个新组件// app/actions/albums/edit/page.tsx import type { Handle } from remix/ui; import { css } from remix/ui; import { AlbumEditForm } from ./public/album-edit-form.tsx; import { Document } from ../../document.tsx; import type { Album } from ../data.ts; export function AlbumEditPage(handle: Handle{ album: Album }) { return () { let { album } handle.props; return ( Document title{Edit ${album.title} — Albums} main mix{css({ padding: 1rem })} h1Edit {album.title}/h1 AlbumEditForm album{album} / /main /Document ); }; }此时页面外观与之前完全一致。7.2 用clientEntry标记客户端入口接下来把表单标记为 client entry。import.meta.url告诉资产服务器应该把哪个源文件转换为浏览器可加载模块// app/actions/albums/edit/public/album-edit-form.tsx import { clientEntry, css } 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 }) { // ... }, );页面依旧看起来、行为上也不变。clientEntry(...)只是告诉 Remix在服务端渲染这个表单、序列化其 props并在客户端运行时启动时于浏览器中 Hydrate 该组件。7.3 用变量即状态实现 pending 交互既然表单能在浏览器中运行就可以加 pending 状态了。在 Remix 中状态就是一个变量在 setup 作用域本例即组件函数体、return 之前创建你的状态变量——let pending// app/actions/albums/edit/public/album-edit-form.tsx // ... export const AlbumEditForm clientEntry( import.meta.url, function AlbumEditForm(handle: Handle{ album: Album }) { let pending false; return () { // ... }; }, );表单提交时把pending从false翻转为true并调用handle.update()——它告诉 Remix UI 用新的局部状态重新渲染这个组件。监听提交则通过on助手添加事件监听器并把事件数组传入mixprop// app/actions/albums/edit/public/album-edit-form.tsx import { clientEntry, css, on } 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 }) { let pending false; return () { let { album } handle.props; return ( form action{routes.albums.edit.action.href({ albumId: album.id })} methodpost mix{[ css({ display: grid, gap: 0.75rem, maxWidth: fit-content }), on(submit, () { pending true; handle.update(); }), ]} {/* ... */} button disabled{pending} typesubmit {pending ? Saving… : Save album} /button /form ); }; }, );这里再次呼应第五节的两阶段组件模型pending声明在 setup 阶段所以跨渲染存活每次handle.update()触发内层 render 重新执行读取翻转后的pending值更新按钮文案与禁用态。现在提交表单时按钮会被禁用并显示 Saving…而不是让用户面对一个无响应的界面。mixprop 接受数组把样式css(...)与事件绑定on(...)组合到同一个元素上这是 Remix UI 样式与行为都是声明式数组项的设计思路。这里还能做很多事来避免整页导航如处理取消、错误状态等渐进增强Interactivity 章节会覆盖 client entries、事件与渐进增强progressive enhancement的完整语义。八、小结从本章带走什么按 01-start-here.md 的完整走查你已经完成了一个跨全栈的完整功能并建立了对 Remix 架构的地图认知关注点落点关键 API运行时入口server.tscreateRequestListenerremix/node-fetch-server、router.fetch(request)URL 契约app/routes.tsroute、get、form、:param、routes.x.y.href(params)请求装配app/router.tscreateRouter、router.map(map, controller)、中间件数组staticFiles/formData/render、模块增强声明context类型业务处理app/actions/**/controller.tsxcreateController、context.params、context.render、redirect数据与校验data.tsremix/data-schemaf.object/f.field、s.parseSafe、coerce.number()UI 与 Hydrationpublic/目录内组件两阶段组件、clientEntry(import.meta.url, fn)、handle.props/handle.update()、css/mix/on几个值得反复咀嚼的设计判断请求以 WebRequest/Response贯穿全栈服务器适配、路由、中间件与 action 都围绕它编程路由定义与控制器分离URL 生成与请求匹配共享同一份类型化契约服务器/浏览器边界由文件结构表达app/**/public/**即浏览器源码边界而不是由构建配置隐式决定。下一步官方 Routing and Controllers 章节会深入路由叶子route leaves、嵌套路由图与控制器——也就是本章已经开始使用的那套机制的完整版。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考