资讯动态

视图元数据驱动与动态列过滤:SaaS前端如何摆脱硬编码

发布时间:2026/10/16 11:54:49 来源:尧图企业网站定制
折腾过 SaaS 前端的朋友几乎都遇到过同一个让人抓狂的场景同一个列表页A 租户要看 8 列B 租户只要 4 列C 租户还非要把“创建时间”排第一。表面上看只是配置差异但如果前端把这种差异写死在组件里那真是把自己送进了一个无底洞。今天聊聊我做了几个 SaaS 项目之后反复用的一套做法视图元数据驱动 动态列过滤。这套架构解决的核心问题就是标题里说的“拒绝硬编码”——把列配置、过滤条件、排序规则从代码里拆出去交给服务端下发、前端解释执行让不同角色、不同租户甚至不同用户都能在同一套页面里看到自己真正关心的数据视图。先说明一下受众。如果你是刚接触前端架构不久的新人这篇文章能帮你理解为什么“写死”是老路配置化不是终点如果你已经在做中后台系统这里面的权限联动、版本迁移和自定义视图存储方案可以直接拿去当作设计蓝图。全文以我一线的实操视角出发该给代码的地方给代码该踩的坑也明确标出来。1. 先搞明白SaaS 前端为什么要和硬编码死磕到底1.1 一个常见需求引发的连锁反应假设你负责一个订单管理模块产品经理提了个需求“客户反馈运营角色希望列表里只显示订单号、金额、状态这三列并且默认过滤掉已取消的订单管理员角色需要看到五列还要能按客户名称筛选。”很多人的第一反应是直接写const columns isAdmin ? allColumns : onlyThreeColumns;如果只有两种角色这代码撑得过去。但 SaaS 产品的真实场景是租户有几十个角色有好几层每个租户还可以新增自定义字段。今天加一个“支付渠道”明天删一个“退货原因”后天运营又要“先按金额降序”。每次变更都落到if分支里组件代码越来越肿权限判断散落各处。更麻烦的是硬编码还会带来了一个很隐蔽的问题你很难对表格的权限边界做系统性约束。多租户系统里不同的租户访问同一个页面看到的字段必须不同数据范围也必须不同。一旦把列配置写死在前端后端接口就只能把全量字段返回靠前端代码去藏。藏得住吗藏不住。接口里多返回的字段浏览器控制台里都能看到这在安全审计上就是一个漏洞。所以“拒绝硬编码”不只是一个工程美学问题它直接关系到多租户隔离和权限合规。1.2 双重魔法到底解决了哪三类问题我第一次认真做这套架构的时候把它拆成了三个问题第一字段可见性。谁能看到哪一列谁不能看到哪一列。这关系到数据安全和界面的信息密度。第二数据过滤与排序。同一批数据我想要只看待审批、金额大于一万、按时间倒序。这是行级数据范围的收敛。第三视图保存与恢复。用户改完了列顺序、加好了过滤条件能不能把这一整套组合存成一个“视图”下次一键恢复把这三个问题统一交给“元数据”接管前端代码里就不会再出现“用户ID等于某个值就显示某列”这种场景判断了。页面变成元数据的执行器用户看到的任何表格形态都是元数据解释后的结果。2. 视图元数据驱动别把 JSON 当摆设2.1 一份合格的元数据到底包含什么很多人一听“元数据驱动”就说不就是把列配置放到 JSON 嘛。对但难点在于 JSON 能不能承载完整的页面语义。我这里给一份简化版但五脏俱全的示例{ view_id: view_order_default, view_name: 运营默认视图, is_system: true, column_meta: [ { key: order_no, title: 订单号, width: 180, frozen: left, sortable: true, filterable: true, roles: [admin, operator], exportable: true }, { key: amount, title: 订单金额, width: 140, sortable: true, filterable: true, roles: [admin], exportable: true }, { key: status, title: 状态, width: 120, sortable: false, filterable: true, roles: [admin, operator], exportable: true } ], filter_fields: [ { field: amount, label: 订单金额, type: number }, { field: status, label: 状态, type: select }, { field: customer_name, label: 客户名称, type: text } ], default_filter: [ { field: status, operator: neq, value: cancelled } ], default_sort: [ { field: created_at, order: desc } ] }这里面有几个容易被忽略的设计点roles放在列级别但它不只是“渲染控制”它是后端下发给该用户前就已经做过一次裁剪的结果。真正的敏感字段不应该出现在这个 JSON 里这相当重要。filter_fields与column_meta独立存在。这意味着表格里不一定展示“客户名称”这一列但顶部筛选区仍然可以用客户名称去过滤数据。default_filter是视图的“起手式”用户重置筛选时应该回到这个默认条件而不是回到“空条件”。2.2 前端如何消费这份元数据一份元数据下发到前端中间至少要经过三个步骤拉取、解析、合并操作。拉取阶段。页面初始化的时候调用一次视图配置接口。要注意这个接口的返回值一定是已经结合了当前用户租户和角色做过裁剪的结果。如果前端拿到的是全量配置再在前端过滤角色那之前提到的字段泄露问题依然存在。解析阶段。前端把元数据转换成表格组件能识别的配置。这个阶段最忌讳的事情是把服务端下发的默认元数据和用户本地自定义状态混在一起。用户拖宽了一列这是本地偏好服务端默认列的宽度是另一个东西。我的经验是准备两层数据第一层服务端元数据不可随意改动。第二层用户偏好覆盖层比如列宽、列顺序、用户自定义过滤。渲染时把两层数据合并在一起而不是直接修改第一层。合并阶段。用户调整了列的顺序点击了排序输入了过滤条件前端把变更部分保存成“用户偏好”同时写回服务端或本地持久化层。这样切换设备、刷新页面视图状态都不会丢。2.3 元数据驱动不是“配置化换皮”有人可能会说自己做表格的时候也写了个 config 数组塞到组件里这跟元数据驱动有什么区别区别在于“语义的集中性”。写一个 config 数组丢在组件旁边那只是页面级常量。真正意义上的元数据驱动要求视图配置是可查询、可版本管理、可动态修改的。前端只是一个通用解释器。服务端改一份元数据整个租户下的所有页面视角全部变化不需要前端发版。这才是“驱动”的含义。反正我就是这么理解的一个列表页如果配置是躺在代码仓库里的 JSON那还是硬编码只是换了件衣服如果配置存在服务端能被不同角色请求后得到不同结果并且前端组件可以解释任意合法配置那才叫元数据驱动。3. 动态列过滤细节里全是魔鬼3.1 先把三件事拆开列显隐、行过滤、排序动态列过滤听起来很简单好像就是把筛选下拉框放到列头上。但真做下去坑全藏在边界里。举个例子。订单列表页给用户两个能力用户把“客户名称”列从列表里隐藏。用户按“金额大于一万”过滤查询。第一件事是列显隐它只影响表格渲染的 column 配置不发请求。第二件事是行过滤它直接改变查询条件需要重新请求后端数据。如果这两个能力耦合在一个状态对象里就会出现一个经典 bug用户只调整了列顺序结果整个表格数据被重新请求了一遍还因为请求参数变了把用户当前页给重置了。正确做法是前端里把列配置和查询条件彻底分开管理。列配置变化引起的是 UI 重渲染查询条件变化才触发数据请求。这两条路互不干扰。3.2 过滤条件到底放在哪URL 还是视图对象很多系统习惯把过滤条件拼在 URL 上比如?statuspendingamount_gte5000。刷新页面不丢这是它的优点。但到了 SaaS 场景这个玩法往往会出问题。假设用户 A 在页面上筛选出一批数据然后把链接复制给用户 B。用户 B 打开链接发现页面自动执行了用户 A 的筛选条件但用户 B 根本没有某些列的数据权限页面要么报错要么出现意料之外的数据范围。更别提 URL 长度有限制过滤条件一多连参数都拼不下。我个人更建议把过滤条件关联到“视图 ID”。用户进入页面的时候路由里只保留view_id。用户操作完过滤条件前端把条件保存成一个视图对象下次进入携带同一个view_id后端根据视图对象还原过滤条件。这样链接是干净的权限边界是可控的也能给每个视图单独做缓存。3.3 用代码把动态过滤串起来我实现动态过滤的时候会把“列配置”和“查询条件”拆成两个前端模块。这里给一个精简但完整的思路。// useViewConfig.ts export function useViewConfig(viewId: string) { const [serverMeta, setServerMeta] useStateViewMeta | null(null); const [userPref, setUserPref] useStateUserColumnPref | null(null); useEffect(() { // 拉取服务端裁剪后的视图元数据 fetchViewMeta(viewId).then((meta) { setServerMeta(meta); }); // 加载本地已有的用户偏好比如列宽、列顺序 setUserPref(loadUserColumnPref(viewId)); }, [viewId]); const mergedColumns useMemo(() { if (!serverMeta || !userPref) return []; // 用户偏好和服务端默认配置合并生成最终的表格列 return mergeColumnConfig(serverMeta.column_meta, userPref); }, [serverMeta, userPref]); return { mergedColumns, filterFields: serverMeta?.filter_fields ?? [] }; }// useDynamicFilter.ts export function useDynamicFilter(viewId: string) { const [filters, setFilters] useStateFilterItem[]([]); const [sortMeta, setSortMeta] useStateSortItem[]([]); const applyQuery useMemo(() { return debounce((nextFilters, nextSort) { queryListData({ view_id: viewId, filters: nextFilters, sort: nextSort }); }, 400); }, [viewId]); const changeFilter (nextFilters: FilterItem[]) { // 先改状态界面马上刷新 setFilters(nextFilters); // 防抖后请求数据 applyQuery(nextFilters, sortMeta); }; return { filters, sortMeta, changeFilter }; }这段代码的灵魂不在于封装了多少 API而在于把“列配置”和“查询条件”拆成两个独立的 Hook。表格组件只关心columns和dataSource过滤行为被隔离到useDynamicFilter里。想加一个“执行查询”按钮就是在防抖和手动触发之间切换想去掉某个筛选条件就在changeFilter里移除一项。后续所有复杂逻辑都有地方放。3.4 过滤操作和权限的联动这是整个方案里最容易翻车的地方。我的原则有两个都是实战换来的。第一个原则前端按元数据渲染筛选项不能有写死的字段。如果元数据里没有“金额”这个字段顶部筛选区就不能出现“金额”。这是体验问题更是数据安全问题。用户看不到的字段不允许进入过滤条件。第二个原则后端必须再做一次过滤字段白名单校验。前端再怎么限制HTTP 请求依然可以被伪造。后端拿到filter参数以后逐项检查field是否在当前用户的字段白名单列表里不在就直接拒绝。特殊情况也要考虑一个字段没有放在表格列里但就是需要出现在顶部筛选区。这时候就走filter_fields让字段具备“可筛选”属性但不具备“可展示”属性。我见过有人为了这个需求单独硬编码一个“高级筛选”面板结果权限模型一塌糊涂后期根本维护不下去。3.5 过滤表达式要不要做成树形结构简单条件可以是一个平铺数组金额大于 100、状态等于完成、创建时间在某个范围内。但真实业务经常出现“或”和“且”嵌在一起的组合。我建议一开始就把过滤条件抽象成一层简单的表达式节点哪怕初期产品只用到平铺结构。比如{ op: and, items: [ { field: amount, op: gte, value: 10000 }, { op: or, items: [ { field: status, op: eq, value: paid }, { field: status, op: eq, value: completed } ] } ] }这种结构的优点是后端的查询构造器可以递归拼接 SQL 条件前端也可以递归渲染嵌套筛选面板。如果一开始就用平铺数组后期支持“或”逻辑时数据结构要么重新设计要么就得硬编码很多奇奇怪怪的组合代码。嵌套结构早一点建好后面做高级搜索功能的时候能省很多重构。4. 落地实操接口设计、版本迁移和自定义视图4.1 两个核心接口怎么定整个前端架构依赖的后端接口可以精简成两个。第一个是视图元数据接口GET /api/views/{view_id}/meta这个接口返回当前用户视角下裁剪后的视图配置。后端在返回之前至少要完成三件事校验租户隔离、校验角色权限、合并系统默认和用户自定义配置。第二个是列表数据接口GET /api/views/{view_id}/data ?filter%7B%22op%22%3A%22and%22%7D page1 page_size20filter参数使用 URL 编码后的 JSON后端解析时把field映射到真实数据库列名。映射关系只能来自服务端的元数据配置不能直接拼接前端传入的字符串。这样做既能防 SQL 注入又能拦截越权字段。两个接口的分工很明确一个管结构一个管数据。用户改列宽、改列顺序时只需要操作视图数据用户改过滤条件时先保存视图再请求数据。两者可以异步发生互不阻塞。4.2 元数据版本迁移怎么做才不翻车SaaS 产品迭代快今天的元数据在下个月就可能变化。“支付渠道”列被加上了“创建人”从管理员角色扩展到运营角色。用户保存的自定义视图是基于旧元数据生成的如果直接照搬列就可能会对不上。我建议每个视图配置都带上schema_version。用户保存视图的时候记录当前版本前端加载视图时检查版本发现不一致就执行迁移脚本。const MIGRATIONS: Recordstring, (view: ViewConfig) ViewConfig { 1.0-rc: (view) { return { ...view, column_meta: view.column_meta.filter((col) col.key ! legacy_field), filter_config: migrateFilter(view.filter_config, renameMap), }; }, 1.0-rc2: (view) { return { ...view, column_meta: addColumnIfMissing(view.column_meta, payment_channel) }; }, }; function migrateView(view: ViewConfig) { let next view; while (next.schema_version ! CURRENT_VERSION) { const step MIGRATIONS[next.schema_version]; if (!step) break; next step(next); } return next; }迁移的过程必须做到幂等。也就是说同一个视图跑了两次迁移脚本结果要一样。列的重命名、删除、新增都要有明确的判断条件。我见过同事在迁移脚本里不加过滤条件导致升级完以后同一列出现了两遍用户一脸问号地过来问为什么多了一列。4.3 自定义视图怎么存增量覆盖而非全量快照自定义视图是 SaaS 里的常见功能。用户把当前表格调整为“只看金额超一万的已完成订单且金额列放在最前面”然后保存为“我的催收视图”。后端设计的存储策略有两种全量快照和增量覆盖。全量快照就是保存一份完整的列配置简单直接但问题在于系统默认视图一旦升级新增的列不会出现在用户自定义视图里因为用户保存的是一个旧的全量快照。我更推荐增量覆盖。自定义视图底层先关联一个系统基础视图用户的自定义内容只保存相对默认配置的差异部分。渲染时先取系统基础视图再叠加差异。这样系统后续新增字段时只要用户没有显式删除新字段会自动出现在自定义视图里体验上会舒服很多。数据模型大致是这样视图ID 租户ID 创建人ID 基础视图ID关联系统视图 列偏好JSON用户调整过的列宽、顺序、可见性 过滤条件JSON 排序JSON 共享范围私有/角色共享 版本号每次渲染时前端先取基础视图元数据再覆盖用户偏好最终生成完整列配置。4.4 前端启动流程完整串一遍有了上面这些设计一个页面的初始化流程可以写得很顺页面入口拿到路由里的view_id。前端调用元数据接口拿到当前用户视角下的元数据。前端检查视图版本号如果低于当前版本执行迁移脚本。前端合并系统默认配置与用户覆盖层生成表格columns。前端从元数据里取出default_filter和default_sort发起首次数据请求。表格渲染用户开始操作列配置变化只更新本地过滤条件变化触发防抖请求。用户保存视图时前端把当前列偏好和过滤条件整体提交到后端。整个过程看起来简单但每一步都有它存在的意义。没有版本迁移老用户会白屏没有用户覆盖层默认视图升级会把用户调整好的布局冲掉没有防抖用户输入筛选条件的时候接口会被打爆。5. 排查实录这些坑我基本都踩过5.1 表格组件明明更新了配置界面却不变很多人以为是可视化表格组件的问题翻了一堆文档最后发现是缓存问题。视图配置接口如果被 CDN 或者浏览器缓存了用户刷新以后拿到的还是旧配置。这个接口建议不要用强缓存要么no-store要么用ETag配合版本号。如果确实不是缓存那就要看列配置的引用。表格组件通常会把columns存进内部状态如果前后两次传进来的columns引用指向同一个对象或者新对象内部某些列缺少稳定的key组件内部可能直接复用旧状态界面就不会变。给每个列都设置稳定且唯一的key更新时创建一个新引用多数问题都能解决。5.2 保存视图后再次进入页面空白常见的原因是迁移脚本只处理了列配置忘了处理过滤配置和排序配置。比如数据库里status字段改成了order_status列配置里改了映射但用户保存的过滤条件里面还引用着status后端一解析就报错。前端拿到过滤配置后应该对每个字段做一次“存活检查”。字段不存在就删掉并在界面上给出提示排序配置也必须做同样的检查。很多小白做迁移的时候只盯着表格的列最后排查半天才发现是过滤条件把页面搞挂了。5.3 同一个视图不同角色看到的数据不一样这个坑通常不在前端而在后端的数据权限拼接。视图只定义了“看哪些列、默认过滤条件是什么”真正的行级数据权限仍然要由后端根据当前用户的租户和角色来拼接。后端拼接 SQL 的时候一定要把租户隔离条件放最前面视图的默认过滤条件放后面两者用括号明确分开。否则可能出现一种极端情况用户把自己所在租户的数据权限绕过查到了其他租户的数据。排查的时候要直接看后端实际执行的 SQL不要猜。5.4 导出 Excel 时导出的列和页面上对不上页面上明明只有 6 列导出文件里却有 10 列这个问题在操作后台系统的时候经常被吐槽。原因一般是导出走了独立模板和前端当前视图没有任何关系。正确的做法是前端在发起导出请求时把当前用户正看到的列配置快照一起传给后端。后端只需要校验列名白名单然后按照传入的列顺序生成文件。如果导出耗时比较长是异步任务就把列快照存到导出任务表里。否则用户发起导出后调整列导出文件又对不上了。5.5 过滤条件只改了一个值却连续发了三次请求这是典型的防抖没做好。多个过滤字段如果分别绑定各自的change事件每次触发都可能调用一次查询。解决方法是把查询动作聚合成一个出口所有字段变化先更新本地状态等到确认稳定了再发请求。还有一种情况是下拉框联动的需求A 字段变化以后B 字段的可选项要变化。这时候需要注意循环更新问题。我的经验是给每个字段更新做一次“来源标记”程序触发的更新和用户触发的更新分开处理否则一个操作能引出一连串无意义的请求。5.6 问题排查速查表现象可能原因第一排查点新列不显示接口缓存 / 列缺少稳定 key查看接口响应和 Cache 头视图恢复后白屏迁移脚本没处理过滤条件检查 filter_config 字段引用同视图数据串租户权限条件拼接顺序错误看后端 SQL 条件层级导出列与视图不一致导出模板独立于视图检查导出请求是否带列快照重复请求防抖没生效 / 联动更新没有来源标记看网络面板请求间隔重置后不会回到默认条件default_filter 和用户过滤耦合检查重置时是否清除了全部过滤6. 最后分享一点我的实操体会这套架构真正给到我的收益不是一个炫酷的表格组件而是让“变化”变得可控。列是少量变化的过滤条件是组合变化的视图的优先级是逐层叠加的。把这三样东西抽象成元数据以后新增一个业务字段往往只需要动服务端配置前端代码基本不动。如果你是第一次在项目里落地这套方案我建议不要一上来就搞一个通用查询引擎。步子太大会很难收场。先从两步走第一步把列配置从组件里抽出来交给接口返回第二步把过滤条件从 URL query 挪到视图对象里。这两个小动作做完你就已经和“硬编码”说再见了。后面再逐步补上版本迁移和自定义视图就会发现前面这几步的边界划分会让你少改很多莫名其妙的 bug。说到底动态列过滤和视图元数据驱动不是银弹。它更像一套管理复杂度的框架你有多少复杂性它就能把复杂性平摊到服务端配置、前端解释器和数据模型里而不是堆在一个什么都不该关心的列表组件中。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