数据驱动编程:一种应对复杂业务的前端设计方式

24 阅读7分钟

最近在做一个军工的地图项目,我发现这个项目:

  • 业务类型多,而且会持续增长
  • 同一类数据,在不同场景下展示不同
  • 页面结构可能很简单,但背后的规则很复杂
  • 业务变化主要是改文案、调整颜色、换个顺序、加新类型、同一规则套到新页面
  • 业务逻辑需要“集中治理”:同一个状态,在 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 退回成结果,把复杂度交给数据和配置。