从零到一构建企业级 BI 可视化平台的技术实践

Hewitt 38 分钟阅读 5,536 字

初来公司,到手的第一个项目就是 BI,下面我将分享我们团队从零开始构建一个企业级商业智能(BI)数据可视化平台的完整技术实践,涵盖架构设计、核心技术选型、关键问题解决方案以及踩坑经验总结。

背景与挑战

我们面临的核心挑战包括:

  • 功能复杂性:需要支持 20+种图表类型,满足不同业务场景
  • 交互丰富性:实现下钻、联动、筛选等高级分析功能
  • 性能要求:处理大数据量、多图表及其联动时保持流畅的用户体验
  • 扩展性:支持快速添加新的图表类型和业务功能

最终公司的前辈构建了一个基于 Vue2.js 的现代化 BI 平台,我们在此基础上进行改造和优化。

技术架构设计

整体架构思路

我们采用了经典的三面板布局设计,结合分层架构模式,确保各层职责清晰、低耦合高内聚:

三面板布局架构

┌─────────────────────────────────────────────────────────────────┐
│                        BI 可视化平台                            │
├──────────────┬──────────────────────────────┬───────────────────┤
│              │                              │                   │
│   左侧面板   │          中间画布            │    右侧面板       │
│              │                              │                   │
│ ┌──────────┐ │ ┌──────────────────────────┐ │ ┌───────────────┐ │
│ │组件库    │ │ │     画布容器             │ │ │  配置面板     │ │
│ │- 图表组件│ │ │  ┌─────────────────────┐ │ │ │ ┌───────────┐ │ │
│ │- 控件组件│ │ │  │  avue-draggable     │ │ │ │ │数据配置  │ │ │
│ │- 筛选组件│ │ │  │  (可拖拽图表组件)   │ │ │ │ │样式配置  │ │ │
│ └──────────┘ │ │  └─────────────────────┘ │ │ │ │动作配置  │ │ │
│              │ │                          │ │ │ └───────────┘ │ │
│ ┌──────────┐ │ │ ┌─────────────────────┐  │ │ └───────────────┘ │
│ │画板列表  │ │ │ │     图表实例        │  │ │                   │
│ │- 页面管理│ │ │ │  - ECharts图表      │  │ │                   │
│ │- 画板切换│ │ │ │  - 表格组件         │  │ │                   │
│ └──────────┘ │ │ │  - 控件组件         │  │ │                   │
│              │ │ └─────────────────────┘  │ │                   │
│              │ └──────────────────────────┘ │                   │
└──────────────┴──────────────────────────────┴───────────────────┘

分层架构设计

┌─────────────────────────────────────┐
│           展示层 (Presentation)      │
│  ┌─────────────┐ ┌─────────────────┐ │
│  │  组件面板   │ │   配置面板      │ │
│  │  画布容器   │ │   图表组件      │ │
│  └─────────────┘ └─────────────────┘ │
├─────────────────────────────────────┤
│           业务逻辑层 (Service)       │
│  ┌─────────────┐ ┌─────────────────┐ │
│  │ 图表服务    │ │   数据服务      │ │
│  │ 拖拽服务    │ │   筛选服务      │ │
│  └─────────────┘ └─────────────────┘ │
├─────────────────────────────────────┤
│           状态管理层 (Store)         │
│  ┌─────────────┐ ┌─────────────────┐ │
│  │ GlobalStorer│ │   Mutations     │ │
│  └─────────────┘ └─────────────────┘ │
├─────────────────────────────────────┤
│           数据访问层 (API)           │
│  ┌─────────────┐ ┌─────────────────┐ │
│  │  数据接口   │ │   配置接口      │ │
│  └─────────────┘ └─────────────────┘ │
└─────────────────────────────────────┘

核心技术栈选型

经过充分的技术调研,我们选择了以下技术栈:

  • 前端框架:Vue 2.x + Vue Router 3.x
  • UI 组件库:Element UI 2.x
  • 图表库:ECharts 5.x
  • 状态管理:自定义 Observable 状态管理
  • 构建工具:Vue CLI 4.x + Webpack
  • 编程语言:TypeScript

选型特点:

  1. Vue.js:渐进式框架,学习成本低,生态成熟
  2. ECharts:功能强大,图表类型丰富,性能优秀
  3. TypeScript:提供类型安全,提升代码质量
  4. 自定义状态管理:相比 Vuex,更适合复杂的图表交互场景

