ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

图片热区插件开发:坐标归一化、SVG适配与偏移排查

图片热区插件开发:坐标归一化、SVG适配与偏移排查

简介:这是一款基于jQuery的图片热区JavaScript插件,面向需要为网页图片添加可点击区域的前端开发者与网页设计人员,可用于在线地图、产品展示、交互式图像教程等场景。它支持在图片上自由绘制矩形、圆形及不规则热区,通过拖放和调整大小精确定位,并为每个区域绑定不同链接,配合CSS可自定义外观,源码注释较完整,便于二次开发与团队维护。压缩包共8个文件,包含2个JavaScript脚本、2个PNG图片、1个CSS样式、1个HTML示例页以及XML配置和Markdown说明,整体约209KB,体量轻便,适合快速查看与集成。已有2275人学习,读者可从中获得热区绘制、区域定位、链接绑定和样式调整的完整参考,也可借助示例页理解插件调用方式与目录组织,适合前端入门者练手或项目中原型验证。

1. 图片热区插件拆包:真正难的并非画框,难点在坐标在缩放后还对得上

运营提供一张设备示意图,图上有七八个点位要分别跳转到不同页面,PC 端宽 1200px、手机端 375px。第一次遇到这种需求,多数人觉得五分钟就能写完:在大图上盖几个绝对定位的 div 而已。真正动手才会发现,图纸一换、屏幕一改、图片被object-fit加了两道白边,之前量好的坐标就全部失效,点击位置整整偏出去一个图标。图片热区这件事的核心难点从来不在“画框”,而在像素坐标、缩放系数、DPR 三层叠加之后的换算关系。

这份 js 插件处理的内容,就是把“坐标采集、渲染适配、事件命中、数据校验”从业务代码里拆出来,让换图不再带动改代码。它适合做活动页多点位导购、商品组合图、设备拓扑图、楼层平面图、教学标注的前端同学,也适合只会在静态页里挂几个链接、不想碰构建工具的运营和后台同学。

2. 热区数据的坐标系:为什么我只存 0~1 的比例值

一份热区数据入库之后,所有偏移问题都会在后续处理中成倍放大。我见过最典型的一份数据,是有人在 750px 宽的设计稿上手写{ x: 320, y: 480 },本机预览没问题,上线到 1440 的显示器上,所有点位整体往左上收了一大截。问题不在代码,在于这份数据没有记录“它是相对谁量的”。所以第一步必须先把坐标系固定下来,再谈渲染。

2.1 像素坐标的三种基准,混用一次就偏移

同一张图,坐标至少有三套基准。设计稿基准,比如 750×1000;图片原始基准,naturalWidth/naturalHeight,导出时可能是 1500×2000;渲染基准,即getBoundingClientRect()拿到的那组数,PC 上可能是 900×1200。同一个热区在三个坐标系里的数值完全不同,混用的后果就是“开发机上是对的,换台机器全歪”。

换算公式本身很简单:

x_ratio = x_design / design_width x_px = x_ratio * rendered_content_width

这个式子在任何缩放比、任何 DPR 下都成立,但有个前置条件常被忽略:rendered_content_width必须是图片内容区的宽,而不是父容器的宽,也不是img.getBoundingClientRect().width。只要图片带了 padding、border,或者用了object-fit: contain产生了留白,这三个值就不相等。我一般会在数据里显式带一个design块,把基准写进去,后面所有换算都以它为唯一真相源。

另外提醒一句:设计稿基准和图片原始尺寸比例不一致时,怎么算都是错的。750×1000 的设计稿配一张 1500×1900 的图,纵向会被拉伸,热区必然偏。这条放到第 4 章细说。

2.2 换算成比例:除以哪个宽高是有讲究的

采集阶段用像素最直观,因为运营和设计只认设计稿上的数字。入库前统一跑一遍归一化,把像素换成 0~1 的比例值:

