《OpenLayers从入门到企业级GIS系统实战》二

1 阅读10分钟

OpenLayers 环境搭建进阶:Vue3/Vite、React 集成与国内公共底图替换

上一篇我们把地图跑起来了,通常是一个 OSM 底图加一个 new Map()。这一步很重要,但放到真实项目里还不够。

实际项目里,前端地图页面一般会马上遇到三个问题:

  • Vue3 / React 组件生命周期怎么和 OpenLayers Map 实例配合
  • OSM 底图在国内访问不稳定,怎么换成天地图、高德这类公共底图
  • 底图切换、token、图层释放这些细节怎么组织,后面才不容易失控

这篇文章就围绕这些问题展开。它不是再写一个“Hello OpenLayers”,而是把环境搭建往工程化方向推进一步。

前端架构先想清楚

在 Vue3 或 React 里接入 OpenLayers,真正要处理的不是“怎么把地图显示出来”,而是前端框架和地图引擎之间的职责边界。

一个比较稳的拆法是这样的:

  • Vite 负责项目构建、模块加载、环境变量和开发服务
  • Vue3 / React 负责页面组件、业务状态、路由和生命周期
  • OpenLayers Map 实例负责地图渲染、视图控制、图层集合、事件系统和交互行为
  • 底图、业务图层、绘制工具、Overlay 弹窗应该封装成独立模块,而不是全部塞进页面组件

这个结构看起来比直接在 App.vueApp.tsx 里写 new Map() 麻烦一点,但后面会省很多事。比如底图从 OSM 切到天地图、高德时,页面组件不需要知道瓦片 URL 怎么拼;业务图层增加农田地块、车辆轨迹、设备点位时,也不应该影响地图容器的生命周期。

实际项目里我更建议把地图相关代码分成三层:

页面组件层:负责 DOM 容器、按钮、表单、业务交互
地图能力层:负责创建 Map、View、Layer、Source、Overlay
服务配置层:负责底图 provider、token、瓦片 URL、坐标系策略

这张图表达的就是这个关系:前端框架只是承载 OpenLayers 的运行环境,地图内核仍然要用 OpenLayers 自己的方式管理。

framework-integration.svg

OpenLayers 和 Vue、React 的关系,很多新手一开始会理解错。

Vue 和 React 并不会接管地图内部渲染。它们负责的是页面结构、状态和生命周期;真正负责地图视图、瓦片加载、图层渲染、交互事件的是 OpenLayers 自己。

也就是说,框架组件只需要做好几件事:

  • 准备一个真实 DOM 容器
  • 在组件挂载后创建 Map
  • 在组件卸载时释放 Map
  • 把底图、业务图层、交互能力封装成可维护的模块

这个边界如果没想清楚,后面很容易把 Feature 数组、图层实例、业务状态全部塞进 Vue 或 React 状态里。小 demo 能跑,真实项目会越来越难维护。

Vite 项目里安装 OpenLayers

无论 Vue3 还是 React,Vite 集成 OpenLayers 的基础依赖都很简单:

npm install ol

OpenLayers 的样式需要显式引入:

import 'ol/ol.css';

如果你使用天地图,还建议把 token 放到环境变量里:

VITE_TDT_TOKEN=你的天地图tk

注意,前端环境变量最终会进入浏览器环境,不适合放高权限密钥。天地图这种浏览器端瓦片访问 token 可以这样配置,但企业项目里如果涉及内部服务鉴权,最好通过后端代理或网关处理。

Vue3 + Vite 集成 OpenLayers

Vue3 里最稳的方式是:用 ref 拿到地图容器,在 onMounted 里创建 Map,在 onBeforeUnmount 里释放。

核心代码如下:

<script setup lang="ts">
import 'ol/ol.css';
import Map from 'ol/Map';
import View from 'ol/View';
import { fromLonLat } from 'ol/proj';
import { onBeforeUnmount, onMounted, ref } from 'vue';

const mapRef = ref<HTMLDivElement | null>(null);
let map: Map | null = null;