三面板核心实现

1. 左侧组件面板实现

组件库设计

左侧面板包含两个核心部分:组件库和画板列表。组件库提供丰富的图表类型供用户选择:

// configs/charts.ts - 图表组件配置
export const groupCharts: GroupChart[] = [
	{
		title: '柱状图',
		id: 'bar',
		charts: [
			{
				id: 'bar',
				name: '基础柱状图',
				imgUrl: require('@/assets/imgs/bar.png')
			},
			{
				id: 'bar-stack',
				name: '堆叠柱状图',
				imgUrl: require('@/assets/imgs/bar-stack.png')
			}
		]
	},
	{
		title: '控件',
		id: 'other',
		charts: [
			{
				id: 'date-filter',
				name: '日期筛选',
				imgUrl: require('@/assets/imgs/time-filter.png')
			},
			{
				id: 'button',
				name: '按钮',
				imgUrl: require('@/assets/imgs/button.png')
			}
		]
	}
	// ... 更多图表类型
]

组件面板 UI 实现

<!-- designer-nav.vue - 组件选择面板 -->
<template>
	<div class="designer-nav">
		<ul>
			<li v-for="chart in chartsNav" :key="chart.id" @click="createChart(chart)">
				<el-tooltip :content="chart.name" placement="top">
					<a href="javascript:void(0)">
						<img :src="chart.imgUrl" />
					</a>
				</el-tooltip>
			</li>

			<!-- 更多图表类型下拉菜单 -->
			<li @mouseover="showAllType = true" @mouseleave="showAllType = false">
				<div v-show="showAllType" class="designer-nav-dropdown">
					<div v-for="group in groupCharts" :key="group.id">
						<p>{{ group.title }}</p>
						<ul>
							<li v-for="chart in group.charts" :key="chart.id" @click="createChart(chart)">
								<img :src="chart.imgUrl" />
								<p>{{ chart.name }}</p>
							</li>
						</ul>
					</div>
				</div>
			</li>
		</ul>
	</div>
</template>

画板列表管理

<!-- painter-list.vue - 画板管理 -->
<template>
	<div class="painter-list-wrapper">
		<draggable class="painter-list" v-model="painterList" @end="dragEnd">
			<li
				v-for="(painter, i) in painterList"
				:key="painter.id"
				:class="{ active: painter.active }"
				@click="painterSelect(i)"
			>
				<img src="./imgs/painter.png" />
				<input v-model="painter.name" @blur="modifyPainterName(painter)" />
				<!-- 操作菜单 -->
				<div class="operation-wrapper">
					<a @click.stop="toggleOperation($event, painter)">
						<img src="./imgs/operation.png" />
					</a>
				</div>
			</li>
		</draggable>
	</div>
</template>

2. 中间画布实现

可拖拽画布容器

中间画布是整个系统的核心,使用 avue-draggable 组件实现拖拽功能:

<!-- subgroup.vue - 画布容器 -->
<template>
	<div ref="subgroupWrapper" class="subgroup-wrapper">
		<avue-draggable
			v-for="(item, i) in chartsList"
			:key="item.id + currentPainter.id"
			v-bind="item"
			:gridAreaOption="item.gridAreaOption"
			:current="currentChart.id === item.id"
			:isEdit="isEdit"
			@focus="handleFocus"
			@changedGridArea="changedGridArea"
			@gridAreaChange="gridAreaChange"
		>
			<!-- 图表组件渲染 -->
			<avue-echart-chart
				v-if="item.isChart"
				:chart="item"
				:data="item.chartConfig.data"
				:option="item.chartConfig.options"
				@chartClick="chartClick"
			/>

			<!-- 表格组件 -->
			<bi-table v-else-if="item.type === TABLE" :data="item.tableData" :columns="item.columns" />

			<!-- 其他组件类型... -->
		</avue-draggable>

		<!-- 拖拽阴影提示 -->
		<div
			class="subgroup-shadow-wrapper"
			:style="shadowGridAreaStyle"
			v-if="isEdit"
			v-show="shadowShow"
		>
			<div class="subgroup-shadow"></div>
		</div>
	</div>
</template>

网格布局系统

画布采用网格布局系统,支持精确的组件定位:

// 网格区域配置
interface GridArea {
	rowStart: number // 起始行
	columnStart: number // 起始列
	rowSpan: number // 行跨度
	columnSpan: number // 列跨度
}

// 网格样式计算
const gridAreaStyle = computed(() => {
	const { rowStart, columnStart, rowSpan, columnSpan } = gridAreaOption
	return {
		gridArea: `${rowStart + 1} / ${columnStart + 1} / span ${rowSpan} / span ${columnSpan}`
	}
})

3. 右侧配置面板实现

配置面板结构

右侧配置面板采用选项卡设计,包含数据、样式、动作三个配置模块:

<!-- board.vue - 配置面板主容器 -->
<template>
	<div class="build-board">
		<div v-show="chartBoardShow" class="board-head">
			<span>图表设计</span>
		</div>

		<div v-show="chartBoardShow" class="board-tabs">
			<el-tabs v-model="currentTab">
				<!-- 数据配置 -->
				<el-tab-pane v-if="dataBoardShow" label="数据" name="data">
					<board-data></board-data>
				</el-tab-pane>

				<!-- 样式配置 -->
				<el-tab-pane v-if="styleBoardShow" label="样式" name="style">
					<board-style></board-style>
				</el-tab-pane>

				<!-- 动作配置 -->
				<el-tab-pane v-if="actionBoardShow" label="动作" name="action">
					<board-action></board-action>
				</el-tab-pane>
			</el-tabs>
		</div>
	</div>
</template>

数据配置面板

数据配置面板支持拖拽字段配置,实现维度和度量的灵活组合:

<!-- board-data-panel.vue - 数据配置 -->
<template>
	<div class="board-data-panel-wrapper">
		<div class="board-data-panel">
			<ul>
				<!-- 度量配置 -->
				<data-field
					v-if="chartSettingsShow"
					v-model="measureEles"
					title="值轴/度量"
					type="measure"
					:handleOpenFilterDialog="handleOpenFilterDialog"
				/>

				<!-- 维度配置 -->
				<data-field
					v-if="normalDimShow"
					v-model="dimensionEles"
					title="类别轴/维度"
					type="dimension"
					:num="1"
					:hasDrill="true"
					:drillDim="drillDim"
				/>

				<!-- 筛选配置 -->
				<data-field
					v-if="filterShow"
					v-model="filterEles"
					title="筛选"
					:handleOpenFilterDialog="handleOpenFilterDialog"
				/>
			</ul>

			<div class="bottom-menus">
				<a class="refresh" @click="refresh()">更新</a>
			</div>
		</div>
	</div>
</template>

拖拽字段组件

数据字段支持从左侧数据源拖拽到配置区域:

<!-- data-field.vue - 字段拖拽组件 -->
<template>
	<ul class="main">
		<li class="config-data-item">
			<div class="config-data-item_head">
				<span class="config-data-item_head-title">{{ title }}</span>
			</div>

			<ul class="field-box" @drop="handleDrop" @dragover="handleDragOver">
				<draggable v-model="dragColumns" group="field">
					<li v-for="(ele, i) in dragColumns" :key="ele.id" class="field-box-component">
						<img
							v-if="ele.dataType === 'number'"
							src="@/pages/build/components/board/imgs/number.png"
						/>
						<span>{{ ele.columnDesc }}</span>

						<div class="field-operation">
							<el-tooltip content="删除" placement="top">
								<a @click="deleteField(i)">
									<img src="@/pages/build/components/board/imgs/delete.png" />
								</a>
							</el-tooltip>
						</div>
					</li>
				</draggable>
			</ul>
		</li>
	</ul>
</template>

核心技术实现

1. 图表系统架构设计

图表基类设计

我们设计了一个通用的图表基类,所有图表类型都继承自这个基类:

// configs/baseConfigs/base.ts
export default class Base {
	id: string
	name: string
	type: string
	isChart: boolean
	gridAreaOption: GridArea

	constructor() {
		this.id = uuid.v4()
		this.isChart = false
		this.gridAreaOption = {
			rowStart: 0,
			columnStart: 0,
			rowSpan: 4,
			columnSpan: 6
		}
	}
}

具体图表实现

以柱状图为例,展示图表类的具体实现:

// configs/baseConfigs/bar.ts
import Base from './base'
import { BAR } from './consts'

