
Remix 3 表单怎么从 HTML 提交逐步增强为 fetch 提交【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remixRemix 3 的表单推荐“先 HTML、后增强”的路径先让一个真实的form methodPOST在服务端动作action配合下完成提交、校验和重定向再逐步加入浏览器 JavaScript让同一个表单获得 pending 状态和 fetch 提交能力。本文沿 Remix 3 官方指南的示例把一个专辑编辑表单从纯 HTML 提交一路增强到带 pending UI 和错误展示的 fetch 提交。全程只依赖remix这一个运行时依赖。准备创建并启动一个 Remix 3 应用Remix 3 的指南基于 Node 服务端模板。首次创建应用npx remixnext new my-remix-app然后安装依赖并启动开发服务器cd my-remix-app npm ipackage.json中只有一个运行时依赖{ name: my-remix-app, private: true, type: module, dependencies: { remix: ^3.0.0 } }最后启动 dev servernpm run dev服务器启动后会打印本地 URL默认监听 http://localhost:44100。第一步先让 HTML 表单提交真正工作指南的主线示例是一个专辑编辑功能。先在app/routes.ts里用form()路由助手定义表单路由。form(path)默认在同一 URL 上创建两条叶子路由一个indexGET 叶子和一个actionPOST 叶子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.indexGET和routes.albums.edit.actionPOST。表单的action地址由此生成控制器通过router.map(...)挂到路由上。要让 action 读到表单字段先在app/router.ts安装formData()中间件它一次性解析浏览器表单请求体并把FormData提供给路由动作import { createRouter, type RouterContext } from remix/router; import { formData } from remix/middleware/form-data; import { render } from remix/middleware/render; import { staticFiles } from remix/middleware/static; // ... export const router createRouter({ middleware: [staticFiles(./public, { index: false }), formData(), render()], });控制器一侧用remix/data-schema/form-data把FormData解析成带类型的数据校验失败返回400成功后用303重定向到结果页——这能避免刷新时意外重复提交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); }, }, });表单页面用真实控件、name属性和routes...action.href(...)生成的地址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 ); }; }验证 HTML 提交打开 http://localhost:44100/albums/thriller/edit应看到带回填数据的表单。修改某个字段例如把 year 从 1983 改为 1982后点击 Save album无 JavaScript 参与浏览器把FormDataPOST 到 actionaction 校验并更新数据然后303重定向回专辑页如果提交的字段不满足 schema会得到400响应。这一步确认服务端契约成立之后才开始加浏览器逻辑。第二步标记 clientEntry 并加入 pending 状态把表单从page.tsx抽到路由同级的浏览器模块app/actions/albums/edit/public/album-edit-form.tsx。这个public/目录是显式的浏览器源码边界模板的资源服务器允许app/**/public/**下的源码被转换为浏览器模块app/routes.ts被单独允许用于类型安全的链接。组件先保持普通写法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 ); }; }然后做两处改动。第一用clientEntry(import.meta.url, ...)标记这个组件——import.meta.url告诉资源服务器哪个源码文件要变成浏览器可加载模块在服务器上它仍像普通组件一样渲染在浏览器里 Remix 用服务端页面传入的 props 启动它。注意 client entry 的 props 要可序列化字符串、数字、布尔、null、undefined、普通对象和数组、JSX不能传函数、类实例或不透明的运行时值。import { clientEntry, css } from remix/ui; import type { Handle } from remix/ui; import { routes } from ../../../../routes.ts; // ... export const AlbumEditForm clientEntry( import.meta.url, function AlbumEditForm(handle: Handle{ album: Album }) { // ... }, );第二在 setup scope 里声明pending变量用on(submit, ...)监听提交并调用handle.update()触发重新渲染。on(type, handler, capture?)把类型化 DOM 事件挂到宿主元素上mix属性接受一个 mixin 数组import { clientEntry, css, on } from remix/ui; import type { Handle } from remix/ui; import { routes } from ../../../../routes.ts; // ... 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 ); }; }, );此时注意一个关键点这个 submit 处理器没有调用event.preventDefault()。指南明确说明只有当增强路径会“做完浏览器本该做的事”时才调用preventDefault()如果处理器只是在普通提交前加了 pending 状态应让提交继续走原生 HTML 流程。所以这一步的行为是提交瞬间按钮禁用、文案变成 “Saving…”随后浏览器照常完成整页 POST 和重定向。第三步用 fetch 拦截提交并本地处理响应pending 状态之外fetch 增强解决的是“提交前不做整页导航”页面保留 pending 或行内错误 UI成功后再跟随 action 的重定向 URL。指南强调的做法是不新增第二套 mutation 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} /form其中error和pending同样是 setup scope 变量on(submit, ...)的第二个参数signal是 Remix 为这次异步工作提供的取消信号同一次 handler 再次运行或元素被移除时它会中止把它传给fetch()并配合signal.aborted检查可以防止过期的慢响应覆盖较新的 UI 状态。需要导航时从remix/ui引入navigate(href, options)。这一步的event.preventDefault()是合理的因为增强路径接管了浏览器本该完成的提交和跳转。验证增强后的行为提交时按钮禁用、显示 “Saving…”页面不发生整页导航服务端返回303重定向时navigate(response.url, { history: replace })跟随 action 的 URL历史记录按 replace 更新服务端返回非成功响应如校验失败的400时页面本地展示错误而不是整页刷新关闭 JavaScript 或直接提交未增强的表单时仍然走原生 POST 流程拿到同样的重定向或错误响应——指南要求服务端 action 在 JavaScript 加载失败或增强被移除时保持正确。选择同步边界什么时候用哪种提交方式指南在表单增强一节给出了四条边界规则可以照着判断你当前 UI 该停在上面哪一步文档级导航本身就是正确结果时就让表单按原生方式正常提交页面需要在导航前展示 pending 或行内错误 UI 时用fetch()拦截提交浏览器端模型拥有数据、且没有服务端渲染区域需要变化时发送 JSON数据独立于当前页面变化时轮询一个小型 JSON 端点。同时要避免一个反模式为一个已经有有用 HTML 响应和重定向路径的表单另建一套并行的 JSON mutation 层。如果还需要“mutation 只重载一个路由拥有的区域而不导航整个文档”的能力这是可选分支指南将其放在 Frames 章节见 Streaming UI with Frames不在本路径的必需步骤内。相关章节Forms and MutationsHTML-first 工作流、校验失败回显、303重定向和 PUT/PATCH/DELETE 的_method覆盖方式Interactivityclient entry、事件、取消和表单增强的完整参考Start Here本文示例代码所在的入门章节。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考