onMounted(() => {
  if (!mapRef.value) return;

  map = new Map({
    target: mapRef.value,
    layers: [],
    view: new View({
      center: fromLonLat([116.397428, 39.90923]),
      zoom: 11,
    }),
  });
});

onBeforeUnmount(() => {
  map?.setTarget(undefined);
  map = null;
});
</script>

<template>
  <section ref="mapRef" class="map"></section>
</template>

这里有两个细节很重要。

target 必须是组件挂载后的 DOM。不要在模块顶层直接创建 Map,也不要假设 document.getElementById 一定能拿到容器。

卸载时要调用 map.setTarget(undefined)。OpenLayers 会绑定 DOM、监听事件、管理渲染循环。如果组件切换频繁但不释放,内存和事件监听迟早会堆起来。

React + Vite 集成 OpenLayers

React 里对应的是 useRefuseEffect

import 'ol/ol.css';
import Map from 'ol/Map';
import View from 'ol/View';
import { fromLonLat } from 'ol/proj';
import { useEffect, useRef } from 'react';

export default function App() {
  const mapElementRef = useRef<HTMLDivElement | null>(null);
  const mapRef = useRef<Map | null>(null);

  useEffect(() => {
    if (!mapElementRef.current) return;

    mapRef.current = new Map({
      target: mapElementRef.current,
      layers: [],
      view: new View({
        center: fromLonLat([116.397428, 39.90923]),
        zoom: 11,
      }),
    });

    return () => {
      mapRef.current?.setTarget(undefined);
      mapRef.current = null;
    };
  }, []);

  return <section ref={mapElementRef} className="map" />;
}

React 示例有一个实际项目里很容易踩的点:不要把 Map 实例放进 useState

Map 是一个复杂对象,内部有图层集合、事件系统、渲染状态。它不适合作为 React 的响应式状态参与重复渲染。更合理的做法是用 useRef 保存实例,再用普通函数操作地图。

为什么不能一直用 OSM 底图

OSM 很适合学习和快速验证。它无需 token,代码也最简单:

import TileLayer from 'ol/layer/Tile';
import OSM from 'ol/source/OSM';

const osmLayer = new TileLayer({
  source: new OSM(),
});

但在国内业务项目里,一直使用 OSM 通常会有几个问题:

  • 访问速度和稳定性不可控
  • 中文注记体验不一定符合业务预期
  • 政企项目可能要求使用指定地图服务
  • 坐标体系、瓦片来源和合规要求需要提前确认

所以环境搭建阶段就应该知道:底图不是写死的,它应该被封装成可替换能力。

base-layer-switch.svg

统一封装底图工厂

把 OSM、天地图、高德统一封装到了一个工厂函数里:

import TileLayer from 'ol/layer/Tile';
import OSM from 'ol/source/OSM';
import XYZ from 'ol/source/XYZ';

export type BaseLayerProvider = 'osm' | 'tianditu-vector' | 'tianditu-image' | 'amap-vector' | 'amap-image';

export interface BaseLayerOptions {
  tiandituToken?: string;
}

export interface BaseLayerGroup {
  name: string;
  layers: TileLayer<OSM | XYZ>[];
}

const TIANDITU_SUBDOMAINS = ['0', '1', '2', '3', '4', '5', '6', '7'];

function createTiandituUrls(layerType: 'vec_w' | 'cva_w' | 'img_w' | 'cia_w', token: string) {
  return TIANDITU_SUBDOMAINS.map(
    (subdomain) =>
      `https://t${subdomain}.tianditu.gov.cn/DataServer?T=${layerType}&x={x}&y={y}&l={z}&tk=${token}`,
  );
}

function createTileLayer(source: OSM | XYZ, zIndex: number) {
  return new TileLayer({
    source,
    zIndex,
  });
}

