最近在做一个军工的地图项目,我发现这个项目:
- 业务类型多,而且会持续增长
- 同一类数据,在不同场景下展示不同
- 页面结构可能很简单,但背后的规则很复杂
- 业务变化主要是改文案、调整颜色、换个顺序、加新类型、同一规则套到新页面
- 业务逻辑需要“集中治理”:同一个状态,在 A 页面能操作,在 B 页面不能,规则改一次,要全局生效
我逐渐意识到一个问题:
UI 的复杂度,往往并不来自页面本身,而是来自规则、分类和变化本身。
页面本身可能只是「一些点位 + 图例」,
但背后往往是:
- 点位类型不断增加
- 不同类型对应不同展示规则
- 图例、地图、弹窗之间存在大量隐式耦合
从组件堆砌开始的问题
项目早期,我采用的是最常见的方式:
- 不同类型用不同组件
- 模板里大量
v-if / v-show - 规则散落在各个组件中
一开始还能接受,但随着业务扩展,很快出现问题:
- 新增一种点位类型,需要改多个组件
- 图例和地图点位的展示规则无法复用
- 改一个颜色或形状,要全局搜索替换
- 模板越来越臃肿,但没有一行是真正“稳定”的
这时候我意识到一个根本问题:
我并不是在写 UI,而是在用模板硬编码业务规则。
思路转变:不再“写 UI”,而是“描述 UI 应该长什么样”
后来我换了一个角度思考:
页面真正关心的不是规则,而是结果。
如果我能用数据描述清楚:
- 这个页面有哪些图例
- 每个图例长什么样
- 每个图例在地图上如何显示
- 不同业务类型对应什么展示形态
那么页面本身就可以变得非常简单、稳定。
于是我开始将 UI 复杂度从模板中抽离,拆成几个层次:
- 页面层
- 数据层
- 配置层
- 映射层
- 逻辑层
下面结合项目的图例与点位渲染来说明。
页面层:只负责展示和交互
页面层的职责非常单一:
根据传入的数据结构渲染 UI,不理解任何业务含义。
<div
class="legend-item"
v-for="(v, k) in list"
:key="k"
@click="clickLegend(v, k)"
:style="`${noClick ? '' : 'cursor: pointer;'}`"
>
<component :is="v.Entity" />
<span :style="{ color: showMap[k] ? '' : '#666' }">
{{ v.label }}
</span>
</div>
在这个模板中:
component负责渲染图例图标span负责展示文本- 页面并不知道这是“劫持”“登船”还是别的业务类型
数据层:描述“页面需要展示什么”
数据层用于定义:
当前页面需要哪些图例,以及它们的最终展示形态。
export const pirateLegend: LegendItem[] = [
{
base: pointConfigMap.海盗页.劫持,
label: "劫持",
Entity: getPixel(pointConfigMap.海盗页.劫持.color)
},
{
base: pointConfigMap.海盗页.登船,
label: "登船",
Entity: getPixel(pointConfigMap.海盗页.登船.color)
}
]
这里的关键点在于:
- 页面不再拼 UI
- 而是直接使用描述好的 UI 结果
UI 生成函数:将配置转为可渲染节点
为了避免在模板中处理细节,我通过函数生成图例的 UI 结构:
// 正方形
const getSquare = (color: string) => {
return createVNode(
"span",
{ style: `width:8px;height:8px;background-color:${color};display:inline-block` }
)
}
// 图片
const getImg = (src: string) => {
return createVNode("img", { src })
}
// 圆形像素点
const getPixel = (color: string, isBorder?: boolean) => {
return createVNode(
"span",
{
style: `
width:10px;
height:10px;
background-color:${color};
border-radius:5px;
display:inline-block;
${isBorder ? "border: 1px solid #ffffff" : ""}
`
}
)
}
页面层不关心这些细节,只关心最终的 Entity。
配置层:业务规则的统一来源
随着图例和地图点位开始共用同一套规则,我引入了配置层:
海盗页: {
劫持: {
id: "海盗页-劫持",
label: "劫持",
type: "pixel",
color: colorMap["红色"],
shape: shapeMap["正方形"]
},
登船: {
id: "海盗页-登船",
label: "登船",
type: "pixel",
color: colorMap["橙色"],
shape: shapeMap["正方形"]
}
}
这个配置对象的作用是:
“劫持”这个业务类型,所有相关规则只在这里定义一次。
它既服务于图例,也服务于地图点位。
随着项目推进,“劫持”“登船”这类业务类型并不会只存在于图例和地图点位中。
例如,后续可能出现这些新需求:
- 统计面板
在侧边栏增加一个统计模块,需要按「劫持 / 登船」分类展示事件数量、占比和趋势。 - 筛选与查询条件
在列表页或地图上方增加筛选条件,允许用户勾选「只看劫持事件」或「只看登船事件」。 - 详情弹窗 / 事件卡片
点击地图点位后弹出的详情卡片,需要根据事件类型展示不同的图标、颜色或标题样式。 - 预警与高亮规则
某些类型(如“劫持”)需要在地图上以更醒目的颜色或形状展示,甚至触发闪烁、高亮或告警提示。
在这些场景中,页面形态各不相同,但它们指向的是同一类业务事实。
通过配置层:
只要项目中出现过“劫持”“登船”这类业务类型,就统一从这一份配置中获取它的语义、样式和规则。
这样新增一个业务类型时,只需要在配置层补充一次定义,就可以被图例、地图点位、统计、筛选、弹窗等多个模块同时复用,而不需要在各个页面中重复添加判断逻辑。
映射层:解耦业务语义与技术实现
为了让业务表达更直观,我刻意使用中文 key:
export const shapeMap = {
正方形: new URL("@/assets/svg/fangkuai.svg", import.meta.url).href,
梯形: new URL("@/assets/svg/tixing.svg", import.meta.url).href,
三角形: new URL("@/assets/svg/sanjiao.svg", import.meta.url).href,
圆形: new URL("@/assets/svg/yuanxing.svg", import.meta.url).href
}
export const colorMap = {
红色: "#E6342E",
橙色: "#FF7733",
深蓝: "#3232FF",
浅蓝: "#33C6FF"
}
映射层的意义在于:
- 业务侧关心的是“正方形”“红色”
- 技术实现可以随时替换,而不影响业务语义
这里需要特别理解产品经理的视角。
在产品的认知中,页面上的颜色通常只有:
红、橙、黄、绿、青、蓝、紫
它们是语义化的颜色,而不是具体的色值。
产品在沟通需求时,往往会这样描述:
- “这个红色是风险点”
- “这个蓝色不够明显,要再深一点”
- “能不能区分一下深蓝和浅蓝?”
- “红色和橙色的差异要再大一些,不然看不出来”
在这些讨论中,没有人关心 #E6342E 还是 #FF2D2D,
他们关心的是:
这个颜色在语义上是不是‘红’,在视觉上够不够区分。
这正是映射层存在的意义。
通过这种方式:
- 产品始终在使用“红色 / 深蓝 / 浅蓝”这样的业务语言
- 技术侧可以随时调整具体色值
- 甚至可以在不同主题、不同地图底色下统一替换整套颜色方案
更重要的是,当后续出现新的需求:
- “把所有‘红色风险点’再加深一点”
- “地图夜间模式需要一套新的颜色体系”
只需要修改映射层,而不需要触碰任何业务逻辑或页面代码。
映射层实际上承担的是一个角色:
“业务语义的稳定锚点”
只要这个锚点不变,
无论 UI 如何调整、设计如何迭代、技术实现如何替换,
业务层和逻辑层都不会被迫跟着改动。
逻辑层:规则的执行者
逻辑层负责将:
- 配置
- 数据
- 行为规则
组合起来执行。
比如地图的打点方法:
drawPoints({
layer: "homePageLayer",
list: africaPiracyIncidents,
setType: item => pointMap.海盗页[item.shijian_leixing],
setShape: item => pointMap.海盗页[item.shijian_leixing],
setSize: () => 12,
setColor: item => pointMap.海盗页[item.shijian_leixing],
fields: {
tooltip: "haidao_quyu",
lon: "longitude",
lat: "latitude"
}
})
它只负责一件事:
按照既定规则,执行渲染流程。
在逻辑层看来,所有点位都是“待渲染的数据集合”,
它接收到的输入,只包含:
- 坐标
- 需要展示的业务类型
- 渲染类型(pixel / icon / text)
- 点位的大小
- 已经映射完成的颜色、形状等表现参数
逻辑层只需要关心我打点的是“劫持”还是“登船”,打点多大什么形状什么颜色
把“怎么画”和“画什么”彻底分开
如果没有 drawPoints 这种统一入口,代码很容易演变成:
- 业务里判断形状
- 页面里判断颜色
- 各处散落着 drawCircle / drawRect
- 修改一种渲染规则,要全项修改
总结
这套结构的目标是解决一个现实问题:
当业务类型不断增加时,UI 复杂度不要随之失控。
当 UI 的复杂度来自规则和变化本身时,
最有效的方式就是:
让 UI 退回成结果,把复杂度交给数据和配置。