// normalize.js —— 把设计稿像素坐标归一化成 0~1 的比例值 // design 为坐标基准,shapes 为可视化采集出来的原始数据 function normalize(design, shapes) { const W = design.width; const H = design.height; // 保留 4 位小数:0.0001 * 4000px = 0.4px,肉眼已经完全看不出来 const round = (v) => Math.round(v * 10000) / 10000; return shapes.map((s) => { switch (s.type) { case 'rect': return { ...s, x: round(s.x / W), y: round(s.y / H), w: round(s.w / W), h: round(s.h / H) }; case 'circle': // 半径只除以宽度基准:渲染时容器与图片等比,圆才不会被拉成椭圆 return { ...s, cx: round(s.cx / W), cy: round(s.cy / H), r: round(s.r / W) }; case 'poly': return { ...s, points: s.points.map(([x, y]) => [round(x / W), round(y / H)]) }; default: throw new Error(`未知形状类型: ${s.type}`); } }); }

design传的是采集时用的画布尺寸,不是图片的naturalWidth,两者只要比例一致,结果完全等价,因为比例值本身是相对量。小数位定在 4 位是个折中:2 位在大屏上能差出 4px,6 位对 JSON 体积没意义,浏览器的浮点精度也吃不满。

还有一个细节,圆形的r我只除以宽度。原因是后面渲染用的是等比容器,宽高缩放系数相同,圆的形状自动保持;如果哪天有人把容器改成非等比拉伸,圆会变成椭圆,这在采集阶段就得拦住。

2.3 一份能被校验的数据结构

归一化之后的数据建议长这样,字段少一点、语义明确一点,比什么都能塞的万能对象好维护:

{ "design": { "width": 750, "height": 1000 }, "shapes": [ { "id": "gpu", "type": "rect", "x": 0.16, "y": 0.26, "w": 0.24, "h": 0.12, "href": "/detail/gpu", "label": "显卡模块" }, { "id": "fan", "type": "circle", "cx": 0.6933, "cy": 0.42, "r": 0.0933, "href": "/detail/fan", "label": "散热风扇" }, { "id": "port", "type": "poly", "points": [[0.4, 0.7], [0.56, 0.69], [0.5867, 0.76], [0.4133, 0.77]], "href": "/detail/port", "label": "接口区" } ] }

id是主键,也是埋点和回归测试的抓手,一定不能重复;href和action二选一,前者跳链接后者触发自定义事件;label不是给开发看的注释,它是无障碍的aria-label来源,也是运营在后台列表里唯一能读懂的字段。校验脚本我一般在上线前跑一遍,挡住 90% 的低级错:

// validate.js —— 热区入库前必须过这一关 function boundingBox(s) { if (s.type === 'rect') return { x0: s.x, y0: s.y, x1: s.x + s.w, y1: s.y + s.h }; if (s.type === 'circle') return { x0: s.cx - s.r, y0: s.cy - s.r, x1: s.cx + s.r, y1: s.cy + s.r }; const xs = s.points.map((p) => p[0]); const ys = s.points.map((p) => p[1]); return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }; } function validate(shapes) { const errors = []; const seen = new Set(); for (const s of shapes) { if (seen.has(s.id)) errors.push(`[重复 id] ${s.id}`); seen.add(s.id); if (!s.href && !s.action) errors.push(`[无动作] ${s.id} 既没配跳转也没配事件`); const b = boundingBox(s); if (b.x0 < 0 || b.y0 < 0 || b.x1 > 1 || b.y1 > 1) errors.push(`[越界] ${s.id} 超出 0~1`); if ((b.x1 - b.x0) * (b.y1 - b.y0) < 0.0004) errors.push(`[过小] ${s.id} 手指点不中`); } // 两两求交:重叠面积超过小热区的 30% 就报出来,让人工确认谁该在上面 for (let i = 0; i < shapes.length; i++) { for (let j = i + 1; j < shapes.length; j++) { const a = boundingBox(shapes[i]); const b = boundingBox(shapes[j]); const w = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0); const h = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0); if (w <= 0 || h <= 0) continue; const smaller = Math.min((a.x1 - a.x0) * (a.y1 - a.y0), (b.x1 - b.x0) * (b.y1 - b.y0)); if ((w * h) / smaller > 0.3) errors.push(`[重叠] ${shapes[i].id} 与 ${shapes[j].id}`); } } return errors; }

重叠判断用包围盒是偷懒的做法,多边形实际可能一点不挨着,会误报。误报可以人工放行,漏报才是灾难,所以宁可多报。面积阈值0.0004是按最小可点区域反推的:0.02×0.02 在 400px 宽的图上只有 8×8 像素,远小于推荐的可点尺寸,直接判死。

2.4 形状选型:矩形、圆、多边形各自的使用边界