export function createBaseLayers(provider: BaseLayerProvider, options: BaseLayerOptions = {}): BaseLayerGroup {
  if (provider === 'osm') {
    return {
      name: 'OpenStreetMap',
      layers: [createTileLayer(new OSM({ crossOrigin: 'anonymous' }), 0)],
    };
  }

  if (provider === 'tianditu-vector' || provider === 'tianditu-image') {
    if (!options.tiandituToken) {
      throw new Error('使用天地图底图需要传入 tiandituToken,建议通过 VITE_TDT_TOKEN 配置。');
    }

    const isImage = provider === 'tianditu-image';
    const baseType = isImage ? 'img_w' : 'vec_w';
    const labelType = isImage ? 'cia_w' : 'cva_w';

    return {
      name: isImage ? '天地图影像' : '天地图矢量',
      layers: [
        createTileLayer(
          new XYZ({
            urls: createTiandituUrls(baseType, options.tiandituToken),
            crossOrigin: 'anonymous',
            maxZoom: 18,
          }),
          0,
        ),
        createTileLayer(
          new XYZ({
            urls: createTiandituUrls(labelType, options.tiandituToken),
            crossOrigin: 'anonymous',
            maxZoom: 18,
          }),
          1,
        ),
      ],
    };
  }

  const amapStyle = provider === 'amap-image' ? '6' : '7';

  return {
    name: provider === 'amap-image' ? '高德影像' : '高德矢量',
    layers: [
      createTileLayer(
        new XYZ({
          url: `https://webrd04.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=${amapStyle}&x={x}&y={y}&z={z}`,
          crossOrigin: 'anonymous',
          maxZoom: 18,
        }),
        0,
      ),
    ],
  };
}

核心类型是:

export type BaseLayerProvider =
  | 'osm'
  | 'tianditu-vector'
  | 'tianditu-image'
  | 'amap-vector'
  | 'amap-image';

业务侧只需要关心 provider,不用到处拼瓦片 URL:

const group = createBaseLayers('amap-vector');

group.layers.forEach((layer) => {
  layer.set('role', 'base');
  map.addLayer(layer);
});

这里返回的是 BaseLayerGroup,不是单个 Layer。原因很简单:有些底图不是一层。

比如天地图矢量底图通常需要:

  • vec_w:矢量底图
  • cva_w:中文注记

影像底图通常需要:

  • img_w:影像底图
  • cia_w:影像注记

tile-layer-stack.svg

这里只展示React的图层效果

osm-map.png

gd-map.png

接入天地图

天地图使用 XYZ 数据源即可接入。

import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';

const token = import.meta.env.VITE_TDT_TOKEN;

const tiandituVectorLayer = new TileLayer({
  source: new XYZ({
    url: `https://t0.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=${token}`,
    crossOrigin: 'anonymous',
    maxZoom: 18,
  }),
});

真实项目里一般不会只写 t0,而是配置多个子域名:

const urls = ['0', '1', '2', '3', '4', '5', '6', '7'].map(
  (subdomain) =>
    `https://t${subdomain}.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=${token}`,
);

const layer = new TileLayer({
  source: new XYZ({
    urls,
    crossOrigin: 'anonymous',
    maxZoom: 18,
  }),
});

如果要显示中文注记,再叠加一个 cva_w 图层:

const labelLayer = new TileLayer({
  source: new XYZ({
    urls: ['0', '1', '2', '3', '4', '5', '6', '7'].map(
      (subdomain) =>
        `https://t${subdomain}.tianditu.gov.cn/DataServer?T=cva_w&x={x}&y={y}&l={z}&tk=${token}`,
    ),
    crossOrigin: 'anonymous',
    maxZoom: 18,
  }),
});

这里需要特别注意:天地图 token 不要直接写死在源码里。至少放到 .env.local,并且不要提交到仓库。

接入高德底图

高德瓦片也可以通过 XYZ 接入:

import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';

const amapVectorLayer = new TileLayer({
  source: new XYZ({
    url: 'https://webrd04.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}',
    crossOrigin: 'anonymous',
    maxZoom: 18,
  }),
});

常见的 style

style含义
7矢量道路图
6影像底图

高德底图在国内加载体验通常比较好,但企业项目里要特别关注坐标问题。高德使用 GCJ02 坐标体系,而 OpenLayers 默认很多示例都按 EPSG:3857 / WGS84 处理。只是加载底图时问题不明显,一旦你叠加业务点位、轨迹、行政区边界,偏移问题就会出现。