export default class Bar extends Base {
	dimensions: Dimension[]
	measures: Measure[]
	chartConfig: ChartConfig
	linkageActions: Action[]

	constructor() {
		super()
		this.isChart = true
		this.name = '柱图'
		this.type = BAR
		this.dimensions = []
		this.measures = []
		this.chartConfig = {
			data: {
				categories: [],
				series: []
			},
			options: {}
		}
		this.linkageActions = []
	}
}

这种设计的优势:

  • 统一接口:所有图表都有相同的基础属性和方法
  • 易于扩展:新增图表类型只需继承基类并实现特有逻辑
  • 类型安全:TypeScript 提供编译时类型检查

6. 自定义状态管理系统

为什么选择自定义状态管理?

在复杂的 BI 系统中,我们发现传统的 Vuex 存在一些局限性:

  1. 状态结构复杂:图表间的关联关系难以用扁平化状态表示
  2. 性能问题:频繁的状态更新可能导致不必要的组件重渲染
  3. 调试困难:复杂的异步操作链路难以追踪
  4. 三面板协同:需要处理左侧组件库、中间画布、右侧配置面板的复杂交互

GlobalStorer 架构实现

我们设计了基于 Observable 模式的全局状态管理系统:

// storer/index.ts
class GlobalStorer {
	store: StoreState
	mutations: Mutations

	constructor() {
		this.store = reactive({
			// 画板相关
			painters: [], // 画板列表
			currentPainter: null, // 当前画板
			analysisBoardId: '', // 分析板ID

			// 图表相关
			charts: [], // 图表列表
			currentChart: null, // 当前选中图表
			relativeCharts: [], // 关联图表

			// 数据源相关
			dataSources: [], // 数据源列表
			dataSourceId: '', // 当前数据源ID

			// 筛选器相关
			globalFilter: {}, // 全局筛选器
			filters: {}, // 局部筛选器

			// 导航相关
			globalNav: [] // 全局导航配置
		})

		this.mutations = {
			// 画板操作
			setPainters: (painters) => {
				this.store.painters = painters
			},
			setCurrentPainter: (painter) => {
				this.store.currentPainter = painter
			},

			// 图表操作
			setCharts: (charts) => {
				this.store.charts = charts
			},
			setCurrentChart: (chart) => {
				this.store.currentChart = chart
			},

			// 数据源操作
			setDataSources: (dataSources) => {
				this.store.dataSources = dataSources
			},

			// 筛选器操作
			setGlobalFilter: (filter) => {
				this.store.globalFilter = filter
			},

			// 缓存操作
			setCachePainters: (painter) => {
				// 缓存画板数据,用于性能优化
				const existingIndex = this.store.painters.findIndex((p) => p.id === painter.id)
				if (existingIndex !== -1) {
					this.store.painters[existingIndex] = painter
				}
			}
		}
	}

	// 单例模式
	static instance: GlobalStorer
	static getGlobalStorer() {
		if (!GlobalStorer.instance) {
			GlobalStorer.instance = new GlobalStorer()
		}
		return GlobalStorer.instance
	}
}

export default GlobalStorer

三面板状态协同

状态管理系统需要协调三个面板的交互:

// 左侧面板 - 组件创建时更新画布状态
const createChart = (chartType) => {
	const newChart = new ChartConfig(chartType)
	const charts = [...store.charts, newChart]
	mutations.setCharts(charts)
	mutations.setCurrentChart(newChart)
}

// 中间画布 - 选中图表时更新配置面板
const handleChartFocus = (chartId) => {
	const chart = store.charts.find((c) => c.id === chartId)
	mutations.setCurrentChart(chart)
}

// 右侧配置面板 - 配置变更时更新画布
const updateChartConfig = (config) => {
	const charts = store.charts.map((chart) =>
		chart.id === store.currentChart.id ? { ...chart, ...config } : chart
	)
	mutations.setCharts(charts)
}

核心优势:

  • 响应式更新:基于 Vue 3 的响应式系统,自动追踪依赖关系
  • 单例模式:确保全局状态一致性,避免状态冲突
  • 简化调试:直接的状态变更,易于追踪和调试
  • 高性能:精确的依赖追踪,减少不必要的重渲染
  • 三面板协同:专门设计用于处理复杂的面板间交互

7. 数据处理与图表渲染

三面板数据流架构