矩形覆盖九成场景,一个显卡槽位、一台机柜、一件衣服,都是方的。它的事件命中由浏览器原生负责,没有额外逻辑,出错概率最低。圆形适合旋钮、指示灯、头像这类天然不规则的元件,判定的用户体验比外接矩形更好。多边形适合设备剖面图、机房俯视图这种“按机房隔间分区”的场景,代价是数据难手写,必须靠可视化采集。

至于<map>/<area>那套老方案,我不推荐。它的坐标绑定的是图片像素,图片一被 CSS 缩放就全错,而且几乎没有样式能力,hover 高亮要自己另画一层。Canvas 命中检测适合热区数量上百且互相压叠的极端场景,但键盘可达、焦点环、读屏全部要自己补,对一个通用插件来说并不划算。这也是第 3 章渲染选型的出发点。

3. 渲染与适配:用一层 SVG overlay 接住所有缩放

数据定型之后,渲染层要解决的是“同一份比例数据,在 375px 的手机和 2560px 的显示器上都落在正确位置”。这里没有银弹,但有明显更省事的路线。插件在接入层通常暴露三四个参数:el挂载节点、shapes数据、design基准、mode交互态(预览态 / 编辑态),实现细节才是分水岭。

3.1 四种渲染方案的取舍

方案坐标单位缩放后能否自动对齐hover / 动画无障碍多边形成本
绝对定位 divCSS px否,每次 resize 都要手算原生支持好高,要靠 clip-path 或 div 拼
SVG overlaySVG 用户单位是,viewBox 自动映射支持好,元素可 focus低,一个 polygon 搞定
Canvas 命中检测画布 px否,还要乘 DPR全部手写差中,自己做点在多边形内判断
image map / area图片 px否几乎没有一般中

我选 SVG overlay,理由很朴素:viewBox天生就承担了“把设计稿坐标映射到任意渲染尺寸”这件事,浏览器帮我做了缩放,我只需要保证外层容器和图片内容区严丝合缝。剩下的工作只有一个,把容器的位置算准。

3.2 用 viewBox 把设计稿坐标直接当用户单位

渲染时我不把比例值乘回去,而是把viewBox设成0 0 design.width design.height,形状里存的仍是设计稿像素值,比例只在需要做 canvas 或 CSS 定位的备选路径上使用。这样调试时拿设计稿上的数字一比就能对上,省掉一层心算。

// mount.js —— 生成热区层(只贴关键逻辑) const NS = 'http://www.w3.org/2000/svg'; function createLayer(shapes, design) { const svg = document.createElementNS(NS, 'svg'); // viewBox 用设计基准,浏览器负责把用户单位缩放到容器实际像素 svg.setAttribute('viewBox', `0 0 ${design.width} ${design.height}`); svg.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none'; for (const s of shapes) { const tag = s.type === 'rect' ? 'rect' : s.type === 'circle' ? 'circle' : 'polygon'; const el = document.createElementNS(NS, tag); el.setAttribute('data-hotspot-id', s.id); el.setAttribute('data-href', s.href || ''); el.setAttribute('tabindex', '0'); // 键盘可达,别漏 el.setAttribute('role', 'button'); el.setAttribute('aria-label', s.label || s.id); const W = design.width; const H = design.height; if (s.type === 'rect') { el.setAttribute('x', s.x * W); el.setAttribute('y', s.y * H); el.setAttribute('width', s.w * W); el.setAttribute('height', s.h * H); } else if (s.type === 'circle') { el.setAttribute('cx', s.cx * W); el.setAttribute('cy', s.cy * H); el.setAttribute('r', s.r * W); } else { el.setAttribute('points', s.points.map(([x, y]) => `${x * W},${y * H}`).join(' ')); } svg.appendChild(el); } return svg; }

s里存的是比例值还是像素值,取决于归一化做在哪一步;这里假设是比例值,所以乘了design.width/height还原。svg本身设pointer-events: none,形状元素在 CSS 里单独pointer-events: auto,不这么做整层会盖住图片上的其他交互,比如长按保存图片。

还有一处容易漏:stroke的高亮描边在缩放后会跟着变粗变细,用vector-effect="non-scaling-stroke"把它固定成 1px 更稳。

3.3 object-fit: contain 的留白必须自己算

