折腾过 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 上,比如?status=pending&amount_gte=5000。刷新页面不丢,这是它的优点。但到了 SaaS 场景,这个玩法往往会出问题。
假设用户 A 在页面上筛选出一批数据,然后把链接复制给用户 B。用户 B 打开链接,发现页面自动执行了用户 A 的筛选条件,但用户 B 根本没有某些列的数据权限,页面要么报错,要么出现意料之外的数据范围。更别提 URL 长度有限制,过滤条件一多,连参数都拼不下。
我个人更建议把过滤条件关联到“视图 ID”。用户进入页面的时候,路由里只保留view_id。用户操作完过滤条件,前端把条件保存成一个视图对象,下次进入携带同一个view_id,后端根据视图对象还原过滤条件。这样链接是干净的,权限边界是可控的,也能给每个视图单独做缓存。
3.3 用代码把动态过滤串起来
我实现动态过滤的时候,会把“列配置”和“查询条件”拆成两个前端模块。这里给一个精简但完整的思路。
// useViewConfig.ts export function useViewConfig(viewId: string) { const [serverMeta, setServerMeta] = useState<ViewMeta | null>(null); const [userPref, setUserPref] = useState<UserColumnPref | 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] = useState<FilterItem[]>([]); const [sortMeta, setSortMeta] = useState<SortItem[]>([]); 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 &page=1 &page_size=20filter参数使用 URL 编码后的 JSON,后端解析时把field映射到真实数据库列名。映射关系只能来自服务端的元数据配置,不能直接拼接前端传入的字符串。这样做既能防 SQL 注入,又能拦截越权字段。
两个接口的分工很明确:一个管结构,一个管数据。用户改列宽、改列顺序时,只需要操作视图数据;用户改过滤条件时,先保存视图,再请求数据。两者可以异步发生,互不阻塞。
4.2 元数据版本迁移怎么做才不翻车
SaaS 产品迭代快,今天的元数据在下个月就可能变化。“支付渠道”列被加上了,“创建人”从管理员角色扩展到运营角色。用户保存的自定义视图是基于旧元数据生成的,如果直接照搬,列就可能会对不上。
我建议每个视图配置都带上schema_version。用户保存视图的时候记录当前版本,前端加载视图时检查版本,发现不一致就执行迁移脚本。
const MIGRATIONS: Record<string, (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。
说到底,动态列过滤和视图元数据驱动不是银弹。它更像一套管理复杂度的框架,你有多少复杂性,它就能把复杂性平摊到服务端配置、前端解释器和数据模型里,而不是堆在一个什么都不该关心的列表组件中。