数据在三面板之间的完整流转过程:

// 数据流:左侧数据源 → 右侧配置 → 中间画布渲染

// 1. 左侧数据源面板 - 提供可拖拽的字段
// board-data-origin.vue
export default {
  async mounted() {
    // 获取数据源结构
    const { dimensionTree, measures, tableInfo } = await dataService.getDataDetail(dataSourceId);
    this.dimensionTree = dimensionTree; // 维度树
    this.measures = measures;           // 度量列表
    this.tableInfo = tableInfo;         // 表信息
  },

  methods: {
    // 处理字段拖拽开始
    handleDimDragStart(e, dim) {
      e.dataTransfer.setData('DimEle', JSON.stringify(dim));
    },

    handleMeasureDragStart(e, measure) {
      e.dataTransfer.setData('MeasureEle', JSON.stringify(measure));
      e.dataTransfer.setData('tableInfo', JSON.stringify({id: this.tableInfo.id}));
    }
  }
}

// 2. 右侧配置面板 - 接收字段并配置图表
// board-data-panel.vue
export default {
  methods: {
    // 字段配置完成后刷新图表
    async refresh() {
      const { echartData, inputFilterDims } = await chartDataService.refreshChart(
        this.dataSourceId,
        this.tableInfo,
        this.dimensionEles,   // 从拖拽配置的维度
        this.measureEles,     // 从拖拽配置的度量
        this.filterEles,      // 筛选条件
        this.currentChart,
        { store: this.storer.store }
      );

      // 更新图表数据到状态管理
      this.updateChartData(echartData);
    }
  }
}

// 3. 中间画布 - 渲染图表
// subgroup.vue
<template>
  <avue-draggable v-for="item in chartsList" :key="item.id">
    <avue-echart-chart
      v-if="item.isChart"
      :data='item.chartConfig.data'      // 来自右侧配置的数据
      :option='item.chartConfig.options'  // 样式配置
      @chartClick='chartClick'            // 图表交互事件
    />
  </avue-draggable>
</template>

数据处理服务层

核心的数据处理服务,负责数据格式化和图表适配:

// services/chartData.service.ts
export async function refreshChart(
	dataSourceId,
	tableInfo,
	dimensions,
	measures,
	filters,
	currentChart,
	{ store }
) {
	// 1. 构建查询参数
	const { data, inputFilterDims } = await getQueryParams(
		dataSourceId,
		tableInfo,
		dimensions,
		measures,
		filters,
		currentChart,
		{ store }
	)

	// 2. 数据格式化 - 根据图表类型适配
	const { echartData } = await chartDataFormat(data?.list || [], measures, dimensions, currentChart)

	// 3. 数据过滤处理
	filterChartData(echartData)

	return { echartData, inputFilterDims }
}

图表数据格式化

不同图表类型需要不同的数据格式,我们实现了统一的格式化处理:

// utils/chartDataFormat.ts
export default async function chartDataFormat(data, measures, dimensions, currentChart) {
	const measureIds = measures.map((measure) => measure.columnName)
	const dimensionIds = dimensions.map((dimension) => dimension.columnName)

	// 获取分类数据
	let categories = getCategories(data, dimensionIds)

	// 根据图表类型处理数据
	if (isBarLineChart(currentChart.type)) {
		return getBarLineData(series, categories, measures)
	}

	if (isMapChart(currentChart.type)) {
		return await getMapChart(/* 参数 */)
	}

	if (isScatterChart(currentChart.type)) {
		return getScatterData(measures, dimensions, data)
	}

	// 默认处理
	let series = getSeries(measureIds, measures, data)

	return {
		echartData: {
			categories,
			series
		}
	}
}

4. 拖拽交互系统实现

数据源拖拽机制

系统支持从左侧数据源面板拖拽字段到右侧配置面板,实现直观的数据配置:

// board-data-origin.vue - 数据源拖拽
export default {
	methods: {
		// 处理维度拖拽事件
		handleDimDragStart(e, dim) {
			e.dataTransfer.setData('DimEle', JSON.stringify(dim))
		},

		// 处理度量拖拽事件
		handleMeasureDragStart(e, measure) {
			e.dataTransfer.setData('MeasureEle', JSON.stringify(measure))
			e.dataTransfer.setData('tableInfo', JSON.stringify({ id: this.tableInfo.id }))
		}
	}
}