图片按object-fit: contain展示时,元素盒子里有一半是空白,热区层如果贴着盒子边缘走,所有点位都会偏到留白里。真实绘制区域要算:

// 计算 contain 模式下图片真正绘制的矩形(相对图片元素自身) function layout(img, layer) { const nw = img.naturalWidth; const nh = img.naturalHeight; if (!nw || !nh) return; // 图还没解码完,先跳过,别硬算 const rect = img.getBoundingClientRect(); // 已包含 CSS transform 与 DPR 换算后的实际像素 const scale = Math.min(rect.width / nw, rect.height / nh); const w = nw * scale; const h = nh * scale; // 前提:img 自身没有 padding / border,且父容器与 img 同尺寸 layer.style.left = `${(rect.width - w) / 2}px`; layer.style.top = `${(rect.height - h) / 2}px`; layer.style.width = `${w}px`; layer.style.height = `${h}px`; } // 用 ResizeObserver 而不是 window.resize:容器被 flex 挤扁时也要重算 const ro = new ResizeObserver(() => requestAnimationFrame(() => layout(img, layer))); ro.observe(img);

getBoundingClientRect()拿到的数值已经吃过transform: scale(),所以对 transform 导致的视觉缩放也成立,这是它比clientWidth更可靠的地方。用它算问题不大;用它减去 padding 再去算,才会出问题。

图片未解码完时naturalWidth是 0,这时候算出来的scale是Infinity,热区层会直接消失或撑满整个屏幕,看起来像“偶现的样式错乱”。加一道img.decode()的守卫,比事后排查省太多时间。

3.4 事件用委托,不要给每个形状挂监听

热区数量一多,逐个addEventListener既不经济,也不利于动态增删。委托给 SVG 一层,靠closest('[data-hotspot-id]')找目标。移动端还有两件事必须做:用 pointer 系列事件统一鼠标与触摸,以及给一个位移阈值来区分“点击”和“滑动”。

