第十二章:表单处理与验证

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第十二章:表单处理与验证

1. Dioxus 表单基础

在 Dioxus 中,表单处理围绕 Signal 和事件展开。每个输入框都与一个 Signal 绑定,实现数据双向同步。

1.1 受控组件模式

#[component]
fn SimpleForm() -> Element {
    let mut username = use_signal(|| String::new());
    let mut email = use_signal(|| String::new());

    rsx! {
        form {
            onsubmit: move |_| {
                // 提交时读取 Signal 中的值
                println!("Username: {}, Email: {}", username(), email());
            },
            div { class: "mb-4",
                label { "用户名" }
                input {
                    class: "border rounded px-3 py-2 w-full",
                    value: "{username}",
                    oninput: move |e| username.set(e.value()),
                    placeholder: "请输入用户名",
                }
            }
            div { class: "mb-4",
                label { "邮箱" }
                input {
                    class: "border rounded px-3 py-2 w-full",
                    value: "{email}",
                    oninput: move |e| email.set(e.value()),
                    placeholder: "请输入邮箱",
                }
            }
            button { class: "bg-blue-500 text-white px-4 py-2 rounded",
                type: "submit",
                "提交"
            }
        }
    }
}

为什么用 value + oninput 而不是 initial_value

value + oninput 是受控组件模式 —— Signal 是唯一数据源,输入框显示的值始终与 Signal 同步。initial_value 只在首次渲染时生效,后续 Signal 变化不会更新输入框。

1.2 不同类型输入的处理

#[component]
fn AllInputTypes() -> Element {
    let mut text = use_signal(|| String::new());
    let mut password = use_signal(|| String::new());
    let mut age = use_signal(|| 0u32);
    let mut agreed = use_signal(|| false);
    let mut gender = use_signal(|| "".to_string());
    let mut bio = use_signal(|| String::new());

    rsx! {
        // 文本输入
        input {
            value: "{text}",
            oninput: move |e| text.set(e.value()),
        }
        // 密码输入
        input {
            r#type: "password",
            value: "{password}",
            oninput: move |e| password.set(e.value()),
        }
        // 数字输入
        input {
            r#type: "number",
            value: "{age}",
            oninput: move |e| {
                if let Ok(n) = e.value().parse::<u32>() {
                    age.set(n);
                }
            },
        }
        // 复选框
        input {
            r#type: "checkbox",
            checked: "{agreed}",
            oninput: move |e| agreed.set(e.checked()),
        }
        // 单选按钮
        for g in ["男", "女", "其他"] {
            label {
                input {
                    r#type: "radio",
                    name: "gender",
                    value: "{g}",
                    checked: gender() == g,
                    oninput: move |_| gender.set(g.to_string()),
                }
                "{g}"
            }
        }
        // 文本域
        textarea {
            value: "{bio}",
            oninput: move |e| bio.set(e.value()),
        }
    }
}

2. 表单验证

2.1 实时验证

在用户输入时即时反馈,而不是等提交时才检查:

#[component]
fn ValidatedForm() -> Element {
    let mut email = use_signal(|| String::new());
    let mut email_error = use_signal(|| Option::<String>::None);

    // 使用 use_memo 派生验证状态
    let mut email_valid = use_memo(move || {
        let val = email();
        if val.is_empty() {
            None
        } else if val.contains('@') && val.contains('.') {
            Some(true)
        } else {
            Some(false)
        }
    });

    rsx! {
        div { class: "mb-4",
            label { "邮箱地址" }
            input {
                r#type: "email",
                class: "border rounded px-3 py-2 w-full",
                value: "{email}",
                oninput: move |e| email.set(e.value()),
            }
            // 实时显示验证结果
            match email_valid() {
                Some(true) => rsx! {
                    p { class: "text-green-500 text-xs mt-1", "✓ 邮箱格式正确" }
                },
                Some(false) => rsx! {
                    p { class: "text-red-500 text-xs mt-1", "✗ 邮箱格式不正确" }
                },
                None => rsx! { div {} },
            }
        }
    }
}

2.2 完整的表单验证模型

// 验证规则 trait
trait Validatable {
    type Value;
    fn validate(&self, value: &Self::Value) -> Result<(), String>;
}

// 常见验证规则
struct Required;
impl Validatable for Required {
    type Value = String;
    fn validate(&self, value: &String) -> Result<(), String> {
        if value.trim().is_empty() {
            Err("此字段不能为空".to_string())
        } else {
            Ok(())
        }
    }
}