配置面板拖拽接收

配置面板通过 drop 事件接收拖拽的字段数据:

// data-field.vue - 字段拖拽接收
export default {
	methods: {
		// 处理拖拽over事件
		handleDragOver(e) {
			e.preventDefault()
			e.dataTransfer.dropEffect = 'copy'
		},

		// 处理字段drop事件
		handleDrop(e) {
			e.preventDefault()
			e.stopPropagation()

			try {
				// 根据字段类型获取数据
				const dimData = e?.dataTransfer?.getData('DimEle')
				const measureData = e?.dataTransfer?.getData('MeasureEle')

				const ele = JSON.parse(dimData || measureData)

				// 验证字段类型匹配
				if (this.type === 'dimension' && !dimData) return
				if (this.type === 'measure' && !measureData) return

				// 添加到字段列表
				const newFields = _.cloneDeep(this.dragColumns)
				newFields.push(ele)

				// 去重处理
				this.dragColumns = _.uniqWith(newFields, (a, b) => a.id === b.id)

				this.$emit('change', this.dragColumns)
			} catch (e) {
				console.log('拖拽数据解析失败:', e)
			}
		}
	}
}

画布组件拖拽

画布支持组件的拖拽移动和尺寸调整:

// subgroup.vue - 画布拖拽处理
export default {
	methods: {
		// 组件网格区域变化
		changedGridArea(gridAreaOption, id) {
			const charts = _.cloneDeep(this.charts)
			const currentChartItem = charts.find((chart) => chart.id === id)

			if (currentChartItem) {
				currentChartItem.gridAreaOption = gridAreaOption
				this.storer?.mutations.setCharts(charts)
			}
		},

		// 实时网格变化反馈
		gridAreaChange(gridAreaOption) {
			this.shadowGridAreaStyle = {
				gridArea: `${gridAreaOption.rowStart + 1} / ${
					gridAreaOption.columnStart + 1
				} / span ${gridAreaOption.rowSpan} / span ${gridAreaOption.columnSpan}`
			}
			this.shadowShow = true
		}
	}
}

5. 复杂交互系统实现

图表联动机制

图表联动是 BI 系统的核心功能之一,我们的实现方案:

// services/subgroup.service.ts
export const handleLinkageAction = async (currentChart, dimensionValue, linkageAction, storer) => {
	// 1. 解析维度值
	let dimValues = []
	if ([SCATTER, PIPELINE].includes(currentChart.type)) {
		dimValues = dimensionValue
	} else {
		dimValues = dimensionValue?.split(dimensionSplitSymbol) ?? []
	}

	// 2. 构建联动参数
	const attachValues = getAttachValues(currentChart, dimValues)
	const action = getAction(linkageAction, attachValues)

	// 3. 设置关联图表
	setRelativeCharts(action, 'linkage', currentChart, storer)

	// 4. 刷新关联图表
	const currentAttachCharts = action.attachPainters.find(
		(item) => item.id === storer?.store?.currentPainter?.id
	)?.charts

	for (let i = 0; i < currentAttachCharts?.length; i++) {
		const chartId = currentAttachCharts[i]
		const attachChart = storer?.store?.charts.find((item) => item.id === chartId)

		if (!_.isEmpty(attachChart)) {
			const { echartData, inputFilterDims } = await chartDataService
				.refreshChart
				/* 参数 */
				()
			attachChart.actionValues = filterService.getActionValueTags(
				storer?.store,
				attachChart,
				inputFilterDims
			)
			refreshChartData(attachChart, echartData)
		}
	}
}

下钻分析实现

下钻分析允许用户逐层深入数据细节:

export const handleDrill = async (currentChart, chartTarget, drills, storer) => {
	// 1. 获取当前下钻层级
	const currentDrillIndex = drills.findIndex((drill) => drill.current)
	if (currentDrillIndex === drills.length - 1) {
		return // 已到最深层
	}

	// 2. 获取维度值
	const measureVal = getMeasureVal(currentChart, chartTarget)
	const lastCurrentIndex = drills.findIndex((drill) => !!drill.current)
	drills[lastCurrentIndex].value = measureVal

	// 3. 切换到下一层
	let currentIndex = lastCurrentIndex + 1
	const dimensions = [drills[currentIndex]]

	drills.forEach((drill, i) => {
		drill.current = i === currentIndex
	})

	// 4. 刷新图表数据
	const measures = getMeasures(currentChart)
	const { echartData } = await chartDataService
		.refreshChart
		/* 参数 */
		()

	// 5. 更新状态
	const charts = _.cloneDeep(storer?.store?.charts)
	const currentChartItem = charts.find((chart) => chart.id === storer?.store?.currentChart.id)
	currentChartItem.drills = drills
	currentChartItem.dimensions = dimensions
	refreshChartData(currentChartItem, echartData)
	storer?.mutations.setCurrentChart(currentChartItem)
	storer?.mutations.setCharts(charts)
}