let down = null; svg.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY, t: Date.now() }; }); svg.addEventListener('pointerup', (e) => { if (!down) return; const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y); const elapsed = Date.now() - down.t; down = null; // 位移超过 8px 或按住超过 600ms,判定为滑动/长按,不跳转 if (moved > 8 || elapsed > 600) return; const el = e.target.closest('[data-hotspot-id]'); if (el) location.href = el.dataset.href; }); // 键盘同样要能触发,否则 tabindex 白加 svg.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const el = e.target.closest('[data-hotspot-id]'); if (!el) return; e.preventDefault(); location.href = el.dataset.href; });

8px 这个阈值不是玄学,指头抖动通常在 3~5px,取 8 能覆盖绝大多数误触,又不至于让正常的短按失效。600ms 对应移动端长按手势的起点。两个条件用“或”,是因为滑动很快但位移可能不足 8px,长按则位移几乎为 0,各自挡住一类误触。

4. 排查热区偏移与误触:五个翻车现场的现象、原因和修法

这套东西我在四个项目里踩过坑,有些坑第一次遇到时能查一整个通宵。下面五条按发生频率排,每条都按“现象 → 原因 → 解决”来写,遇到对应症状可以直接对照。

4.1 整体偏移:页面缩放与容器偏移叠加

现象:热区整体比图片大一圈或者小一圈,PC 上偏移量几十像素,手机上看着又还好;浏览器缩放 80% 时偏移量同步变化。

原因:坐标换算用的是 CSS 逻辑像素,而实际渲染走了父级的transform: scale()或者浏览器缩放,getBoundingClientRect()返回的是已缩放尺寸,和offsetWidth不是一个口径,两套数混算就出偏移。

解决:只用一种测量口径——全部走getBoundingClientRect(),并且热区层和图片放在同一个定位父级里,left/top都相对于这个父级。父级上加transform时,把热区层作为兄弟节点一起被缩放,不让它单独算位置。

4.2 热区整个消失:图片还没解码就量尺寸

现象:首屏刷新偶尔看不到热区,滚动一下或者改个窗口大小又回来了;安卓低端机上复现率高。

原因:<img>的naturalWidth在图片解码完成前是 0,scale算成Infinity,热区层宽高被设成Infinity px,浏览器直接忽略这条样式。

解决:等待img.decode()(或complete && naturalWidth > 0)之后再初始化,ResizeObserver的回调里也保留同样的守卫。SSR 场景下把初始化放到onLoad里,别在 DOM 就绪时抢跑。

4.3 滑动页面反被跳转:click 判定的边界没设

现象:手机上想往下滑,手指从某个热区起手,页面直接跳走了,用户以为中病毒。

原因:只监听click,浏览器在touchend后即便有较大位移也会补发一次 click(视内核和滚动状态而定),加上热区面积大,起手点落在里面的概率很高。

解决:按 3.4 的写法,pointerdown记录起点,pointerup校验位移和时长,两个条件任一不满足就不触发。同时给热区层加touch-action: manipulation,减少移动端 300ms 的点击判定延迟和双击缩放干扰。

4.4 下面那个点不到:重叠与层级顺序

现象:两个热区挨着或者嵌套,用户怎么点都只触发上面那个,明明视觉上点的是下面那块。

原因:SVG 里后插入的元素天然在上层,DOM 顺序即层级,跟面积大小无关。大热区后插入就把小热区整个盖住了。

解决:渲染前按包围盒面积从小到大排序,小的后插入、压在上面;或者在数据里加显式priority字段。校验脚本的重叠检测负责把这种情况提前报出来,别等用户投诉。

4.5 换图之后全歪:设计稿比例和图片比例不一致

现象:同一份 JSON,配 A 图是准的,换 B 图整体纵向拉长。

原因:设计稿 750×1000,出图时为了裁掉底部空白导出成 1500×1900,比例从 0.75 变成 0.789,纵向压缩后热区必然错位。

解决:归一化脚本里加一道断言,Math.abs(design.width / design.height - nw / nh) > 0.005直接抛错。这条断言救过我一次,图片是从设计工具里重新导出的,尺寸悄悄变了,热区数据没动,上线前被脚本拦下来了。

5. 可视化采集与自动校验:让运营画框,让脚本挑错

手写坐标这件事,短期看快,长期一定是灾难。图一多、点位一密,开发就成了人肉坐标录入机,而且每次改图都要重来。插件如果带编辑态,最省事的做法是把采集这件事交给真正知道要点哪里的人,开发只负责接数据。

5.1 编辑态和预览态要共用一套渲染逻辑

常见做法是同一个组件、两个mode:preview只读,形状透明、hover 高亮;edit可拖拽、可缩放、可删除,右下角一个导出按钮。共用渲染逻辑的好处是“画出来的样子”和“用户看到的样子”永远一致,不会出现编辑器里准、上线后偏的经典事故。

编辑态还需要额外三样东西:一个网格吸附(按 0.5% 取整,避免出现一堆0.16333这种数)、一个形状列表面板(点击列表项高亮对应形状,方便查漏)、一个撤销栈(至少支持回退一步,改错了不至于重画)。

5.2 拖拽画框的最小实现

指针事件的坐标换算和预览态完全一样,都是先拿图片内容区的getBoundingClientRect(),再归一化。差别只在要处理拖拽的起止和最小面积。

// editor.js —— 在图片上拖出一个矩形热区,输出比例坐标 function bindDraw(stage, img, onDone) { let start = null; let ghost = null; stage.addEventListener('pointerdown', (e) => { const r = img.getBoundingClientRect(); // 直接除以 rect 的宽高,得到 0~1;contain 留白由 3.3 的 layer 边界保证不会画到外面 start = { x: clamp((e.clientX - r.left) / r.width), y: clamp((e.clientY - r.top) / r.height), }; ghost = document.createElement('div'); ghost.className = 'hotspot-ghost'; stage.appendChild(ghost); stage.setPointerCapture(e.pointerId); // 手指滑出容器也不丢事件 }); stage.addEventListener('pointermove', (e) => { if (!start || !ghost) return; const r = img.getBoundingClientRect(); const cur = { x: clamp((e.clientX - r.left) / r.width), y: clamp((e.clientY - r.top) / r.height), }; const box = { x: Math.min(start.x, cur.x), y: Math.min(start.y, cur.y), w: Math.abs(cur.x - start.x), h: Math.abs(cur.y - start.y), }; Object.assign(ghost.style, { left: `${box.x * 100}%`, top: `${box.y * 100}%`, width: `${box.w * 100}%`, height: `${box.h * 100}%`, }); }); stage.addEventListener('pointerup', (e) => { if (!start || !ghost) return; const r = img.getBoundingClientRect(); const cur = { x: clamp((e.clientX - r.left) / r.width), y: clamp((e.clientY - r.top) / r.height), }; const box = { x: round4(Math.min(start.x, cur.x)), y: round4(Math.min(start.y, cur.y)), w: round4(Math.abs(cur.x - start.x)), h: round4(Math.abs(cur.y - start.y)), }; ghost.remove(); ghost = null; start = null; // 太小的一律丢弃,避免误点产生 1px 的垃圾热区 if (box.w * box.h > 0.0004) onDone(box); }); } const clamp = (v) => Math.min(1, Math.max(0, v)); const round4 = (v) => Math.round(v * 10000) / 10000;

setPointerCapture是关键一行。不设的话,手指拖到图片外面,pointerup会派发到别的元素上,ghost就永远留在页面上,看起来像卡死。clamp保证拖出边界也不会产生负坐标或超过 1 的值,这个校验在导出前还会再跑一遍,双保险。

5.3 三道校验接到 CI 上

采集完的 JSON 要走一次提交,与其靠人眼,不如挂到流水线上。我一般分三道:

第一道是结构校验,用 2.3 的validate(),越界、过小、重叠、无动作全部拦下,有 error 直接退出码 1。第二道是比例断言,比对design和图片实际尺寸的宽高比,差值超过 0.5% 就报错,这一步挡住的是“悄悄换图”的场景。第三道是截图比对,无头浏览器里把热区层截出来,和上一次的基线图做像素差,超过阈值就人工确认——热区位置改动通常是有意的,但意外改动也常常是这一层发现的。

脚本用 Node 跑,一条命令串起来:

# 归一化 → 校验 → 生成产物,任何一步失败都阻断发布 node scripts/normalize.js --in raw.json --out hotspots.json \ && node scripts/validate.js hotspots.json \ && node scripts/assert-ratio.js hotspots.json assets/main.jpg

--in是采集导出的原始像素数据,--out是归一化后的产物;assert-ratio.js的第一个参数是数据文件,第二个是图片路径,它自己读图片头拿宽高,不依赖浏览器。三个脚本都不引第三方依赖,跑起来不到一秒,接到pre-commit里也不会拖慢提交。

6. 上线前把每个热区点一遍:用无头浏览器做坐标回归

坐标这种问题,肉眼审查是查不出来的。我的做法很笨:用无头浏览器把每个热区的中心点真点一次,断言跳转或事件触发符合预期。这套脚本跑一次十几秒,比人工点十几分钟靠谱得多,而且能进 CI。

// e2e-hotspots.mjs —— 逐点回归,坐标必须用「图片内容区」而不是元素盒子 import { chromium } from 'playwright'; import fs from 'node:fs'; const data = JSON.parse(fs.readFileSync('hotspots.json', 'utf8')); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto('http://localhost:3000/demo'); await page.locator('.hotspot-img').waitFor(); // contain 模式下元素盒子含留白,必须还原成真实绘制区 const box = await page.locator('.hotspot-img').evaluate((img) => { const r = img.getBoundingClientRect(); const s = Math.min(r.width / img.naturalWidth, r.height / img.naturalHeight); const w = img.naturalWidth * s; const h = img.naturalHeight * s; return { x: r.left + (r.width - w) / 2, y: r.top + (r.height - h) / 2, w, h }; }); for (const s of data.shapes) { const c = centerOf(s); // 各形状的比例中心,圆形直接取 cx/cy await page.mouse.click(box.x + c.x * box.w, box.y + c.y * box.h); await page.waitForTimeout(120); console.log(`[ok] ${s.id} -> ${page.url()}`); await page.goBack(); } await browser.close();

centerOf对矩形取x + w/2,对圆形取cx/cy,对多边形取包围盒中心——多边形取顶点平均值容易落到凹多边形外面,包围盒中心更稳,真落到空隙里说明热区本身设计得不够大。视口特意设成 390×844,这是最容易暴露 contain 留白问题的一档宽度;跑完再切 1440×900 跑第二遍,两个尺寸都过,基本可以放心。

从那以后我每次热区上线前都强制走一遍这个脚本,哪怕只改了一个href,也就多花十几秒,比上线后从监控里看到“点击率异常低”再回头查要划算太多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进