Dioxus 表单处理:从输入、校验到文件上传,一条链路讲透
前言前面几篇把rsx!、Signal、组件、路由、桌面和 Server Functions 都铺了一遍。真到表单这里Dioxus 才开始有点“干活”的味道。因为 Demo 和项目之间差的往往不是“再写一个组件”而是这些具体问题用户输到一半页面要不要实时反馈提交按钮点下去哪些检查放前端哪些必须放后端表单字段很多时是每个输入都进状态还是提交时再读一次带文件的表单为什么一下就变成 multipart 了这些事单拎出来都不大凑到一起就很烦。可惜写业务的时候一个都绕不过去。所以这篇我不想写成 API 清单只想把一条链路讲顺表单、验证、文件上传放到 Dioxus 里到底怎么接。1. 先把链路说清楚表单不是input的堆叠很多人一开始写表单脑子里只有“放几个输入框再加个提交按钮”。但真写起来表单更像一条小的数据管道用户输入前端决定是实时收状态还是提交时再解析发生onsubmit先做客户端校验再把数据送到 Server Function服务端再做一次真正的校验成功后把结果返回 UI官方文档在 0.7 的Forms and Multipart里说得很直白普通 HTML form 可以直接映射成FormT带文件的表单会走 multipart表单元素要有nameparsed_values()才能把字段还原成结构体GET会把值编码到 URL 里复杂表单更适合POST举个例子登录页和头像上传页本质上就不是一类东西登录页多半是纯文本字段适合FormT头像页一旦带文件就该走 multipart这个分界早点想清楚后面代码会省事很多。2. 受控和非受控不是宗教问题是成本问题这块我一直觉得没必要争“谁更高级”先看场景。2.1 字段少、联动强就用受控如果页面上只有两三个字段而且你还想要实时提示错误输入时联动别的控件按钮是否可点要跟着变那直接用Signal控住通常最省心。举个例子一个资料编辑页里昵称一边输入一边检查长度确实适合受控。usedioxus::prelude::*;#[component]fnProfileHeader()-Element{letmutnicknameuse_signal(String::new);leterrornickname.read().chars().count()12;rsx!{div{input{value:{nickname},oninput:move|evt|nickname.set(evt.value()),placeholder:昵称}iferror{p{class:error,昵称最多 12 个字}}}}}这种写法好在哪其实一眼就能看出来反馈快UI 状态和输入状态绑得紧逻辑一眼能看出2.2 字段多、只在提交时用就别把每个字符都塞进状态如果表单很大比如个人资料内容发布后台编辑页这时你要是还把每个输入都单独塞进状态十有八九只是在给自己加样板。这时候更顺的做法是在onsubmit里直接用FormEvent::parsed_values()读出结构体。官方文档的示例也是这个思路FormEvent只要有name属性就能把字段解析回结构体。3. 校验要分两层前端负责快服务端负责准这里是很多人最容易写偏的地方。3.1 前端校验解决的是“别让用户白等”前端校验先解决的不是安全是体验。比如必填项没填邮箱格式明显不对简介超长上传文件太大这些问题如果等请求打到服务端再报错用户体验会很差。所以前端先挡一层至少别让用户白点提交。3.2 服务端校验才是最终裁判但你不能只靠前端。原因很简单前端校验可以被绕过浏览器表单可以被伪造文件上传更不能只信客户端传来的类型所以真正的业务规则还是得在 Server Function 里再验一次。这个不能偷懒。举个例子个人资料页昵称、邮箱、简介都有规则。usedioxus::prelude::*;useserde::{Deserialize,Serialize};#[derive(Clone, Debug, Serialize, Deserialize)]pubstructProfileForm{pubnickname:String,pubemail:String,pubbio:String,}fnvalidate_profile(form:ProfileForm)-Result(),String{ifform.nickname.trim().is_empty(){returnErr(昵称不能为空.into());}if!form.email.contains(){returnErr(邮箱格式不对.into());}ifform.bio.chars().count()120{returnErr(简介最多 120 个字.into());}Ok(())}#[post(/api/profile/save)]asyncfnsave_profile(form:FormProfileForm)-ResultString,String{validate_profile(form.0)?;// 这里才是落库、写缓存、发事件的地方Ok(保存成功.into())}这段代码我喜欢的地方不是短而是职责分得清前端挡体验问题服务端挡业务规则最终写库的逻辑只认服务端结果这才是一个能上线的表单链路。4. 一个更像真实业务的提交页概念说完直接看个更像实战的页面。这个页面做三件事读表单字段本地先检查一遍通过FormProfileForm发给服务端usedioxus::prelude::*;useserde::{Deserialize,Serialize};#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]pubstructProfileForm{pubnickname:String,pubemail:String,pubbio:String,}fnvalidate_profile(form:ProfileForm)-Result(),String{ifform.nickname.trim().is_empty(){returnErr(昵称不能为空.into());}if!form.email.contains(){returnErr(邮箱格式不对.into());}Ok(())}#[post(/api/profile/save)]asyncfnsave_profile(form:FormProfileForm)-ResultString,String{validate_profile(form.0)?;Ok(保存成功.into())}#[component]fnProfileEditor()-Element{letmutmessageuse_signal(||None::String);letsubmitmove|evt:FormEvent|asyncmove{evt.prevent_default();letvalues:ProfileFormmatchevt.parsed_values(){Ok(values)values,Err(_){message.set(Some(表单字段缺失无法解析.into()));return;}};ifletErr(err)validate_profile(values){message.set(Some(err));return;}matchsave_profile(Form(values)).await{Ok(ok)message.set(Some(ok)),Err(err)message.set(Some(err)),}};rsx!{form{onsubmit:submit,label{昵称}input{name:nickname,r#type:text,placeholder:输入昵称}label{邮箱}input{name:email,r#type:email,placeholder:输入邮箱}label{简介}textarea{name:bio,placeholder:最多 120 字}button{r#type:submit,保存资料}ifletSome(msg)message(){p{class:form-message,{msg}}}}}}这段代码里有几个地方后面基本都会反复碰到name属性不能省不然parsed_values()没法还原字段前端和服务端共用一套校验函数规则不会两边写成两份错误信息直接回 UI比静默失败强太多表单这一块底子差不多就是这些东西。5. 文件上传别硬塞进普通表单它本来就是 multipart到了上传文件这一步味道就变了。因为文件不是普通的 key-value 文本它会进入 multipart 请求体。官方文档这里给的路线其实很直客户端把FormEvent转成 multipart服务端接收MultipartFormData然后用next_field()一个字段一个字段读不过这里有个很现实的点multipart 这一层更偏原始流处理不会像普通FormT那样天然整齐地映射成一个结构体。所以上传文件时通常还是得自己遍历字段分清哪项是文本哪项是文件。5.1 一个最小上传接口usedioxus::prelude::*;#[post(/api/avatar/upload)]asyncfnupload_avatar(mutform:MultipartFormData)-ResultString,String{whileletOk(Some(field))form.next_field().await{letnamefield.name().unwrap_or(none).to_string();letfile_namefield.file_name().unwrap_or(none).to_string();letcontent_typefield.content_type().unwrap_or(none).to_string();letbytesfield.bytes().await.map_err(|err|err.to_string())?;tracing::info!(field%name,file_name%file_name,content_type%content_type,sizebytes.len(),received upload field);}Ok(上传完成.into())}这段代码至少把三件事摆明了文件名、类型、内容都得由服务端再看一遍上传成功不等于可直接入库通常还要做大小、格式、病毒扫描等判断multipart 不是“表单的附属品”它是另一种请求格式5.2 客户端提交 multipart客户端这边也不复杂直接把表单事件转成 multipart 再发出去。#[component]fnAvatarForm()-Element{letmutnoticeuse_signal(||None::String);letsubmitmove|evt:FormEvent|asyncmove{evt.prevent_default();matchupload_avatar(evt.into()).await{Ok(msg)notice.set(Some(msg)),Err(err)notice.set(Some(err)),}};rsx!{form{onsubmit:submit,input{name:display_name,r#type:text,placeholder:展示名}input{name:avatar,r#type:file,accept:.png,.jpg,.jpeg}button{r#type:submit,上传头像}ifletSome(msg)notice(){p{{msg}}}}}}这个例子不花哨但我觉得很接近实际项目。很多业务系统里的上传说穿了就是“一个文本字段 一个文件字段 一次服务端校验”。这条链路跑顺了后面扩到封面图、附件、批量上传思路也不会变太多。6. 什么时候该用哪种写法我自己一般这么分字段少实时反馈强就用受控组件字段多只有提交时有意义就用FormEvent::parsed_values()规则简单先做前端校验规则涉及权限、完整性、文件安全必须再过服务端只要带文件就直接按 multipart 想别把所有表单都写成一个模子里刻出来的东西。小登录页和内容发布页本来就不该用同样的写法。前者要轻后者要稳上传页还得再多一层 multipart。总结如果前面几篇还在讲 Dioxus 怎么“写页面”那这一篇其实已经是在讲它怎么开始“接业务”了。表单、验证、文件上传拆开看都不难麻烦的是把它们接成一条不拧巴的链路。Dioxus 0.7 现在已经把这条路铺出来了简单表单可以直接用FormT提交时可以用FormEvent::parsed_values()校验要前后两层都做文件上传走MultipartFormData带文件的场景别硬塞进普通表单我对这一块的判断还是那个意思它已经够你做真实业务了但写法上要克制一点。别把 Dioxus 的全栈能力写成一堆看着热闹、实际不好维护的装饰语法。

相关新闻