做过地图业务的兄弟应该都清楚,Leaflet 本身是一套特别轻、特别灵活的地图库,API 设计得也足够清爽,但一旦要在 Vue 项目里落地,直接用原生写法往往会遇到一堆重复劳动:地图实例的创建、生命周期管理、图层增删、事件监听、组件卸载时的清理……这些逻辑散落在各个业务页面里,维护成本很快就上来了。所以很多前端团队都会做一件事:把 Leaflet 封装成 Vue 组件,形成一套内部的“地图组件库”,让业务方只需要写几个标签、传几个 props 就能完成地图展示和交互。这篇文章就围绕“leaflet封装vue组件”这件事,把我在实际项目里踩过的坑、总结出来的设计思路、完整的实现代码,以及排查问题的经验一次性整理出来。无论是刚接触地图组件的初级前端,还是准备搭建团队级地图组件库的同学,都可以从里面找到能直接照搬的方案。
1. 封装之前,先想清楚这三件事
1.1 业务里那些“地图需求”到底长什么样
先别急着写代码,先看看真实业务里地图页面到底在改什么。我接触过的项目里,地图需求大概有这几种形态:展示一批设备或门店的位置,点击标记点弹出详情;根据搜索条件筛选点位,动态更新标记;让用户在地图上绘制范围,把坐标回传给后端;还有做轨迹回放、热力图、区域聚合的。这些需求有一个共性——它们都离不开“地图实例”和“图层数据”这两个核心。
如果你在 Vue 单文件组件里直接写 Leaflet 逻辑,会发现每个页面都在重复做同样的事:ref一个 div,mounted里初始化地图,watch数据变化之后手动同步图层,页面销毁前把地图 destroy 掉。代码量不大,但架不住页面多。更要命的是,每个页面开发的思路还不一样,有的人把地图实例挂到window上,有的人放在组件的data里,图层更新的方式更是五花八门,最后接手的人只能对着十几个页面逐个救火。
1.2 直接裸用 Leaflet 的问题清单
把问题摊开来看,裸用 Leaflet 在 Vue 里主要有四个痛点:
第一,地图实例的生命周期和 Vue 组件的生命周期是脱节的。Vue 组件销毁了,地图实例和它挂载的容器可能还残留在内存里,时间长了页面卡顿甚至报错。
第二,图层管理太啰嗦。每次数据变化都要手动clearLayers()再addLayer(),还要记得哪些图层加了、哪些该移除。这个逻辑放在业务代码里既埋没重点,又容易出 bug。
第三,事件监听不够“Vue”。Leaflet 原生是map.on('click', fn)这种写法,业务里监听了什么、什么时候解绑,全靠自觉。一旦忘了移除监听,交互就会重复触发,尤其是在多个页面来回切换的时候。
第四,扩展成本高。今天要实现一个带弹窗的 Marker,明天要加一个聚合图层,后天又要自定义控件。如果这些逻辑都堆在业务组件里,组件会越来越胖,边界越来越模糊。
1.3 封装方案选型:Vue2 还是 Vue3,全量还是按需
动手之前还要定方案。我现在的项目用的 Vue3 + Vite,但很多存量项目还在 Vue2。封装思路可以统一,这是另一回事:Vue3 里用到provide/inject做地图实例下发会非常顺手;Vue2 里可以通过$parent或者一个简单的状态管理来解决。如果你是从零开始的新项目,我建议直接 Vue3,生态已经非常成熟了。
再说 Leaflet 的引入方式。Leaflet 的 CSS 必须全局引入,这个跑不掉。JS 模块可以整体import L from 'leaflet',也可以用leaflet/dist/leaflet-src.esm.js做按需。我的实践经验是:全量引入问题最少。Leaflet 本身体积不大(压缩后不到 42KB),按需引入省下的带宽非常有限,但带来的模块解析问题却不少,最经典的就是 marker 图标在打包工具里路径错乱。与其折腾那些,不如全量引入,把性能优化精力花在图层数据量上。
提示:无论用哪种构建工具,都记得把 Leaflet 的 CSS 放到项目入口或地图组件里显式引入,否则地图瓦片会显示出来,但控件图标、缩放按钮的位置和样式全部错乱。
2. 组件体系整体设计与目录结构
2.1 先想清楚组件边界
封装组件最怕的就是一个“大泥球”——一个 Map 组件接收所有配置,再通过一堆 props 控制一切。这种方案看起来简单,写着写着就会变得不可维护。我建议划分成三层:
第一层是地图根组件,负责初始化地图、管理地图实例、处理底图切换和基础交互事件。对外就暴露:中心点、缩放级别、地图配置、图层切换能力。
第二层是图层组件,比如 Marker、Popup、Polyline、Polygon,把它们设计成与地图根组件相互嵌套的组件。数据通过 props 传入,内部通过注入的地图实例把自己加到地图上。
第三层是业务封装层,就是具体业务场景的组合组件。比如“设备分布组件”内部由根组件 + Marker 聚合 + Popup 弹窗组合而成,业务页面只管传入设备列表。
这样划分的收益很直接:每一层都只关心自己的职责,地图的初始化、图层的数据绑定、业务的自定义交互,互相不干扰。出了问题也很好定位,是图层没加上、还是数据没传入,看一眼组件边界就明白了。
2.2 一个实际可用的目录结构
我项目里的组织方式是这样的:
src/ ├── components/ │ └── map/ │ ├── index.js # 统一导出入口 │ ├── LMap.vue # 地图根组件 │ ├── LTileLayer.vue # 底图图层组件 │ ├── LMarker.vue # 标记点组件 │ ├── LPopup.vue # 弹窗组件 │ ├── LPolyline.vue # 线图层组件 │ ├── LPolygon.vue # 面图层组件 │ ├── useMap.js # 管理地图实例的 composable │ └── mixins/ │ └── layerMixin.js # 图层组件的公共逻辑(Vue2 场景用)如果你在 Vue2 项目里,可以把useMap.js换成mapBus.js,用事件总线或简单的 store 把地图实例共享出来。思路一致,实现上略微调整就行。
2.3 组件通信设计:父传子、子传父、跨层传递
地图组件体系的通信比普通业务组件要复杂一点,因为地图实例本身需要从根组件“渗透”到任意层级的子组件里。我在设计时主要用了三套机制:
props 向下传数据。比如 LMarker 的position、LPolyline 的points、LTileLayer 的url,这些都是典型的父传子场景。数据流向始终单向,子组件内部 watch props 变化,决定怎么更新图层。
事件向上抛。地图的点击、缩放、标记点的拖拽结束,这些交互行为通过$emit抛给父级。业务方在自定义组件里监听这些事件,跟平时写 Vue 组件没有任何区别。
provide/inject 实现跨层共享。地图实例就是跨层共享的数据,LMap 把自己通过provide提供出去,任何深度的子组件都能inject到。这个方案比逐层$parent传递优雅得多,也符合 Vue3 官方推荐的依赖注入思想。
有人会问,为什么不用 Vuex 存地图实例?我的看法是:地图实例本质上不是“全局状态”,它和组件树的生命周期强相关。放进全局 store 里,反而容易造成实例泄漏——组件销毁了,store 里还留着旧实例,后面拿到的全是被销毁的地图,各种 bug 飞起。
2.4 生命周期对接:创建、更新、销毁的全流程
封装的核心工作,其实就是把 Leaflet 的 API 调用翻译成 Vue 组件的生命周期钩子。拿地图根组件举例,完整流程是:
组件mounted时拿到容器 DOM,执行L.map(container, options)创建实例;beforeUnmount时执行map.remove()释放资源。这里有个关键点:地图的创建必须在容器真正渲染完成之后。所以代码里要写在mounted钩子里,或者配合nextTick。
子图层的生命周期就更典型了:mounted里map.addLayer(layer),beforeUnmount里map.removeLayer(layer),props 变化时通过layer.setXXX()更新。我见过不少初学者的代码,在 watch 里直接addLayer新实例,旧图层也不移除,最后地图上叠了一堆重复图层。正确的姿态是:watch 里只更新,不新增。
3. 核心部分具体实现与踩坑点
3.1 地图根组件的实现:一切的起点
我把LMap.vue的代码贴出来,值得注意的地方我在注释里标了:
<template> <div class="lm-map-container"> <slot /> </div> </template> <script setup> import { ref, provide, onMounted, onBeforeUnmount, watch } from 'vue' import L from 'leaflet' import 'leaflet/dist/leaflet.css' const props = defineProps({ center: { type: Array, default: () => [30.5, 114.3] }, zoom: { type: Number, default: 12 }, maxZoom: { type: Number, default: 18 }, minZoom: { type: Number, default: 3 }, scrollWheelZoom: { type: Boolean, default: true }, mapOptions: { type: Object, default: () => ({}) } }) const emit = defineEmits(['ready', 'moveend', 'click']) const containerRef = ref(null) let mapInstance = null // 把自己提供给所有后代组件 provide('mapInstance', { map: () => mapInstance }) onMounted(() => { mapInstance = L.map(containerRef.value, { center: props.center, zoom: props.zoom, maxZoom: props.maxZoom, minZoom: props.minZoom, scrollWheelZoom: props.scrollWheelZoom, ...props.mapOptions }) emit('ready', mapInstance) mapInstance.on('moveend', () => { const center = mapInstance.getCenter() emit('moveend', { center: [center.lat, center.lng], zoom: mapInstance.getZoom() }) }) mapInstance.on('click', (e) => { emit('click', { latlng: [e.latlng.lat, e.latlng.lng] }) }) // 监听中心点变化,注意判断来源,避免地图移动又触发视图更新 watch(() => props.center, (val) => { const [lat, lng] = val // 如果当前中心点和目标点接近,就别飞过去了 const current = mapInstance.getCenter() if (Math.abs(current.lat - lat) > 0.0001 || Math.abs(current.lng - lng) > 0.0001) { mapInstance.setView([lat, lng]) } }) }) onBeforeUnmount(() => { if (mapInstance) { mapInstance.remove() mapInstance = null } }) // 容器尺寸改变时通知地图重新计算,否则会出现只显示一半的灰色块 const resize = () => { if (mapInstance) { mapInstance.invalidateSize() } } // 暴露一个方法,供需要手动触发渲染的父组件调用 defineExpose({ resize }) </script> <style scoped> .lm-map-container { width: 100%; height: 100%; position: relative; } </style>这段代码里有几个细节要说明:
第一,provide我提供了一个返回地图实例的函数,而不是直接 provide 一个 ref。原因是地图实例在mounted之后才存在,如果 provide 一个初始为 null 的对象,子组件的inject就只能拿到 null,还要再做一堆空值判断。用函数的话,子组件每次可以拿到最新值。
第二,watch center里做了一个距离判断。如果不判断,用户手动拖动地图触发moveend,父组件收到了新 center 又把它传回来,就会导致地图“自己拽自己”——用户在地图上拖一下,地图又弹回原地。这个问题很容易被忽略,我在实际项目里就是因为这个 watch 被调用链绕进去过,搞了半天才定位到。
第三,invalidateSize()这个 API 一定要知道。凡是地图容器初始是隐藏的(比如在弹窗、Tab 切换、抽屉里),组件挂载完地图都拿不到正确的宽度高度,等容器显示的时候地图就是一块灰板。解决办法就是在容器显示后调用map.invalidateSize(),让地图重新计算尺寸。
3.2 图层组件的公共逻辑抽象
子组件的公共逻辑其实很固定,我总结为四个步骤:inject 地图实例、mounted 加图层、watch 更新、beforeUnmount 移除。这四步如果每个组件都完整写一遍,代码重复率极高。所以我做了一个useLayerLayer的 composable(Vue2 可以用 mixin 实现同样的效果)。
// useLayer.js import { inject, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue' export function useLayer(createLayer, updateLayer) { const { map } = inject('mapInstance') let layer = null onMounted(() => { const instance = map() if (!instance) return layer = createLayer(instance) if (layer) layer.addTo(instance) }) onBeforeUnmount(() => { if (layer && map()) { map().removeLayer(layer) } }) return { getLayer: () => layer, // 由组件内部调用,子组件 watch props 时更新 update: (props) => { if (!layer || !map()) return updateLayer(layer, props, map()) } } }使用的时候,比如 Marker 组件内部就是:
const { getLayer, update } = useLayer( (mapInstance) => L.marker(props.position, markerOptions), (layer, props) => { layer.setLatLng(props.position) // icon 等其他属性更新 } )这个抽象的收益在于,新增加一种图层组件时,只需要关心“创建”和“更新”两个方法,而生命周期管理、实例获取、移除清理这些通用逻辑全部复用。
3.3 组件通信:父传子、子传父、跨层传递的实际代码
刚才设计里提到了三套通信机制,这里补上几句实际的落地经验。
props 方向。子组件会 watchprops.position、props.points这样的数据。如果数据是一个大数组(比如轨迹点),Vue 的深度 watch 会有性能开销,而且 Leaflet 的 setter(比如setLatLngs)本身需要数组引用,不需要逐项比较。我的做法是浅 watch 数据引用,只有引用变化才去调 setter:
watch(() => props.points, (val) => { if (layer) polyline.setLatLngs(val) })事件方向。如果子组件内部有交互(比如 Marker 拖拽),$emit出去的事件名遵循@update:position这种 update: 前缀模式,方便配合v-model:position使用。
provide/inject 跨层传递。除了注地图实例,我还会注一个“父图层实例”。比如 LMarker 内部嵌套了一个 LPopup,Popup 需要知道 Marker 的实例才能绑定到 Marker 上。这个场景同样用 provide/inject 解决,只不过 provide 的键从mapInstance换成了parentLayer。这套机制让嵌套组件的层级关系自由伸缩,不需要硬编码父子关系。
3.4 Slot 插槽:让 Popup 内容完全掌握在业务手里
Popup 组件我最推荐做成容器组件,内部通过<slot>接收业务侧传入的任意模板内容,然后在地图上打开弹窗时把这个 slot 的 DOM 挪进 Leaflet 的 Popup 容器里。
关键代码如下:
<script setup> import { h, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue' import L from 'leaflet' const props = defineProps({ content: { type: String, default: '' } }) const emit = defineEmits(['open', 'close']) const { map } = inject('mapInstance') let popup = null let slotHost = null let currentMarker = null // 由父组件传入 onMounted(() => { // 把当前组件渲染生成的 DOM 提取出来作为弹窗内容 slotHost = document.createElement('div') // 渲染插槽内容到 slotHost const slotContent = getCurrentInstance().slots.default?.() if (slotContent) { slotHost.append(...slotContent.map(vnode => { const el = document.createElement('div') // 这里要做 vnode 到 DOM 的挂载,项目里借助 h 函数和 render 更简单 return el })) } popup = L.popup({ content: slotHost, closeButton: true }) popup.on('add', () => emit('open')) popup.on('remove', () => emit('close')) }) </script>这个写法里最麻烦的是把 Vue 的 VNode 渲染成 DOM 再塞给 Leaflet。我实际用的方案是:在自己的组件里维护一个隐藏的容器 div,把这个 div 作为 Popup 的 content,再把插槽内容渲染到这个 div 里。用户的插槽模板可以正常使用 Vue 的响应式、事件绑定、组件嵌套,Popup 显示的就是一个活生生的 Vue 应用片段,而不是静态字符串。
Vue2 的场景里这个操作更麻烦一点,需要new Vue({ render: h => h(...) }).$mount(),但在 Vue3 里借助render函数和createApp也能实现,只是要注意清理,避免组件卸载时弹窗内容还在内存里。
3.5 动态组件与异步加载
地图组件库里有一个经常被忽略的需求:业务页面可能需要按需加载某些图层类型,尤其是聚合图层、热力图这种体积较大、又不一定能用上的功能。
我的做法是把复杂图层做成异步组件。通过defineAsyncComponent加载,地图业务页面只引用它需要的那几个组件。需要注意的是,异步组件在渲染完成前,地图容器可能已经创建了,图层组件加载完成后才去添加图层,这个时序上不会冲突,因为我们的useLayer逻辑是在组件的onMounted里执行的,而onMounted一定发生在异步组件加载完成之后。
另外,动态组件标签(<component :is="xxx">)在图层组件里也很有用。比如一个图层列表,可以根据 item.type 决定渲染 LMarker 还是 LPolygon,代码结构一目了然。
4. 实操过程:从零到一完整搭建,可直接抄作业
4.1 环境准备与依赖安装
先初始化 Vue3 项目,然后安装依赖:
npm create vite@latest my-map-app -- --template vue cd my-map-app npm install leaflet这里顺便说下 Vue 环境的常见坑。我之前遇到过一个诡异问题,地图能出来,但 Leaflet 的缩放控件图标全是小叉号,仔细排查发现是 CSS 没引对。不要把 CSS 放到某个组件的 style 里,也不要放到 scoped 样式里,直接在 main.js 里import 'leaflet/dist/leaflet.css'最稳。
4.2 搭建 LMap 根组件与底图切换能力
按 3.1 节的 LMap 组件搭好根容器后,先加一个底图切换的能力。底图的本质就是不同的 TileLayer,我通过一个tileLayerComponent来管理:
<script setup> import { inject, onMounted, onBeforeUnmount } from 'vue' const props = defineProps({ url: { type: String, required: true }, attribution: { type: String, default: '' }, options: { type: Object, default: () => ({}) } }) const { map } = inject('mapInstance') let tileLayer = null onMounted(() => { tileLayer = L.tileLayer(props.url, { attribution: props.attribution, ...props.options }) tileLayer.addTo(map()) }) watch(() => props.url, (newUrl) => { if (!tileLayer) return tileLayer.setUrl(newUrl) }) onBeforeUnmount(() => { if (tileLayer) tileLayer.remove() }) </script>之后地图根组件里就可以自由嵌套底图层:
<LMap :center="[30.5, 114.3]" :zoom="12"> <LTileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> </LMap>三层结构已经可以跑通了。接下来加入 Marker 和 Popup。
4.3 实现 LMarker 组件与 Popup 插槽
Marker 的封装核心在于:position 变化时更新坐标、icon 变化时重新设置图标、嵌套 Popup 时把弹窗绑定到当前 marker 上。
<script setup> import { inject, watch, onMounted, onBeforeUnmount } from 'vue' const props = defineProps({ position: { type: Array, required: true }, icon: { type: Object, default: null }, draggable: { type: Boolean, default: false }, options: { type: Object, default: () => ({}) } }) const emit = defineEmits(['update:position', 'click']) const { map } = inject('mapInstance') let marker = null const createMarker = () => { marker = L.marker(props.position, { icon: props.icon, draggable: props.draggable, ...props.options }) marker.addTo(map()) marker.on('dragend', () => { const latlng = marker.getLatLng() emit('update:position', [latlng.lat, latlng.lng]) }) marker.on('click', () => emit('click')) } onMounted(createMarker) watch(() => props.position, (val) => { if (marker) marker.setLatLng(val) }) watch(() => props.icon, (val) => { if (marker) marker.setIcon(val) }) onBeforeUnmount(() => { if (marker) marker.remove() }) defineExpose({ getMarker: () => marker }) </script>注意这里的 drag 结束事件会反向 emitupdate:position,配合父组件写v-model:position就能实现双向绑定的效果。
Popup 的嵌套需要一点小技巧。LMarker 内部对默认插槽做了一次“劫持”——当检测到插槽里有 LPopup 组件时,把它的内容渲染绑定到 marker 上,而不是让 LPopup 自己去找地图加弹窗。这个逻辑可以在组件间通过 provide/inject 传递 marker 实例来实现,跟地图实例的传递同一个思路。
4.4 给地图组件加上旋转能力
热搜词里有个“leaflet地图旋转”,实际场景中确实有——比如某些业务地图需要把整个地图倾斜展示,或者设备方向与地图方向不一致。Leaflet 原生没有公开的旋转 API,但可以通过修改地图容器 transform 或者使用leaflet-rotatedmarker这类插件做单个 Marker 旋转。
如果是整图旋转,我的建议是不要硬转 Leaflet 内部,而是在容器外再加一层样式 transform:
.map-rotate-20 { transform: rotate(20deg); }但这里有一个严重问题:旋转之后地图上的交互(点击、拖拽)坐标会跟着错位,因为 Leaflet 还认为自己是正北方向。所以最靠谱的方案是结合业务场景去评估,普通展示需求用 CSS transform 没问题,需要精确交互的地图就不要加旋转了。
如果你只想要某个 Marker 图标旋转(比如车辆方向的箭头),那直接给L.marker传一个带旋转角度的自定义 divIcon 就行,这个完全没有副作用:
L.divIcon({ html: '<div class="car-icon" style="transform: rotate(45deg)">→</div>', className: '' })4.5 在业务页面里组合使用
封装完成后的业务页面代码极其清爽。以“设备分布页”为例:
<template> <div class="device-page"> <LMap :center="mapCenter" :zoom="13" @ready="onMapReady"> <LTileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> <LMarker v-for="device in devices" :key="device.id" :position="device.position" > <LPopup> <div class="device-info"> <h4>{{ device.name }}</h4> <p>{{ device.status }}</p> </div> </LPopup> </LMarker> </LMap> </div> </template>业务方只需要关心“设备列表从哪来、点开弹窗显示什么”,地图的底层逻辑被完全隔离在组件库内部。这才是封装该有的样子。
5. 踩坑实录:常见问题与排查速查表
5.1 地图容器尺寸为 0 的经典问题
症状:地图只显示灰色背景,或者地图瓦片加载不出来,控制台报Map container is being reused。
原因:地图初始化时容器的宽高为 0,Leaflet 计算视口尺寸失败。
排查顺序:
- 确认容器 CSS 有明确的宽高,
height: 100%需要父级也有高度。 - 确认地图组件是否在
v-if、v-show、display:none的容器里,是的话等容器显示后再调invalidateSize()。 - 确认初始化时机,不要在数据加载前就初始化地图。
我的经验是:地图组件尽量独立占满一个路由页面或弹窗层级,不要把地图塞进一个高度不定的普通 div 里当小部件。如果非要塞进弹性布局里,就监听容器尺寸变化(ResizeObserver),自动调invalidateSize()。
5.2 初始化时机与 nextTick 的坑
有些开发者在setup里直接拿容器 ref 创建地图,结果拿到的是 null。原因很简单:setup执行时组件还没有挂载到 DOM 上,容器元素还不存在。
我之前还见过一个更隐蔽的坑:地图组件在同一页面多个 Tab 里切换,Tab 内容用v-if控制显示。第一次切换到 Tab 地图正常显示,第二次切换时地图报错或者白屏。原因是前一次创建的地图实例没有销毁,容器 DOM 被 Vue 移除后,Leaflet 内部还在尝试操作旧容器。
解决思路就两条:
- 地图组件挂载时检查容器是否存在,不存在就等待。
- 组件卸载时一定要
map.remove(),不是map.off()也不是只清空 DOM。
5.3 属性更新不生效:setXXX 还是 addLayer
这是封装子图层组件时最高频的问题。很多人这样写 Marker 的更新:
watch(() => props.position, (val) => { L.marker(val).addTo(map()) // 错误:每次新建一个 marker })结果就是地图上堆积了一堆标记点。正确的做法是:
watch(() => props.position, (val) => { marker.setLatLng(val) // 正确:复用同一实例 })Leaflet 几乎每个图层都有对应的 setter:Marker 有setLatLng,Polyline/Polygon 有setLatLngs,Circle 有setLatLng和setRadius,TileLayer 有setUrl。封装的 composable 里,updateLayer 就是把这些 setter 对应起来的地方。
5.4 内存泄漏与组件销毁
Leaflet 实例如果不主动 remove,它的 DOM 事件监听、Tile 请求这些都会残留。Vue 组件卸载了,但 map 实例还活在地图上,尤其是 SPA 里地图页面被频繁切换时,内存会持续增长。
我在封装时立了几条规矩:
- 地图根组件
beforeUnmount里必须map.remove()。 - 所有子图层组件必须移除自己创建的图层。
- 所有
map.on的监听都要对应map.off,或者直接依赖地图实例销毁来清理。 - 不要在地图实例上挂业务数据,尤其不要挂引用类型的对象,容易造成闭包引用链。
5.5 大批量 Marker 性能优化
几千个 Marker 一次性加到地图上,页面会明显卡顿。我实测的经验是:
数据量在 1000 个以内时,直接循环添加 Marker 还能接受;超过 1000,我会优先考虑切换为 Canvas 渲染图层,或者做聚合图层。
Leaflet 生态里做聚合最成熟的是leaflet.markercluster。在封装组件库里可以做一个LMarkerCluster组件,内部接收一个markers数组,创建聚合图层后不断替换数据。注意聚合图层的chunkedLoading和animation参数也要根据数据量调节。
另一个优化思路是用 Canvas 图层替代 DOM Marker。把大量点位绘制到 Canvas 上,几十万个点也能丝滑。封装实现也简单:做一个LPointLayer组件,内部用L.canvas图层加上几百行的 Canvas 绘制代码,对外只接收 points 数组。
5.6 常见问题速查表
| 问题 | 典型原因 | 快速解法 |
|---|---|---|
| 地图灰屏 | 容器尺寸为 0 | 检查 CSS 或调用invalidateSize() |
| 缩放控件图标错乱 | CSS 加载顺序错误 | 全局引入 leaflet.css |
| Marker 图标失踪 | 打包路径错乱 | 引 marker-icon.png 的 CDN 地址或自定义 divIcon |
| Popup 点击不响应 | DOM 被 Leaflet 拖到外部 | 用事件委托或检查事件冒泡 |
| 拖一下地图又弹回 | center watch 循环 | 在 watch 里做距离判断 |
| 内存只增不减 | map.remove() 没调用 | 检查根组件销毁逻辑 |
| 异步组件加载后地图空白 | 容器时机不对 | 异步组件加载完再调invalidateSize() |
| 多个 Tab 地图混乱 | 地图实例未被销毁 | 确保每次 Tab 切走的组件都执行移除逻辑 |
6. 最后分享几点我个人的实操体会
封装 Leaflet 组件这件事,做得越早,后面业务验收时越省心。我最大的体会是:别一上来就想着封装成通用组件库,先把自己项目里的三个真实页面用起来,踩过一遍生命周期、数据更新、事件绑定的坑,再回头抽象公共逻辑。直接闭门造车造出来的组件库,很可能和实际业务脱节,定出来的 API 也不好用。
还有一个小技巧:给地图组件库配一个独立的演示页面,把 LMap、LMarker、LPopup、LPolyline 这些基础组件的用法全部列出来,每新增一个能力就同步更新演示页。这个页面对内部业务团队是最好的文档,比写 markdown 文档实用得多,我也是被业务组同事问烦了之后才意识到这一点的。
如果后续想继续扩展,可以考虑把这些组件包发布成团队内部私有的 npm 包,版本管理、按需加载、类型定义都可以做得更完善。但前期别急,先把核心封装的稳定性打磨好,才是这套组件库能不能被长期使用的关键。