vue 和 elementUI 这套组合在中后台项目里的占有率有多高,做过几个管理系统的同学心里都有数。而地图这个东西,几乎每个跟门店、车辆、设备、工单、配送沾边的后台都躲不开,门店分布要打点,车辆要跑轨迹,设备要按区域聚合,工单要圈选范围,这时候腾讯地图就成了绕不开的一块拼图。麻烦的地方在于,vue 的组件生命周期和地图 SDK 的命令式 API 是两套思路,elementUI 的弹窗、Tab、表格又都会影响地图容器的尺寸计算,稍不留神就是一片白板,或者地图挤在一个 0 像素高的 div 里。
这篇东西主要聊的就是我这几年前前后后踩下来的一套做法:从 key 怎么申请、白名单怎么配,到脚本怎么加载才不拖累首屏,再到标记聚合、信息窗口里塞 elementUI 组件、地址搜索与表单回填、组件销毁时的内存清理,最后是那一堆让人头大的故障排查。适合已经能写 vue 表单和列表、但没怎么碰过地图 SDK 的同学,也适合已经做完一版、正被白屏和卡顿折磨的同学。代码以 Vue 2 + Element UI 为主线,Vue 3 + Element Plus 的差异我会单独标出来。
1. 地图选型:为什么在 Vue 项目里挑了腾讯位置服务
1.1 三家常被拿来对比的地图服务
先把选型这块说清楚,因为后面所有的坑都和这个选择有关。国内能用的 Web 端地图服务就那么几家,我在不同项目里都用过,横向摆一下会更直观:
| 对比维度 | 腾讯位置服务 | 高德开放平台 | 百度地图开放平台 |
|---|---|---|---|
| 坐标体系 | GCJ-02 | GCJ-02 | BD-09(自家加密) |
| GL 版渲染 | 有,JS API GL | 有 | 有 |
| 与微信生态打通 | 好,小程序同源 | 一般 | 一般 |
| 服务类接口风格 | Promise 化,写法接近 axios | 回调 + Promise 双形态 | 回调为主 |
| 主题定制 | 支持样式 ID 定制底图 | 支持 | 支持 |
坐标体系这一栏最值得盯。腾讯和高德都是 GCJ-02,数据在两个平台之间迁移时基本不用转换;百度用的是自家的 BD-09,如果你从百度换到腾讯,几十万条存量坐标就得全量纠偏一次,这个工作量在项目中期是很要命的。我遇到过最惨的一次是历史数据存的是百度坐标,换平台后所有点整体偏了几百米,排查了一整天才反应过来是坐标系的问题。
另一个让我倾向腾讯的点是服务类接口的写法。它的TMap.service.Geocoder这类对象直接返回 Promise,配合async/await写起来跟调自己的后端接口没什么区别,不用再包一层回调转 Promise 的壳子。这一点在 vue 项目里特别顺手,因为组件的async mounted里可以直接串起来写,代码读起来是一条线,不是一堆.then嵌套。
1.2 Vue 技术栈下地图 SDK 的适配成本
选型只是第一步,真正决定开发体验的是地图 SDK 和 vue 的磨合方式。地图 SDK 是典型的命令式 API:new TMap.Map(...)拿到实例,然后不断调方法改状态。而 vue 是声明式的,数据变了视图自己跟着变。这两套东西硬凑在一起,最容易出的问题就是「我改了 data,地图没反应」。
我的应对原则是:地图实例本身不进响应式系统。Vue 2 里如果你把地图实例塞进data,Vue 会递归遍历这个对象的所有属性,给它们全部挂上getter/setter,而地图实例内部挂着几百上千个 DOM 节点和几何对象引用,这一圈遍历下来初始化就会慢一大截,后续 SDK 内部的每次属性读写都要过一遍拦截,缩放和拖动会明显发涩。正确做法是挂在组件实例的非响应式属性上:
// Vue 2:以下划线开头、且不在 data 中声明的属性不会被响应式处理 export default { data() { return { shops: [], // 这些是要驱动视图的数据,正常放 data activeShop: null }; }, created() { this._map = null; // 地图实例,刻意避开响应式 this._markerLayer = null; } };// Vue 3:用 shallowRef 或 markRaw 显式声明不追踪 import { shallowRef, markRaw } from 'vue'; const mapRef = shallowRef(null); const markerLayer = shallowRef(null); function initMap(TMap) { const map = new TMap.Map(document.getElementById('map-container'), { /* ... */ }); mapRef.value = markRaw(map); }第二个适配点是生命周期对齐。地图必须在 DOM 已经渲染完成之后才能初始化,所以初始化放在mounted;而销毁必须放在beforeDestroy(Vue 3 是beforeUnmount),并且要显式调用地图的销毁方法,否则路由切换十几次之后你会发现页面越来越卡,chrome 的任务管理器里这个标签页的内存曲线一路向上不回头。
第三个点是响应式和地图的双向同步。业务数据变了要更新地图上的点,地图上点了某个点又要回写到业务数据。我的做法是单向为主:业务数据是唯一真相来源,地图只做渲染层。用户点地图上的标记时,先找到对应的业务数据对象、更新数据,再写一个watch监听数据变化、整体重绘地图上的几何对象。这样逻辑只有一个入口,不会出现「地图显示的和列表里的不一致」这类玄学问题。
2. 动手前必须搞定的三件事:Key、域名白名单、依赖形态
2.1 Key 的申请与配额策略
腾讯位置服务的 key 在控制台里创建,流程不复杂,但有几个地方新手特别容易踩。
第一步是注册开发者账号,个人和企业都能用,区别主要在配额和服务范围上。第二步是新建应用,一个应用下可以挂多个 key。第三步是给 key 勾选服务类型,这一步最关键:如果你要用地址解析、关键词搜索、路线规划这类服务类接口,必须勾选 WebServiceAPI,只勾了 JavaScript API GL 是不够的,调用服务接口时会直接返回权限错误。我见过太多人卡在这里,地图能显示、就是搜不到地址,查半天代码,最后发现是 key 少勾了一个选项。
第四步是按需管理配额。控制台里有每个 key 的日调用量统计,建议一个业务场景配一个 key,比如 Web 端展示一个、服务端批量解析一个。混用一个 key 的后果是调用量一超,全部业务一起挂掉,而且日志里根本看不出是哪个模块把额度吃光了。另外,服务端的 key 千万别下发到前端,服务端调用是需要签名校验的,前端拿着也用不了,还会增加泄露风险。
2.2 安全域名与 Referer 校验
key 后面的那个「安全域名」配置,是新手最容易忽略也最容易被反噬的地方。它的原理是:浏览器加载地图脚本和发起服务请求时,会带上当前页面的Referer,服务端校验这个来源在不在白名单里,不在就直接拒绝。
几个实操细节值得记一下:
- 填域名的时候不带协议、不带端口、不带路径,一般只写主机名,比如
localhost、127.0.0.1这类本地开发地址是可以直接加进去的。 - 本地开发时端口经常变,
8080、8081、5173来回切,白名单里不用写端口,写主机名就够了。 - 测试环境如果是用 IP 直接访问的,把那个 IP 也加进去,别等到提测当天才发现地图是白的。
- 生产环境用泛域名的时候想清楚,泛域名开太大等于没校验,别人扒走你的 key 也能用。
还有一个隐藏逻辑:很多平台的 Referer 校验对空 Referer 是放行的(因为服务端调用天然没有 Referer)。你在浏览器地址栏直接敲地址访问是不会有问题的,但从一个页面跳转过去就带 Referer 了,这时候如果域名没配,就会精准地只在跳转场景下报错,特别迷惑。
注意:key 属于敏感配置,不要提交到代码仓库,也不要在前端代码里硬编码多份。统一走环境变量注入,构建时替换。
2.3 依赖形态:npm 包还是 script 标签
这个问题我被问过很多次。目前主流的接入方式还是用 script 标签加载官方的 CDN 脚本,社区里虽然有第三方做的封装包,但大多滞后于官方版本,遇到新接口或者 bug 修复就得等维护者跟进,风险不可控。
那在 webpack 或 vite 项目里怎么处理?我的做法是:
- 把 key 写进
.env文件,用构建工具的环境变量注入到代码里; - 脚本本身不写在
index.html里静态引入,而是在需要地图的页面里动态注入,避免所有页面都白白加载一百多 KB 的 SDK; - 在构建配置里给地图相关的 CDN 域名加一条
externals或者分包规则(vite 下一般不用特别处理,动态注入的 script 不参与打包)。
至于版本号,脚本地址里那个版本参数建议锁死一个明确版本,不要用浮动的表达式。官方更新是向前兼容的,但总有边界情况,锁版本能让你的线上环境在半年后依然保持一致的表现,这比追新版本重要得多。下面第三章会给出动态加载的完整封装代码。
3. 地图脚本加载方式:三种写法与我的取舍
3.1 最省事的静态引入
最简单的方式是在public/index.html里直接加一行 script:
<script src="https://map.qq.com/api/gljs?v=1.exp&key=你的KEY"></script>优点是省事,全局直接能拿到window.TMap,不用管加载时序。缺点是所有页面都会加载这一百多 KB,包括那些跟地图八竿子打不着的登录页、配置页。而且 key 直接暴露在 HTML 里,构建产物里也是明文,安全性差一些。
如果你的项目只有一两个页面用到地图,而且整个系统就是个地图为主的工具,这种写法完全可以接受。除此之外我都建议往下看。
3.2 动态注入加 Promise 封装
这是我用得最多的方案。核心思路是写一个加载器,第一次调用时注入 script,后续调用直接复用同一个 Promise:
// src/utils/loadTMap.js const KEY = process.env.VUE_APP_TMAP_KEY; let task = null; export default function loadTMap() { if (window.TMap) return Promise.resolve(window.TMap); if (task) return task; task = new Promise((resolve, reject) => { const script = document.createElement('script'); script.type = 'text/javascript'; script.async = true; script.src = `https://map.qq.com/api/gljs?v=1.exp&key=${KEY}`; script.onload = () => { if (window.TMap) { resolve(window.TMap); } else { task = null; // 允许下次重试 reject(new Error('脚本已加载但 TMap 未挂载,通常是 key 无效或域名未加白')); } }; script.onerror = () => { task = null; reject(new Error('地图脚本加载失败,检查网络与脚本地址')); }; document.head.appendChild(script); }); return task; }这段代码有两个细节值得说。一是失败后把缓存的 Promise 置空,否则一次网络抖动会让这个页面在本次会话里永远拿不到 TMap,用户只能刷新页面,体验很差。二是区分了onload和onerror两种失败:onload触发了但全局对象不存在,说明脚本本身返回了内容(比如一段错误提示),这时候问题基本锁定在 key 和域名上,错误信息写得越具体,排查越快。
在组件里的用法就比较清爽了:
import loadTMap from '@/utils/loadTMap'; export default { data() { return { mapReady: false }; }, async mounted() { try { const TMap = await loadTMap(); this._TMap = TMap; this.initMap(); this.mapReady = true; } catch (e) { this.$message.error(e.message); } } };配合v-loading或者骨架屏,地图区域在加载期间给个占位,用户不会看到一块空白。
3.3 封装成 Vue 插件,全项目复用
如果项目里超过三个页面要用地图,我会再往上封一层插件,把 loader 和常用能力挂到 Vue 原型上:
// src/plugins/tmap.js import loadTMap from '@/utils/loadTMap'; const TMapPlugin = { install(Vue) { let ready = false; const waiters = []; loadTMap().then((TMap) => { Vue.prototype.$TMap = TMap; ready = true; waiters.splice(0).forEach((fn) => fn(TMap)); }); // 组件里用 await this.$tmapReady() 保证拿到实例 Vue.prototype.$tmapReady = function () { if (ready) return Promise.resolve(Vue.prototype.$TMap); return new Promise((resolve) => waiters.push(resolve)); }; } }; export default TMapPlugin;这样组件里只要await this.$tmapReady()就能拿到 SDK 对象,不用担心加载时序。Vue 3 里对应改成插件的app.config.globalProperties加inject,思路一样。
提醒:如果你的项目做了预渲染或者服务端渲染,插件安装阶段会直接执行 loader,而服务端没有
document,会报错。这种情况下要把加载逻辑挪到mounted或onMounted里再触发,不要写在插件安装的顶层。
4. 从零跑通第一个地图组件
4.1 容器与初始化参数逐个说清
容器就是普通的 div,但样式上有一条铁律:必须有明确的高度。百分比高度依赖父级链条,链条上任何一环没高度,地图就是零高,表现就是一片空白。我一般直接给死高度,或者用flex: 1配合父容器的display: flex。
<template> <div class="map-page"> <div id="map-container" class="map-container"></div> </div> </template> <style scoped> .map-page { display: flex; flex-direction: column; height: calc(100vh - 84px); /* 减去顶部导航的实际高度 */ } .map-container { flex: 1; min-height: 400px; } </style>初始化参数我常用的就这几个:
initMap() { const TMap = this._TMap; this._map = new TMap.Map(document.getElementById('map-container'), { center: new TMap.LatLng(31.2397, 121.499), // 初始中心点,换成你的业务坐标 zoom: 13, minZoom: 5, maxZoom: 18, pitch: 0, rotation: 0, viewMode: '2D', baseMap: { type: 'vector', // 矢量底图,可换 satellite 卫星图 features: ['base', 'building', 'point'] }, doubleClickZoom: false, // 双击缩放会干扰双击选点,业务里通常关掉 showControl: false // 默认控件我一般关掉,自己加需要的 }); // 只加一个比例尺就够了,缩放控件在业务界面上另外做 const scale = new TMap.control.ScaleControl({ position: TMap.constants.CONTROL_POSITION.BOTTOM_LEFT }); this._map.addControl(scale); }几个参数的解释。viewMode设成2D是因为 3D 模式在某些低端显卡上渲染压力明显更大,除非业务真的需要俯仰视角,否则没必要开。features里的building是三维建筑块,如果页面只是打点展示,去掉它能少渲染一堆东西,流畅度有肉眼可见的提升。doubleClickZoom关掉是因为地图上的双击往往被业务占用了,比如双击选中某个区域。
4.2 生命周期里该做什么、不该做什么
组件生命周期的编排,我总结成一句话:在 mounted 里创建,在 beforeDestroy 里销毁,中间所有异步操作都要判空。
mounted之后容器已经进入文档流,可以拿到真实的宽高,初始化参数里如果有基于容器尺寸计算的逻辑(比如根据屏幕宽度决定缩放级别)就放在这里。如果容器在此时仍然是隐藏状态(比如地图在一个默认不展开的折叠面板里),那就不能初始化,得等到面板展开后再初始化,这个后面故障章节会细讲。
beforeDestroy里必须做的三件事:清掉所有定时器(尤其是轨迹回放的setInterval)、解绑所有地图事件监听、调用地图实例的销毁方法。
beforeDestroy() { // 清定时器 if (this._timer) { clearInterval(this._timer); this._timer = null; } // 解绑事件(用命名函数,别用匿名函数,否则解绑不掉) if (this._map) { this._map.off('click', this.handleMapClick); this._map.destroy(); this._map = null; } this._markerLayer = null; this._infoWindow = null; }特别注意:事件监听一定要用命名函数。写成
map.on('click', () => {...})这种匿名形式,off的时候你连引用都拿不到,只能靠销毁整个实例来兜底,多个组件共用地图时就会出问题。
5. 业务功能落地:标记、聚合、信息窗口与 Element UI 联动
5.1 点标记与自定义样式
GL 版的标记是「图层化」的,一个MultiMarker管理一批点,比旧版一个个new Marker的方式性能好一大截。样式通过MarkerStyle定义,可以在样式层面切换状态:
initMarkers() { const TMap = this._TMap; this._markerLayer = new TMap.MultiMarker({ id: 'shop-layer', map: this._map, styles: { normal: new TMap.MarkerStyle({ width: 26, height: 34, anchor: { x: 13, y: 34 }, // 锚点设成底部中心,图标尖端才对得准坐标 src: require('@/assets/marker-normal.png') }), active: new TMap.MarkerStyle({ width: 34, height: 44, anchor: { x: 17, y: 44 }, src: require('@/assets/marker-active.png') }) }, geometries: this.shops.map((item) => ({ id: String(item.id), styleId: 'normal', position: new TMap.LatLng(item.lat, item.lng), properties: { raw: item } })) }); this._markerLayer.on('click', this.handleMarkerClick); }anchor这个参数是新手最容易忽略的。默认锚点在图标左上角,如果你不做设置,标记会整体向右下偏移,坐标点对着的是图标的左上角,看起来「总是偏」。把锚点设成图标底部中心,尖端才会精确落在经纬度位置上。
点击切换选中态的写法,利用styleId就够了:
handleMarkerClick(evt) { const id = evt.geometry.id; const shop = this.shops.find((s) => String(s.id) === id); if (!shop) return; // 先把上一个选中态恢复 const updates = []; if (this.activeId && this.activeId !== id) { updates.push({ id: this.activeId, styleId: 'normal', position: this.findPosition(this.activeId) }); } updates.push({ id, styleId: 'active', position: evt.geometry.position }); this._markerLayer.updateGeometries(updates); this.activeId = id; this.activeShop = shop; // 驱动侧边卡片显示 this.openInfoWindow(shop); }updateGeometries按id匹配,存在就更新、不存在就新增,比清空重画高效得多。这里有个坑:更新时如果不带position,某些版本会把点位置重置掉,所以我习惯每次更新都把position一并带上,稳妥。
5.2 信息窗口里塞一个 Element UI 组件
信息窗口的内容默认是纯 DOM,想在里面用 elementUI 的按钮和标签,得先把内容渲染成 HTML 字符串再塞进去。这时候enableCustom参数就很关键,打开它之后窗口容器不会强制套默认皮肤,你的自定义样式才有发挥空间。
openInfoWindow(shop) { const TMap = this._TMap; if (!this._infoWindow) { this._infoWindow = new TMap.InfoWindow({ map: this._map, position: new TMap.LatLng(shop.lat, shop.lng), offset: { x: 0, y: -44 }, enableCustom: true }); } const html = ` <div class="shop-popup"> <div class="popup-title">${this.escapeHtml(shop.name)}</div> <div class="popup-row">状态:<span class="tag-${shop.status}">${shop.statusText}</span></div> <div class="popup-row">今日单量:${shop.orderCount}</div> </div> `; this._infoWindow.setContent(html); this._infoWindow.setPosition(new TMap.LatLng(shop.lat, shop.lng)); this._infoWindow.open(); }字符串拼接最大的问题是 XSS 和转义,门店名称里带个引号或者尖括号,页面就乱了,所以我写了个escapeHtml工具函数统一处理。
那如果信息窗口里的内容复杂到必须用 elementUI 组件怎么办?比如要放一个带分页的小表格。我的做法是不在信息窗口里塞组件,改用右侧的抽屉或者卡片。元素渲染到地图浮层里,脱离了 vue 的组件树,this.$refs拿不到,事件绑定要靠原生addEventListener,数据更新还得手动重绘,维护成本很高。交互稍微复杂一点的需求,一律甩到侧边栏用el-drawer承载,地图上只保留一个标记和一条线,职责清晰,开发也快。
5.3 海量点位交给聚合图层
点位超过几百个以后,直接把所有标记都渲染出来会明显掉帧,缩放的时候整个地图一顿一顿的。这时候用聚合:
initCluster() { const TMap = this._TMap; const geometries = this.devices.map((d) => ({ id: String(d.id), styleId: 'normal', position: new TMap.LatLng(d.lat, d.lng), properties: { raw: d } })); this._cluster = new TMap.MarkerCluster({ map: this._map, minimumClusterSize: 2, // 两个点以上就合并,视密度调整 geometries }); this._cluster.on('click', (evt) => { // 聚合点的点击和单点的点击是同一个事件,靠 geometry 里有没有聚合信息区分 if (evt.geometry && evt.geometry.geometries) { this._map.setCenter(evt.geometry.position); this._map.setZoom(this._map.getZoom() + 2); } else { this.handleMarkerClick(evt); } }); }minimumClusterSize这个值要根据实际密度调。门店分布本来就稀疏的场景,设成 2 会频繁出现两三个点抱团,视觉上反而乱,我一般设成 4 或 5。另外聚合的数量气泡如果想改成自己的配色,可以通过样式配置去覆盖,具体字段名各版本略有差异,以你锁定的那个版本的官方文档为准,别照着老文章抄。
数据量再大(上万点)的时候,聚合也不够了,要考虑后端做网格聚合,前端只取当前视野范围内的聚合结果。这种方案在设备监控类项目里很常见,思路是监听地图的移动和缩放事件,把视野边界传给后端,后端按网格返回带计数的聚合点。
5.4 地址搜索与表单回填
地址搜索要用服务类接口,注意前面说的 key 必须勾选 WebServiceAPI。推荐用联想搜索,响应快、结果轻:
async searchAddress(keyword) { const TMap = this._TMap; if (!this._suggestion) { this._suggestion = new TMap.service.Suggestion({ pageSize: 10 }); } const res = await this._suggestion.getSuggestions({ keyword, region: this.currentCity, region_fix: 1 }); return res.data || []; }输入框直接用 elementUI 的el-autocomplete,把fetch-suggestions指向上面这个方法,天然带防抖,省得自己写。回填表单的时候,除了拿到经纬度,一般还要把省市区结构化字段一起填上,这时候再用逆地址解析:
async reverseAddress(lat, lng) { const TMap = this._TMap; if (!this._geocoder) { this._geocoder = new TMap.service.Geocoder(); } const res = await this._geocoder.getAddress({ location: new TMap.LatLng(lat, lng) }); const result = res.result || {}; const comp = result.address_component || {}; return { address: result.address, province: comp.province, city: comp.city, district: comp.district }; }注意:逆地址解析是有配额的,列表页千万不要列表渲染时逐条调用。批量场景要么后端统一处理、要么加缓存,同一个经纬度重复解析没有任何意义。
6. 常见故障速查:白屏、Key 报错、尺寸错乱
6.1 地图白屏的六种可能
白屏是最高频的问题,我把排查顺序整理成了一张表,按这个顺序查基本十分钟内能定位:
| 现象 | 大概率原因 | 排查方式 |
|---|---|---|
| 容器区域纯白,控制台无报错 | 容器高度为 0 | 用开发者工具看容器盒子高度 |
| 控制台提示 key 相关错误 | key 无效/过期/域名未加白 | 看网络面板里脚本请求的返回内容 |
| 脚本请求 404 或跨域 | 脚本地址写错、版本参数非法 | 直接浏览器打开脚本 URL |
| 地图区域灰底带水印 | key 是演示 key 或配额耗尽 | 控制台查调用量与配额 |
| 地图出现但偏移严重 | 坐标系不一致 | 检查数据来源是否为 BD-09 |
| 偶发白屏,刷新就好 | 加载时序问题 | 检查是否在 mounted 前访问了实例 |
第一种占了一半以上。很多同学在 vue 里给地图容器写了height: 100%,但父级 div 是自适应高度,一路往上都没人给高度,最后算出来就是 0。解决方式要么给链条上一环设死高度,要么给容器min-height。
还有一种特别隐蔽的情况:地图在v-show隐藏的元素里初始化。v-show是display: none,元素不参与布局,宽高都是 0,这时候初始化地图,地图会以 0×0 的容器去计算渲染参数,之后即使显示了也是错乱的。正确做法是用v-if控制挂载时机,或者等显示之后再初始化。
6.2 弹窗和 Tab 里的地图尺寸不对
elementUI 的el-dialog默认是懒渲染的,关闭后销毁内容;el-tabs则是切换时隐藏非激活面板。这两种情况下,地图容器在初始化时的尺寸和最终展示尺寸往往对不上,表现就是地图只渲染左上角一小块,或者灰边一大片。
我通用的处理套路是:容器可见之后,等一帧再通知地图重新计算尺寸。
onDialogOpened() { this.$nextTick(() => { requestAnimationFrame(() => { if (!this._map) { this.initMap(); // 首次打开才初始化 } else { // 已有实例:把中心点重设一遍,让渲染器重算视口 this._map.setCenter(this._map.getCenter()); } }); }); }用setCenter重设中心点是个小技巧,它等价于「重新走一遍渲染流程」,比找 resize 方法更通用,各版本都吃这一套。放在el-dialog的opened事件里最稳,因为那时候动画也结束了,容器尺寸是最终值。
Tab 场景同理,监听el-tabs的tab-click或者用v-if控制每个面板的挂载,让地图在面板激活时才创建。多地图实例同时存在虽然能跑,但内存和渲染压力会翻倍,能避免就避免。
6.3 组件销毁与内存泄漏
判断有没有泄漏,最简单的办法是在页面上反复进出地图页面二十次,看浏览器内存曲线是不是一路向上。如果一直涨不回落,那就是没销毁干净。
常见的几个泄漏点:
- 地图实例没调用
destroy(); - 定时器没清;
- 事件用匿名函数绑定,解绑不掉;
- 在标记的
properties里存了整个业务对象数组的引用,导致地图对象和业务数据互相持有。
第四条很多人想不到。地图的geometry.properties里塞大数据是会出问题的,它会被 SDK 内部保留,组件销毁后如果 SDK 对象被别的地方引用,整棵数据树都释放不掉。我的习惯是properties里只放一个id,需要完整数据时用id回到业务数组里查,这样内存关系是单向的,清理起来没有死角。
6.4 el-table 固定列变透明与地图列表联动的坑
最后说一个和地图页经常一起出现的问题:elementUI 的表格固定列有时候会变透明,滚动的时候能看到下层的文字透上来。这个问题的根源通常在样式覆盖上,el-table 的固定列是独立的一层绝对定位元素,背景色继承自表格的主题变量,一旦你的全局样式或者主题配置把背景色变量改成了透明,或者用了backdrop-filter、transform之类的属性触发了新的层叠上下文,就会出现这个现象。
修复方式很直接,在全局样式里把固定列的背景补回去:
.el-table__fixed, .el-table__fixed-right { background-color: #fff; z-index: 3; } .el-table__fixed::before, .el-table__fixed-right::before { background-color: transparent; }地图页里为什么容易碰到?因为地图页的布局经常用transform做侧边栏的动画,而transform会让后代元素进入新的层叠上下文,固定列的定位和层级计算就会跟着变。我的建议是:地图页的侧边栏动画用left或者width,别用transform,虽然性能差一点点,但省掉一大堆层级问题,性价比很高。
另外地图和表格联动时,还有一个常见的性能陷阱:表格行悬停高亮对应的地图标记。这个逻辑如果不加节流,鼠标快速划过几十行的时候,地图会连续触发几十次updateGeometries,卡到怀疑人生。我一般用一个 100 毫秒的防抖兜底,交互反馈几乎无感,性能能提升一个量级。
7. 再往前走一步:轨迹回放与主题统一
7.1 车辆轨迹回放怎么做
轨迹回放的核心是三块:线、点、定时器。先用折线把所有轨迹点连起来,再让一个小车图标沿着线移动。
initTrack(points) { const TMap = this._TMap; const path = points.map((p) => new TMap.LatLng(p.lat, p.lng)); // 轨迹线 this._line = new TMap.MultiPolyline({ map: this._map, styles: { track: new TMap.PolylineStyle({ color: '#3b82f6', width: 6, borderWidth: 2, borderColor: '#ffffff', lineCap: 'round' }) }, geometries: [{ id: 'track', styleId: 'track', paths: path }] }); // 车辆图标 this._car = new TMap.MultiMarker({ map: this._map, styles: { car: new TMap.MarkerStyle({ width: 32, height: 32, anchor: { x: 16, y: 16 }, src: require('@/assets/car.png') }) }, geometries: [{ id: 'car', styleId: 'car', position: path[0] }] }); this._index = 0; this._timer = setInterval(() => { if (this._index >= path.length) { clearInterval(this._timer); this._timer = null; return; } this._car.updateGeometries([{ id: 'car', styleId: 'car', position: path[this._index] }]); this._index += 1; }, 200); }几点经验。速度别设太快,200 毫秒一个点在大多数场景下体验刚好,太快了像瞬移。轨迹点如果很密,先做抽稀,不然几万个点连成线,渲染和回放都会卡。还有个小细节,小车图标要根据行驶方向旋转,可以在更新位置时用相邻两点的坐标算一下方位角,把rotation属性带上,效果会自然很多。
很多车载监控类的需求旁边还会挂一路实时视频,HLS 的 m3u8 流在 vue 里用播放器库挂到 video 标签上就行,重点是把视频的时间戳和轨迹点的时刻对齐,这样拖进度条的时候地图上的车也能跟着跳到对应位置,两边同步做好了,整个回放功能的专业感立刻就上来了。
7.2 自定义覆盖物与主题统一
默认的标记和信息窗口风格跟后台系统的视觉规范往往不搭。有两条路可以统一:一条是底图定制,在控制台里配置底图样式、生成样式标识、初始化时带上,能把路网颜色、水域颜色都调成跟系统主题一致的色系;另一条是覆盖物全部自定义。
自定义覆盖物用DOMOverlay,你可以把任意 DOM 结构贴到地图上,好处是能直接用 HTML 和 CSS 写,坏处是数量多了会拖慢渲染,超过一百个就不建议用了。我的一般原则是:数量少、样式要求高的用 DOM 覆盖物,数量多的用图片标记。
信息窗口的样式统一还有一个取巧的办法:把系统的主题色定义成 CSS 变量,信息窗口的 HTML 字符串里直接引用这些变量,底图换个皮肤,窗口颜色自动跟着变,不用改两处代码。
提示:自定义覆盖物的样式一定要加作用域前缀,地图浮层是挂在文档流末尾的,很容易被全局样式误伤。用类名前缀或者 CSS Modules 都行,别直接写
.title {}这种裸选择器。
最后分享一个我踩过的坑。地图组件的初始化我一开始是写在mounted里的,后来为了首屏速度做了路由懒加载和组件异步化,结果某些低端手机上,组件mounted的时候容器还没完成布局,地图初始化的尺寸是错的。后来我改成在mounted里用requestAnimationFrame包一层再初始化,这个问题就再没出现过。多花的那一帧,换来的是稳定性,我觉得划算。