第十二章:表单处理与验证
博客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