
前阵子接了个数据可视化大屏的活需求说得很直白一张中国地图鼠标点某个省能像翻书一样钻进去看这个省下面各地级市的分布再点一下退回全国视角。第一反应当然是上现成图表库挂个 geo 组件、注册地图 JSON、写几行配置就出来了。但甲方后面加的两条要求让事情变得不那么轻松一是地级市边界要能单独控制描边粗细、填充透明度甚至某个市要单独做闪烁呼吸效果二是地图上要叠一层实时流动的光效和粒子跟地图轮廓严丝合缝地对齐。这类需求落到通用图表库上就会很别扭——你很难拿到它内部那份已经投影好的路径数据叠上去的东西永远差半拍。所以最后决定用原生JS加HTML5 Canvas手撸一套。这就是「JS特效第25弹」的由来从零把中国地图画出来并且支持展开地级市子地图全程可控、可扩展。整套东西不依赖任何地图库核心代码不到 600 行你拿去就能改成自己项目里的样子。这篇文章适合三类人正在做数据大屏、需要地图钻取交互的前端想搞明白地图从经纬度变成屏幕像素中间到底发生了什么的人以及被图表库的黑盒行为折腾过、想拿回控制权的开发者。基础要求不高会canvas.getContext(2d)和基本的数组遍历就能跟上投影那块我会把公式拆开讲。1. 整体方案设计与技术选型拆解动手前先想清楚一件事这套东西到底难在哪。很多人以为画地图难在画其实画只是最简单的一步——把一堆经纬度点连成闭合路径填个色就完事了。真正麻烦的是三件事坐标怎么从球面落到平面上、点上去之后怎么知道点的是哪个省、以及省份和地级市之间怎么切换。1.1 为什么不用 ECharts 的 geo 组件先说说选型对比免得你走弯路。ECharts 的 geo/map 系列确实开箱即用注册一个registerMap(china, json)就能跑省市区三级联动也有现成方案。但它在几个场景下会卡住你方案优势卡点适用场景ECharts geo五分钟出图、内置缩放平移路径数据拿不到、自定义图层受限、地级市单独控样式麻烦常规统计地图、交付周期紧SVG 手绘DOM 可绑定事件、CSS 直接改样式节点数上万后滚动缩放明显掉帧小体量、交互复杂的地图Canvas 手绘路径完全可控、可以随便叠特效、性能上限高命中检测要自己写、没有 DOM 事件大屏特效、地图钻取、需要精细控制WebGL/Three.js十万级要素无压力上手成本陡增纯 2D 地图属于杀鸡用牛刀三维地球、海量点位渲染canvas这条路的代价是点击事件不会自己找上你得自己算坐标做命中检测。但换来的是路径数据全部握在自己手里想怎么描边、怎么叠滤镜、怎么加粒子流都行。大屏项目基本都吃这一套。1.2 三层数据结构的组织方式可展开地级市子地图听着玄本质就是个树形结构加懒加载。我把它拆成三层const geoRegistry { // 第一层全国children 是省级行政区的编码 country: { key: 100000, name: 全国, source: data/china.json, children: [110000, 120000, /* ... */] }, // 第二层省children 是下辖地级市编码 provinces: { 440000: { key: 440000, name: 广东省, source: data/province/440000.json, children: [440100, 440300, /* ... */] } }, // 第三层地级市叶子节点不再往下钻 cities: { 440100: { key: 440100, name: 广州市, source: data/city/440100.json, children: [] } } };关键设计在于编码先行全国那份数据里每个省级要素都带一个adcode属性这个编码既是对应省级文件名的依据也是children数组里的值。这样点击一个省的时候不需要预先加载这个省的地级市数据只要拿到adcode再去请求同名文件就行。全国数据大概 12MB如果一口气把所有地级市数据也塞进去体积直接飙到十几 MB首屏必炸。注意行政区划编码和名称数据请使用权威渠道发布的版本并且要留意行政区划调整比如某些地方撤市设区、代管关系变化编码表建议做成可热更新的 JSON 而不是硬编码在代码里。1.3 整体运行流程一次完整的钻取交互走下来是这样的页面初始化只请求data/china.json解析出所有省级多边形的经纬度环。把经纬度做墨卡托投影得到平面坐标。计算所有点的包围盒算出适配画布的scale和偏移把整张地图铺满。用Path2D把每个省的多边形路径构建好缓存起来。鼠标移动时做命中检测高亮悬停的省。点击某个省根据adcode懒加载对应的地级市数据播一段视野聚焦动画动画结束后把子地图画上去。提供返回按钮或者面包屑退回上一级。这七步里第 2 步的投影和第 5 步的命中检测是新手最容易翻车的地方后面单独展开。2. 地图数据预处理与墨卡托投影实战拿到一份 GeoJSON 之后直接连线画图你会发现地图是扁的、上下颠倒的、比例失调的——因为经纬度是球面坐标直接当平面坐标用必然出问题。2.1 GeoJSON 与 TopoJSON 怎么选GeoJSON 的结构很直观一个Feature对应一个行政区geometry.coordinates里是一层层嵌套的数组多边形是[环][点][经纬度]多多边形则多一层[多边形][环][点][经纬度]。它的好处是标准、工具链全、肉眼可读。TopoJSON 是 GeoJSON 的压缩版它把所有共享边界只存一份然后用弧段索引引用。同一份全国地级市数据GeoJSON 可能是 11MBTopoJSON 压到 2MB 左右再用 gzip 一压能到 600KB 上下。代价是解压时需要引入拓扑库或者自己写解码逻辑。我的建议是全国层级用 TopoJSON 客户端解码单个省的地级市数据用 GeoJSON。因为单省文件本身就不大通常几十到几百 KB没必要为了省这点体积增加解码复杂度。解码 TopoJSON 时用topojson-client这个库就够几十行的事情import { feature } from topojson-client; const topo await fetch(data/china.topo.json).then(r r.json()); const geojson feature(topo, topo.objects.china); // geojson.features 就是标准的 Feature 数组了2.2 墨卡托投影公式的推导与实现经纬度直接线性映射的问题在于经度每 1 度对应的实际距离随纬度升高而缩短纬度方向如果也等距排列越往北画得越长。俄罗斯、加拿大这类高纬地区会被拉得面目全非反过来赤道附近又被压扁。墨卡托投影的核心思路是纬度方向的拉伸与经度方向的拉伸保持同样比例这样地图上任意一点的角度关系不变术语叫等角形状看起来就对了。公式是这样的x R · λ y R · ln( tan(π/4 φ/2) )其中 λ 是经度转弧度φ 是纬度转弧度R 是地球半径取值 6378137 米。这里有个很多人没意识到的点R 取多少其实无所谓。因为它是个线性系数最后会被你在下一节算出来的scale完全吸收掉。哪怕你把 R 设成 1画出来的图形状也完全一样只是scale的数值会大得离谱。所以别在 R 上纠结关键是所有点用同一个值。// 经纬度 - 墨卡托平面坐标 function mercator(lng, lat) { const R 6378137; const x (lng * Math.PI / 180) * R; // 纬度夹紧防止在极点附近 ln 爆炸 const clampedLat Math.max(-85.051129, Math.min(85.051129, lat)); const y Math.log(Math.tan(Math.PI / 4 (clampedLat * Math.PI / 180) / 2)) * R; return [x, y]; }纬度要夹在 ±85.051129 度之间这个数字不是随便来的它正好是墨卡托投影在 y 方向上趋于无穷大的临界点。不夹紧的话数据里万一有个 90 度的坐标Math.tan(π/2)会返回一个接近Infinity的值整张图直接废掉。2.3 计算自适应 scale 与偏移量投影完还是一堆几十万量级的大数字R 是 6378137经度转成米大概也是几十万到几百万必须缩放平移到画布坐标系里。做法是先求出所有点的包围盒再算一个刚好能装下的比例function fitBounds(points, canvasW, canvasH, padding 40) { let minX Infinity, maxX -Infinity, minY Infinity, maxY -Infinity; for (const [x, y] of points) { if (x minX) minX x; if (x maxX) maxX x; if (y minY) minY y; if (y maxY) maxY y; } const boxW maxX - minX || 1; const boxH maxY - minY || 1; const usableW canvasW - padding * 2; const usableH canvasH - padding * 2; // 取较小的那个比例保证长边完整可见 const scale Math.min(usableW / boxW, usableH / boxH); // 居中偏移注意 y 轴要翻转 const offsetX padding (usableW - boxW * scale) / 2 - minX * scale; const offsetY padding (usableH - boxH * scale) / 2 maxY * scale; return { scale, offsetX, offsetY, minX, maxX, minY, maxY }; }坐标系转换函数function project(x, y, view) { return [ x * view.scale view.offsetX, -y * view.scale view.offsetY // 注意这个负号翻转 y 轴 ]; }这个负号是必须的。墨卡托坐标的 y 值随纬度升高而增大而画布的 y 轴向下增长。不加负号你画出来的地图会上下颠倒新疆跑到左下角去。提示fitBounds里的padding别设太小留出 4060px 是为了给地图标注文字和边缘光效留呼吸空间贴边画出来的图视觉上会非常憋屈。2.4 数据抽稀与精度取舍原始地理数据往往精度高得离谱一个县级边界能有几千个点。全国范围全部渲染点数量轻松突破几十万Path2D构建的时候就能卡死主线程。抽稀我用的是经典的 Douglas-Peucker 算法思路很简单一条折线的起点和终点连成一条直线找出离这条直线最远的点如果距离超过阈值就保留它然后对分出的两段递归处理否则整段压缩成直线。阈值怎么定全国视图0.01 度大约 1.1 公里肉眼完全看不出锯齿省级视图0.005 度地级市视图0.002 度阈值是经纬度单位不是像素所以它跟缩放级别无关需要根据当前视图层级选不同的数据文件。实际项目里我会在构建阶段就用脚本把三个精度版本预先生成好运行时按需加载而不是在浏览器里实时抽稀——后者本身就要遍历一遍所有点省不了多少时间。3. Canvas 路径构建与分层渲染数据准备好了接下来是往画布上画。这一步的写法直接决定了后面交互顺不顺、缩放卡不卡。3.1 用 Path2D 缓存每个行政区最直白的方式是遍历坐标ctx.beginPath()、ctx.moveTo()、ctx.lineTo()、ctx.closePath()、ctx.fill()。但这么写有个致命问题每次重绘高亮效果时所有多边形都要重新走一遍路径构建过程。三十多个省、每个省几千个点一帧下来就是十几万次lineTo帧率直接掉到个位数。Path2D就是为这个场景准备的。它把路径独立于上下文对象缓存下来构建一次可以反复fill(path)、stroke(path)而且还能直接用于命中检测function buildRegionPath(geometry, view) { const path new Path2D(); // GeoJSON 的嵌套层级MultiPolygon - Polygon - Ring - Point const polygons geometry.type Polygon ? [geometry.coordinates] : geometry.coordinates; for (const polygon of polygons) { for (const ring of polygon) { ring.forEach(([lng, lat], i) { const [x, y] project(...mercator(lng, lat), view); if (i 0) path.moveTo(x, y); else path.lineTo(x, y); }); path.closePath(); } } return path; }这里有个细节内环和外环要分别closePath。中国的行政区里存在飞地和洞的情况比如某些省被其他区域包围的独立地块、或者管辖范围内包含其他行政区的飞地GeoJSON 用多个环来表示第一个环是外轮廓后面的环是内轮廓洞。如果你把所有环连成一条路径飞地会被错误地填色视觉上会看到不该连的地方连起来了。另外填充规则要用evenoddctx.fill(path, evenodd);nonzero规则下内环如果和外环方向一致就不会挖空evenodd就没这个问题奇偶交叉自动判定省心。3.2 双画布分层底图与交互层分离地图这类应用有个典型特征底图几乎不变变化的是高亮、tooltip、粒子特效。把两者画在同一张画布上每次鼠标移动都要重绘整个地图纯属浪费。我的做法是两层离屏底图层用一个独立的OffscreenCanvas或者隐藏的普通 canvas把已经投影好的所有行政区路径一次性画好填色、描边全部完成。只有在视图发生变化缩放、平移、切换层级时才重绘。主画布交互层每帧先drawImage把底图贴上来再叠加高亮区域的填充、悬停描边、tooltip、粒子动画。// 底图重绘低频 function renderBase() { const octx offscreen.getContext(2d); octx.clearRect(0, 0, W, H); for (const region of regions) { octx.fillStyle region.baseColor; octx.fill(region.path, evenodd); octx.strokeStyle rgba(120, 200, 255, 0.55); octx.lineWidth 1; octx.stroke(region.path); } } // 每帧渲染高频 function renderFrame() { ctx.clearRect(0, 0, W, H); ctx.drawImage(offscreen, 0, 0); if (hovered) { ctx.fillStyle rgba(80, 200, 255, 0.45); ctx.fill(hovered.path, evenodd); ctx.strokeStyle #7fe7ff; ctx.lineWidth 2; ctx.stroke(hovered.path); } drawParticles(); requestAnimationFrame(renderFrame); }实测下来这套分层在 30 多个省级要素、总共约 8 万个点的情况下底图重绘一次大概 815ms而每帧的合成开销稳定在 2ms 以内60fps 完全没问题。3.3 配色与描边的一些细节大屏地图的配色很讲究因为投影仪和 LED 屏的色差比显示器大得多。我踩过的坑是用纯黑底色配深蓝填充在自己的显示器上看着层次分明投到现场大屏上就成了一坨黑。稳妥的一组配色是const palette { background: #050d1a, // 深蓝黑不要用纯黑 regionFill: #0d2a4a, // 区域底色和背景拉开明度差 regionStroke: rgba(94, 190, 255, 0.5), hoverFill: rgba(64, 196, 255, 0.4), hoverStroke: #8ceaff, labelText: rgba(200, 235, 255, 0.85) };描边宽度也要跟着缩放级别动态调整。全国视图下lineWidth设 1 就够但如果用户放大到某个省1px 的线会显得特别细弱。做法是让线宽反比于缩放倍数ctx.lineWidth Math.max(0.6, 1.2 / view.scale * baseScale);baseScale是fitBounds算出来的初始比例。这样一来无论放大多少倍描边在屏幕上的视觉粗细基本恒定。3.4 高分屏适配别漏了canvas在视网膜屏上如果只按照 CSS 尺寸设置width/height属性会糊成一片。标准做法是const dpr window.devicePixelRatio || 1; const cssW container.clientWidth; const cssH container.clientHeight; canvas.width Math.round(cssW * dpr); canvas.height Math.round(cssH * dpr); canvas.style.width cssW px; canvas.style.height cssH px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);setTransform这一步很关键它让后续所有绘制指令自动按 dpr 放大。但要注意这个变换会影响到命中检测——这是下一节的核心坑点很多人在这里卡一整天。4. 点击命中检测与地级市子地图钻取画出来只是开始能点、能钻取才是这个项目的灵魂。而 Canvas 没有 DOM 事件点击了哪个省全靠自己算。4.1 isPointInPath 的坐标系陷阱ctx.isPointInPath(path, x, y)是官方给的正规解法比遍历所有多边形做射线法简单得多。但它有个极易踩的坑规则是这样的传入的x、y是在 canvas 元素的坐标空间里的值不受当前变换矩阵影响。翻译成人话你在前面用setTransform(dpr, 0, 0, dpr, 0, 0)设置了缩放路径已经按 dpr 放大绘制了但isPointInPath收到的点不会自动帮你乘 dpr。最稳的写法是在检测前把变换复位function hitTest(clientX, clientY) { const rect canvas.getBoundingClientRect(); const dpr window.devicePixelRatio || 1; // 换算到 canvas 的后备存储坐标物理像素 const x (clientX - rect.left) * dpr; const y (clientY - rect.top) * dpr; ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); // 复位消除一切变换影响 let hit null; for (let i regions.length - 1; i 0; i--) { // 倒序遍历后画的在上层优先命中 if (ctx.isPointInPath(regions[i].path, x, y)) { hit regions[i]; break; } } ctx.restore(); return hit; }三个要点再强调一遍必须乘以 dpr。因为路径是按物理像素绘制的getBoundingClientRect给的是 CSS 像素不换算就永远差着 dpr 倍。必须setTransform复位。只要有一次变换没清干净命中区域就会整体偏移。倒序遍历。后绘制的要素视觉上在顶层命中检测也应该优先命中它。省和地级市叠在一起的时候这条规则很重要。实操心得如果发现命中区域整体偏移了固定的距离八成是你忘了减去rect.left和rect.top——clientX是相对于视口的页面一滚动就对不上了。这个问题在带滚动条的大屏页面里特别常见。4.2 从省到市钻取逻辑的实现点击命中之后处理流程分两种情况如果点的是全国视图下的某个省就加载这个省的地级市数据并切换过去如果点的是地级市视图下的某个市就触发选中态不再往下钻。async function drillDown(region, clientX, clientY) { const meta geoRegistry.provinces[region.adcode]; if (!meta || !meta.children.length) return; // 已经到底了 // 缓存命中避免重复请求 if (!meta.features) { const raw await fetch(meta.source).then(r r.json()); meta.features raw.features; } currentLevel city; currentParent region.adcode; await animateTo(meta.features, /* 保持点击位置视觉锚点 */); buildRegions(meta.features); renderBase(); }这里有个体验优化点如果直接淡出旧地图、淡入新地图视觉上会很跳。更好的做法是让视野聚焦——以点击的那个省为目标平滑缩放到它的包围盒动画结束后再用新数据替换。这样用户的心理模型是我点的这块被放大了而不是画面整个换掉了。4.3 视野聚焦动画插值要选对方式动画本身用requestAnimationFrame加缓动函数就能实现但缩放值的插值必须用对数插值这是很多人忽略的地方。原因是人对缩放的感知是对数的从 1 倍到 1.1 倍和从 10 倍到 11 倍视觉上的变化幅度其实是一样的都是 10%。如果线性插值 scale动画前半程会慢得几乎看不出变化后半程突然飞快地冲过去。function lerpLog(a, b, t) { if (a 0 || b 0) return a (b - a) * t; return a * Math.pow(b / a, t); } const easeInOutCubic t t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; function animateTo(targetPoints, duration 620) { return new Promise(resolve { const from { scale: view.scale, ox: view.offsetX, oy: view.offsetY }; const target computeTargetView(targetPoints); // 同样用 fitBounds 算 const start performance.now(); function step(now) { const t Math.min(1, (now - start) / duration); const e easeInOutCubic(t); view.scale lerpLog(from.scale, target.scale, e); view.offsetX from.ox (target.ox - from.ox) * e; view.offsetY from.oy (target.oy - from.oy) * e; rebuildPaths(); // 路径依赖 view必须重新构建 renderBase(); if (t 1) requestAnimationFrame(step); else resolve(); } requestAnimationFrame(step); }); }注意rebuildPaths()这一步是躲不掉的Path2D里存的是已经投影好的绝对坐标view 一变所有路径都失效。所以动画期间的每一帧都要重建路径。这确实有开销所以我在动画期间会把抽样精度进一步降低用低精度的那份数据参与动画动画结束后再换成高精度版本重绘一次用户完全感知不到。4.4 返回上一级的实现返回逻辑比钻取简单但有两个细节要注意。一是必须保存进入当前层级之前的 view 快照否则返回时会重新 fitBounds落点和进入前不一致用户会觉得回不去了。二是面包屑要能支持跨级跳转。const viewStack []; function enterLevel(newView) { viewStack.push({ level: currentLevel, parent: currentParent, view: { ...view } }); currentLevel newView.level; currentParent newView.parent; Object.assign(view, newView.view); } function goBack() { const prev viewStack.pop(); if (!prev) return; currentLevel prev.level; currentParent prev.parent; animateToView(prev.view); // 动画回到保存的 view不重新计算 }view快照里保存的是{ scale, offsetX, offsetY }三个值够用了。别去存Path2D那玩意儿重建一次也就十几毫秒。5. 交互增强缩放平移与悬停反馈地图能点能钻了但用户还会做两件事滚轮缩放、按住拖动。这两个交互的体验好坏直接决定别人觉得这东西专业还是学生作业。5.1 以鼠标位置为中心的滚轮缩放新手最容易犯的错是让缩放围绕画布中心进行view.scale * factor。这样做的结果是——你想放大某个角落结果它往中心跑鼠标下面那个地方越来越偏得反复拖着找回来。正确的做法是让鼠标下的那个地理点保持不动。推导一下设鼠标在画布上的位置为m当前偏移为o缩放为s那么鼠标指向的世界坐标是(m - o) / s。缩放后s s * k我们希望这个点仍然落在m上即(m - o) / s (m - o) / s解出o m - (m - o) · k代码canvas.addEventListener(wheel, e { e.preventDefault(); const rect canvas.getBoundingClientRect(); const mx e.clientX - rect.left; const my e.clientY - rect.top; const k e.deltaY 0 ? 1.12 : 1 / 1.12; const nextScale clamp(view.scale * k, minScale, maxScale); const realK nextScale / view.scale; // 用夹紧后的实际比例 view.offsetX mx - (mx - view.offsetX) * realK; view.offsetY my - (my - view.offsetY) * realK; view.scale nextScale; rebuildPaths(); renderBase(); }, { passive: false });{ passive: false }不能少不然preventDefault()会被浏览器忽略滚轮还是会带着页面一起滚。另外注意realK那一步——如果你把 scale 夹紧在[minScale, maxScale]范围内就必须用夹紧之后的比例去算偏移否则到达边界时地图会诡异地漂移。5.2 拖拽平移与惯性缓动拖拽本身很好写记录mousedown时的起点mousemove时算位移增量加到 offset 上就行。加惯性也不复杂在mouseup时读一下最后几帧的移动速度然后每帧衰减。let velocity { x: 0, y: 0 }; const FRICTION 0.92; function applyInertia() { if (Math.abs(velocity.x) 0.05 Math.abs(velocity.y) 0.05) return; view.offsetX velocity.x; view.offsetY velocity.y; velocity.x * FRICTION; velocity.y * FRICTION; rebuildPaths(); renderBase(); requestAnimationFrame(applyInertia); }不过要提醒一句惯性滑动期间如果用户又按下鼠标必须立刻打断惯性把 velocity 清零。否则会出现手已经抓住地图了地图还在往前溜的割裂感这个细节在移动端尤其明显。5.3 悬停高亮与 tooltip 的性能处理悬停是高频事件mousemove一秒钟能触发上百次。如果每次都在回调里做完整的命中检测加渲染主线程会被拖垮。我的处理方式是把mousemove的事件坐标先存到一个变量里然后用requestAnimationFrame做节流每帧只处理一次let pendingPointer null; let rafId null; canvas.addEventListener(mousemove, e { pendingPointer { x: e.clientX, y: e.clientY }; if (rafId) return; rafId requestAnimationFrame(() { rafId null; const hit hitTest(pendingPointer.x, pendingPointer.y); if (hit ! hovered) { hovered hit; canvas.style.cursor hit ? pointer : default; updateTooltip(hit, pendingPointer); } }); });tooltip 我不画在 Canvas 里而是用一个绝对定位的div。原因有两个一是文字渲染在 Canvas 上有字体加载、换行计算的麻烦二是 DOM 元素的 CSS 过渡、阴影、圆角都现成改样式不用重新打包。这是个很实用的取舍——Canvas 只负责图形文字信息交给 DOM。提示tooltip 定位要用transform: translate3d(x, y, 0)而不是改left/top前者走合成层不会触发重排鼠标快速移动时不会掉帧。6. 常见问题与排查技巧实录这部分是我自己踩过的坑以及被同事问过最多的问题。整理成速查表遇到问题先来这里对号入座。6.1 高频故障速查表现象大概率原因排查动作解决方式地图上下颠倒投影后 y 轴没翻转检查project里有没有-y加负号翻转或调整 offsetY 符号地图左右镜像经度符号搞反打印几个已知城市的投影坐标确认lng正负号和公式一致点击位置整体偏移没减rect.left/top或没乘 dpr点击画布左上角看返回坐标是不是 (0,0)用(clientX - rect.left) * dprisPointInPath总是 false变换矩阵没复位在检测前打印ctx.getTransform()ctx.setTransform(1,0,0,1,0,0)地图边缘被裁掉padding 太小或包围盒算错打印 minX/maxX/minY/maxY加大 padding检查是否漏了飞地坐标缩放时地图漂移用原始 k 而非夹紧后的 realK在边界处反复滚轮用夹紧后的比例算 offset高亮区域不刷新底图缓存没重建看renderBase有没有被调用视图变化后必须重绘底图地级市数据 404adcode 与文件名不对应对比数据里的 adcode 和实际文件名文件名统一用 6 位补零字符串首屏白屏几秒全国数据太大Network 面板看首包体积换 TopoJSON或先画降精度版本悬停卡顿mousemove 没节流Performance 面板看主线程rAF 节流 分层渲染6.2 关于 adcode 和文件命名的一个坑这个坑很隐蔽单独说一下。行政区划编码是 6 位数字但很多数据源里的 adcode 是数值类型JSON 解析出来之后前导零会丢掉。比如某个编码是011000这种带前导零的情况JSON.parse之后变成11000你再拿它拼文件名就必然 404。务必在数据处理阶段统一成字符串const adcode String(props.adcode).padStart(6, 0);同时文件名也用440100.json这种 6 位定长格式一一对应不要用中文名做文件名——中文名在不同系统上的编码处理会让你怀疑人生。6.3 内环填充导致的黑洞前面提过用evenodd填充规则但还有一种情况某些数据源里内环的点顺序是顺时针有的是逆时针如果你手动做挖洞判断比如根据环绕方向决定是否填充代码会变得很脆。直接用evenodd是成本最低的方案不用关心点的方向。如果发现某个区域该填的地方没填上先怀疑两件事一是路径里有没有混入 NaN 坐标投影时纬度超范围会返回 NaN整个路径就废了二是这个多边形的环有没有被正确closePath。6.4 数据加载失败的兜底地图数据文件通常不小网络抖动时加载失败很正常。一定要有兜底加载失败时保留当前视图弹一个轻量的重试提示而不是让地图变成空白。另外一个实用技巧是在构建阶段给数据文件生成一份极简版——只有最外层大轮廓、精度极低的那种。可以在正式数据加载完成前先把极简版画上去用户立刻就能看到地图轮廓然后再无缝替换成高清版本。这种渐进增强的观感比转圈圈的 loading 好太多。7. 性能优化与工程化落地建议最后聊聊怎么把 demo 变成能上线的工程代码。7.1 路径合并与绘制批次优化同一层级的行政区如果填充色一样、描边样式一样其实可以合并成一条Path2D一次性绘制减少fill/stroke的调用次数。三十多个独立的stroke调用和合并成一次stroke一个大路径在低端设备上能差出 58ms。代价是合并后没法单独控制样式所以我的策略是默认样式合并绘制特殊样式单独绘制绝大多数区域走合并路径只有需要高亮或者特殊配色的少数几个单独画。7.2 视口裁剪减少无效绘制当用户放大到某个省的时候其他省份的路径其实完全在视口外画了也看不见。可以在rebuildPaths的时候先做一次包围盒与视口的相交判断不相交的直接跳过路径构建。function inViewport(bbox, view, W, H) { const [x1, y1] project(bbox.minX, bbox.maxY, view); const [x2, y2] project(bbox.maxX, bbox.minY, view); return x2 0 y2 0 x1 W y1 H; }这个优化的收益在放大状态下非常明显尤其是全国数据里有大量小省份的时候。注意 bbox 要在解析数据阶段就算好存下来不要每次现算。7.3 移动端与大屏的适配差异移动端和大屏是两种完全不同的使用场景得分别处理移动端屏幕小、Dpr 普遍 23、没有滚轮。要加双指捏合缩放touchstart/touchmove里算两指距离比tooltip 改成点击弹出而不是悬停命中检测的容差要放大一些因为手指点击本身就有几像素的误差。大屏分辨率高、性能好、无触摸。可以放心上粒子、光效、阴影滤镜。但要注意大屏往往长时间运行requestAnimationFrame循环里不能有内存泄漏事件监听器记得用 AbortController 统一清理。const ac new AbortController(); canvas.addEventListener(wheel, onWheel, { passive: false, signal: ac.signal }); canvas.addEventListener(mousedown, onDown, { signal: ac.signal }); // 组件卸载时 ac.abort();这套写法比手动removeEventListener靠谱得多尤其是监听器多的时候不会漏。8. 几个真实项目里攒下的经验关于地图数据的获取有一点必须提醒矢量地理数据请从权威渠道获取并在合规范围内使用。不同来源的数据在边界表示、精度、坐标系上都有差异混用会出现相邻区域对不齐的情况。工程上的做法是整条链路只用一套数据源从全国到省到市保持同源这样边界天然吻合。关于Path2D的重建开销我的经验是不要过早优化。很多人一开始就想着路径缓存起来不就好了但真正需要高频重建的只有动画那一小段时间剩下的绝大多数时间路径是稳定的。先把功能跑通用 Performance 面板看看rebuildPaths到底占了多少时间一般也就几毫秒不值得为它引入复杂的缓存失效逻辑。关于动画时长620ms 这个数字是我反复调出来的。低于 400ms 会显得仓促用户还没看清从哪来就已经到了超过 800ms 又会让人等得不耐烦尤其是需要连续钻取好几级的时候。如果你想再稳一点可以按缩放倍率动态调时长——从小范围跳到大范围用长一点相邻层级之间跳用短一点。最后分享一个小技巧调试地图投影的时候别盯着整张图看挑两三个你熟悉的城市把它们的经纬度手动代入公式算出屏幕坐标然后用ctx.fillRect(x - 2, y - 2, 4, 4)在对应位置点几个红方块。如果红方块落在地图上的位置对了说明投影和坐标系转换全是正确的如果错了你也能立刻判断出是方向问题还是比例问题。这个土办法帮我省过至少半天时间比盲目改参数高效得多。至于这套代码后续还能怎么扩展我目前在做的是把每个地级市的实时数据接进来用渐变色映射数值高低再叠一层从市中心向外扩散的涟漪。核心思路没变——Path2D负责形状Canvas 负责表现数据层只管往回调里塞值。