
先回答上一篇发布后大家问得最多的问题遮罩能不能同时出现多个鼠标扫过地图时遮罩能不能跟着走挖出来的那块透明区域能不能是圆角这些问题其实都落到同一个点上——基础遮罩能用但真拿到项目里远远不够。这篇文章就把 OpenLayers 4 下地图遮罩的进阶用法一次讲透多区域遮罩怎么做、动态遮罩怎么刷、异形遮罩怎么画以及这个过程中我踩过的那些坑。先说清楚我会以 OL4 的矢量图层方案为主线后面穿插一个 Canvas 方案和一个 Overlay 方案做对比因为不同场景下的最优选型完全不同不能指望一种写法吃遍所有需求。如果你只想要一个能跑的 Demo直接跳到第 3 章看代码如果你想搞懂为什么遮罩要这么写建议从头读完环的顺序和刷新机制这两个点是后期排查问题的主要战场。1. 回顾基础遮罩的三种做法和它们的天花板1.1 矢量 Feature 方案的基本写法如果你看过上一篇应该记得 OL4 里最省事的矢量遮罩写法拉一个覆盖全图的 Polygon填上半透明黑色再用一个或多个内环把中央区域挖空。代码大概是这样的var outerRing [ ol.proj.fromLonLat([-180, -85]), ol.proj.fromLonLat([180, -85]), ol.proj.fromLonLat([180, 85]), ol.proj.fromLonLat([-180, 85]), ol.proj.fromLonLat([-180, -85]) ]; // 构造一个圆形洞 var hole []; for (var i 0; i 64; i) { var angle (i / 64) * Math.PI * 2; hole.push(ol.proj.fromLonLat([ 116.4 0.5 * Math.cos(angle), 39.9 0.5 * Math.sin(angle) ])); } var polygon new ol.geom.Polygon([outerRing].concat([hole])); var feature new ol.Feature(polygon); feature.setStyle(new ol.style.Style({ fill: new ol.style.Fill({ color: rgba(0, 0, 0, 0.65) }) })); var maskSource new ol.source.Vector({ features: [feature] }); var maskLayer new ol.layer.Vector({ source: maskSource }); map.addLayer(maskLayer);这段代码的思路是对的后面的进阶操作全都在这个结构上演进。但只用它你在真实项目里马上会碰到三个绕不开的问题第一多个遮罩区域一旦叠加透明填充会重复叠色。本来 65% 的暗色两个区域交汇处会变成 88%看起来像一块没揉匀的黑眼圈用户一眼就能看出是两块布叠在一起。第二遮罩是死的。鼠标拖动、缩放、切换行政区它都不跟只能手动重新生成。第三矢量 Feature 的 fill 在 OL4 里基本只能给纯色渐变、斜纹、径向光晕这类样式做不出来。而这些效果在某些大屏可视化项目里反而是刚需。1.2 三种遮罩方案的对比我习惯把 OL4 地图遮罩的实现方式分成三类各有各的脾气方案交互能力样式丰富度性能特点适用场景矢量 Feature 带洞多边形强可随时改 geometry 跟随鼠标弱基本是纯色填充几何对象多时渲染压力大但单 Feature 可控行政区高亮、动态跟随、多区域遮罩ImageCanvas 渲染图层弱重绘成本高强任意 canvas 2D 效果每次地图变动都重新执行 canvasFunction静态高光、渐变遮罩、纹理遮罩DOM Overlay 遮罩中但跟随地图有延迟强纯 CSS 随便写无地图重绘开销但 DOM 操作频繁简单弹窗式引导蒙层、短时间内效果这篇的主角是矢量 Feature 方案因为它是 OL4 生态里最可控、动态能力最强的一条路。Canvas 和 Overlay 我会在第 5 章拿出来做样式补充讲清楚哪些效果必须交给它们。1.3 上一篇方案的实际短板清单在我自己的一次街区数据展示项目里客户提出三个需求直接把基础方案打回原形一是周边三个重点片区要同时压暗但片区之间允许有半透明连接带二是鼠标在图上移动时要有一个圆形高亮区跟着走告诉用户当前正在查看哪个范围三是遮罩的透明度要能按状态切换选中片区时暗色变浅。这三个需求分别对应多区域、动态更新和样式控制基础 Feature 方案全部搞不定只能另起炉灶。2. 带洞多边形是遮罩的真相环的方向与顺序2.1 Polygon 坐标数组的结构很多初学者写 OL4 的 Polygon 只记住了“一组坐标围成一个面”但遮罩这个场景必须吃透它的数组结构一个 Polygon 的坐标数组第一项是外环从第二项开始全是内环。外环定义了这个面的外边界内环则在面上挖出空洞。所以那个看起来像“外框”的矩形和那些挖空的小圆洞在几何上不是分离的它们同属于一个多边形。这个多边形在视觉上就是一个带洞的矩形填充色只画在矩形和内环之间的环形区域。这也是为什么它能当遮罩用矩形盖住全图内环区域不填充底图就从洞里露出来。有一个隐藏细节值得注意OL4 的 Polygon 坐标可以是[outer, hole1, hole2, ...]这种形式你可以往数组里塞任意数量的洞只要洞之间不重叠、不超出外环这个几何就是合法的。2.2 环方向的坑为什么部分工具会报错OL4 在渲染时其实不会强制要求外环内环的绕向顺时针逆时针都能画出来。但一旦你把几何导出成 GeoJSON或者丢给 JSTS、Turf 这类几何运算库做合并、求交问题就来了。GeoJSON 规范明确要求外环逆时针、内环顺时针很多校验工具拿这个标准卡你一旦方向反了union 的结果可能会变成“整个地图都被吞掉”的废几何。我在 JSTS 合并两个洞的时候踩过一次两个圆形洞的环方向全是顺时针合并结果直接返回一个覆盖全世界的大矩形当时排查了一下午最后用 GeoJSON 校验工具一查才看到方向问题。从现在开始凡是写遮罩几何我都统一按规范来外环逆时针内环顺时针。OL4 里你可以通过polygon.getCoordinates()手动调整也可以写完用ol.format.GeoJSON().writeGeometry()导出去验证一下。2.3 内环与外环的边界关系内环不能超出外环边界这是几何有效性的底线。遮罩场景里我们通常把外环设成覆盖全图的矩形洞怎么画都出不了边界所以这条限制看起来无所谓。但如果你哪天把外环换成一个不规则的多边形比如某个行政区的轮廓内部再挖一个洞那就要小心了洞一旦伸到外面这个环是无效的。更隐蔽的是两个内环不能有公共边它们要么完全分离要么用几何运算合并成一个环否则 OL4 渲染时边缘会出现细线闪动。2.4 构造遮罩的通用工厂函数基于上面的规律我写了一个通用函数后续所有遮罩都会复用它function createMaskFeature(holes, style) { var outer [ ol.proj.fromLonLat([-180, -85]), ol.proj.fromLonLat([180, -85]), ol.proj.fromLonLat([180, 85]), ol.proj.fromLonLat([-180, 85]), ol.proj.fromLonLat([-180, -85]) ]; var coordinates [outer].concat(holes); var geometry new ol.geom.Polygon(coordinates); var feature new ol.Feature(geometry); feature.setStyle(style || new ol.style.Style({ fill: new ol.style.Fill({ color: rgba(0, 0, 0, 0.65) }) })); return feature; }holes 是一个二维数组每一项是一组经纬度转换后的坐标环。只要保证 hole 数组里的洞互不重叠、不越界这个函数就能帮你在几行代码内构造任意数量的遮罩区域。3. 多个遮罩区域怎么做从多 Feature 到几何合并3.1 为什么不能简单叠加多个 Feature地图上有三个圆形重点区域想把周边全部压暗有些人图省事直接创建三个 Feature每个 Feature 都是一个小圆洞对应的带洞矩形。结果就是第 1 章说的叠色问题两个矩形在非目标区域重叠半透明黑色叠加变成更深的黑色整个画面黑一块灰一块。原因在于矢量图层的填充走的是 Canvas 的fill()逻辑同一位置被两个半透明 geometry 覆盖时颜色是逐层叠加的。要解决就得保证同一像素只被一个 geometry 覆盖也就是说遮罩必须是同一个 Feature 里的同一个几何。3.2 最简单方案一个矩形挖多个洞如果多个区域之间不需要重叠、不需要连通最省事的方法就是把所有洞的坐标数组全部塞进一个 Polygon。var holeA createCircleHole([116.4, 39.9], 0.5); var holeB createCircleHole([116.8, 39.6], 0.4); var holeC createCircleHole([116.2, 39.5], 0.6); var feature createMaskFeature([holeA, holeB, holeC]); maskSource.clear(true); maskSource.addFeature(feature);这里 createCircleHole 返回的是经纬度坐标环数组createMaskFeature 内部会把每个环通过 ol.proj.fromLonLat 转换到适合显示的坐标系。这段代码跑起来三个洞互不干扰遮罩层也始终只有一个 Feature渲染性能没有问题。要注意的是这种方案要求洞之间物理分离圆心距离必须大于两个洞的半径之和。一旦洞与洞有交叉这个 Polygon 的拓扑关系就不合法了渲染结果会变得不可预期。3.3 洞之间必须连通时交给几何合并如果产品要求两个高亮区域连成一片比如地铁沿线的两个站区要作为一个整体高亮这时就不能简单塞洞数组了必须把两个圆形区域先做几何合并再把合并结果作为一个内环。OL4 本身不提供几何合并算法我需要借助 JSTS 这类工具库。// 引入 jsts var jsts require(jsts); // 把两个圆洞的坐标转成 jsts 几何 var reader new jsts.io.GeoJSONReader(); var circleA reader.read({ type: Polygon, coordinates: [holeA] }); var circleB reader.read({ type: Polygon, coordinates: [holeB] }); var union circleA.union(circleB); var writer new jsts.io.GeoJSONWriter(); var mergedGeoJSON writer.write(union); // 转回 OL4 几何 var mergedGeometry new ol.format.GeoJSON().readGeometry(mergedGeoJSON); mergedGeometry.transform(EPSG:4326, EPSG:3857); // 仍要把合并结果作为洞塞进外环 var feature createMaskFeature([mergedGeometry.getCoordinates()[0]], maskStyle);这段操作我建议在 EPSG:4326 坐标系下做也就是经纬度坐标做完再转换到 3857。因为 JSTS 的运算不关心投影但在 4326 下数值范围小容易排查环的顺序是否正确转回 3857 之后数值巨大出错时肉眼根本看不出来。3.4 多区域方案对比与选型建议方案视觉效果性能实现复杂度适用场景多个 Feature 叠加重叠区颜色加深不可控差低基本不推荐一个矩形挖多个洞洞之间必须分离否则几何非法好低多个独立高亮区域JSTS 几何合并洞可以连通合并自然好中相邻区域合并高亮我在实际项目里的判断标准很简单区域之间没有相交关系用多洞有相交或需要背景连通用 JSTS 合并。不要为了省一个依赖库强行用多洞方案去凑相邻区域一旦产品改了需求你会为这个“省事”付出几倍排查时间。4. 让遮罩动起来动态更新、区域联动与增量刷新4.1 动态遮罩的三个关键动作动态遮罩的本质就是三件事拿到目标坐标更新 Feature 的几何然后通知图层刷新。听起来简单但顺序搞错或者漏掉最后一步画面纹丝不动很多人就卡在这里。OL4 里要注意source.addFeature()、clear()、removeFeature()这类操作会自动触发图层重新渲染但直接调用polygon.setCoordinates()改几何时OL4 根本不知道几何变了必须手动调用source.changed()否则地图不会更新。这是从静态遮罩跨到动态遮罩最重要的一个认知门槛。4.2 跟随鼠标的圆形遮罩我用一个跟随鼠标的圆形遮罩来演示完整流程。思路是在 pointermove 事件里拿到鼠标对应的经纬度再用这个经纬度生成一个圆替换 Feature 原有的洞。var maskFeature createMaskFeature([createCircleHole([0, 0], 1)]); maskSource.addFeature(maskFeature); var lastTick 0; map.on(pointermove, function (evt) { if (evt.dragging) return; var now Date.now(); if (now - lastTick 50) return; lastTick now; var lonLat ol.proj.toLonLat(evt.coordinate); var hole createCircleHole(lonLat, 0.3); var geometry maskFeature.getGeometry(); var outer geometry.getCoordinates()[0]; geometry.setCoordinates([outer, hole]); maskSource.changed(); });这个例子里有几个细节处理都是我踩过之后补上的第一pointermove事件在鼠标抖动时会高频触发直接执行几何构造和坐标转换低端机上会掉帧。我加了一个 50 毫秒的节流既保证流畅度又不至于太费 CPU。第二外环不需要每次重新生成。我从原来的 geometry 里把第一个环取出来复用只替换洞的部分减少数组构造压力。第三evt.dragging判断一定要加。否则鼠标按住拖拽地图时遮罩会跟着鼠标乱跑体验非常奇怪。拖拽过程中让遮罩停住拖拽结束再更新才是正常交互节奏。4.3 动态更新时的性能控制动态遮罩性能最大的敌人不是几何运算而是频繁创建新对象。有人实现动态效果时每次事件回调里都new ol.Feature()、new ol.geom.Polygon()然后source.clear()再addFeature()。这样写功能能跑但内存里会产生大量等待垃圾回收的临时对象时间一长界面会越来越卡帧率呈阶梯式下降。正确做法是复用同一个 Feature 和同一个几何对象只更新它的坐标数组。坐标数组本身也是可变的甚至可以复用同一个数组把坐标点一个个改掉。不过为了代码可读性我通常只在洞的数量变化时才重新构造数组否则就原地修改。4.4 区域联动点击列表地图遮罩自动切到对应区域动态遮罩还有一个高频场景左侧是街道列表鼠标悬停某一行地图遮罩自动切换到该街道的范围。这种实现和在 pointermove 里更新洞本质上一样区别在于坐标来源不同——从列表项拿到行政区的边界坐标然后调用更新函数。关键点是把行政区边界的 GeoJSON 预先解析成 OL4 几何对象并缓存下来不要每次悬停都重新读 GeoJSON。我在项目里是建立一个 id 到 geometry 的映射鼠标悬停时直接从映射里取几何取出geometry.getCoordinates()塞进遮罩多边形作为洞。这套操作在 OL4 里可以做到几乎无延迟切换比重新请求后端数据然后渲染快一个数量级。5. 异形与风格化遮罩圆角、渐变、纹理的扩展玩法5.1 圆角遮罩用几何造一个圆角矩形一旦两个功能需求摆在面前你很快就会发现纯圆洞不够用。客户经常提“能不能让中间这块区域变成圆角矩形”这种效果在遮罩上尤其常见。实现思路不复杂圆角矩形就是四段直线加四段圆弧的组合把圆弧离散成坐标点拼进环里就行。function roundedRectHole(center, width, height, radius, segments) { var cx center[0], cy center[1]; var halfW width / 2, halfH height / 2; var coords []; var seg segments || 8; // 右上角圆弧 for (var i 0; i seg; i) { var angle Math.PI / 2 * (i / seg); coords.push([cx halfW - radius radius * Math.cos(angle), cy halfH - radius radius * Math.sin(angle)]); } // 左上角圆弧 for (var j 0; j seg; j) { var angle2 Math.PI / 2 * (j / seg) Math.PI / 2; coords.push([cx - halfW radius radius * Math.cos(angle2), cy halfH - radius radius * Math.sin(angle2)]); } // 左下角圆弧 for (var k 0; k seg; k) { var angle3 Math.PI / 2 * (k / seg) Math.PI; coords.push([cx - halfW radius radius * Math.cos(angle3), cy - halfH radius radius * Math.sin(angle3)]); } // 右下角圆弧 for (var m 0; m seg; m) { var angle4 Math.PI / 2 * (m / seg) Math.PI * 1.5; coords.push([cx halfW - radius radius * Math.cos(angle4), cy - halfH radius radius * Math.sin(angle4)]); } coords.push(coords[0].slice()); return coords; }这段代码生成的是经纬度平面上的圆角矩形我会在调用处统一用ol.proj.fromLonLat()把每个点转成投影坐标。半径段的离散程度用 8 段已经能看出明显圆角想要更丝滑可以提到 12 段但不要超过 20否则顶点数量爆炸影响渲染。5.2 渐变和纹理遮罩ImageCanvas 的舞台纯色遮罩在视觉上多少有点单调尤其在大屏可视化场景里客户要求遮罩边缘带一点光晕感。这种效果矢量 Feature 做不了得请出 ImageCanvas。var maskLayer new ol.layer.Image({ source: new ol.source.ImageCanvas({ canvasFunction: function (extent, resolution, pixelRatio, size) { var canvas document.createElement(canvas); canvas.width size[0]; canvas.height size[1]; var ctx canvas.getContext(2d); var center ol.proj.fromLonLat([116.4, 39.9]); var px (center[0] - extent[0]) / resolution / pixelRatio; var py (extent[3] - center[1]) / resolution / pixelRatio; var radius 80 * pixelRatio; var gradient ctx.createRadialGradient( px, py, 0, px, py, radius ); gradient.addColorStop(0, rgba(0, 0, 0, 0)); gradient.addColorStop(0.6, rgba(0, 0, 0, 0.4)); gradient.addColorStop(1, rgba(0, 0, 0, 0.8)); ctx.fillStyle gradient; ctx.fillRect(0, 0, size[0], size[1]); return canvas; } }) }); map.addLayer(maskLayer);这是基础遮罩的高颜值版整个视野被径向渐变覆盖中心区域是透明的往外逐渐变暗像是聚光灯打在重点位置上。这段代码有两个容易出错的细节像素坐标换算公式px (coord - extent[0]) / resolution / pixelRatio和py (extent[3] - coord) / resolution / pixelRatio注意 y 轴方向和屏幕坐标是反的我第一次写的时候把 py 算反了结果渐变中心跑到画面外面去了。radius 乘上 pixelRatio 是为了适配高 DPI 屏幕否则在 Retina 屏上圆形光晕会偏小。如果你不需要这个精度直接把 pixelRatio 相关计算全部去掉代码更好懂。5.3 DOM Overlay 方案CSS 也能做遮罩还有一种取巧的遮罩方式用 Overlay 挂一个全屏 divdiv 用box-shadow模拟中间的透明区域比如box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.65)。这个方案的好处是可以用 CSS 的transition做淡入淡出动画视觉体验非常顺滑代码量也少。但它的缺点也很明显div 是 HTML 元素不会跟着地图的平移和缩放做像素级同步平移时透明区域会滞后一帧在快速拖动地图时尤其明显。所以我只用它做“引导蒙层”这类临时需求比如第一次进入页面时用圆框高亮某个按钮地图只是背景不需要实时交互。生产环境的核心遮罩我坚持用矢量 Feature 方案。5.4 遮罩图层的位置与层级遮罩图层放在 map 的 layers 数组里位置决定了它盖住谁、不被谁盖住。我通常把遮罩层放在底图正上方、业务图层正下方这样底图被压暗而点位、标注、弹窗都在遮罩上面保持清晰。如果你把遮罩放在 layers 数组最后它会盖住所有图层连地图控件都变暗用户会以为页面出 bug 了。6. 遮罩踩坑实录六个问题一条排查链路6.1 遮罩区域显示错位最典型的现象圆形高亮位置和鼠标点击位置差了好远位置完全对不上。第一次遇到这个问题的同学十有八九是忘了坐标系转换。OL4 默认视图投影是 EPSG:3857范围是米制单位而鼠标通过ol.proj.toLonLat()得到的是经纬度。拿经纬度坐标直接塞进 EPSG:3857 的几何里画出来的位置自然会偏。排查顺序是先确认事件坐标是什么坐标系再确认几何坐标是什么坐标系最后确认中间有没有经过fromLonLat转换。这三个确认完错位问题基本解决。6.2 多个 Feature 叠加产生暗斑这个问题会导致遮罩区域颜色不均匀严重不均匀时中间会出现明显的分界线。原因是多个 Feature 的填充互相叠加半透明色叠了两层。修复方式就是第 3 章的多洞方案把所有洞塞进同一个 Polygon。6.3 动态更新不生效明明调用了setCoordinates画面就是不动。先检查有没有调用source.changed()再检查你是不是每次都新建了 Feature 而不是复用同一个。如果你每次创建一个新 Feature 却没 add 到 source那不管你怎么改都不会出现在地图上。OL4 没有自动脏检查机制几何变化需要手动告知。6.4 几何合并后自相交JSTS 合并两个圆形洞通常没问题但如果你合并的是复杂多边形比如带大量折点的行政区边界结果很容易出现自相交。这种几何放进 OL4 渲染会出现黑块、白线甚至整个图层崩溃。我的经验是合并前先把环方向统一合并后再检查一次 GeoJSON 结果用jsts.operation.valid.IsValidOp判断几何是否合法。一旦自相交不要硬修退回去检查环方向。6.5 高纬度地区圆形洞变成椭圆在 EPSG:3857 投影下按固定经纬度半径生成的圆环纬度越高视觉上越扁。根源在于 3857 在垂直方向做了拉伸度数相同的跨度在高纬度对应的实际屏幕距离更大。如果你要求高精度视觉呈现就不要用经纬度半径生成圆换成在屏幕上算半径拿到鼠标像素坐标往右偏移 N 像素再转回地图坐标用这个距离当半径。或者直接用 Canvas 方案在像素坐标系里画圆完全绕开投影变形问题。6.6 顶点数量过多导致帧率下降遮罩动态更新时如果每个圆洞用 128 个顶点生成一秒更新 20 次OL4 每次都要重算多边形填充路径这在区域较大的时候表现明显。严格来说 64 个顶点已经足够平滑32 个顶点在大多数缩放级别下肉眼看不出差别。可以先看 SVG/Canvas 的渲染调试工具定位到顶点数和帧率的关系再决定要不要降采样。对于不需要弧线的矩形洞四个顶点就够了不要再无脑 64 段。7. 一点我自己的选型心得做遮罩到现在我的默认方案已经固定成单 Feature、带洞 Polygon、动态setCoordinates加手动changed()。这套组合在 OL4 里最稳定动态能力最强也不挑渲染环境。只有两种例外会让我换成别的思路一是需要渐变光晕那直接上 ImageCanvas不要尝试用矢量 Feature 硬凑二是需要透明度平滑过渡的引导蒙层那用 DOM Overlay 配合 CSS transition 体验最好。如果你现在正被某个遮罩问题卡住先把坐标系、环方向、source.changed()这三关过了八成问题都能解决。遮罩不复杂复杂的是你以为它只是盖一块布。实际上几何、刷新机制和图层层级缺一不可。希望这篇能帮你少走点弯路。