Rust 全栈项目里,我写了一个不再重复造轮子的泛型表格组件

最近在用 Rust + Leptos 写一个家政行业的 CRM 系统,后台管理页面里表格是绝对的主角------客户列表、订单列表、排班列表、服务项目列表......每个页面都要一个表。

刚开始我也是老老实实每个页面手写 <table>,写了三个页面后实在受不了了,于是抽了一个泛型表格组件出来。这篇文章就来聊聊 DaisyTable 的设计思路和其中 3 个关键模式。

问题:为什么手写表格撑不住

传统的做法很简单:每个页面自己写 <For> 循环渲染行,每行自己写 <td>。看起来没问题,但三个页面后你会发现:

  • 排序、加载态、空状态,每个页面都要重写一遍
  • 列定义散落在视图代码里,维护起来得扒拉半天
  • 操作列里的"查看/编辑/删除"按钮,每行拿当前行数据的方式各不相同

目标很明确:定义一个泛型表格,用一个数据结构描述"有哪些列",每个列自带渲染逻辑,行数据通过 Provider 机制自动注入,排序、加载态、空态全部内置。

设计一:Column 插槽 ------ 用声明式 DSL 描述列

Leptos 的 #[slot] 宏让我们可以定义"插槽组件"------父组件接收一组子组件作为配置项。Column 就是这样一个插槽:

rust 复制代码
#[derive(Clone)]
#[slot]
pub struct Column {
    pub label: String,
    #[prop(default = false)]
    freeze: bool,
    #[prop(default = false)]
    sort: bool,
    prop: String,
    #[prop(optional, into)]
    pub class: Option<String>,
    pub children: ChildrenFn,
}

使用时就是一个声明式的 DSL,列定义和渲染逻辑写在一起:

rust 复制代码
<DaisyTable data=data on_sort=on_sort>
    <Column slot:columns freeze=true prop="user_name".to_string()
             label="姓名".to_string() class="font-bold" sort=true>
        {
            let user: Option<Contact> = use_context::<Contact>();
            view! {
                <span class="font-medium">
                    {user.map(|u| u.user_name).unwrap_or_default()}
                </span>
            }
        }
    </Column>
    <Column slot:columns label="电话".to_string() prop="phone_number".to_string()>
        {
            let user: Option<Contact> = use_context::<Contact>();
            view! { <span>{user.map(|u| u.phone_number).unwrap_or_default()}</span> }
        }
    </Column>
    // ... 更多列
</DaisyTable>

每个 Column 的 children 是一个闭包,由表格内部在渲染时调用,你只管定义"这一列长什么样"。freeze 列渲染为 <th>(表头单元格样式),普通列渲染为 <td>

设计二:Provider 注入 ------ 子组件无感获取当前行数据

这是整个设计里最巧妙的地方。看表格内部的渲染逻辑:

rust 复制代码
<For each=move || items key=|item| item.id() children=move |item| {
    view! {
        <Provider value=item>
            <tr>
                <For each=move || columns key=... children=move |(_, col)| {
                    if col.freeze {
                        view! { <th class=...>{(col.children)()}</th> }
                    } else {
                        view! { <td class=...>{(col.children)()}</td> }
                    }
                } />
            </tr>
        </Provider>
    }
}/>

注意 <Provider value=item> 把当前行数据注入了上下文。所以每个 Column 的 children 闭包里,可以直接 use_context::<T>() 拿到当前行数据,不需要手动传参、不需要闭包捕获、不需要索引访问

举个例子 :操作列里需要当前行的 contact_uuid 来做编辑和删除,传统做法要通过闭包层层传参,而这里直接:

rust 复制代码
<Column slot:columns freeze=true label="操作".to_string() prop="".to_string()>
    {
        let user: Option<Contact> = use_context::<Contact>();
        let uuid = user.map(|u| u.contact_uuid).unwrap_or_default();
        view! {
            <button on:click=move |_| delete(uuid.clone()) class="btn btn-ghost btn-xs">
                "删除"
            </button>
        }
    }
