数万设备流畅渲染:我用 AntV L7 封装 Vue3 地图组件的血泪经验
3万条管道数据,第一次渲染花了8秒。优化后,800毫秒。这篇文章记录了我踩过的所有坑。
效果图
一个真实的问题
去年接手一个智慧供热项目。需求很明确:在地图上展示全市的供热管网——管道、阀门、换热站,还要有流向动画。
最终选了 AntV L7。原因很简单:国产开源,符合信创要求;底图可以随意切换,高德、百度、天地图都行;最关键的是——支持完全离线部署。
这个决定,让我踩了三个月的坑。
为什么 L7 适合这类项目
在深入代码之前,先说说我理解的 L7 核心优势。
国产化不是口号,是刚需。政府、能源、金融项目,信创是硬指标。L7 是蚂蚁金服开源的,中文文档完善,社区活跃,不用担心"用着用着突然收费"。
底图切换是个隐藏福利。开发阶段用高德地图,上线后切天地图或自建瓦片服务,代码几乎不用改。我们项目就是这样:内网环境用本地瓦片,外网演示用高德,一套代码两套环境。
离线部署能力救了我们的命。项目部署在政务内网,完全断网。L7 的 BlankMap 配合本地瓦片服务,完美解决。
// 离线部署的核心代码
import { Scene } from '@antv/l7'
import { Map as BlankMap } from '@antv/l7-maps'
const scene = new Scene({
id: 'map',
map: new BlankMap({
center: [116.397428, 39.90923],
zoom: 12,
}),
})
// 加载本地瓦片
scene.on('loaded', () => {
const tileLayer = new TileLayer({
source: {
data: 'http://内网服务器/tiles/{z}/{x}/{y}.png',
parser: { type: 'rasterTile' },
},
})
scene.addLayer(tileLayer)
})
组件架构:从混乱到清晰
最初的混乱
一开始,我把所有逻辑都塞在一个 Vue 文件里。2000 行代码,改一个地方,三个地方报错。
重构后的结构
l7mapRender/
├── index.vue # 主组件(300行)
├── composables/ # 图层渲染逻辑
│ ├── useDeviceRender.ts # 设备图层
│ ├── useLineRender.ts # 管道图层
│ └── ...
├── types/ # TypeScript 类型
└── utils/ # 工具函数
├── filter.ts # 过滤逻辑
└── color.ts # 颜色计算
核心思路:每个图层一个 composable,主组件只负责调度。
// 主组件的核心逻辑
function referRender() {
// 清除旧图层
layerMap.forEach((layer) => scene.removeLayer(layer))
// 按需渲染
for (const name of props.renderName) {
switch (name) {
case 'topologyDevices':
processDeviceData(scene, topologyKey)
break
case 'topologyLinesFlow':
processLineFlowData(scene, true, topologyKey)
break
}
}
}
性能优化:从8秒到800毫秒
这是我踩坑最多的地方。
坑一:ref 导致的卡顿
L7 的 Scene 对象内部属性极多。我用 ref 包了一层,Vue 对每个属性都做了响应式处理。
结果:每次地图移动,页面卡顿2秒。
解决:改用 shallowRef。
// ❌ 错误
const scene = ref<Scene | null>(null)
// ✅ 正确
const scene = shallowRef<Scene | null>(null)
性能提升:70%。
坑二:频繁创建图层
用户缩放地图时,我直接销毁旧图层、创建新图层。
结果:缩放5次,GPU 内存占用飙到 2GB。
解决:用 setData 更新数据,而不是重建图层。
// ❌ 错误
scene.removeLayer(oldLayer)
const newLayer = new LineLayer().source(newData)
scene.addLayer(newLayer)
// ✅ 正确
layer.setData(newData, { parser: { type: 'json', coordinates: 'latLng' } })
坑三:WebGL 内存泄漏
组件卸载后,GPU 内存没有释放。用户反复进出地图页面,浏览器直接崩溃。
解决:手动清理 WebGL 上下文。
onBeforeUnmount(() => {
if (scene.value) {
scene.value.removeAllLayer()
// 关键:释放 GPU 资源
const canvas = container?.querySelector('canvas')
if (canvas) {
const gl = canvas.getContext('webgl')
const ext = gl?.getExtension('WEBGL_lose_context')
ext?.loseContext()
canvas.remove()
}
scene.value.destroy()
}
})
坑四:没有过滤机制
3万条管道全部渲染,地图直接卡死。
解决:多维度过滤。
function filterDevice(item: TopologyDevice, bounds: MapBounds): boolean {
// 1. 状态过滤
if (item.userData?.status?.delete) return false
// 2. 视图范围过滤(只渲染可见区域)
if (!isInView(item.latLng, bounds)) return false
// 3. 渲染等级过滤(根据缩放级别)
if (mapZoom < item.mapGrade) return false
// 4. 业务过滤(供回水类型、区域等)
if (!matchBusinessFilter(item)) return false
return true
}
过滤后,平均渲染数量从 30000 降到 2000。
坑五:短管合并为长管渲染
这是最有效的一个优化,也是我之前在 ArcGIS + ECharts 管网渲染优化 中分享过的技巧。
问题:城市管网中,短管数量远多于长管。一条主干道上可能有几十条短管,每条都要单独渲染一个 LineLayer 图元。
思路:在低缩放级别时,把多条短管合并为一条长管显示。
关键约束:只有单条路径上的短管才能合并。为什么?因为单条路径上的管道流向必然一致,合并后不会出现流向矛盾。
// 短管合并的核心逻辑
function mergeShortLines(lines: TopologyLine[], mapZoom: number): LongLine[] {
const longLines: LongLine[] = []
const merged = new Set<string>()
for (const line of lines) {
if (merged.has(line.tpId)) continue
// 寻找可以合并的连续短管
const chain = findContinuousChain(line, lines)
if (chain.length >= 3) {
// 合并为长管
const longLine = {
tpId: `long_${line.tpId}`,
latLng: chain.flatMap(l => l.latLng),
userData: {
children: chain,
mapGrade: calculateMapGrade(chain), // 根据总长度计算显示等级
},
}
longLines.push(longLine)
chain.forEach(l => merged.add(l.tpId))
}
}
return longLines
}
渲染策略:
function processLineFlowData(scene: Scene, hasFlow: boolean, topologyKey?: string) {
const mapZoom = scene.getZoom()
const longLineConfig = ProjectConfig.renderConfig.longLineConfig
// 低缩放级别 → 使用长管模式
const useLongLineMode = longLineConfig.start && mapZoom <= longLineConfig.mapGrade
let lineList = []
if (useLongLineMode) {
// 合并短管,渲染长管
const longLines = publicTopology.getLongLines()
for (const longLine of longLines) {
lineList.push({
tpId: longLine.tpId,
latLng: longLine.latLng, // 合并后的坐标
color: getLongLineColor(longLine),
width: getLongLineWidth(longLine),
})
}
} else {
// 高缩放级别 → 渲染原始短管
const lines = publicTopology.getTopologyData().lines
for (const line of lines) {
lineList.push({
tpId: line.tpId,
latLng: line.latLng,
color: getLineColor(line),
width: getLineWidth(line),
})
}
}
// 创建图层...
}
效果对比:
| 场景 | 短管数量 | 合并后长管数量 | 渲染图元减少 |
|---|---|---|---|
| 全市视图(zoom=8) | 28000 | 3500 | 87% |
| 区域视图(zoom=12) | 15000 | 8000 | 47% |
| 街道视图(zoom=16) | 3000 | 3000 | 0%(不合并) |
这个优化让低缩放级别的渲染性能提升了 5 倍。
流向动画:L7 的杀手锏
管道流向动画是 L7 的亮点。实现原理很简单:
- 基础层:渲染所有管道,设置低透明度
- 流向层:只渲染有流向的管道,添加动画
// 基础层
const baseLayer = new LineLayer()
.source(lineData, { parser: { type: 'json', coordinates: 'latLng' } })
.shape('line')
.color('color')
.style({ opacity: 0.3 }) // 降低透明度
// 流向动画层
const flowLayer = new LineLayer()
.source(flowData, { parser: { type: 'json', coordinates: 'latLng' } })
.shape('line')
.animate({
interval: 1,
trailLength: 0.5,
duration: 1,
})
scene.addLayer(baseLayer)
scene.addLayer(flowLayer)
关键点:流向方向由坐标数组顺序决定。[起点, 终点] 和 [终点, 起点] 的动画方向相反。
底图切换:一套代码,多种环境
L7 的底图适配器设计得很优雅。切换底图只需改一行代码:
// 高德地图
import { GaodeMap } from '@antv/l7-maps'
// 百度地图
import { BaiduMap } from '@antv/l7-maps'
// 天地图
import { TMap } from '@antv/l7-maps'
// 空白底图(离线)
import { Map as BlankMap } from '@antv/l7-maps'
我们的项目配置:
const mapConfig = {
center: [87.700286, 43.759448],
zoom: 10.64,
// ... 其他配置
}
// 根据环境选择底图
let MapAdapter
if (isOffline) {
MapAdapter = new BlankMap(mapConfig)
} else if (useGaode) {
MapAdapter = new GaodeMap(mapConfig)
} else {
MapAdapter = new TMap(mapConfig)
}
const scene = new Scene({
id: 'map',
map: MapAdapter,
})
最终效果
优化后的性能数据:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首次渲染 | 8秒 | 800毫秒 |
| 缩放响应 | 2秒卡顿 | 流畅 |
| GPU 内存 | 持续增长 | 稳定在 200MB |
| 支持数据量 | 5000条 | 100000条 |
| 低缩放级别图元数 | 28000 | 3500(短管合并后) |
组件使用示例
完整代码已上传 GitHub:weiweiweigang/topo-l7map
<template>
<L7MapRender
ref="mapRef"
:render-name="['topologyDevices', 'topologyLinesFlow']"
:map-config="{ center: [116.4, 39.9], zoom: 12 }"
@layer-click="onDeviceClick"
/>
</template>
<script setup lang="ts">
import { L7MapRender } from '@/components/l7mapRender'
const mapRef = ref()
function onDeviceClick(data) {
console.log('点击了设备:', data.tpId)
}
// 外部触发重新渲染
function refresh() {
mapRef.value?.referRender()
}
</script>
总结
三个月踩坑,换来这些经验:
- shallowRef 不是可选项:L7 对象必须用 shallowRef,否则性能灾难
- setData 而非重建:更新数据用 setData,避免频繁创建销毁图层
- WebGL 必须手动清理:组件卸载时释放 GPU 资源,否则内存泄漏
- 过滤是性能的关键:只渲染可见区域的数据
- 短管合并是最有效的优化:低缩放级别下,合并短管可减少 80% 以上的渲染图元
- 底图切换是隐藏福利:一套代码适配多种环境
L7 不是完美的,文档有坑,API 有变动。但对于国产化要求、离线部署需求的项目,它是目前最好的选择。
相关链接:
- AntV L7 官方文档
- L7 GitHub 仓库
- L7 底图适配器文档
- 我之前的管网渲染优化文章(ArcGIS + ECharts 版本,短管合并思路的来源)
有问题欢迎评论区交流,我看到都会回复。