性能优化实践

1. 数据缓存策略

// services/data.service.ts
export const getDataDetail = async (analysisModelId: string) => {
	// 检查缓存
	const cacheData = sessionStorage.getItem(`tree_${analysisModelId}`)
	if (cacheData) {
		return JSON.parse(cacheData)
	}

	// 获取数据
	const [, res] = await modelDetail({ analysisModelId })
	if (res?.data) {
		const resData = processData(res.data)
		// 缓存数据
		sessionStorage.setItem(`tree_${analysisModelId}`, JSON.stringify(resData))
		return resData
	}
}

2. 大数据量渲染优化

// 虚拟滚动实现
const virtualScrollConfig = {
	itemHeight: 40,
	visibleCount: Math.ceil(containerHeight / 40),
	bufferCount: 5
}

// 只渲染可见区域的数据
const visibleData = computed(() => {
	const start = Math.max(0, scrollTop.value - bufferCount)
	const end = Math.min(data.length, start + visibleCount + bufferCount * 2)
	return data.slice(start, end)
})

3. 图表渲染性能优化

// 防抖处理图表更新
const debouncedUpdate = _.debounce((chart, data) => {
	chart.chartConfig.data = data
}, 100)

// 懒加载图表组件
const ChartComponent = () => import(`@/components/charts/${chartType}`)

项目架构演进

初期架构问题

在项目初期,我们遇到了一些架构问题:

  1. 组件耦合度高:图表组件直接调用 API,难以复用
  2. 状态管理混乱:没有统一的状态管理机制
  3. 性能问题:大量图表同时渲染时页面卡顿

重构方案

我们进行了系统性的重构:

// 重构前:组件直接调用 API
export default {
	async mounted() {
		const data = await axios.get('/api/chart-data')
		this.chartData = data
	}
}

// 重构后:通过服务层抽象
export default {
	async mounted() {
		const data = await chartDataService.getChartData(this.chartConfig)
		this.chartData = data
	}
}

服务层设计

我们引入了完整的服务层架构:

// services/index.js
export { default as chartDataService } from './chartData.service'
export { default as filterService } from './filter.service'
export { default as dataService } from './data.service'
export { default as chartService } from './chart.service'

每个服务都有明确的职责划分:

  • chartDataService:图表数据处理
  • filterService:筛选器逻辑
  • dataService:数据源管理
  • chartService:图表生命周期管理

踩坑经验总结

1. ECharts 内存泄漏问题

问题描述:频繁切换图表时出现内存泄漏

解决方案

// 组件销毁时正确清理 ECharts 实例
beforeDestroy() {
  if (this.chartInstance) {
    this.chartInstance.dispose()
    this.chartInstance = null
  }
}

2. 大数据量渲染性能问题

问题描述:数据量超过 1 万条时页面卡顿严重

解决方案

  • 实现数据分页加载
  • 使用虚拟滚动技术
  • 图表数据采样显示

3. 状态管理复杂度问题

问题描述:图表间联动关系复杂,状态难以维护

解决方案

// 引入关系图谱管理联动关系
const setRelativeCharts = (action, actionType, currentChart, storer) => {
	const actionCharts = action.attachPainters?.map((chartId) => ({
		chartId,
		attachValues: action?.attachValues.map((item) => ({
			...item,
			actionType,
			originChartId: currentChart?.id,
			relativeId: `${currentChart.id}_${actionType}_${chartId}_${item.id}`
		}))
	}))

	// 更新关联关系
	storer?.mutations?.setRelativeCharts(actionCharts)
}

4. TypeScript 类型定义问题

问题描述:复杂的图表配置类型定义困难

解决方案

