这个地图组件是我在做省级业务数据看板时被逼出来的。当时的需求很明确首页一张中国地图鼠标划过省份要高亮点击之后要“钻”进这个省把地级市的轮廓展开铺满整个画布还要带一段平滑的过渡动画再点一下空白处要能退回来。我先用图表库试了一版省级下钻能做但那个展开动画怎么看怎么别扭——它是在两个独立的图表实例之间硬切省界和市界之间没有任何视觉上的连续性产品经理一句“能不能像地图 App 那样缩进去”就把我打回了原形。于是我把整块实现推倒重来用 HTML5 Canvas 从经纬度开始自己画最后出来的效果反而比图表库版本轻了三分之二。这篇就把这套 JS 特效从头到尾讲透数据怎么选、投影怎么算、Path2D 怎么缓存、鼠标怎么命中、下钻动画怎么插值以及我在真机上踩过的那些坑。1. 一块地图画了三天为什么最后我放弃了现成图表库拿到“中国地图 地级市下钻”这个需求绝大多数人的第一反应都是打开图表库文档找地图组件配一个geo或者series-map就收工。我一开始也是这么干的而且确实半小时就跑通了。问题出在第二天联调的时候——设计稿里要求点击省份之后整个画布有一个“镜头推近”的感觉被点击的省份从原来的位置和大小平滑地放大并且居中到画布正中间同时其他省份要淡出地级市的轮廓要淡入。这种动画在图表库体系里几乎没有现成的口子可以插因为图表库把你的数据和坐标系封装得太严实了你只能操作它暴露出来的itemStyle和emphasis动画的起点终点全由它自己算。后来我算了一笔账我要的其实不是一个“图表”而是一个带交互的矢量图形渲染器。地图只是它渲染的内容之一。把需求抽象到这一层答案就很清楚了——用 Canvas 自己画。1.1 图表库、SVG、Canvas 三条路的取舍在动手之前我把三条技术路线认真对比了一遍结论写在下表里这个判断到今天为止我都没后悔过。维度图表库地图组件原生 SVG原生 Canvas开发速度极快配置即出图中等需要手动建节点慢投影、命中、重绘全自己写下钻展开动画几乎没有定制空间可以靠 CSS transition 改 path完全自由任意插值省级 市级节点量无所谓全国市级上千个 path 节点DOM 会喘一个 Path2D 就搞定无压力文字标注现成现成但缩放会跟着变形需要自己在屏幕坐标系另画一层鼠标命中现成浏览器帮你做要自己实现拾取算法高 DPI 适配现成自动手动乘 devicePixelRatio包体积全量引入几百 KB几乎为零几乎为零关键决策点其实只有两个节点数量和动画自由度。全国省级 34 个加上地级市接近 400 个如果每个行政区都是一个 SVGpath再加上描边、高亮态、文字DOM 节点轻松破千。我之前在另一个项目里用 SVG 做过 800 个节点的散点图Chrome 上勉强能跑一到低端安卓机就变成幻灯片。Canvas 只有一个 DOM 节点绘制开销花在 GPU 上这条路明显更稳。而动画自由度就更不用说了视图的scale和translate完全是我自己维护的两个变量想让镜头怎么飞就怎么飞。1.2 手绘地图要付出的三笔成本天下没有免费的午餐选 Canvas 就等于主动接下了三份活。第一笔是数据。图表库里内置的地图数据是打包好的你不需要关心它长什么样。自己画就得自己去拿 GeoJSON还得关心坐标系、精度、文件体积。全国省界不简化的原始数据有 6MB 以上直接扔给前端用户是灾难。第二笔是投影。经纬度是球面上的角度值画布是平面直角坐标系中间必须做一次数学映射。用错了投影地图要么被拉扁要么在南北方向严重变形。第三笔是交互。Canvas 里画的图形不是对象浏览器不知道你画了什么鼠标滑过去不会给你任何事件。你得自己实现一套“坐标反查”——把鼠标位置映射回某个行政区。这三笔活听着吓人但真做下来每一项都有成熟套路。后面几节我会把每一步的完整代码和推导都摊开讲。1.3 这个特效最终长什么样先把交互形态说清楚后面讲实现的时候你才有画面感。初始状态是全国视图34 个省级行政区以统一的浅灰蓝填充、白色细分隔线呈现。鼠标在省份上移动时该省填充变为高亮色同时画布右上角浮出一个信息卡显示省名和对应的业务数值。这个过程的响应必须足够跟手不能有肉眼可见的延迟否则体验会很差。点击某个省份后进入过渡阶段当前视图的变换矩阵缩放倍数与平移量在 600 毫秒内插值到“刚好把这个省框满画布”的目标值同时省界淡化、该省的地级市边界从中央向外淡入。过渡结束后进入省级视图此阶段只渲染这一个省的地级市鼠标悬停高亮的是市而不是省。再点击画布空白区域或者右上角的返回按钮视图反向插值回全国状态地级市层淡出、省界层淡入回到初始态。整个过程中所有图形都在同一张 Canvas 上绘制没有第二张图没有重排重绘这也是它流畅的根本原因。2. 地图数据的选型与瘦身从 6.3MB 压到 380KB地图能不能跑起来八成取决于数据准备得好不好。我见过太多人卡在这一步好不容易找到一份全国地图数据打开一看傻眼了一个省的坐标点有几万个文件大得吓人浏览器加载完直接卡死。这一节把数据这条线从头捋清楚。2.1 读懂 GeoJSON 的三层嵌套不管数据从哪来落到前端基本都是 GeoJSON 格式。它的结构看着吓人其实就是一个固定的嵌套层级理解了就再也不会晕。{ type: FeatureCollection, features: [ { type: Feature, properties: { name: 某省, adcode: 340000 }, geometry: { type: MultiPolygon, coordinates: [ [ [ [116.1, 33.2], [116.3, 33.4], ...] ], [ [ [117.0, 30.1], ...] ] ] } } ] }逐层拆开看FeatureCollection是整个文件features数组里每个元素是一个行政区。Feature包含properties属性比如名称和行政区划代码和geometry几何形状。geometry.type有三种可能Polygon单块、MultiPolygon多块比如有岛屿的省份、GeometryCollection极少见。坐标数组的维度是[多边形][环][点][经纬度]。注意这个四层结构写路径构建代码的时候最容易在这里踩坑。这里的adcode是你后面做数据关联、按需加载的关键字段务必保留。名称字段反而可能因为数据版本不同而有差异做唯一标识还是得靠代码。提示行政界线数据建议使用官方或正规渠道发布的版本用于商业产品上线前按相关规定确认是否需要走审图流程这一点在做地图类产品时不能省。2.2 用 mapshaper 做拓扑简化原始测绘数据的精度远超屏幕需要。你在一个 1200 像素宽的画布上画全国地图一个省能分到的宽度也就一两百像素那些精确到小数点后六位的顶点在屏幕上完全是重合的。把它们全留着纯属浪费带宽和绘制时间。我用的工具是 mapshaper命令行和网页版都有这里给命令行的做法方便塞进构建流程mapshaper china-provinces.json \ -simplify 3% keep-shapes \ -o precision0.01 formatgeojson china-provinces.min.json两个参数值得说一下。-simplify 3%表示保留原顶点的 3%keep-shapes保证简化后每个行政区都不会消失——这个参数非常关键不加的话小面积的行政区可能被简化成空几何地图上直接缺一块。precision0.01把坐标精度限制在小数点后两位大概相当于一公里级的精度对屏幕渲染完全够用。实测数据一份 6.3MB 的全国省级数据简化后 380KB 左右地级市级别因为数量多单省文件大概在 40 到 150KB 之间。这个体积对前端是完全可接受的。还有一个选择是转成 TopoJSON它会把相邻行政区的公共边界只存一次体积能再压 60% 以上。代价是前端需要引入解码库我在这个项目里权衡之后没用因为省级数据 380KB 已经够小加一个库反而得不偿失。2.3 分包加载省界首屏、市界按需一个很容易犯的错误是把全国所有省界加所有市界打成一个包。全国地级市三百多个全量数据加起来轻松超过 10MB首屏直接完蛋。我的做法是分成两层文件/data ├── provinces.json // 全国省级380KB └── cities/ ├── 340000.json // 某省地级市 ├── 110000.json └── ...首屏只加载provinces.json页面能立刻渲染出全国轮廓。当用户点击某个省份时再用这个省的adcode拼出文件名去请求对应的市界数据。这里有个细节要处理好用户点击之后到文件加载完成之间有一段空白期如果你什么都不做用户会感觉点击没生效。我的处理是点击瞬间立即启动视图动画动画时长 600 毫秒同时并行发起请求。经验上局域网缓存命中时几十毫秒就回来了公网首次请求通常在 200 毫秒以内动画还没播完数据就到了用户感知不到任何等待。为了保险我还加了 Loading 状态如果动画播完数据还没到就在画布中央显示一个细环形进度指示。另外一定要做内存缓存用一个Map存已经加载过的省级市界数据用户来回切换省份时不会重复请求。这个缓存我限制在 8 个省份超出后按 LRU 淘汰避免长时间使用后内存持续增长。3. 经纬度怎么变成屏幕上的像素这一节是整个特效的数学地基。很多人写地图卡住就是卡在这一步没想明白我手里是经纬度画布上是像素中间那个转换到底怎么做。答案拆成两步先把经纬度做一次球面到平面的投影得到一个平面坐标系再从平面坐标系做一次视图变换映射到画布像素。两步分开处理逻辑会清晰很多。3.1 墨卡托投影的公式与“度当量”技巧最偷懒的做法是直接把经度当 x、纬度当 y这叫等距圆柱投影。在中国这个纬度范围大约北纬 3° 到 54°用这种投影南北方向会被明显拉长视觉上就是地图看起来“瘦长”不对劲。我用的是墨卡托投影公式如下const RAD Math.PI / 180; const MAX_LAT 85.05112878; // 墨卡托的极点截断值 // 经度直接线性映射 function projectX(lng) { return lng; } // 纬度取墨卡托的 y 值 function projectY(lat) { const l Math.max(-MAX_LAT, Math.min(MAX_LAT, lat)); return Math.log(Math.tan(Math.PI / 4 (l * RAD) / 2)) / RAD; }这里有一个我自己琢磨出来的实用技巧分享出来能省你不少事注意最后那个/ RAD。墨卡托投影的标准输出是弧度值x 和 y 的量纲不一致后续算缩放比例时要分别处理很容易出错。除以RAD之后y 的输出被转换成了“度当量”——也就是说在这个坐标系里数值 1 代表的范围和经度上的 1 度是同一尺度。这样 x 和 y 就可以用同一个缩放系数处理代码干净很多。纬度为什么要在 85.05 度截断因为墨卡托投影在极点处趋于无穷大。虽然中国最北也就北纬 53 度多理论上用不到截断但如果你的数据里混进了异常坐标这种情况真的遇到过某份数据里有个顶点纬度写成了 90没有保护的话Math.tan会算出Infinity后面整个路径就废了地图会莫名其妙地消失。3.2 视图状态只用一个缩放系数和两个平移量投影完成后我得到了一个平面坐标系。接下来把这个坐标系映射到画布用一个最简单的仿射变换就够了screenX mapX * k tx screenY mapY * k ty整个视图状态就三个数k缩放倍数、tx、ty平移量。所有操作——缩放、拖拽、下钻动画——本质上都是在改这三个数。const view { k: 1, tx: 0, ty: 0 };我额外维护了一个minK和maxK限制缩放范围。minK取初始“全国刚好铺满画布”的那个缩放值用户不能再缩小maxK大概取minK * 40再大就没意义了一个地级市会占满整个屏幕。初始化的时候我会先遍历所有省级要素算出投影后的整体包围盒bbox然后调用一个fitToBounds把全国装进画布function fitToBounds(bbox, cssW, cssH, padding 24) { const kx (cssW - padding * 2) / (bbox.maxX - bbox.minX); const ky (cssH - padding * 2) / (bbox.maxY - bbox.minY); const k Math.min(kx, ky); // 取小的那个保证完整装下 const cx (bbox.minX bbox.maxX) / 2; const cy (bbox.minY bbox.maxY) / 2; return { k, tx: cssW / 2 - cx * k, ty: cssH / 2 - cy * k }; }Math.min那一步是必须的取大的那个会导致某个方向溢出画布。这个函数后面还会复用——下钻到某个省的时候把那个省的包围盒传进去就得到了省级视图的目标变换。3.3 以鼠标为锚点缩放滚轮缩放如果只是简单改k地图会以画布左上角为锚点缩放体验很糟糕。正确的做法是让鼠标指向的那个地图点保持不动这样才有“放大镜对准这里”的感觉。推导过程其实只有三行。设鼠标屏幕坐标为(sx, sy)缩放前该点对应地图坐标m (sx - tx) / k。缩放后希望这个m仍然映射到同一个sxsx m * k2 tx2 tx2 sx - m * k2 sx - (sx - tx) / k * k2代码function zoomAt(sx, sy, factor) { const k2 Math.min(maxK, Math.max(minK, view.k * factor)); const ratio k2 / view.k; view.tx sx - (sx - view.tx) * ratio; view.ty sy - (sy - view.ty) * ratio; view.k k2; }有了这个函数滚轮事件里滚一格就factor 1.1往下滚就factor 1 / 1.1非常顺手。反过来把屏幕坐标反查成地图坐标也很简单function screenToMap(sx, sy) { return { x: (sx - view.tx) / view.k, y: (sy - view.ty) / view.k }; }这两个函数是整个交互层的基础后面做命中测试和 tooltip 定位都要用到。写的时候一定要确保鼠标坐标是相对画布左上角的用event.clientX - canvas.getBoundingClientRect().left换算否则当页面有滚动或者画布不在视口左上角时所有交互都会偏移。4. Path2D 缓存与三层绘制管线数学搞定之后就进入真正画画的部分了。这一节的重点不是“怎么画出来”而是“怎么画得快”。前者随便写写就有结果后者才是能不能上线跑三百个地级市还保持 60 帧的关键。4.1 路径只在初始化时构建一次如果每一帧都遍历 GeoJSON 数组、算投影、moveTo/lineTo三百个地级市几万个顶点一秒钟六十帧就是几百万次调用浏览器直接跪。正确做法是用Path2D。它可以把一条路径编译成一个对象绘制的时候直接ctx.fill(path)就完事不用再重复构建坐标。function buildPath(feature, px, py) { const path new Path2D(); const geom feature.geometry; if (!geom) return path; // 统一成 MultiPolygon 的结构处理 const polygons geom.type Polygon ? [geom.coordinates] : geom.coordinates; for (const polygon of polygons) { for (const ring of polygon) { for (let i 0; i ring.length; i) { const [lng, lat] ring[i]; const x px(lng); const y py(lat); if (i 0) path.moveTo(x, y); else path.lineTo(x, y); } path.closePath(); // 每个环都要闭合 } } return path; }注意那个结构转换Polygon的coordinates是三层数组MultiPolygon是四层用一行三元表达式统一成四层之后后面的循环逻辑就只用写一套。这个技巧能省掉大量重复代码。还有一点每个环结束都要closePath包括内环。少了这句填充会出现奇怪的缺口尤其是带岛屿和内陆湖的行政区。构建好的 Path2D 直接挂在数据对象上regions.forEach(r { r.path buildPath(r.feature, projectX, projectY); });三百个地级市一次性构建大概 30 到 50 毫秒这点开销完全可以接受。之后无论怎么缩放平移都只是改ctx的变换矩阵路径本身不动。4.2 三层分离填充、描边、文字绘制管线我分成三层每层的坐标系不一样这是我在实践中总结出来最重要的一条经验。第一层是背景层用屏幕坐标系。画一个渐变底色或者浅色背景板它不需要跟着地图缩放。第二层是行政区层用地图坐标系。用setTransform把视图变换应用上去然后批量填充和描边。ctx.save(); ctx.setTransform(dpr * view.k, 0, 0, dpr * view.k, dpr * view.tx, dpr * view.ty); // 1. 批量填充 ctx.fillStyle #c9d8e8; for (const r of regions) { if (r hovered) continue; // 高亮的单独画避免重复 ctx.fill(r.path, evenodd); } // 2. 高亮的那一个 if (hovered) { ctx.save(); ctx.shadowColor rgba(40, 120, 220, 0.45); ctx.shadowBlur 12 / view.k; // 阴影也要按比例补偿 ctx.fillStyle #4a9eff; ctx.fill(hovered.path, evenodd); ctx.restore(); } // 3. 统一的白色描边线宽要补偿 ctx.lineWidth 0.6 / view.k; ctx.strokeStyle rgba(255,255,255,0.9); ctx.lineJoin round; for (const r of regions) ctx.stroke(r.path); ctx.restore();第三层是标注层回到屏幕坐标系画文字。这一步特别重要理由在第七章会详细讲——如果把文字也画在缩放坐标系里一放大就糊成一坨。ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.font 12px PingFang SC, Microsoft YaHei, sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillStyle #2c3e50; for (const r of regions) { const sx r.center.x * view.k view.tx; const sy r.center.y * view.k view.ty; if (sx 0 || sy 0 || sx cssW || sy cssH) continue; // 屏幕外直接跳过 ctx.fillText(r.name, sx, sy); }r.center是每个行政区的标注锚点。它不能简单用包围盒中心因为像某些形状不规则的省包围盒中心可能落在省外。我用的做法是取该要素所有环上顶点的平均值这个点基本都落在行政区内部效果不错。4.3 高 DPI 适配与线宽补偿Canvas 在高分屏上默认会糊这是老生常谈。解决方案是让画布的像素尺寸等于 CSS 尺寸乘以设备像素比然后再把上下文整体缩放回去。const dpr window.devicePixelRatio || 1; const cssW canvas.clientWidth; const cssH canvas.clientHeight; canvas.width Math.round(cssW * dpr); canvas.height Math.round(cssH * dpr);关键点是CSS 尺寸靠样式控制像素尺寸靠属性控制两者不能混。我见过有人只改了canvas.width就以为搞定了结果画布在视觉上被拉大了两倍。设置完尺寸后每次渲染开始都要重置变换ctx.setTransform(dpr, 0, 0, dpr, 0, 0)。注意是setTransform不是scale因为渲染是多帧循环的用scale会不断累积第二帧之后画布就飞出屏幕了。至于线宽补偿原理很简单在setTransform(k, ...)之后lineWidth 1意味着屏幕上 1 个地图单位的宽度实际像素宽是1 * k。想让描边永远是 1 个 CSS 像素就得写lineWidth 1 / k。这是个很小的细节但不做的话放大到省级视图时省界会变成又粗又难看的白条。5. 鼠标悬停怎么知道指向哪个市前端的 Canvas 交互绕不开一个问题浏览器不知道你画了什么它只知道有个矩形画布鼠标在上面移动。判断“鼠标在哪个市上面”这件事必须自己实现。5.1 颜色拾取法比遍历路径省心得多最直观的思路是遍历。把鼠标位置用screenToMap反算成地图坐标然后对每个行政区的 Path2D 调用ctx.isPointInPath(path, x, y)第一个返回 true 的就是命中目标。这个方案能用但有几个麻烦isPointInPath的坐标参数是在当前变换上下文下解释的你得小心地在调用前设置好变换或者干脆手动把屏幕坐标转成地图坐标传进去再一个遍历三百个路径每次鼠标移动都要跑一遍虽然单次只要几毫秒但高频触发时会有明显的 CPU 占用。我更推荐颜色拾取法也就是额外准备一张离屏画布把每个行政区填充成独一无二的颜色鼠标移动时直接读那一个像素的颜色值反推出索引。const pickCanvas document.createElement(canvas); pickCanvas.width cssW; pickCanvas.height cssH; const pickCtx pickCanvas.getContext(2d, { willReadFrequently: true }); function indexToColor(i) { const n i 1; // r 存低 8 位g 存高 8 位b 固定为 0 return rgb(${n 255},${(n 8) 255},0); } function rebuildPick() { pickCtx.setTransform(1, 0, 0, 1, 0, 0); pickCtx.clearRect(0, 0, pickCanvas.width, pickCanvas.height); // 用和主画布完全一致的视图变换 pickCtx.setTransform(view.k, 0, 0, view.k, view.tx, view.ty); regions.forEach((r, i) { pickCtx.fillStyle indexToColor(i); pickCtx.fill(r.path, evenodd); }); } function hitTest(sx, sy) { const d pickCtx.getImageData(sx | 0, sy | 0, 1, 1).data; if (d[3] 0) return -1; // 透明说明点在空白处 return ((d[1] 8) | d[0]) - 1; // 反解索引 }两个坑要提前说。第一拾取画布的尺寸用的是 CSS 像素不要乘 dpr因为鼠标坐标也是 CSS 像素两边尺度必须一致否则所有命中都会偏移。第二getContext时传{ willReadFrequently: true }这个提示会让浏览器把画布放在内存里而不是显存里getImageData的读取速度会快一个数量级。不传这个参数的话在高频读取时控制台会给你告警。关于填充规则这里必须和主画布用同样的规则。主画布用evenodd拾取画布也要用evenodd否则带空洞的行政区会错位。5.2 拾取画布的重建时机拾取画布不需要每帧重建只在视图变换改变时才需要重画也就是拖拽和缩放的时候。即使这样拖动过程中每帧重建三百个路径的填充也有点浪费。我的优化是分层重建拖拽过程中先用一个低精度的拾取画布只画省级不画地级市拖拽结束后再用requestIdleCallback重建高精度版本。多数情况下用户拖拽只是为了调整视野不会在拖拽的瞬间去悬停所以这个降级是安全的。重建本身的开销也做了控制只对当前视口内的行政区做填充视口外的直接跳过用路径包围盒快速判断。全国视图下省界数量少无所谓地级市视图下这个裁剪能省掉 70% 以上的填充操作。5.3 tooltip 定位与防抖悬停高亮的时候我需要弹出一个信息卡显示名称和数据。这个东西我一开始是用 DOM 元素做的绝对定位到鼠标位置。它比在 Canvas 里画要方便得多——圆角、阴影、多行排版全都是 CSS 的事而且能直接选中文本。定位的时候有个细节鼠标靠近画布右边缘或者下边缘时卡片会溢出容器。我做了一个简单的翻转逻辑如果sx tipWidth 16 cssW就把卡片放到鼠标左侧纵向同理。防抖方面getImageData是同步操作本身很快但悬停事件触发频率极高每一帧都读也没必要。我的做法是在mousemove里只记录鼠标坐标真正的拾取放在requestAnimationFrame回调里做一帧最多拾取一次。这一个小改动能让悬停时的 CPU 占用下降一半以上。同时用一个变量记录上一次命中的索引只有索引变了才更新高亮和 tooltip避免无谓的重绘。另外鼠标移出画布的时候一定要记得清空悬停状态并重绘一次不然高亮会尴尬地留在屏幕上。6. 点击下钻省级子地图的加载与展开动画前面五节把所有基础设施都搭好了这一节把它们串成一个完整的交互闭环。6.1 一个极简的状态机整个应用其实只有两个状态national全国视图和province省级视图。我用一个对象描述当前状态const state { mode: national, // national | province currentAdcode: null, // 进入省级视图时记录 regions: [], // 当前渲染的行政区数组 animating: false, animStart: 0, animDuration: 600, viewFrom: null, viewTo: null };regions这个字段是关键——它让渲染管线完全不用关心自己现在画的是省还是市只管遍历数组。切换状态时只需替换regions和视图变换其他逻辑一行都不用改。点击处理的重心不是切换本身而是在切换之前把该准备的东西都准备好。具体来说点击一个省的时候我要做三件事算出该省的地理包围盒、用它算出目标视图变换、发起市界数据请求。async function drillDown(feature, index) { if (state.animating || state.mode ! national) return; state.animating true; const bbox computeBBox(feature); const target fitToBounds(bbox, cssW, cssH, 40); const adcode feature.properties.adcode; startViewAnimation(target, 600); const cities await loadCities(adcode); state.currentAdcode adcode; state.mode province; state.regions cities.map(c ({ ...c, path: buildPath(c.feature, projectX, projectY) })); state.animating false; rebuildPick(); requestRender(); }注意这里没有等数据加载完再启动动画两个动作是并行的。这样即使网络慢一点用户也能立刻看到地图在移动感知上比“点了没反应然后突然切换”好太多。6.2 视图矩形插值与缓动动画的本质是让view的三个参数从当前值平滑过渡到目标值。听起来简单但有几个细节决定了观感好坏。细节一缩放倍数用对数插值。如果从 1 缩放到 20 是线性插值你会感觉动画前半段几乎没动后半段突然冲过去。原因是人眼对缩放的感知是对数关系的——从 1 到 2 的变化和从 10 到 20 的变化视觉冲击是一样的。所以应该插值log(k)const kFrom Math.log(viewFrom.k); const kTo Math.log(viewTo.k); const k Math.exp(kFrom (kTo - kFrom) * eased);细节二平移量要在插值之后再反推。如果单独插值tx和ty动画过程中缩放中心会飘看起来像是边走边抖。稳妥做法是先在“地图坐标”里插值出一个中间的视图矩形左、上、宽、高然后再统一换算成k/tx/ty。不过我在实际项目里试过只要缓动曲线够平滑直接插值tx/ty的观感也能接受所以图省事就用了简单版。细节三缓动函数要选对。线性运动看起来非常机械。我用的是easeInOutCubicfunction easeInOutCubic(t) { return t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; }前段加速、后段减速收尾很稳是这类“镜头推进”动画最通用的选择。动画循环本身就是一个requestAnimationFramefunction tick(now) { const t Math.min(1, (now - state.animStart) / state.animDuration); const e easeInOutCubic(t); const kFrom Math.log(state.viewFrom.k); const kTo Math.log(state.viewTo.k); view.k Math.exp(kFrom (kTo - kFrom) * e); view.tx state.viewFrom.tx (state.viewTo.tx - state.viewFrom.tx) * e; view.ty state.viewFrom.ty (state.viewTo.ty - state.viewFrom.ty) * e; // 透明度也一起插值实现省界淡出、市界淡入 state.fade e; render(); if (t 1) requestAnimationFrame(tick); }state.fade这个变量交给渲染管线使用省界的 alpha 设为1 - e市界的 alpha 设为e两者交叉淡入淡出视觉上就是一个层溶解成另一个层的过程。6.3 子地图上的二次交互与返回进入省级视图之后交互逻辑和全国视图几乎一样只是regions换成了地级市数组。上一节的颜色拾取、tooltip、高亮全都不用改代码这是前面把渲染管线和数据解耦带来的红利。返回逻辑我做了两个入口。点击空白处返回这是最符合直觉的右上角再放一个返回按钮给那些习惯找按钮的用户用。两者的处理完全一样function drillUp() { if (state.animating || state.mode ! province) return; state.animating true; const target fitToBounds(nationalBBox, cssW, cssH, 24); startViewAnimation(target, 480); state.mode national; state.regions provinceRegions; // 切回省级数组省界 Path2D 早就缓存好了 state.fade 1 - state.fade; // 反向淡入淡出 setTimeout(() { state.animating false; rebuildPick(); }, 480); }有个细节值得单独说反向动画的时长我设成了 480 毫秒比下钻的 600 毫秒短。这不是随手写的而是有心理学依据的——用户主动进入某个细节时愿意多等一会儿来获得“进入”的仪式感但退出时用户的心理预期是“赶紧回到全貌”动画拖长了会让人烦躁。这个 1.25 倍的时长差异实际体验上的差别相当明显。判断“点击空白处”靠的就是命中测试返回 -1canvas.addEventListener(click, (e) { const { sx, sy } getPos(e); const idx hitTest(sx, sy); if (idx -1) { if (state.mode province) drillUp(); return; } if (state.mode national) drillDown(state.regions[idx].feature, idx); });7. 上线前踩过的五个坑功能跑通只是第一步真正让我熬夜的是上线前那一周的联调。下面这五个问题每一个都足够让特效在真机上翻车。7.1 文字跟着缩放糊成一团这是最容易犯也最明显的错。我第一版把所有东西都画在同一个缩放变换下包括省名。结果全国视图下文字正常一旦下钻放大到省级那些字被放大了十几倍边缘全是锯齿而且越大越糊。根因是 Canvas 的文字渲染是基于当前变换矩阵在光栅化阶段处理的缩放之后浏览器是把小字号位图放大而不是用新字号重新排版。所以正确做法就是我前面说的文字单独一层在屏幕坐标系里画。具体就是在渲染流程里先把变换重置成setTransform(dpr, 0, 0, dpr, 0, 0)然后对每个行政区把它的标注锚点用当前的k/tx/ty换算成屏幕坐标再fillText。这样无论地图放大多少倍字都是同一个字号、同一个清晰度。唯一的额外工作是判断锚点是否在视口内——屏幕外的直接跳过省下大量无用的文字绘制。另外提醒一句ctx.font的字符串里中文字体名必须加引号写12px PingFang SC在某些浏览器里会静默失效回退到默认字体写成12px PingFang SC才稳妥。这个问题我排查了快一个小时才找到。7.2 空洞填不上nonzero 与 evenodd有天测试同事跑过来跟我说某个省的中间那块明明是个湖怎么被填成实心的了。我一看果然是填充规则的问题。Canvas 的fill默认使用nonzero规则它按路径的方向顺时针/逆时针来判断一个点是在内部还是外部。如果外环和内环的方向一致内环就不会被当成洞。很多 GeoJSON 数据在简化和转换过程中环的方向会被破坏结果就是空洞被填掉。解决办法就是显式指定evenodd它按射线穿过路径的次数来判断奇数次在内部、偶数次在外部跟方向完全无关只要有嵌套关系就天然形成洞。ctx.fill(path, evenodd);这一句话解决了我遇到的所有空洞问题。顺便说evenodd在多重嵌套比如岛中湖中岛的情况下也表现正确比nonzero省心太多。代价是拾取画布必须也用evenodd两边规则不一致的话鼠标滑到空洞上会被误判成命中了行政区。这个坑我当时踩了表现是鼠标移到湖面上还会高亮查了半天才发现是拾取画布漏了参数。7.3 移动端触摸与双指缩放PC 上调通之后上手机第一反应是“完全不能用”——单指拖动页面跟着滚双指想缩放结果页面整体被缩放了。处理方法是三件事。第一给画布加touch-action: none阻止浏览器的默认手势接管。第二touchstart/touchmove/touchend里按手指数量分派逻辑一根手指走拖拽两根手指走缩放。第三所有touch事件里都要preventDefault否则滚动会跟拖动打架。canvas.addEventListener(touchstart, (e) { if (e.touches.length 1) { lastPoint { x: e.touches[0].clientX, y: e.touches[0].clientY }; } else if (e.touches.length 2) { lastDist distance(e.touches[0], e.touches[1]); lastCenter center(e.touches[0], e.touches[1]); } e.preventDefault(); }, { passive: false });注意{ passive: false }这个选项不加的话preventDefault会失效并且控制台会给你一个警告。这是 Chrome 从某个版本开始对触摸事件做的优化很多人会在这里卡住。双指缩放的中心点要用两指的中点而不是画布中心否则缩放手感会很怪。实现上就是算出两指距离的变化比例然后用它去调zoomAt(centerX, centerY, ratio)复用前面写好的函数。单指拖动的时候还要处理一个细节手指抬起后惯性滑行要不要做我试过做但移动端上很容易滑过头反而不好控制。最后砍掉了惯性改成直接跟随手指交互更“实”。7.4 连续缩放时的帧率掉到 20功能都做完之后我用性能面板跑了一次发现连续滚动缩放的时候帧率从 60 掉到了 20 左右卡顿感明显。排查下来主要有两个原因。第一每次缩放我都重建了拾取画布三百个地级市的填充开销太大。第二主画布每帧都在遍历所有行政区做填充和描边视口外的那部分纯属浪费。第一个问题的解法是前面提到的分层重建拖拽和缩放的进行中只更新低精度拾取画布手势结束 120 毫秒后再重建高精度版本。这个改动把缩放时的拾取开销几乎降到了零。第二个问题的解法是视口裁剪。给每个行政区预先算一个投影坐标系下的包围盒渲染前先用当前视图把它映射到屏幕坐标如果完全在画布外就跳过。function visible(r) { const x0 r.bbox.minX * view.k view.tx; const x1 r.bbox.maxX * view.k view.tx; const y0 r.bbox.minY * view.k view.ty; const y1 r.bbox.maxY * view.k view.ty; return x1 -20 x0 cssW 20 y1 -20 y0 cssH 20; }加了 20 像素的余量避免边界上的图形因为抗锯齿边缘出现闪烁。这两个优化加完再跑性能面板缩放全程稳定在 55 到 60 帧低端安卓机上也能维持在 40 帧以上基本可用了。7.5 数据版本更新导致 adcode 对不上最后一个坑最隐蔽。项目上线后两个月我更新了一版地图数据结果有几个省份点击之后加载不出地级市控制台报 404。原因是行政区划代码会变。有些省经历过区划调整代码会新增或废弃。我的cities/{adcode}.json文件是按旧代码命名的新数据里的代码变了自然对不上。解决办法是在构建阶段加一个校验脚本遍历最新的省级数据把每个adcode取出来检查cities/目录下是否有对应的文件缺失的直接报错中断构建。同时在前端加载失败时做兜底fetch返回 404 就不进入省级视图而是给用户一个轻提示至少不会让整个页面卡在动画状态里出不来。这个经验说白了就是一句话凡是依赖 ID 关联的数据都必须有构建期的完整性校验。手工维护的文件对应关系迟早会出错。8. 关于这套方案我自己的几点使用体会写到这里核心内容基本都覆盖了。最后分享几个我在多个项目里复用这套代码之后攒下来的心得可能跟具体实现无关但能帮你少走弯路。投影函数的坐标轴方向一定要在项目初期就定死。我见过有人在同一个项目里地图层用 y 轴向下、标注层用 y 轴向上最后所有的标注都跑到地图外面去了排查了很久。我这边的约定是投影之后 y 值随纬度增大而增大跟屏幕坐标相反所有涉及屏幕的地方统一在最后一步翻转换算绝不在中间层混合使用。Path2D 的缓存粒度值得仔细想。我现在的做法是省级一层、市级一层各自独立缓存切换视图的时候直接换数组不销毁也不重建。这套结构让全国到省级的来回切换几乎没有开销用户狂点也能秒响应。内存占用大概在 20 到 40MB 之间对桌面端完全无压力移动端上我会在切换超过 8 个省份之后主动清掉最早的缓存。颜色拾取法还有一个额外好处就是它能顺便支持“框选”和“批量统计”。我后来在做另一个需求时直接用拾取画布做了矩形范围内的省份统计几行代码就搞定了比遍历路径判断快得多。如果你后续要做区域多选之类的功能这套基础设施可以原样复用。如果你也想动手做一版我的建议是先不要碰交互就老老实实把全国轮廓画出来、能用滚轮缩放。这一步跑通了剩下的交互和动画都是在这套视图变换上加逻辑难度会低很多。真正难的部分从来不是画图而是把那三个数字k/tx/ty管好。