前言
前面几篇把 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 控住,通常最省心。
举个例子,一个资料编辑页里,昵称一边输入一边检查长度,确实适合受控。
rust
use dioxus::prelude::*;
#[component]
fn ProfileHeader() -> Element {
let mut nickname = use_signal(String::new);
let error = nickname.read().chars().count() > 12;
rsx! {
div {
input {
value: "{nickname}",
oninput: move |evt| nickname.set(evt.value()),
placeholder: "昵称"
}
if error {
p { class: "error", "昵称最多 12 个字" }
}
}
}
}
这种写法好在哪,其实一眼就能看出来:
- 反馈快
- UI 状态和输入状态绑得紧
- 逻辑一眼能看出
2.2 字段多、只在提交时用,就别把每个字符都塞进状态
如果表单很大,比如:
- 个人资料
- 内容发布
- 后台编辑页
这时你要是还把每个输入都单独塞进状态,十有八九只是在给自己加样板。
这时候更顺的做法,是在 onsubmit 里直接用 FormEvent::parsed_values() 读出结构体。
官方文档的示例也是这个思路:FormEvent 只要有 name 属性,就能把字段解析回结构体。
3. 校验要分两层:前端负责快,服务端负责准
这里是很多人最容易写偏的地方。
3.1 前端校验解决的是"别让用户白等"
前端校验先解决的不是安全,是体验。
比如:
- 必填项没填
- 邮箱格式明显不对
- 简介超长
- 上传文件太大
这些问题,如果等请求打到服务端再报错,用户体验会很差。
所以前端先挡一层,至少别让用户白点提交。
3.2 服务端校验才是最终裁判
但你不能只靠前端。
原因很简单:
- 前端校验可以被绕过
- 浏览器表单可以被伪造
- 文件上传更不能只信客户端传来的类型
所以真正的业务规则,还是得在 Server Function 里再验一次。这个不能偷懒。
举个例子:个人资料页,昵称、邮箱、简介都有规则。
rust
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileForm {
pub nickname: String,
pub email: String,
pub bio: String,
}
fn validate_profile(form: &ProfileForm) -> Result<(), String> {
if form.nickname.trim().is_empty() {
return Err("昵称不能为空".into());
}
if !form.email.contains('@') {
return Err("邮箱格式不对".into());
}
if form.bio.chars().count() > 120 {
return Err("简介最多 120 个字".into());
}
Ok(())
}
#[post("/api/profile/save")]
async fn save_profile(form: Form<ProfileForm>) -> Result<String, String> {
validate_profile(&form.0)?;
// 这里才是落库、写缓存、发事件的地方
Ok("保存成功".into())
}
这段代码我喜欢的地方,不是短,而是职责分得清:
- 前端挡体验问题
- 服务端挡业务规则
- 最终写库的逻辑只认服务端结果
这才是一个能上线的表单链路。
4. 一个更像真实业务的提交页
概念说完,直接看个更像实战的页面。
这个页面做三件事:
- 读表单字段
- 本地先检查一遍
- 通过
Form<ProfileForm>发给服务端
rust
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ProfileForm {
pub nickname: String,
pub email: String,
pub bio: String,
}
fn validate_profile(form: &ProfileForm) -> Result<(), String> {
if form.nickname.trim().is_empty() {
return Err("昵称不能为空".into());
}
if !form.email.contains('@') {
return Err("邮箱格式不对".into());
}
Ok(())
}
#[post("/api/profile/save")]
async fn save_profile(form: Form<ProfileForm>) -> Result<String, String> {
validate_profile(&form.0)?;
Ok("保存成功".into())
}
#[component]
fn ProfileEditor() -> Element {
let mut message = use_signal(|| None::<String>);
let submit = move |evt: FormEvent| async move {
evt.prevent_default();
let values: ProfileForm = match evt.parsed_values() {
Ok(values) => values,
Err(_) => {
message.set(Some("表单字段缺失,无法解析".into()));
return;
}
};
if let Err(err) = validate_profile(&values) {
message.set(Some(err));
return;
}
match save_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", "保存资料" }
if let Some(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 一个最小上传接口
rust
use dioxus::prelude::*;
#[post("/api/avatar/upload")]
async fn upload_avatar(mut form: MultipartFormData) -> Result<String, String> {
while let Ok(Some(field)) = form.next_field().await {
let name = field.name().unwrap_or("<none>").to_string();
let file_name = field.file_name().unwrap_or("<none>").to_string();
let content_type = field.content_type().unwrap_or("<none>").to_string();
let bytes = 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 再发出去。
rust
#[component]
fn AvatarForm() -> Element {
let mut notice = use_signal(|| None::<String>);
let submit = move |evt: FormEvent| async move {
evt.prevent_default();
match upload_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", "上传头像" }
if let Some(msg) = notice() {
p { "{msg}" }
}
}
}
}
这个例子不花哨,但我觉得很接近实际项目。
很多业务系统里的上传,说穿了就是"一个文本字段 + 一个文件字段 + 一次服务端校验"。这条链路跑顺了,后面扩到封面图、附件、批量上传,思路也不会变太多。
6. 什么时候该用哪种写法
我自己一般这么分:
- 字段少,实时反馈强,就用受控组件
- 字段多,只有提交时有意义,就用
FormEvent::parsed_values() - 规则简单,先做前端校验
- 规则涉及权限、完整性、文件安全,必须再过服务端
- 只要带文件,就直接按 multipart 想
别把所有表单都写成一个模子里刻出来的东西。
小登录页和内容发布页,本来就不该用同样的写法。前者要轻,后者要稳,上传页还得再多一层 multipart。
总结
如果前面几篇还在讲 Dioxus 怎么"写页面",那这一篇其实已经是在讲它怎么开始"接业务"了。
表单、验证、文件上传拆开看都不难,麻烦的是把它们接成一条不拧巴的链路。Dioxus 0.7 现在已经把这条路铺出来了:
- 简单表单可以直接用
Form<T> - 提交时可以用
FormEvent::parsed_values() - 校验要前后两层都做
- 文件上传走
MultipartFormData - 带文件的场景别硬塞进普通表单
我对这一块的判断还是那个意思:它已经够你做真实业务了,但写法上要克制一点。别把 Dioxus 的全栈能力写成一堆看着热闹、实际不好维护的装饰语法。