// 使用泛型和条件类型
interface ChartConfig<T extends ChartType> {
	type: T
	data: T extends 'bar' ? BarData : T extends 'line' ? LineData : T extends 'pie' ? PieData : any
	options: ChartOptions<T>
}

企业级特性实现

1. 多租户支持

// 租户隔离中间件
const tenantMiddleware = (req, res, next) => {
	const tenantId = req.headers['x-tenant-id']
	req.tenantId = tenantId
	next()
}

// 数据查询时自动添加租户过滤
const addTenantFilter = (query, tenantId) => {
	return {
		...query,
		filters: [...query.filters, { field: 'tenant_id', value: tenantId }]
	}
}

2. 权限管理

// 基于角色的权限控制
const hasPermission = (user, resource, action) => {
	return user.roles.some((role) =>
		role.permissions.some(
			(permission) => permission.resource === resource && permission.actions.includes(action)
		)
	)
}

// 组件级权限控制
const PermissionWrapper = {
	functional: true,
	render(h, { props, children }) {
		if (hasPermission(props.user, props.resource, props.action)) {
			return children
		}
		return null
	}
}

部署与运维

1. 构建优化

// vue.config.js
module.exports = {
	configureWebpack: {
		optimization: {
			splitChunks: {
				chunks: 'all',
				minSize: 300000,
				maxInitialRequests: 5,
				cacheGroups: {
					vendor: {
						test: /[\\/]node_modules[\\/]/,
						name(module) {
							const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1]
							return packageName.replace('@', '')
						}
					}
				}
			}
		}
	}
}

2. Docker 容器化

# Dockerfile
FROM node:14-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

3. 监控告警

// 性能监控
const performanceMonitor = {
	trackChartRender(chartId, renderTime) {
		if (renderTime > 1000) {
			console.warn(`Chart ${chartId} render time: ${renderTime}ms`)
			// 发送告警
			this.sendAlert('SLOW_RENDER', { chartId, renderTime })
		}
	},

	trackError(error, context) {
		// 错误上报
		this.reportError({
			message: error.message,
			stack: error.stack,
			context,
			timestamp: Date.now()
		})
	}
}

总结与展望

项目成果

经过一年多的开发和迭代,我们成功构建了一个功能完整的企业级 BI 平台:

三面板架构优势

  • 直观的用户体验:左侧组件库 + 中间画布 + 右侧配置的经典布局,符合用户操作习惯
  • 高效的工作流程:拖拽式操作,从组件选择到数据配置再到样式调整,一气呵成
  • 强大的交互能力:支持组件间联动、下钻分析、实时筛选等复杂 BI 功能
  • 灵活的扩展性:模块化设计,易于添加新的图表类型和功能组件

核心功能特性

  • 功能完整性:支持 20+ 种图表类型,覆盖柱状图、折线图、饼图、散点图、地图等主流场景
  • 性能表现:支持万级数据量的流畅渲染,优化的拖拽和网格布局系统
  • 用户体验:所见即所得的设计器,直观的拖拽式数据配置
  • 企业级特性:多租户、权限管理、高可用部署,满足企业级应用需求

技术收获

  1. 三面板架构设计:深入理解经典 BI 设计器的架构模式和交互逻辑
  2. 拖拽交互系统:掌握复杂拖拽场景的技术实现,包括数据传递和状态同步
  3. 自定义状态管理:针对复杂业务场景设计专用状态管理系统
  4. 组件化架构:构建可复用、可扩展的图表组件体系
  5. 性能优化实践:大数据量场景下的前端渲染优化和交互优化

未来规划

  1. 技术升级

    • 迁移到 Vue 3.x,利用 Composition API 优化逻辑复用
    • 引入 WebAssembly 提升数据处理性能
    • 集成 WebGL 支持更复杂的 3D 可视化
  2. 功能扩展

    • 实时数据流处理
    • 移动端适配
    • 图表组件库开源
  3. 开源计划

    • 提取通用组件库开源
    • 分享最佳实践和设计模式

写在最后

构建企业级 BI 平台是一个复杂的系统工程,需要在功能完整性、性能、用户体验和技术架构之间找到平衡。希望我们的实践经验能为同样在做数据可视化项目的团队提供一些参考和启发。

技术选型没有银弹,架构设计需要结合具体业务场景。最重要的是保持技术敏感度,持续学习和改进,才能构建出真正有价值的产品。