从模糊到清晰:一次组件重构里的开发哲学
同系列里,之前写过一篇关于「查表法」的文章------AI 能写功能,但优雅需要你的判断力。
这一篇换一条线:不是展示层配色,而是通用组件与定制组件如何分层。故事来自设备类型管理里的「关联因子」弹窗------要在原有勾选能力上,增加单位、上下限的行级编辑。
需求听起来不复杂,但一开始,画面是模糊的。
一、开发哲学:模糊是正常的,先上路
实现一个功能,最初往往想不透。各种方案在脑子里排列组合,像某种「量子纠缠态」------每一种选择都会牵出新的不确定性:改通用组件?复制一份?抽 composable?插槽怎么传?响应式会不会断?
想太多,精力就散了。
这次实践里,我慢慢摸出一条适合自己的节奏:
层层递进、按部就班,不要想太多,just do it。
要有画面感、抽象能力和架构意识,可以想想可能遇到的问题,但别因此卡住整个流程。
先把看得见的、能做的做起来。
车到山前必有路:遇水搭桥,遇上开路,看见一个改一个。
具体到这个需求,路径是这样展开的:
| 阶段 | 当时的状态 | 做出的决定 |
|---|---|---|
| 起点 | 要在 DeviceTypeBindPollutant 里加 unit / 上下限编辑 |
很模糊,方案很多 |
| 第一次收敛 | 意识到这是「通用勾选」与「因子配置」的分叉 | 不能大改 BaseCheckTable,保持职责单一 |
| 第二次收敛 | 需要 tab 筛选、搜索、行级编辑、恢复默认 | 参考 BaseCheckTable 重写一个专用组件 |
| 第三次收敛 | 发现 70% 壳层重复,但列渲染需要定制 | 薄包装 BaseCheckTable,编辑状态留在子组件 |
| 踏出那一步之后 | 不确定性从「排列组合」变成「几个具体问题」 | 插槽怎么透传?columns 里写 h() 响应式行不行? |
很有意思的一点是:在真正动手之前,前路是模糊的;一旦选了方向、写出第一版,剩下的往往就只剩两三个技术点要验证。
不是一开始就想通了,是走起来才想通。
二、架构分层:通用勾选 vs 业务编辑
为什么不直接改 BaseCheckTable?
BaseCheckTable 被十几个绑定弹窗共用------站房绑监测点、设备绑因子、类型绑类型......它们只需要:
- 全部 / 未选 / 已选 tab
- 搜索过滤
- checkbox 勾选
如果在上面加 editColumns、行级编辑态、恢复默认按钮,通用组件会被设备类型因子的业务逻辑污染。10+ 个简单场景,为一个复杂场景买单,不划算。
为什么不一直独立到底?
第一版 DeviceTypeFactorCheckTable 把 BaseCheckTable 的壳层几乎完整复制了一遍:wrapper、head、search、tableData / searchData / count*、vxe-grid 配置、scoped 样式。
能跑,但不优雅------和 FactorCheckTable.vue 形成了第三份重复。
最终的分工是:
css
DeviceTypeBindPollutant/ ← 弹窗编排、接口、校验
├── server.ts ← 数据合并(主数据 + 已保存配置)
├── DeviceTypeFactorCheckTable ← 列定义 + editState + 行级编辑
└── (复用)BaseCheckTable ← 勾选壳层:筛选 / 搜索 / grid
| 组件 | 职责 |
|---|---|
BaseCheckTable |
勾选基础设施:filterType、searchName、searchData、vxe-grid 透传 |
DeviceTypeFactorCheckTable |
业务扩展:columns、editState、unit/上下限/操作列 |
DeviceTypeBindPollutant |
弹窗生命周期:open、提交、上下限校验 |
勾选是通用状态,编辑是业务状态------边界清楚了,代码自然就薄了。
最终的 DeviceTypeFactorCheckTable 模板只剩一层:
vue
<BaseCheckTable
:columns="columns"
:data-source="dataSource"
:loading="loading ?? false"
check-field="checked"
search-placeholder="搜索因子名称/编码..."
:filter-option="pollutantFilter"
>
<template #unit="{ row, rowIndex }">...</template>
<template #lowerLimit="{ row, rowIndex }">...</template>
<template #upperLimit="{ row, rowIndex }">...</template>
<template #action="{ row, rowIndex }">...</template>
</BaseCheckTable>
这才是我想要的「优雅的状态」。
三、插槽透传与筛选:白名单,而不是无脑全传
要让上面的写法成立,中间层 BaseCheckTable 必须把父组件的 #unit 等插槽透传给内层 vxe-grid。
naive 写法是:
vue
<template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps ?? {}" />
</template>
问题 :以后如果 BaseCheckTable 自己也要插槽呢?比如 #head-extra(头部额外按钮)、#search-extra(搜索框旁)、#footer(表格底部)------这些会被误传给 vxe-grid,轻则无效,重则奇怪报错。
解法:COMPONENT_SLOT_NAMES 白名单
ts
/** BaseCheckTable 自身插槽,其余插槽均透传给 vxe-grid */
const COMPONENT_SLOT_NAMES = ['head-extra', 'search-extra', 'footer'] as const;
const gridSlotNames = computed(() =>
Object.keys(slots).filter(
(name) => !COMPONENT_SLOT_NAMES.includes(name as (typeof COMPONENT_SLOT_NAMES)[number])
)
);
模板里:
vue
<div class="head">
<!-- 组件自己的插槽 -->
<slot name="head-extra" :counts="{ count0, count1, count2 }" />
</div>
<div class="search">
<a-input ... />
<slot name="search-extra" :search-name="state.searchName" />
</div>
<vxe-grid ...>
<!-- 只透传 grid 插槽 -->
<template v-for="name in gridSlotNames" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps ?? {}" />
</template>
</vxe-grid>
<slot name="footer" />
数据流:
ini
DeviceTypeFactorCheckTable #unit="{ row, rowIndex }"
↓
BaseCheckTable <slot name="unit" v-bind="slotProps" />
↓
vxe-grid #unit 渲染单元格
规则 :组件自身插槽 → 显式写在模板对应位置;不在白名单里的 → 自动透传给 vxe-grid。
目前 head-extra / search-extra / footer 是预留的------现在没人用,但结构已经就位。以后加组件插槽,只需两步:名字加入白名单 + 模板里挂 <slot>。
columns 与插槽的配合
vxe-grid 支持两种列渲染方式:
| 写法 | columns 配置 | 渲染来源 |
|---|---|---|
| 具名插槽 | slots: { default: 'unit' } |
模板 #unit |
| 内联函数 | slots: { default({ row }) { return h(...) } } |
columns 里的函数 |
我们选用第一种------columns 只声明插槽名,具体 UI 写在 template。可读性更好,按钮和表单组件一目了然。
ts
const columns = [
{ title: '单位', field: 'unit', slots: { default: 'unit' } },
{ title: '下限', field: 'lowerLimit', slots: { default: 'lowerLimit' } },
{ title: '上限', field: 'upperLimit', slots: { default: 'upperLimit' } },
{ title: '操作', field: 'action', slots: { default: 'action' } },
];
如果全部收进 columns 的 h() 里,确实可以不再依赖插槽透传------但 action 列的按钮组会变得冗长。这是可读性与组合灵活性的取舍,没有唯一正确答案。
四、响应式实验:AI 不确定的事,实测说了算
在尝试把列渲染收进 columns 的 slots.default + h() 时,有一个理论上的担忧:
columns若是普通常量数组,里面的default虽然闭包了editState,但**editIndex变化时不一定会触发单元格重绘**。
AI 的建议是:把 columns 做成 computed,显式依赖 editState.editIndex,更稳妥。
我做了个最小 MVP 实验------只把 upperLimit 一列改成 h(InputNumber, ...) 写法,其余列保持 template 插槽,然后手动测:
- 点「修改」→ 输入框出现 ✓
- 输入数值 → 临时状态更新 ✓
- 点「确定」→ 写回 dataSource,回到展示态 ✓
- 筛选 / 搜索后编辑 → rowIndex 对应正确 ✓
结论:在当前这套 vxe-grid + Vue 3 + ant-design-vue 组合下,非响应式的 columns 常量 + 内联 slot 函数,编辑态切换是正常的。
原因大概是:vxe 渲染单元格时会反复调用 default({ row, rowIndex })。editState.editIndex 变化 → 组件重渲染 → slot 函数重跑 → UI 切换。输入过程中则是 onUpdate:value 驱动子组件自身更新。
| 点 | 结论 |
|---|---|
computed(columns) |
可选优化,不是必须 |
内联 h() |
可行,有利于不依赖插槽透传 |
| template 插槽 | 可读性更好,响应式更直观 |
| AI 的理论边界 | 要验证,不能盲信 |
这件事挺有启发:AI 能列出理论风险,但你的实测才是项目里的真相。 没有观察到「点了修改但不切换」的现象,就不必提前加复杂度。
如果以后真遇到偶发不刷新,再考虑 computed(columns) 或 reloadColumn------YAGNI,看见了再改。
五、内聚:子目录也是一种优雅
除了组件分层,还把模块收进了独立目录:
bash
src/components/bindRelation/DeviceTypeBindPollutant/
├── DeviceTypeBindPollutant.vue
├── DeviceTypeFactorCheckTable.vue
├── server.ts ← 从 bindRelation/server.ts 迁出并增强
└── type.ts
getDeviceTypeFactorRows 负责合并「因子主数据」与「已保存配置」,带上 defaultUnit / defaultLowerLimit / defaultUpperLimit 供恢复按钮使用。弹窗只关心 open → 展示 → modalOk 提交。
相关的放在一起,不相关的不要硬凑。 通用的 getPollutantsChecked 仍留在 bindRelation/server.ts 给 DeviceFormModal 用------没有为了「内聚」而过度迁移。
六、回头看:优雅是走出来的
这次重构如果一上来就追求「完美架构」,可能会在这些问题上卡住:
- 要不要改
BaseCheckTable? - 插槽怎么透传?要不要白名单?
columns用h()还是 template?要不要computed?- 要不要抽
useCheckTableFilter?
每一个都值得想,但不值得在起点就想完。
实际路径是:
- 先建子目录 + 专用表格,快速交付功能
- 发现壳层重复,尝试
h()写法,验证响应式 - 确认可以薄包装
BaseCheckTable,补上插槽透传和白名单 - 列渲染回到 template 插槽------因为可读性更好
没有哪一步是浪费的。前面的「弯路」换来了后面的确定感。
写在最后
那篇文章里说过:AI 的代码质量,是你认知水平的投影。
这一篇补一句:你的认知,也是在一次次「先上路、再修正」里长出来的。
模糊不可怕。可怕的是因为模糊而站着不动,或者因为想一次想全而什么都写不出来。
车到山前必有路------但路是自己一步一步走出来的。
本文基于真实项目经验整理,手工起草文章大纲,AI 辅助润色,于 2026-06-11