struct MinLength(usize);
impl Validatable for MinLength {
    type Value = String;
    fn validate(&self, value: &String) -> Result<(), String> {
        if value.len() < self.0 {
            Err(format!("最少需要 {} 个字符", self.0))
        } else {
            Ok(())
        }
    }
}

struct Email;
impl Validatable for Email {
    type Value = String;
    fn validate(&self, value: &String) -> Result<(), String> {
        let has_at = value.contains('@');
        let has_dot = value.contains('.');
        if !has_at || !has_dot {
            Err("请输入有效的邮箱地址".to_string())
        } else {
            Ok(())
        }
    }
}

2.3 自定义 Hook 封装验证逻辑

#[derive(Clone)]
struct FieldState {
    value: String,
    error: Option<String>,
    touched: bool,
}

fn use_field(validators: Vec<Box<dyn Fn(&str) -> Option<String>>>) -> (Signal<FieldState>, EventHandler<FormEvent>) {
    let state = use_signal(|| FieldState {
        value: String::new(),
        error: None,
        touched: false,
    });

    let on_input = move |e: FormEvent| {
        let val = e.value();
        // 运行所有验证器
        let first_error = validators.iter()
            .find_map(|v| v(&val));
        state.set(FieldState {
            value: val,
            error: first_error,
            touched: true,
        });
    };

    (state, on_input)
}

// 使用
#[component]
fn RegisterForm() -> Element {
    let (username, on_username) = use_field(vec![
        Box::new(|v| if v.is_empty() { Some("用户名不能为空".into()) } else { None }),
        Box::new(|v| if v.len() < 3 { Some("用户名至少3个字符".into()) } else { None }),
    ]);

    let (email, on_email) = use_field(vec![
        Box::new(|v| if !v.contains('@') { Some("邮箱格式不正确".into()) } else { None }),
    ]);

    rsx! {
        form { class: "max-w-md mx-auto mt-8",
            div { class: "mb-4",
                label { "用户名" }
                input {
                    class: "border rounded px-3 py-2 w-full",
                    value: "{username.read().value}",
                    oninput: on_email,
                }
                if let Some(err) = &username.read().error {
                    p { class: "text-red-500 text-xs mt-1", "{err}" }
                }
            }
            // ... 其他字段
        }
    }
}

3. 表单提交处理

3.1 完整提交流程

#[derive(Clone, PartialEq)]
struct LoginForm {
    username: String,
    password: String,
    remember: bool,
}

#[component]
fn LoginForm() -> Element {
    let mut form = use_signal(|| LoginForm {
        username: String::new(),
        password: String::new(),
        remember: false,
    });

    let mut submitting = use_signal(|| false);
    let mut submit_error = use_signal(|| Option::<String>::None);

    let do_submit = move |_| {
        if submitting() { return; }
        submitting.set(true);
        submit_error.set(None);

        // 模拟异步登录
        spawn(async move {
            // 模拟网络请求
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;

            let f = form();
            if f.username == "admin" && f.password == "123456" {
                // 登录成功
                submit_error.set(None);
            } else {
                submit_error.set(Some("用户名或密码错误".to_string()));
            }
            submitting.set(false);
        });
    };

    rsx! {
        form { class: "max-w-sm mx-auto mt-10",
            onsubmit: do_submit,

            if let Some(ref err) = submit_error() {
                div { class: "bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4",
                    "{err}"
                }
            }

            div { class: "mb-4",
                label { class: "block text-sm font-medium mb-1", "用户名" }
                input {
                    class: "w-full border rounded-lg px-3 py-2",
                    value: "{form.read().username}",
                    oninput: move |e| form.write().username = e.value(),
                }
            }
            div { class: "mb-4",
                label { class: "block text-sm font-medium mb-1", "密码" }
                input {
                    r#type: "password",
                    class: "w-full border rounded-lg px-3 py-2",
                    value: "{form.read().password}",
                    oninput: move |e| form.write().password = e.value(),
                }
            }
            div { class: "mb-6",
                label { class: "flex items-center gap-2",
                    input {
                        r#type: "checkbox",
                        checked: "{form.read().remember}",
                        oninput: move |e| form.write().remember = e.checked(),
                    }
                    span { "记住我" }
                }
            }
            button {
                class: "w-full bg-blue-500 text-white py-2 rounded-lg",
                disabled: submitting(),
                if submitting() {
                    "提交中..."
                } else {
                    "登录"
                }
            }
        }
    }
}

