前言
前面几篇把rsx!、Signal、组件、路由、桌面和 Server Functions 都铺了一遍。真到表单这里,Dioxus 才开始有点“干活”的味道。
因为 Demo 和项目之间,差的往往不是“再写一个组件”,而是这些具体问题:
- 用户输到一半,页面要不要实时反馈
- 提交按钮点下去,哪些检查放前端,哪些必须放后端
- 表单字段很多时,是每个输入都进状态,还是提交时再读一次
- 带文件的表单,为什么一下就变成 multipart 了
这些事单拎出来都不大,凑到一起就很烦。可惜写业务的时候,一个都绕不过去。
所以这篇我不想写成 API 清单,只想把一条链路讲顺:表单、验证、文件上传,放到 Dioxus 里到底怎么接。
1. 先把链路说清楚:表单不是input的堆叠
很多人一开始写表单,脑子里只有“放几个输入框,再加个提交按钮”。
但真写起来,表单更像一条小的数据管道:
- 用户输入
- 前端决定是实时收状态,还是提交时再解析
- 发生
onsubmit - 先做客户端校验
- 再把数据送到 Server Function
- 服务端再做一次真正的校验
- 成功后把结果返回 UI
官方文档在 0.7 的Forms and Multipart里说得很直白:
- 普通 HTML form 可以直接映射成
Form<T> - 带文件的表单会走 multipart
- 表单元素要有
name,parsed_values()才能把字段还原成结构体 GET会把值编码到 URL 里,复杂表单更适合POST
举个例子,登录页和头像上传页,本质上就不是一类东西:
- 登录页多半是纯文本字段,适合
Form<T> - 头像页一旦带文件,就该走 multipart
这个分界早点想清楚,后面代码会省事很多。
2. 受控和非受控,不是宗教问题,是成本问题
这块我一直觉得没必要争“谁更高级”,先看场景。
2.1 字段少、联动强,就用受控
如果页面上只有两三个字段,而且你还想要:
- 实时提示错误
- 输入时联动别的控件
- 按钮是否可点要跟着变
那直接用Signal控住,通常最省心。
举个例子,一个资料编辑页里,昵称一边输入一边检查长度,确实适合受控。
usedioxus::prelude::*;#[component]fnProfileHeader()->Element{letmutnickname=use_signal(String::new);leterror=nickname.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:Form<ProfileForm>)->Result<String,String>{validate_profile(&form.0)?;// 这里才是落库、写缓存、发事件的地方Ok("保存成功".into())}这段代码我喜欢的地方,不是短,而是职责分得清:
- 前端挡体验问题
- 服务端挡业务规则
- 最终写库的逻辑只认服务端结果
这才是一个能上线的表单链路。
4. 一个更像真实业务的提交页
概念说完,直接看个更像实战的页面。
这个页面做三件事:
- 读表单字段
- 本地先检查一遍
- 通过
Form<ProfileForm>发给服务端
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:Form<ProfileForm>)->Result<String,String>{validate_profile(&form.0)?;Ok("保存成功".into())}#[component]fnProfileEditor()->Element{letmutmessage=use_signal(||None::<String>);letsubmit=move|evt:FormEvent|asyncmove{evt.prevent_default();letvalues:ProfileForm=matchevt.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 这一层更偏原始流处理,不会像普通Form<T>那样天然整齐地映射成一个结构体。
所以上传文件时,通常还是得自己遍历字段,分清哪项是文本,哪项是文件。
5.1 一个最小上传接口
usedioxus::prelude::*;#[post("/api/avatar/upload")]asyncfnupload_avatar(mutform:MultipartFormData)->Result<String,String>{whileletOk(Some(field))=form.next_field().await{letname=field.name().unwrap_or("<none>").to_string();letfile_name=field.file_name().unwrap_or("<none>").to_string();letcontent_type=field.content_type().unwrap_or("<none>").to_string();letbytes=field.bytes().await.map_err(|err|err.to_string())?;tracing::info!(field=%name,file_name=%file_name,content_type=%content_type,size=bytes.len(),"received upload field");}Ok("上传完成".into())}这段代码至少把三件事摆明了:
- 文件名、类型、内容都得由服务端再看一遍
- 上传成功不等于可直接入库,通常还要做大小、格式、病毒扫描等判断
- multipart 不是“表单的附属品”,它是另一种请求格式
5.2 客户端提交 multipart
客户端这边也不复杂,直接把表单事件转成 multipart 再发出去。
#[component]fnAvatarForm()->Element{letmutnotice=use_signal(||None::<String>);letsubmit=move|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 现在已经把这条路铺出来了:
- 简单表单可以直接用
Form<T> - 提交时可以用
FormEvent::parsed_values() - 校验要前后两层都做
- 文件上传走
MultipartFormData - 带文件的场景别硬塞进普通表单
我对这一块的判断还是那个意思:它已经够你做真实业务了,但写法上要克制一点。别把 Dioxus 的全栈能力写成一堆看着热闹、实际不好维护的装饰语法。