简介:这份资源面向使用 ArcGIS API 4.x for JavaScript 进行 Web GIS 开发的学习者与前端工程师,聚焦地图加载与多气泡窗口(Popup)展示这一常见交互场景。包内共 1 个文件,为单个 html 页面,压缩包约 3KB,体量轻巧,可直接在浏览器中打开运行,便于快速理解地图初始化、MapView 创建、PopupTemplate 配置以及点击要素触发气泡窗口的完整流程。内容围绕 Map、MapView、FeatureLayer 与 popup 属性展开,演示如何为不同图层或要素设置独立模板,并在同一地图上同时呈现多个气泡窗口,帮助读者掌握气泡内容布局、事件监听与 openPopup 调用等关键写法。目前已有 157 人学习,适合作为入门示例或项目中的交互参考,也可在此基础上扩展自定义样式与图层加载状态处理。
1. 地图上同时弹出多个气泡窗口,ArcGIS API 4.x 到底卡在哪
在 ArcGIS API 4.x for JavaScript 里做地图应用,PopupTemplate几乎是每个项目都会碰的东西。默认情况下,MapView只挂一个popup,点哪弹哪,一次只显示一个气泡窗口。但真实业务里经常不是这样:管网巡检要同时对比三个监测点的实时读数,物流调度要在一屏里盯住五个站点的状态,环境监测要把超标点位全部标出来一起看。这时候单弹窗就不够用了,用户要的是「多个气泡窗口同时展示」。
这个需求听起来简单,做起来有几个绕不开的坎:4.x 的View架构和 3.x 完全不同,popup是视图级别的单例;PopupTemplate的content支持函数返回,但多个实例怎么管理;view.popup.open()每次调用会不会互相覆盖;气泡窗口的定位、层级、关闭逻辑怎么处理。这篇笔记就围绕「地图加载多个气泡窗口展示」这条线,把选型、实现、参数和踩坑一次讲清楚。适合已经能跑通 4.x 基础地图、正在被多弹窗需求卡住的同学,也适合想提前摸清 4.x 弹窗边界的熟手。
2. 多气泡窗口的三种实现路线与选型理由
2.1 先搞清楚 4.x 的 popup 为什么是单例
ArcGIS API 4.x 把Popup设计成View的一个属性,view.popup全局只有一个实例。你调用view.popup.open({ features: [...] }),它会把当前弹窗内容替换掉,而不是新开一个。这是 4.x 架构上的刻意选择:Popup绑定在View上,负责「当前聚焦的要素详情」,而不是「多个并存的标注」。
所以想同时展示多个气泡,本质上不是「让 popup 支持多个」,而是「绕开 popup 单例,用别的方式在地图上画多个气泡」。理解这一点,后面的选型就顺了。常见做法有三条路:
- 路线 A:用
view.popup配合features数组,一次展示多个要素,但仍然是一个弹窗容器,只是内容里列出多条。 - 路线 B:用
Graphic+TextSymbol或PointSymbol3D自己画气泡,完全脱离 popup 体系。 - 路线 C:用
view.ui或 DOM 覆盖层,在屏幕坐标上手动定位多个 HTML 气泡。
三条路各有适用场景,下面逐个拆。
2.2 路线 A:单容器多要素,最省事但不算真多气泡
这是官方最推荐的做法,也是很多人误以为「多气泡」的方案。核心是给view.popup.open()传一个features数组,PopupTemplate的content用函数接收feature逐个渲染。
// 假设 view 已经初始化,layer 是 FeatureLayer view.popup.open({ // location 决定弹窗锚点,这里取第一个要素的几何中心 location: features[0].geometry, features: features, // 关键:传数组,弹窗内会分页/列表展示 featureMenuOpen: true, // 打开要素列表菜单 updateLocationEnabled: false // 禁止拖动时自动更新位置,避免多要素时乱跳 }); // PopupTemplate 的 content 用函数,能拿到当前 feature const template = { title: "{name}", content: function (feature) { // feature.graphic.attributes 里是业务字段 const a = feature.graphic.attributes; return `浊度:${a.turbidity} NTU<br/>pH:${a.ph}`; } };逻辑说明:features传数组后,Popup内部会生成一个要素列表,用户可以在弹窗里切换查看每一条。featureMenuOpen: true让列表默认展开。updateLocationEnabled: false很重要,多要素时如果允许拖动更新位置,弹窗会跟着鼠标乱跑。
参数说明:location是弹窗锚点,多要素时通常取第一个或中心点;features里的每个要素必须带geometry和attributes,否则 content 函数拿不到数据。这条路线的局限很明显——它还是一个气泡窗口,只是内容里有多条。如果业务要求「每个点上方各自飘一个气泡」,路线 A 不满足。
2.3 路线 B:Graphic + TextSymbol 自绘气泡
真正要「每个点一个气泡」,最稳的是用Graphic自己画。4.x 的TextSymbol支持背景色、边框,配合Graphic的geometry就能在每个点上方生成一个独立气泡。
// 为每个监测点生成一个气泡 Graphic const bubbleGraphics = features.map((f) => { const attrs = f.attributes; return new Graphic({ geometry: f.geometry, // 气泡锚定在要素几何上 symbol: new TextSymbol({ text: `${attrs.name}\n${attrs.value}`, // \n 换行在 TextSymbol 里有效 color: "white", haloColor: "#1a73e8", haloSize: 2, backgroundColor: [26, 115, 232, 0.85], // 半透明蓝底 borderLineColor: "white", borderLineSize: 1, yoffset: 20, // 关键:向上偏移,避免压住点位 font: { size: 12, family: "sans-serif", weight: "bold" } }), attributes: attrs }); }); // 用一个 GraphicsLayer 承载所有气泡 const bubbleLayer = new GraphicsLayer({ graphics: bubbleGraphics }); map.add(bubbleLayer);逻辑说明:每个Graphic独立渲染,互不干扰,天然支持多个气泡并存。yoffset控制气泡相对点位的垂直偏移,正值向上。backgroundColor用 RGBA 数组能做出半透明效果,视觉上更像气泡而不是标签。
参数说明:haloColor和haloSize是文字描边,保证在深色底图上也能看清;borderLineColor和borderLineSize是气泡边框。TextSymbol的text支持\n换行,但不支持 HTML,所以复杂排版做不了。这条路线的代价是:气泡不能点击展开、不能滚动、不能放按钮,纯展示。如果只是「把数值飘在点上」,它是最省心的。
2.4 路线 C:DOM 覆盖层,交互最全但坐标要自己算
如果气泡里要放按钮、图表、滚动列表,TextSymbol就不够了,得用 DOM 覆盖层。思路是监听view的pointer-move或extent变化,把地图坐标转成屏幕坐标,然后定位绝对定位的 div。
// 创建覆盖容器,挂到 view 的容器里 const overlay = document.createElement("div"); overlay.style.cssText = "position:absolute;top:0;left:0;pointer-events:none;"; view.container.appendChild(overlay); // 为每个要素创建一个气泡 div const bubbles = features.map((f) => { const el = document.createElement("div"); el.className = "map-bubble"; el.innerHTML = `<strong>${f.attributes.name}</strong><br/>${f.attributes.value}`; el.style.cssText = "position:absolute;transform:translate(-50%,-100%);" + "background:#fff;border:1px solid #1a73e8;border-radius:6px;" + "padding:6px 10px;pointer-events:auto;white-space:nowrap;"; overlay.appendChild(el); return { el, geometry: f.geometry }; }); // 每次视图变化时重算屏幕坐标 function reposition() { bubbles.forEach(({ el, geometry }) => { const screen = view.toScreen(geometry); // 地图坐标 -> 屏幕坐标 if (!screen) return; el.style.left = `${screen.x}px`; el.style.top = `${screen.y}px`; // 超出视野就隐藏,避免气泡堆在边缘 el.style.display = screen.x < 0 || screen.y < 0 || screen.x > view.width || screen.y > view.height ? "none" : "block"; }); } view.watch("extent", reposition); // 平移缩放时重算 view.watch("rotation", reposition); // 旋转时也要重算 reposition();逻辑说明:view.toScreen()把地图坐标转成屏幕像素坐标,这是 DOM 覆盖层的核心。transform:translate(-50%,-100%)让气泡底部尖角对准点位。pointer-events:none在容器上、auto在气泡上,保证气泡可点但容器不挡地图操作。
参数说明:view.watch("extent")和view.watch("rotation")是必须的,否则拖动地图气泡不跟手。视野外隐藏的判断用view.width和view.height,这两个是视图容器的像素尺寸。这条路线的坑最多:坐标计算有延迟、旋转时容易错位、气泡多了性能会掉,但交互能力最强。
2.5 三条路线的选型对照
| 维度 | 路线 A 单容器多要素 | 路线 B TextSymbol 自绘 | 路线 C DOM 覆盖层 |
|---|---|---|---|
| 是否真多气泡 | 否,一个容器 | 是 | 是 |
| 交互能力 | 强(官方组件) | 无 | 最强(任意 HTML) |
| 实现成本 | 低 | 中 | 高 |
| 性能 | 好 | 好 | 气泡多时下降明显 |
| 适用场景 | 列表式对比 | 数值飘点 | 复杂卡片、按钮 |
选型建议:只是展示数值,选 B;要列表切换,选 A;要按钮和复杂布局,选 C。实际项目里经常混用,比如 B 做常驻数值气泡,C 做点击后的详情卡片。
3. 用 DOM 覆盖层跑通多气泡的最小可复现步骤
3.1 初始化地图与视图的必备参数
先把基础环境搭好。4.x 的MapView初始化和 3.x 差别很大,basemap和center、zoom是分开的。
require([ "esri/Map", "esri/views/MapView", "esri/layers/GraphicsLayer", "esri/Graphic" ], function (Map, MapView, GraphicsLayer, Graphic) { const map = new Map({ basemap: "topo-vector" // 底图,也可换成自定义的 }); const view = new MapView({ container: "viewDiv", // 对应页面里的 div id map: map, center: [116.4, 39.9], // 经纬度 zoom: 12, // 关键:关闭默认 popup,避免和自定义气泡打架 popup: { autoOpenEnabled: false } }); view.when(() => { // 视图就绪后再加气泡,否则 toScreen 拿不到正确尺寸 initBubbles(view); }); });逻辑说明:popup: { autoOpenEnabled: false }是必须的,否则点击要素时官方 popup 会弹出来,和自定义气泡重叠。view.when()保证视图加载完成,view.width和view.height才有值。
参数说明:center用[经度, 纬度]顺序,别写反。zoom是初始缩放级别。container对应 HTML 里的<div id="viewDiv"></div>,这个 div 必须有明确的宽高,否则视图渲染不出来。
3.2 批量生成气泡并绑定屏幕坐标
接着把数据变成气泡。这里用一组模拟监测点,实际项目里换成FeatureLayer查询结果即可。
function initBubbles(view) { // 模拟数据,实际从 FeatureLayer 或后端接口来 const points = [ { name: "站点A", value: "12.3", lon: 116.40, lat: 39.90 }, { name: "站点B", value: "45.6", lon: 116.42, lat: 39.91 }, { name: "站点C", value: "7.8", lon: 116.38, lat: 39.89 } ]; const overlay = document.createElement("div"); overlay.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;" + "pointer-events:none;overflow:hidden;"; view.container.appendChild(overlay); const bubbles = points.map((p) => { const el = document.createElement("div"); el.className = "map-bubble"; el.innerHTML = `<strong>${p.name}</strong><span>${p.value}</span>`; overlay.appendChild(el); return { el, // 用 Point 几何,注意 spatialReference 要和 view 一致 geometry: { type: "point", longitude: p.lon, latitude: p.lat } }; }); function reposition() { bubbles.forEach(({ el, geometry }) => { const screen = view.toScreen(geometry); if (!screen) { el.style.display = "none"; return; } const inView = screen.x >= 0 && screen.y >= 0 && screen.x <= view.width && screen.y <= view.height; el.style.display = inView ? "block" : "none"; if (inView) { el.style.left = `${screen.x}px`; el.style.top = `${screen.y}px`; } }); } // 监听视图变化,节流避免高频重算 let raf = null; const schedule = () => { if (raf) return; raf = requestAnimationFrame(() => { reposition(); raf = null; }); }; view.watch("extent", schedule); view.watch("rotation", schedule); view.watch("zoom", schedule); reposition(); }逻辑说明:view.toScreen()接受Point几何对象,返回屏幕坐标。用requestAnimationFrame节流,避免拖动时每帧都重算导致卡顿。视野外隐藏用view.width和view.height判断。
参数说明:geometry用longitude/latitude时,API 会自动按视图的spatialReference转换。如果数据是投影坐标,要写成{ type: "point", x: ..., y: ..., spatialReference: { wkid: 3857 } }。view.watch("zoom")也要监听,因为缩放时点位屏幕坐标会变。
3.3 气泡的 CSS 定位与层级控制
DOM 气泡的样式直接决定观感,这里给一份能用的基础样式。
.map-bubble { position: absolute; transform: translate(-50%, -100%); /* 底部尖角对准点位 */ background: #ffffff; border: 1px solid #1a73e8; border-radius: 6px; padding: 6px 10px; font-size: 12px; line-height: 1.4; white-space: nowrap; pointer-events: auto; /* 容器是 none,这里要恢复可点 */ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); z-index: 10; } /* 气泡下方的小三角,指向点位 */ .map-bubble::after { content: ""; position: absolute; left: 50%; bottom: -6px; transform: translateX(-50%); border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid #1a73e8; }逻辑说明:transform: translate(-50%, -100%)让气泡的水平中心对准点位、底部贴住点位。::after伪元素画三角,指向点位。pointer-events的父子配合是关键:容器none不挡地图,气泡auto可点击。
参数说明:z-index控制气泡之间的层级,多个气泡重叠时数值大的在上。white-space: nowrap防止文字换行撑破气泡,如果内容长要改成max-width加换行。
3.4 点击气泡与地图事件的隔离
气泡可点之后,点击事件会冒泡到地图,可能触发地图的点击逻辑。要隔离。
// 气泡点击:阻止冒泡,避免触发地图 click bubbles.forEach(({ el, geometry }) => { el.addEventListener("click", (e) => { e.stopPropagation(); // 关键:阻止冒泡到 view.container console.log("点击了气泡", geometry); // 这里可以打开详情面板、高亮要素等 }); }); // 地图点击:点击空白处关闭所有气泡详情 view.on("click", (e) => { // 判断点击是否落在气泡上,用 e.x/e.y 和气泡屏幕坐标比对 const hit = bubbles.some(({ el }) => { const rect = el.getBoundingClientRect(); return ( e.x >= rect.left && e.x <= rect.right && e.y >= rect.top && e.y <= rect.bottom ); }); if (!hit) { // 关闭详情逻辑 } });逻辑说明:e.stopPropagation()阻止气泡点击冒泡到地图容器。地图的click事件里用getBoundingClientRect()判断点击是否落在气泡上,避免误关。
参数说明:e.x和e.y是相对视图容器的像素坐标,和getBoundingClientRect()的坐标系一致(都是视口坐标,但视图容器通常占满视口,实际项目里要减去容器的offsetLeft/offsetTop)。
4. 多气泡窗口的避坑与排查清单
4.1 气泡位置偏移,拖动地图后对不上
现象:气泡初始位置正确,但拖动或缩放地图后,气泡和点位错开,越拖越偏。
原因:view.toScreen()返回的是相对视图容器的坐标,但气泡的left/top是相对overlay容器的。如果overlay没有铺满视图容器,或者视图容器有padding、border,坐标系就不一致。
解决:确保overlay的position:absolute;top:0;left:0;width:100%;height:100%,并且view.container的position是relative。如果视图容器有边框,用box-sizing:border-box或把overlay的偏移量减掉。
4.2 气泡闪烁,拖动时一卡一卡
现象:拖动地图时气泡明显闪烁,或者跟不上手指。
原因:view.watch("extent")触发频率很高,每次都同步重算所有气泡的 DOM 样式,主线程被占满。
解决:用requestAnimationFrame节流,一帧只算一次。气泡数量超过 50 个时,考虑只渲染视野内的,或者改用TextSymbol路线 B 交给 WebGL 渲染。
4.3 气泡被地图控件遮挡
现象:气泡显示在地图下方,被缩放按钮、比例尺盖住。
原因:view.ui里的控件默认z-index较高,而overlay挂在view.container里,层级不够。
解决:给overlay设一个较高的z-index,比如z-index: 100。但注意不要盖住view.ui的交互控件,必要时把view.ui的控件移到别的角落。
4.4 旋转地图后气泡全部错位
现象:地图旋转(view.rotation不为 0)后,气泡位置全乱。
原因:view.toScreen()在旋转状态下返回的坐标是对的,但气泡的transform: translate(-50%,-100%)没有考虑旋转,视觉上会偏。
解决:监听view.watch("rotation")重算位置。如果要求气泡始终垂直向上,那toScreen的结果直接用即可;如果要求气泡跟着地图转,需要额外计算旋转角度,一般业务里不推荐气泡跟着转,保持垂直更易读。
4.5 气泡内容更新后不刷新
现象:数据变了,气泡里的数值还是旧的。
原因:DOM 气泡的内容是创建时写死的,数据更新后没有重新赋值。
解决:把气泡的 DOM 引用和数据分开管理,数据更新时只改el.innerHTML,不重建 DOM。或者用view.graphics配合Graphic.attributes变化监听,但 DOM 路线要自己维护映射关系。
5. 进阶:用 PopupTemplate 的 content 函数做条件气泡与性能取舍
前面讲的 DOM 覆盖层适合复杂交互,但如果只是「根据数值显示不同颜色的气泡」,其实可以回到PopupTemplate体系,用content函数做条件渲染,配合view.popup的features数组,在单容器里做出「多气泡」的视觉效果。这个技巧在要素数量不多、又不想自己管 DOM 时很好用。
const template = { title: "{name}", content: function (feature) { const a = feature.graphic.attributes; // 根据阈值决定气泡样式,超标标红 const color = a.value > 40 ? "#d93025" : "#1a73e8"; const bg = a.value > 40 ? "#fce8e6" : "#e8f0fe"; return ` <div style="border-left:4px solid ${color};background:${bg}; padding:6px 10px;border-radius:4px;"> <strong>${a.name}</strong><br/> 数值:<span style="color:${color}">${a.value}</span> </div> `; } }; // 打开时传多个要素,弹窗内会列出所有 view.popup.open({ location: centerPoint, features: features, featureMenuOpen: true });逻辑说明:content函数接收feature,能拿到graphic.attributes,据此返回不同 HTML。view.popup.open()传features数组后,弹窗内会生成要素列表,用户逐条查看。这不算真正的多气泡,但在「对比多个点」的场景里,交互比 DOM 覆盖层更稳。
参数说明:featureMenuOpen: true让列表默认展开;location取所有要素的中心点,可以用geometryEngine的union或简单取平均。content返回的 HTML 会被Popup渲染,注意不要放<script>,会被过滤。
性能取舍上,我的习惯是:要素数少于 20、交互要求不高,用PopupTemplate方案,省心;要素数多、要常驻显示、要复杂交互,用 DOM 覆盖层,但要严格节流和视野裁剪。TextSymbol路线适合纯数值飘点,性能最好,但交互为零。三条路线没有绝对优劣,看业务要什么。
最后说个血泪经验:多气泡项目里,最容易翻车的不是气泡本身,而是坐标系和容器尺寸。我一般会在view.when()之后先打印一次view.width、view.height和view.container.getBoundingClientRect(),确认三者一致再往下写。这个习惯帮我省过好几次「气泡整体偏移」的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取