3.2 提交状态管理

一个好用的提交 Hook:

enum SubmitStatus<T> {
    Idle,
    Submitting,
    Success(T),
    Error(String),
}

fn use_submit<F, T>(action: F) -> (Signal<SubmitStatus<T>>, impl Fn())
where
    F: Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<T, String>>>> + 'static,
    T: 'static,
{
    let status = use_signal(|| SubmitStatus::<T>::Idle);

    let submit = move || {
        if matches!(&*status.read(), SubmitStatus::Submitting) {
            return;
        }
        status.set(SubmitStatus::Submitting);

        spawn({
            let action = action.clone();
            async move {
                match action().await {
                    Ok(result) => status.set(SubmitStatus::Success(result)),
                    Err(err) => status.set(SubmitStatus::Error(err)),
                }
            }
        });
    };

    (status, submit)
}

4. 实战:文章编辑器表单

结合之前的所有技术,实现一个文章编辑表单:

#[component]
fn ArticleEditor(existing: Option<ArticleDto>) -> Element {
    let mut title = use_signal(|| existing.as_ref().map(|a| a.title.clone()).unwrap_or_default());
    let mut content = use_signal(|| existing.as_ref().map(|a| a.body.clone()).unwrap_or_default());
    let mut tags = use_signal(|| existing.as_ref().map(|a| a.tags.join(", ")).unwrap_or_default());
    let mut category = use_signal(|| existing.as_ref().map(|a| a.category.clone()).unwrap_or_default());

    let mut errors = use_signal::<Vec<String>>(|| Vec::new());
    let mut saved = use_signal(|| false);

    let validate = move || -> bool {
        let mut errs = Vec::new();
        if title().trim().is_empty() { errs.push("标题不能为空".into()); }
        if title().len() > 100 { errs.push("标题不能超过100字".into()); }
        if content().trim().is_empty() { errs.push("内容不能为空".into()); }
        if content().len() < 50 { errs.push("内容至少50字".into()); }
        errors.set(errs);
        errs.is_empty()
    };

    let save = move |_| {
        if !validate() { return; }
        saved.set(true);
        // 保存到服务器...
    };

    rsx! {
        form { class: "max-w-4xl mx-auto", onsubmit: save,
            if saved() {
                div { class: "bg-green-100 text-green-700 px-4 py-3 rounded mb-4",
                    "文章保存成功!"
                }
            }
            for err in errors.read().iter() {
                div { class: "bg-red-100 text-red-700 px-4 py-2 rounded mb-2", "{err}" }
            }

            div { class: "mb-4",
                label { "文章标题" }
                input {
                    class: "w-full border rounded-lg px-4 py-3 text-lg",
                    value: "{title}",
                    oninput: move |e| title.set(e.value()),
                    placeholder: "输入文章标题",
                }
            }
            div { class: "mb-4 grid grid-cols-2 gap-4",
                div {
                    label { "分类" }
                    input {
                        class: "w-full border rounded px-3 py-2",
                        value: "{category}",
                        oninput: move |e| category.set(e.value()),
                    }
                }
                div {
                    label { "标签(逗号分隔)" }
                    input {
                        class: "w-full border rounded px-3 py-2",
                        value: "{tags}",
                        oninput: move |e| tags.set(e.value()),
                    }
                }
            }
            div { class: "mb-4",
                label { "文章内容(Markdown)" }
                textarea {
                    class: "w-full border rounded-lg px-4 py-3 h-96 font-mono",
                    value: "{content}",
                    oninput: move |e| content.set(e.value()),
                    placeholder: "使用 Markdown 编写...",
                }
            }
            div { class: "flex gap-3",
                button { class: "bg-blue-500 text-white px-6 py-2 rounded-lg", "保存" }
                button { class: "bg-gray-200 px-6 py-2 rounded-lg", "预览" }
            }
        }
    }
}

5. 小结

  • 使用 value + oninput 实现受控组件,保持 Signal 为唯一数据源
  • use_memo 可以派生验证状态,无需额外状态变量
  • spawn 处理异步提交,避免阻塞 UI
  • 封装 use_field / use_submit 等自定义 Hook 可以复用表单逻辑
  • 提交时禁用按钮、显示加载状态,提升用户体验
dioxusformvalidationinputuser-input