</Column>

Provider 充当了"当前行作用域"的角色。每一列的子组件不用知道自己在第几行、数据怎么传进来的------直接从上下文拿即可。

设计三:Identifiable ------ 让 Leptos 精确知道谁变了

Leptos 的 <For> 组件需要一个稳定的 key 来判断列表项的新增、删除和移动。所以我们定义了一个 trait:

rust 复制代码
pub trait Identifiable {
    fn id(&self) -> String;
}

业务数据类型只需实现它:

rust 复制代码
impl Identifiable for Contact {
    fn id(&self) -> String {
        format!("{}-{}", self.contact_uuid, self.updated_at)
    }
}

为什么要拼 updated_at 如果只用 contact_uuid,编辑某个客户后数据刷新了,但 key 没变,Leptos 不会重新渲染对应行。把 updated_at 拼进去后,每次编辑产生新数据时 key 就会变,对应行自然重新渲染。

DaisyTable 的泛型约束直接限定 T: Identifiable,编译器强制你在使用前思考 key 怎么定义,反过来也避免了忘记给 For 设置 key 的常见 bug。

再看看排序和加载态

排序内置在表头里。Column 设置 sort=true 后,表头自动渲染一个 ColumnSorter 组件:

rust 复制代码
// ColumnSorter 内部:点击切换 Asc <-> Desc,回调通知父页面
let handle_click = move |_| {
    let new_value = sort_value.read().reverse();
    set_sort_value.set(new_value);
    if let Some(f) = on_change.as_ref() {
        f(new_value);  // 通知页面重新请求数据
    }
};

加载态和空状态也内置了。Transition 组件包裹 <tbody>------数据没准备好时自动渲染 loading 动画;数据为空时自动展示"暂无数据"。这些原本每个页面要手写的逻辑,全部收到表格内部了。

总结

三个核心模式:

  1. Column 插槽:声明式列定义,列结构和渲染逻辑在一起,一眼看清这个表有哪些列
  2. Provider 注入<Provider value=item> + use_context,列子组件无感获取当前行数据,零参数传递
  3. Identifiable trait:编译器强制定义稳定 key,避免渲染漏更新

整个 DaisyTable 组件(含 ColumnSorter)不到 200 行代码,但已经支撑了 Pico-CRM 里 6 个页面的全部表格渲染------客户管理、订单列表、排班管理、服务项目、商户管理、员工管理。新增一个列表页面只需要实现 Identifiable,然后写几个 <Column> 标签,排序、加载态、空态全部自动到位。

如果你也在用 Rust 写全栈(Leptos / Dioxus / Yew),这种 Provider + Slot 的组合在列表、表单、详情页等场景都可以复用。

大家在自己的项目中是怎么处理表格组件的?评论区聊聊。

相关推荐
008爬虫实战录1 小时前
【码上爬】 题九:webpack调试 堆栈分析
前端·webpack·node.js
码途漫谈1 小时前
让 Coding Agent 记得住:agentmemory 的长期记忆系统拆解
开源·ai编程
不爱吃糖的程序媛1 小时前
贡献指南 | 参与 Harmonybrew 开源社区共建规范
开源
日取其半万世不竭1 小时前
OpenCost:Kubernetes 成本监控,开源的云资源费用分析
容器·kubernetes·开源
zoomdong1 小时前
@utoo/pack: 基于 Turbopack 的下一代 Rust 构建工具
webpack·rust·开源
Maimai108082 小时前
React 多步骤表单工程化落地:从 Zod Schema、React Hook Form 到 Zustand 持久化
前端·javascript·react.js·前端框架·状态模式
程序员码歌2 小时前
我是怎么部署开源 AI 编程助手 OpenCode,并在两个真实场景使用起来的
前端·人工智能·后端
Maimai108082 小时前
React Query + Zustand 正确结合方式:不要把接口数据复制进 Store
前端·javascript·react.js·前端框架·web3·状态模式
天才熊猫君2 小时前
层叠上下文 z-index 的简单理解
前端