所以后面的坐标系专题会专门讲 GCJ02、WGS84、EPSG:3857 之间的转换关系。

底图切换不要重建整个 Map

很多人做底图切换时,会直接销毁 Map 再创建一个新的 Map。

这在 demo 里没什么感觉,但真实项目里代价很高:业务图层、交互状态、弹窗、绘制工具、当前视图范围都可能被重置。

更合适的方式是给底图图层打标记:

layer.set('role', 'base');

切换时只移除旧底图:

const oldBaseLayers = map
  .getLayers()
  .getArray()
  .filter((layer) => layer.get('role') === 'base');

oldBaseLayers.forEach((layer) => map.removeLayer(layer));

再把新的底图组加进去:

const group = createBaseLayers('tianditu-vector', {
  tiandituToken: import.meta.env.VITE_TDT_TOKEN,
});

group.layers.forEach((layer) => {
  layer.set('role', 'base');
  map.addLayer(layer);
});

这种做法的好处是:View 不动,业务图层不动,交互状态也不动。底图只是地图图层栈里的基础层。

目录结构建议

如果只是学习,一个 App.vueApp.tsx 当然可以写完。

但项目一旦要继续扩展,我建议从一开始就把底图能力抽出去:

src/
├── map/
│   ├── baseLayers.ts
│   ├── createMap.ts
│   ├── mapOptions.ts
│   └── types.ts
├── components/
│   └── MapView.vue
└── pages/
    └── DemoPage.vue

React 项目也类似:

src/
├── map/
│   ├── baseLayers.ts
│   ├── createMap.ts
│   └── types.ts
├── components/
│   └── MapView.tsx
└── pages/
    └── DemoPage.tsx

核心思路是:组件负责生命周期,map/ 目录负责 OpenLayers 能力封装。

这样后面继续加图层管理、绘制工具、Overlay、轨迹回放时,项目不会变成一整个巨大的地图组件。

常见问题

1. 地图容器为什么是空白

先检查容器高度。

OpenLayers 不会自动撑开容器,如果 .map 没有高度,地图就是空白:

html,
body,
#app {
  height: 100%;
  margin: 0;
}

.map {
  height: 100%;
}

React 项目里把 #app 换成 #root

2. 为什么天地图加载不出来

优先检查三个点:

  • VITE_TDT_TOKEN 是否配置
  • URL 里的 tk 是否真的带上了
  • 浏览器 Network 面板里瓦片请求是否返回错误

如果 token 没配置,示例里的 createBaseLayers 会直接抛错,这是故意的。真实项目里早点暴露配置问题,比静默空白要好排查得多。

3. 为什么高德底图和业务点位偏移

这通常不是 OpenLayers 初始化问题,而是坐标系问题。

高德底图使用 GCJ02,很多后端业务数据可能是 WGS84 或已经转成 EPSG:3857。坐标系不统一时,点位就会偏。

实际项目里要先确认:

  • 后端返回的是 WGS84、GCJ02 还是 BD09
  • 前端展示底图使用什么坐标体系
  • 是否需要在入库、接口层或前端渲染前统一转换

4. Vue 或 React 热更新后地图重复渲染

检查组件卸载时有没有调用:

map.setTarget(undefined);

另外,不要在组件每次渲染时都重新 new Map()。地图实例应该只在容器首次可用时创建。

工程建议

OpenLayers 环境搭建进阶,本质上不是多写几行配置,而是提前把地图工程的边界划清楚。

我的建议是:

  • 学习阶段可以用 OSM,业务项目尽早接入真实底图
  • Vue3 / React 只管理地图容器和生命周期,不要接管 OpenLayers 内部状态
  • 底图服务统一封装,避免 URL 散落在页面组件里
  • 天地图注记层、影像层、矢量层要按图层组理解
  • 高德底图要提前考虑 GCJ02 坐标偏移问题
  • 底图切换只替换 TileLayer,不要重建整个 Map

把这些基础打好,后面讲图层管理、坐标系、海量点位优化时,代码结构会舒服很多。

下一篇预告:OpenLayers 五大核心对象到底是什么,以及它们在真实 GIS 页面里分别承担什么职责。