前言
前两篇我们搞定了地图初始化和基础交互,但真实的业务地图数据都在后端:几千条管线、地块、监测点位存在数据库里,前端怎么高效拿到、怎么渲染、怎么做空间查询?
本篇讲透一套最经典的开源全栈方案:
前端 Vue + OpenLayers → HTTP → GeoServer(发布服务)→ PostgreSQL + PostGIS(空间数据库)
外加两个高频组件完整代码:
- ol-ext 量算(测距、测面积、面积单位)
- 点聚合 Cluster(上万 POI 不卡顿)
工程化能直接抄,适合水利、管线、自然资源、应急项目。
一、全栈架构图
┌─────────────────────────────┐
│ 前端 Vue3 + OpenLayers │
│ ┌─ WMS 底图/影像 ────────┐ │
│ ├─ WFS 矢量(点击/查询) ──┤ │ HTTP/JSON
│ ├─ ol-ext 量算 │ │ ────────────▶ GeoServer(8080)
│ └─ Cluster 点聚合 │ │ │ JDBC
└─────────────────────────────┘ │
PostgreSQL + PostGIS
(管线/地块/监测点 空间表)
分工一句话:
- PostGIS :存空间数据,做复杂空间计算(
ST_Intersects、ST_Buffer) - GeoServer:把空间表发布成标准 OGC 服务(WMS 瓦片图、WFS 矢量)
- OpenLayers:前端渲染 + 交互 + 轻量计算
大数据空间分析必须放 PostGIS,前端只做小数据量的 Turf。
二、后端准备
1. 安装 PostgreSQL + PostGIS
# Ubuntu/Debian
sudo apt install postgresql postgis
# 创建空间数据库
sudo -u postgres psql -c "CREATE DATABASE gisdb;"
sudo -u postgres psql -d gisdb -c "CREATE EXTENSION postgis;"
2. 建空间表(管线示例)
-- 开启空间扩展
CREATE EXTENSION IF NOT EXISTS postgis;
-- 管线段表
CREATE TABLE pipe_line (
id SERIAL PRIMARY KEY,
name VARCHAR(50),
code VARCHAR(20),
diameter NUMERIC(10,2), -- 管径mm
geom geometry(LineString, 4326) -- 空间字段,WGS84
);
-- 监测点位表
CREATE TABLE monitor_point (
id SERIAL PRIMARY KEY,
name VARCHAR(50),
status INT DEFAULT 1,
geom geometry(Point, 4326)
);
-- 插入测试数据
INSERT INTO pipe_line (name, code, diameter, geom) VALUES
('主管道A', 'P-001', 800, ST_GeomFromText('LINESTRING(108.95 34.27, 108.97 34.28, 108.99 34.26)', 4326)),
('支管B', 'P-002', 400, ST_GeomFromText('LINESTRING(108.96 34.25, 108.98 34.24)', 4326));
INSERT INTO monitor_point (name, status, geom) VALUES
('站点1', 1, ST_SetSRID(ST_MakePoint(108.95, 34.27), 4326)),
('站点2', 0, ST_SetSRID(ST_MakePoint(108.99, 34.26), 4326)),
('站点3', 1, ST_SetSRID(ST_MakePoint(108.97, 34.28), 4326));
空间字段建议都用 4326 存原始经纬度,发布时再投影,避免坐标系混乱。
3. 空间查询(PostGIS 示例,后端接口用)
-- 找某点周围500米内的监测点(注意投影换算)
SELECT name
FROM monitor_point
WHERE ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(108.96, 34.26), 4326)::geography,
500
);
-- 找与某管线相交的所有管线
SELECT a.name, b.name
FROM pipe_line a, pipe_line b
WHERE a.id = 1 AND ST_Intersects(a.geom, b.geom);
-- 算面积、算长度
SELECT ST_Area(geom::geography) FROM some_polygon;
SELECT ST_Length(geom::geography) FROM pipe_line WHERE id=1;
4. GeoServer 发布
- 下载 GeoServer,默认 8080,
admin/geoserver - 新建工作区 workspace → 新建数据源 → PostgreSQL/PostGIS(填库名 / 账号)
- 图层里对每个表新建图层,设置 SRS 为 EPSG:4326,配置样式
- 预览时即可拿到 WMS/WFS 服务地址
重点:GeoServer 默认可能跨域,用 CORS Filter 配置放行,否则前端报跨域。
三、前端全栈对接(Vue3 完整代码)
<template>
<div class="map-wrap">
<div id="map"></div>
<div class="bar">
<button @click="loadWms">加载WMS底图</button>
<button @click="loadWfs">加载WFS管线</button>
<button @click="queryByClick">点击查询</button>
<button @click="clearVector">清除矢量</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import { Tile as TileLayer, Vector as VectorLayer } from 'ol/layer'
import { TileWMS, Vector as VectorSource, Cluster } from 'ol/source'
import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style'
import { fromLonLat } from 'ol/proj'
import GeoJSON from 'ol/format/GeoJSON'
import { bbox as bboxStrategy } from 'ol/loadingstrategy'
import 'ol/ol.css'
let map, vectorSource
// ========= 1. 初始化地图 =========
const initMap = () => {
map = new Map({
target: 'map',
layers: [],
view: new View({
center: fromLonLat([108.96, 34.26]),
zoom: 11,
projection: 'EPSG:3857'
})
})
}
// ========= 2. 加载 WMS(GeoServer发布的底图/影像)=========
const loadWms = () => {
const wmsLayer = new TileLayer({
source: new TileWMS({
url: 'http://localhost:8080/geoserver/gisdb/wms',
params: {
LAYERS: 'gisdb:pipe_line', // workspace:layer
TILED: true
},
serverType: 'geoserver'
})
})
map.addLayer(wmsLayer)
}
// ========= 3. 加载 WFS 矢量(管线,视口内按需加载)=========
const loadWfs = () => {
vectorSource = new VectorSource({
url: (extent) =>
`http://localhost:8080/geoserver/gisdb/ows?service=WFS&version=1.1.0&request=GetFeature` +
`&typeName=gisdb:pipe_line&outputFormat=application/json&srsname=EPSG:3857` +
`&bbox=${extent.join(',')},EPSG:3857`,
format: new GeoJSON(),
strategy: bboxStrategy // 只加载当前视口,平移再按需请求
})
const vectorLayer = new VectorLayer({
source: vectorSource,
style: new Style({
stroke: new Stroke({ color: '#0a6cff', width: 3 })
})
})
map.addLayer(vectorLayer)
}
// ========= 4. 点击查询 WFS 要素属性 =========
const queryByClick = () => {
map.on('singleclick', (evt) => {
const feature = map.forEachFeatureAtPixel(evt.pixel, f => f)
if (feature) {
const p = feature.getProperties()
console.log('管线属性:', { name: p.name, code: p.code, diameter: p.diameter })
alert(`管段:${p.name}\n编码:${p.code}\n管径:${p.diameter}mm`)
}
})
}
const clearVector = () => { vectorSource?.clear() }
onMounted(() => {
initMap()
loadWms()
loadWfs()
})
onUnmounted(() => { map?.dispose() })
</script>
关键点:WFS 用 bbox 策略 +
srsname=EPSG:3857,前端只拿当前屏幕的数据,平移再自动请求,几万条也不会卡。
四、ol-ext 量算(测距 + 测面积)完整代码
原生 OpenLayers 没有量算控件,业界标准是用 ol-ext(法国团队开源,插件生态好)。
npm install ol-ext
<template>
<div class="map-wrap">
<div id="map"></div>
<div class="bar">
<button @click="startMeasure(1)">测距</button>
<button @click="startMeasure(2)">测面积</button>
<button @click="clearMeasure">清除</button>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import { fromLonLat } from 'ol/proj'
import 'ol/ol.css'
// ol-ext 量算控件(按需引入)
import Measure from 'ol-ext/control/Measure'
import 'ol-ext/dist/ol-ext.min.css'
let map, measureControl, measureSource
const initMap = () => {
measureSource = new VectorSource()
map = new Map({
target: 'map',
layers: [
// 这里放你的底图(天地图/GeoServer WMS)
new VectorLayer({ source: measureSource }) // 量算图层
],
view: new View({
center: fromLonLat([108.96, 34.26]),
zoom: 11
})
})
// 创建量算控件
measureControl = new Measure({
source: measureSource, // 测量结果画到哪个图层
maxLength: 200000, // 最大测距(米)
maxArea: 100000000, // 最大测面积(㎡)
geodesic: true, // 球面测距(精确,推荐)
continueMeasure: true, // 连续测量
units: {
length: { 'm': 1, 'km': 1000 },
area: { 'm²': 1, 'km²': 1000000, '亩': 666.667 }
}
})
map.addControl(measureControl)
}
// type: 1测距离, 2测面积
const startMeasure = (type) => {
const isLength = type === 1
measureControl.getInteraction(true) // 重置当前测量
measureControl.set('type', isLength ? 'length' : 'area')
measureControl.activate()
}
const clearMeasure = () => {
measureControl.deactivate()
measureSource.clear()
measureControl.reset()
}
onMounted(initMap)
onUnmounted(() => { map?.dispose() })
</script>
说明:ol-ext 的 Measure 支持距离 / 面积,自带标签实时显示数值和单位,
geodesic:true用球面距离(WGS84)比平面更准,量算单位里可以加 "亩",国内项目常用。
五、点聚合 Cluster 完整代码(上万 POI 不卡顿)
点位几千上万时直接渲染会掉帧,用 OpenLayers 自带的 Cluster 聚合:近的点合成一个带数字的圆,放大自动散开。
<script setup>
import { ref, onMounted } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import Cluster from 'ol/source/Cluster'
import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style'
import { Point } from 'ol/geom'
import Feature from 'ol/Feature'
import { fromLonLat } from 'ol/proj'
let map, clusterSource, vectorLayer
const initCluster = () => {
// 原始点位数据源(可以来自WFS/接口/GeoJSON)
const pointSource = new VectorSource()
// 生成5000个随机测试点(模拟真实POI)
for (let i = 0; i < 5000; i++) {
const lng = 108.9 + Math.random() * 0.12
const lat = 34.2 + Math.random() * 0.12
const f = new Feature({
geometry: new Point(fromLonLat([lng, lat])),
name: `点位${i}`
})
pointSource.addFeature(f)
}
// Cluster 聚合数据源:distance=像素距离,在此范围内合并
clusterSource = new Cluster({
source: pointSource,
distance: 40 // 40像素内的点聚合
})
vectorLayer = new VectorLayer({
source: clusterSource,
style: (feature) => {
const size = feature.get('features').length // 聚合点数
let color = '#1e88e5'
if (size >= 100) color = '#e53935'
else if (size >= 30) color = '#fb8c00'
return new Style({
image: new CircleStyle({
radius: size >= 100 ? 26 : size >= 30 ? 20 : 14,
fill: new Fill({ color }),
stroke: new Stroke({ color: '#fff', width: 2 })
}),
text: new Text({
text: size.toString(), // 显示聚合数量
fill: new Fill({ color: '#fff' }),
font: 'bold 12px sans-serif'
})
})
}
})
map.addLayer(vectorLayer)
// 点击聚合点 → 弹层显示内部点位(可选)
map.on('singleclick', (evt) => {
const feature = map.forEachFeatureAtPixel(evt.pixel, f => f)
if (feature) {
const feats = feature.get('features')
if (feats && feats.length > 1) {
console.log('该聚合含', feats.length, '个点位', feats.map(f => f.get('name')))
}
}
})
}
</script>
说明:
Cluster返回的每个 feature 里带features数组,就是聚合进去的子点位。样式根据数量分色分级,一眼看出密度。真实项目点位从 WFS / 接口拿,替换掉随机生成那段即可。
六、空间计算怎么选?前端 Turf vs 后端 PostGIS
表格
| 场景 | 推荐 | 原因 |
|---|---|---|
| 两个点 / 线算距离 | Turf.js | 前端零请求,秒回 |
| 鼠标实时绘制算面积 | Turf/ol-ext | 交互期,后端来不及 |
| 点是否在面内(单个) | Turf | 轻量 |
| 几千条要素做缓冲区 / 相交 | PostGIS | 前端会卡死,SQL 秒出 |
| 全库范围检索、空间索引 | PostGIS | 数据量大必须后端 |
铁律:前端只算用户能一眼看到、量级小的;批量、全量、需要索引的交给 PostGIS,前端通过接口拿结果。
七、全栈踩坑清单(血泪)
- 坐标系三层不一致:库 4326、GeoServer 发布的 SRS、前端 3857,任何一层没对上就偏移。建议库和 GeoServer 统一 4326,前端投影到 3857
- GeoServer 跨域:默认 CORS 可能没开,前端请求直接失败。配好 CORS Filter
- WFS 一次性加载全库:几万条
addFeatures直接白屏 → 用bbox策略视口加载 - WMS 和 WFS 搞混:WMS 返回图片适合底图 / 影像;WFS 返回矢量适合查询 / 点击。图层多了别都用 WMS
- ol-ext 样式未引入:
ol-ext.min.css不引,量算控件样式全乱 - Cluster 聚合
distance调太小:聚合不明显;调太大:缩放时点位分布失真,一般 30-50 - 量算
geodesic:false:平面量算在大范围时误差大,跨省测距误差可能到几公里,务必开geodesic:true - 内存泄漏:路由切换 / 页面销毁必须
map.dispose()、移除交互和监听,否则切几次页面就卡
八、小结
全栈 WebGIS 的真正难点不在前端画地图,而在空间数据的存取、发布和计算分层:
- 存储 / 大数据计算 → PostGIS
- 服务发布 → GeoServer
- 渲染交互 → OpenLayers
- 轻量量算 → ol-ext / Turf.js