Dioxus 表单处理:从输入、校验到文件上传,一条链路讲透

前言

前面几篇把 rsx!、Signal、组件、路由、桌面和 Server Functions 都铺了一遍。真到表单这里,Dioxus 才开始有点"干活"的味道。

因为 Demo 和项目之间,差的往往不是"再写一个组件",而是这些具体问题:

  • 用户输到一半,页面要不要实时反馈
  • 提交按钮点下去,哪些检查放前端,哪些必须放后端
  • 表单字段很多时,是每个输入都进状态,还是提交时再读一次
  • 带文件的表单,为什么一下就变成 multipart 了

这些事单拎出来都不大,凑到一起就很烦。可惜写业务的时候,一个都绕不过去。

所以这篇我不想写成 API 清单,只想把一条链路讲顺:表单、验证、文件上传,放到 Dioxus 里到底怎么接。

1. 先把链路说清楚:表单不是 input 的堆叠

很多人一开始写表单,脑子里只有"放几个输入框,再加个提交按钮"。

但真写起来,表单更像一条小的数据管道:

  1. 用户输入
  2. 前端决定是实时收状态,还是提交时再解析
  3. 发生 onsubmit
  4. 先做客户端校验
  5. 再把数据送到 Server Function
  6. 服务端再做一次真正的校验
  7. 成功后把结果返回 UI

官方文档在 0.7 的 Forms and Multipart 里说得很直白:

  • 普通 HTML form 可以直接映射成 Form<T>
  • 带文件的表单会走 multipart
  • 表单元素要有 nameparsed_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. 一个更像真实业务的提交页

概念说完,直接看个更像实战的页面。

这个页面做三件事:

  1. 读表单字段
  2. 本地先检查一遍
  3. 通过 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 的全栈能力写成一堆看着热闹、实际不好维护的装饰语法。

相关推荐
用户41659673693551 小时前
WebView 请求异常排查操作手册
android·前端
doiito1 小时前
【Agent Harness】Gliding Horse 上下文动态感知与智能压缩:让 Agent 真正“听得进”每一句话
ai·rust·架构设计·系统设计·ai agent
weedsfly1 小时前
JavaScript 事件流:彻底搞懂捕获、冒泡与事件委托
前端·javascript·react.js
RainmeoX1 小时前
【实战】用纯前端打造绝区零风格 AI 角色助手 WebUI 并联调 vLLM
前端
杨利杰YJlio1 小时前
OpenClaw / clawdbot 是什么?看懂 Agent 体系
前端·后端
CodeSheep2 小时前
他俩只靠写代码,登上了胡润财富榜!
前端·后端·程序员
IT_陈寒2 小时前
React状态更新总是慢半拍?你可能忘了这个默认行为
前端·人工智能·后端
铁皮饭盒3 小时前
TypeBox 比 Zod.js 校验 快10倍, 还兼容AI 工具调用, 他做对了什么?
前端·javascript·后端