简介:这份资源面向使用 ArcGIS API 4.x for JavaScript 进行 Web GIS 开发的学习者与前端工程师,聚焦地图加载与多气泡窗口(Popup)展示这一常见交互场景。压缩包内共 1 个文件,为单个 html 页面,整体约 3KB,可直接在浏览器中打开运行,便于快速理解地图初始化、MapView 挂载与弹窗配置的完整流程。内容围绕 Map、MapView 的创建,PopupTemplate 的标题与内容模板定义,以及通过 FeatureLayer 绑定弹窗、监听 click 事件并调用 openPopup 实现多气泡展示等关键环节展开,同时涉及 hitTest 命中检测与弹窗管理思路。目前已有 157 人学习,适合希望掌握 ArcGIS API 4.x 弹窗机制、需要可运行示例对照调试的开发者参考,也可作为课堂演示或项目原型的起点。
1. 地图上同时弹出多个气泡窗口:ArcGIS API 4.x for JS 的 Popup 到底该怎么用
做 GIS 前端的同学大概率遇到过这种需求:一张底图上撒了几十个点位,用户点一下「全部展开」,每个点旁边都要冒出一个信息卡片,显示名称、地址、状态。用 ArcGIS API 4.x for JS 的时候,第一反应往往是view.popup.open(),结果发现——不管调多少次,屏幕上永远只有一个气泡窗口,后一个把前一个顶掉了。这不是代码写错了,而是Popup这个组件从设计上就是单例的,一个View只绑定一个popup实例。
那标题里说的「地图加载多个气泡窗口展示」到底能不能做?能,但要绕开内置 Popup,走自定义Graphic+TextSymbol或者GraphicsLayer+ HTML 元素的路线。这篇笔记就围绕这个场景,把选型理由、最小可复现代码、参数调优和几个我踩过的坑讲清楚。适合已经能用 ArcGIS API 4.x 加载地图和图层、但被多气泡需求卡住的前端和 GIS 开发同学。下面所有代码基于 4.x 的模块化写法(esri/Map、esri/views/MapView这一套),不涉及 3.x 的dojo老写法。
2. 为什么内置 Popup 撑不起多气泡:先搞懂它的单例机制
2.1 Popup 与 View 的绑定关系
ArcGIS API 4.x 里,MapView和SceneView都自带一个popup属性,类型是esri/widgets/Popup。这个 Popup 在 View 初始化时就被创建,并且和 View 的 DOM 容器绑定。你调用view.popup.open({ location, features })的时候,它做的是:把传入的 features 渲染到同一个弹窗容器里,然后定位到location。因为容器只有一个,所以无论你传几组 features,最终都挤在同一个弹窗里,或者后一次调用直接覆盖前一次。
官方文档里Popup.open()的参数是OpenPopupOptions,里面features是个数组,但这个数组的语义是「同一个位置上的多个要素」,不是「多个位置各弹一个」。这就是很多人第一次翻车的地方——以为传数组就能多弹,实际是同一个气泡里做分页。
2.2 多气泡的三种可行路线对比
既然内置的不行,就得自己造。常见做法有三条,我一般会按下面的维度选:
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| TextSymbol 气泡 | Graphic 用 TextSymbol,字符用带背景的符号 | 纯 GIS 对象,随地图缩放平移 | 样式受限,复杂 HTML 难做 | 只显示短文本标签 |
| GraphicsLayer + 自定义符号 | 用SimpleMarkerSymbol叠加TextSymbol | 可控性比纯 TextSymbol 好 | 仍难嵌 HTML | 中等复杂度标注 |
| HTML 覆盖层 + 屏幕坐标 | 监听 view 事件,把地图坐标转屏幕坐标,绝对定位 div | 完全自由,能写任意 HTML/CSS | 需手动同步,性能要管 | 信息卡片、多气泡展示 |
标题里的「多个气泡窗口展示」,绝大多数真实需求是要显示名称、地址、状态甚至按钮,所以第三条 HTML 覆盖层是主力方案。前两条适合轻量标注。下面重点讲第三条,同时给出前两条的补充写法。
2.3 坐标转换是核心:toScreen 与 watch
HTML 覆盖层的关键是:地图上的点 (x, y) 要实时映射到屏幕像素。ArcGIS API 4.x 提供了view.toScreen(mapPoint),返回{x, y}屏幕坐标。地图一动,屏幕坐标就变,所以必须监听view的变化事件。常用的是view.watch('stationary', ...)或者监听view.on('drag', ...)、view.on('mouse-wheel', ...)。更省事的做法是监听view.watch('extent', ...)和view.watch('zoom', ...),但这两个触发频率高,要做节流。
我一般会用一个requestAnimationFrame包一层,避免拖动时疯狂重排 DOM。下面第 3 章给出完整的最小可复现例子。
3. 用 HTML 覆盖层实现多气泡:最小可复现步骤
3.1 页面骨架与模块引入
先搭一个最简的 HTML 页面,引入 ArcGIS API 4.x 的 CSS 和 JS。注意 4.x 的 AMD 模块写法,用require加载esri/Map、esri/views/MapView、esri/Graphic、esri/layers/GraphicsLayer等。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>多气泡窗口示例</title> <!-- 引入 4.x 主题样式,路径按你实际部署的版本调整 --> <link rel="stylesheet" href="https://js.arcgis.com/4.29/esri/themes/light/main.css" /> <style> html, body, #viewDiv { margin: 0; padding: 0; height: 100%; width: 100%; } /* 气泡容器铺满地图,但本身不拦截鼠标事件 */ #bubbleLayer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; overflow: hidden; } /* 单个气泡,pointer-events 打开,才能点关闭按钮 */ .bubble { position: absolute; min-width: 160px; background: #fff; border: 1px solid #d9d9d9; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.15); padding: 8px 10px; font-size: 13px; pointer-events: auto; transform: translate(-50%, -100%); /* 让气泡底部尖角对准点位 */ } .bubble .close { position: absolute; top: 4px; right: 6px; cursor: pointer; color: #999; font-size: 14px; } .bubble .title { font-weight: 600; margin-bottom: 4px; } </style> </head> <body> <div id="viewDiv"></div> <div id="bubbleLayer"></div> <script src="https://js.arcgis.com/4.29/"></script> <script> // 主逻辑写在下面 3.2 </script> </body> </html>这里#bubbleLayer用pointer-events: none让整层不挡地图操作,单个.bubble再打开pointer-events: auto,这样既能点气泡里的按钮,又不影响拖地图。transform: translate(-50%, -100%)是让气泡的底部中心对准点位,配合后面加的小三角。
3.2 初始化地图与点位数据
接着在require里初始化地图,准备一组点位数据。这里用GraphicsLayer存点,方便统一管理。
require([ "esri/Map", "esri/views/MapView", "esri/Graphic", "esri/layers/GraphicsLayer", "esri/geometry/Point" ], function (Map, MapView, Graphic, GraphicsLayer, Point) { const map = new Map({ basemap: "topo-vector" }); const view = new MapView({ container: "viewDiv", map: map, center: [116.397, 39.908], // 北京附近 zoom: 12 }); const pointLayer = new GraphicsLayer(); map.add(pointLayer); // 模拟点位数据:经纬度 + 业务字段 const points = [ { lon: 116.397, lat: 39.908, name: "点位A", status: "在线" }, { lon: 116.410, lat: 39.915, name: "点位B", status: "离线" }, { lon: 116.385, lat: 39.900, name: "点位C", status: "在线" } ]; // 存下每个点对应的气泡 DOM,方便后续更新位置 const bubbleMap = new Map(); // key: graphic.uid, value: HTMLElement points.forEach(function (p) { const point = new Point({ longitude: p.lon, latitude: p.lat }); const graphic = new Graphic({ geometry: point, attributes: p, symbol: { type: "simple-marker", style: "circle", size: 10, color: "#1e88e5", outline: { color: "#fff", width: 1.5 } } }); pointLayer.add(graphic); }); // 后续逻辑见 3.3 });Graphic的attributes里存业务字段,后面生成气泡内容时直接读。bubbleMap用graphic.uid做键,把 GIS 对象和 DOM 关联起来,这是同步位置的基础。
3.3 生成气泡 DOM 并同步屏幕坐标
核心逻辑:遍历pointLayer.graphics,为每个 graphic 创建一个.bubblediv,插入#bubbleLayer,然后写一个updateBubbles()函数,把每个点的地图坐标转屏幕坐标,设置left和top。
const bubbleLayer = document.getElementById("bubbleLayer"); // 为每个 graphic 创建气泡 DOM pointLayer.graphics.forEach(function (graphic) { const attr = graphic.attributes; const el = document.createElement("div"); el.className = "bubble"; el.innerHTML = '<span class="close">×</span>' + '<div class="title">' + attr.name + '</div>' + '<div>状态:' + attr.status + '</div>'; // 关闭按钮:只隐藏当前气泡,不删 DOM el.querySelector(".close").addEventListener("click", function () { el.style.display = "none"; }); bubbleLayer.appendChild(el); bubbleMap.set(graphic.uid, el); }); // 把地图坐标转屏幕坐标,更新每个气泡位置 function updateBubbles() { pointLayer.graphics.forEach(function (graphic) { const el = bubbleMap.get(graphic.uid); if (!el || el.style.display === "none") return; const screenPoint = view.toScreen(graphic.geometry); // 点不在当前视野内时隐藏,避免气泡堆在边缘 if (!screenPoint || screenPoint.x < 0 || screenPoint.y < 0 || screenPoint.x > view.width || screenPoint.y > view.height) { el.style.visibility = "hidden"; return; } el.style.visibility = "visible"; el.style.left = screenPoint.x + "px"; el.style.top = screenPoint.y + "px"; }); } // 地图稳定后更新一次 view.when(function () { updateBubbles(); }); // 拖动、缩放时实时更新,用 rAF 节流 let ticking = false; function onViewChange() { if (ticking) return; ticking = true; requestAnimationFrame(function () { updateBubbles(); ticking = false; }); } view.on("drag", onViewChange); view.on("mouse-wheel", onViewChange); view.on("zoom", onViewChange); view.watch("extent", onViewChange);view.toScreen()返回的是相对于 view 容器的像素坐标,所以气泡层必须和 view 容器完全重叠,这里用绝对定位铺满。view.width和view.height是 view 的像素尺寸,用来判断点是否在视野内。requestAnimationFrame节流是必须的,否则拖动时每个drag事件都重排 DOM,低端机直接卡成幻灯片。
3.4 参数说明与可调项
几个关键参数和它们的调整方向:
transform: translate(-50%, -100%):控制气泡相对点位的偏移。想让气泡在点的右侧,改成translate(10px, -50%)。min-width和padding:气泡最小宽度和内边距,按内容长度调。view.toScreen的返回:如果点在视野外,4.x 可能返回null或坐标超出范围,所以要做边界判断。- 节流方式:
requestAnimationFrame适合大多数场景;如果点位上千,建议改成view.watch('stationary', ...),只在停止拖动后更新,牺牲实时性换性能。 - 气泡层级:多个气泡重叠时,后插入的 DOM 在上面。可以用
z-index按纬度排序,纬度高的在上。
4. 多气泡的性能与交互避坑:5 个血泪教训
4.1 现象:拖动地图时气泡「漂移」,跟不上点位
原因:只在view.when里更新了一次,没有监听地图变化事件。或者监听了但没做节流,更新频率和渲染不同步。
解决:至少监听drag、mouse-wheel、zoom三个事件,并用requestAnimationFrame包一层。如果还是漂,检查#bubbleLayer的定位方式,必须是position: absolute且和 view 容器同尺寸,不能有 margin 或 padding 偏移。
4.2 现象:气泡挡住了地图,没法拖动和缩放
原因:气泡层没有设pointer-events: none,整层拦截了鼠标事件。
解决:容器层设pointer-events: none,单个气泡设pointer-events: auto。这样空白区域的事件穿透到地图,气泡本身还能点。
4.3 现象:点位多了以后页面卡顿,内存飙升
原因:每个气泡都是独立 DOM,点位上千时 DOM 数量爆炸;加上每次updateBubbles都遍历全部 graphic,复杂度 O(n)。
解决:两个方向。一是视口裁剪,只渲染当前视野内的气泡,视野外的直接display: none或从 DOM 移除。二是聚合,点位密集时先做网格聚合,只给聚合后的代表点弹气泡。我一般会设一个阈值,比如视野内超过 200 个点就只显示前 200 个,或者提示用户放大。
4.4 现象:气泡位置在缩放后偏移,越放大偏得越厉害
原因:view.toScreen依赖 view 的当前状态,如果在view.when回调里缓存了屏幕坐标,缩放后没重新计算,就会偏。
解决:永远实时计算,不要缓存屏幕坐标。缓存地图坐标(graphic.geometry)是安全的,缓存屏幕坐标一定会翻车。
4.5 现象:关闭气泡后,地图一动它又出现了
原因:关闭逻辑只改了display: none,但updateBubbles里判断的是el.style.display === "none"就跳过,逻辑没错。但如果关闭时用的是visibility: hidden,而更新时又把它设回visible,就会复活。
解决:统一用display控制显隐,或者用一个独立的closed标记存在bubbleMap的值里,更新时先查标记。别混用display和visibility。
5. 进阶:用 TextSymbol 做轻量气泡与多气泡的混合方案
5.1 TextSymbol 气泡的最小写法
如果只是显示短标签,不需要 HTML,用TextSymbol更省事,而且它天然随地图缩放平移,不用手动同步坐标。
require([ "esri/Graphic", "esri/symbols/TextSymbol" ], function (Graphic, TextSymbol) { // 假设 point 是已有的几何点 const labelGraphic = new Graphic({ geometry: point, symbol: new TextSymbol({ text: "点位A\n在线", color: "#fff", haloColor: "#1e88e5", haloSize: 2, yoffset: 18, // 向上偏移,避免压住点位 font: { size: 12, family: "sans-serif", weight: "bold" }, backgroundColor: "rgba(30,136,229,0.9)", borderLineSize: 1, borderLineColor: "#fff" }) }); pointLayer.add(labelGraphic); });TextSymbol的backgroundColor和borderLineSize是 4.x 较新版本才支持的,老版本可能只有halo。yoffset控制文字相对点的垂直偏移,正数向上。这种方案适合状态标签,但没法放按钮和复杂布局。
5.2 混合方案:关键点用 HTML,次要标签用 TextSymbol
实际项目里我一般混着用:需要交互(按钮、链接、表单)的点位走 HTML 覆盖层,纯展示的标签走TextSymbol。这样既保证关键交互的灵活性,又减少 DOM 数量。判断标准很简单——这个气泡里有没有需要点击的元素,有就用 HTML,没有就用符号。
5.3 验证多气泡是否正确的三个检查点
写完别急着交付,按这三条过一遍:
- 拖动地图,气泡是否始终贴住点位,没有滞后或漂移。
- 缩放到不同级别,气泡大小是否合理(HTML 气泡不会随地图缩放,这是预期行为,要确认产品能接受)。
- 关闭一个气泡后,拖动地图,它是否保持关闭。
我自己的习惯是,每次改完updateBubbles的逻辑,先在控制台手动调view.goTo跳几个位置,看气泡跟不跟得上。这个笨办法帮我省过好几次返工。多气泡这个需求,难点从来不在「弹出来」,而在「弹出来之后还跟得住、不卡、不挡操作」。把坐标同步和节流这两件事做扎实,剩下的就是样式活了。希望帮到你。
本文还有配套的精品资源,点击获取