
简介这是一款面向网页设计师与前端开发者的图片热区 JavaScript 插件基于 jQuery 构建用于在图片上绘制矩形、圆形及不规则热区并为不同区域绑定链接或交互事件适合在线地图、产品展示、交互式图像教程等场景。压缩包共 8 个文件包含 2 个 JavaScript 脚本、2 张示例图片、1 个 CSS 样式表、1 个 HTML 演示页以及 XML 配置和 Markdown 说明整体约 209KB结构轻量便于直接查看效果并接入现有页面。已有 2275 人学习或下载说明其在图片交互类需求中具有一定参考价值。插件提供自由绘制、拖放调整、自定义链接和较完整的源码注释配套演示页与样式文件可用于快速理解热区定位、外观定制及事件扩展方式也方便在 IDE 中预览调试和二次开发。1. 图片热区不是切图定位先想清楚坐标体系运营在群里甩来一张活动海报说右下角那个“立即参与”要往左上挪几十像素再挂一个圆形的新人礼包入口。如果你把位置写死在 CSS 里这句话就是一次发版换成图片热区 js 插件把区域抽成一份 JSON 存到后台改的只是一条数据。图片热区解决的就是这件事一张图上存在若干可点区域区域坐标与图片本身解耦由数据驱动渲染形状可以是不规则多边形而不只是方块。它适合运营后台配置、商品图导购、设备面板图标注、课件知识点定位这类“图不常改、点位天天变”的场景也适合需要支持任意形状命中、又要保证缩放后不跑偏的场合。先把坐标体系想明白再谈用哪个插件。2. 三条实现路线map 标签、绝对定位层与 SVG 覆盖层热区插件包不是凭空冒出来的它底下必然选了这三种渲染方式中的一种或几种。选型定错了后面所有优化都是在给错误的地基刷漆所以我一般会先花十分钟把三条路线的边界过一遍再决定插件包怎么接。2.1 原生 map area最省事也最先撞墙浏览器自带的图像映射零依赖、零脚本就能跑图片和热区绑定在一起改坐标只是改属性。img srcposter.png usemap#hotmap alt活动海报 map namehotmap !-- 矩形左上角 x,y 与右下角 x,y -- area shaperect coords120,80,320,200 href#rule alt活动规则 !-- 圆形圆心 x,y 与半径 -- area shapecircle coords480,160,60 href#join alt报名入口 !-- 多边形每两个数字构成一个顶点首尾自动闭合 -- area shapepoly coords60,420,180,380,240,470,120,520 href#faq alt常见问题 /map这段的要点在coords的单位它是基于图片固有尺寸的 CSS 像素原点在图片左上角图片通过width/height等比缩放时主流浏览器会把热区一起缩放所以纯width:100%的响应式场景它其实是能扛的。参数上shape决定顶点个数怎么解释rect必须四个数circle必须三个数poly必须是偶数个且至少六个alt不只是无障碍属性移动端长按、读屏都依赖它。真正的墙有三处。第一object-fit: cover裁切时热区不跟随裁切图片被裁掉一部分热区还按原图坐标映射整体错位且没有任何报错。第二样式能力几乎为零你可以给area加 outline但做不了自定义高亮块、hover 浮层、选中态描边运营要的“悬停变亮”直接做不了。第三它只能渲染不能编辑用户想可视化拖拽画热区只能另起一套渲染层等于同一份数据维护两套坐标逻辑血泪经验是这两套迟早对不上。2.2 绝对定位 div 覆盖样式最好玩坑在定位父级把热区做成绝对定位的a或div用百分比定位样式随便写动画、圆角、渐变、毛玻璃都行。figure classstage img srcposter.png alt活动海报 a classhot style--x:10%;--y:5%;--w:25%;--h:11% href#rule活动规则/a a classhot hot--circle style--x:38%;--y:8%;--w:10%;--h:0 href#join报名/a /figure.stage { position: relative; display: inline-block; line-height: 0; } .stage img { display: block; width: 100%; height: auto; } .hot { position: absolute; left: var(--x); top: var(--y); width: var(--w); height: var(--h); /* 圆形热区用 padding-bottom 撑高避免高度写死 */ border-radius: 999px; }关键点在于百分比是相对“最近的定位祖先”的 padding box 计算的而.stage的尺寸来自img所以只有img是唯一内容、且.stage没有 padding/border 时热区坐标才严格等价于图片坐标。给.stage加个padding: 12px做卡片留白热区就会整体偏移而且偏移量随容器宽度变化看起来像玄学其实是盒模型。圆形热区还有额外麻烦width/height按百分比分别相对宽高计算容器宽高比一变圆就变成椭圆。常见做法是用padding-bottom或aspect-ratio把高度绑到宽度上代价是热区的height不再独立可控。多边形就更别提了只能靠clip-path: polygon()但clip-path裁的是绘制不是命中被裁掉的区域依然接收点击得配合pointer-events一点点补维护成本高。2.3 SVG 覆盖层多边形和编辑态的正解SVG 是矢量天然带坐标系和viewBox一个覆盖层同时解决渲染、命中、缩放、编辑四个问题。svg classlayer viewBox0 0 1200 1800 preserveAspectRationone !-- 矩形热区 -- rect classhot>.layer { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; /* 整层不拦截避免挡住图片上的其他交互 */ } .layer .hot { fill: transparent; /* 必须给填充纯 none 不参与命中 */ stroke: #1f7ae0; stroke-width: 2; pointer-events: all; /* 命中交给图形本身 */ cursor: pointer; transition: fill .15s; } .layer .hot:hover { fill: rgba(31,122,224,.25); }viewBox设成图片固有尺寸这里 1200×1800preserveAspectRationone让 SVG 层随容器拉伸图形坐标写原图像素图片等比缩放时两者同步天然响应式不需要监听resize重算。参数上要注意fill: transparent与fill: none的区别none的图形不参与命中测试点击会直接穿过去这是最高频的“热区画了却点不到”的原因没有之一。编辑态也顺手多边形顶点就是points里的坐标对拖拽时改对应数值即可命中检测交给浏览器不用自己写射线法判断点在不在多边形内。代价是 SVG 的pointer-events与 CSS 层叠要理清楚以及 Safari 上部分版本对pointer-events: all的处理略有差异上线前得在真机上点一遍。2.4 选型对照按“要不要编辑”和“形状复杂度”定维度map area绝对定位 divSVG 覆盖层依赖零零零原生或插件支持形状矩形/圆/多边形矩形圆形需技巧矩形/圆/多边形/路径自定义样式极弱最强强缩放响应等比缩放可用裁切失效需百分比约束跟随 viewBox 自动可视化编辑不支持实现成本高实现成本低命中重叠处理由浏览器按 DOM 顺序需手动调 z-index由绘制顺序决定这三条路线决定了插件包的定位以 SVG 为渲染底座、附带编辑器、坐标统一走归一化存储的包落地成本最低只做 div 覆盖的包轻但形状受限基于area的包基本只能当只读热区用。拿到一个包先看它用哪条路线比看文档管用。3. 接入这个热区插件包从绘制到数据落库热区插件包的接口命名各家不同但骨架是一样的一个容器、一张图、一组区域数据编辑器负责把鼠标动作翻译成区域渲染器负责把区域画回去。下面按这类包常见的接口签名写方法名以手上那份为准思路照搬即可。3.1 初始化与配置项先把这六个参数定死import HotzoneEditor from ./hotzone-editor.js; const editor new HotzoneEditor({ container: #stage, // 承载图片与覆盖层的元素 image: { src: /static/poster.png, width: 1200, // 图片固有宽用于 viewBox 与坐标换算 height: 1800 }, shape: rect, // 默认绘制形状rect | circle | polygon unit: ratio, // 坐标单位ratio(0~1) | px(原图像素) minSize: 12, // 最小热区边长单位 px防止误点画出针尖 snap: 1, // 坐标取整精度1 表示取整到 1 个原图像素 onChange(regions) { // 每次增删改后回调用于脏标记 state.dirty true; state.regions regions; } });container必须能确定尺寸如果初始化时它还是display:none弹窗里常见getBoundingClientRect()返回全 0算出来的比例全是NaN插件画出来一片空白且不报错。image.width/height一定要显式传别依赖img.naturalWidth——图片没加载完时它是 0而且很多包在图片走 CDN 裁剪参数时拿到的固有尺寸和你以为的不一致。unit是最容易埋雷的参数px模式下所有坐标基于原图像素好处是和设计稿一一对应坏处是换图就得重算ratio模式存 0~1 的浮点数与图片尺寸解耦换分辨率同一张图直接复用。我一般强制ratio编辑时内部用像素算落库前统一除。minSize和snap是体验参数。minSize太小运营手一抖就画出一个 3px 的热区用户根本点不到snap设太大比如 10拖出来的框对不齐设计稿边缘反而被挑刺。这两个值我通常按图片宽度取宽 1200 的图minSize取 12~16snap取 1。3.2 三种形状的绘制流程与坐标采集// 矩形按下记起点移动实时预览抬起定稿 editor.on(draw:start, ({ point }) { draft.start point; }); editor.on(draw:move, ({ point }) { const x Math.min(draft.start.x, point.x); const y Math.min(draft.start.y, point.y); const w Math.abs(point.x - draft.start.x); const h Math.abs(point.y - draft.start.y); editor.preview({ type: rect, x, y, width: w, height: h }); // 只画草稿不落数据 }); editor.on(draw:end, ({ point }) { const x Math.min(draft.start.x, point.x); const y Math.min(draft.start.y, point.y); const w Math.abs(point.x - draft.start.x); const h Math.abs(point.y - draft.start.y); if (w editor.options.minSize || h editor.options.minSize) return; // 尺寸不够直接丢弃 editor.add({ type: rect, x, y, width: w, height: h }); }); // 多边形单击加点双击或点回起点闭合 editor.setShape(polygon); editor.on(vertex:add, ({ point }) editor.pushVertex(point)); editor.on(polygon:close, ({ points }) { if (points.length 3) return; // 少于三点不是面 editor.add({ type: polygon, points }); });矩形那段用Math.min/abs而不是直接取当前点是因为用户可以从右下往左上拖不处理就会出现负宽高SVG 里width为负直接不渲染看起来像“画了没反应”。这是编辑器最经典的翻车点很多包在文档里不提。多边形闭合的判断要留容差判断点击点与起点距离小于 8px 就算闭合而不是要求精确像素重合否则用户永远闭不上。顶点数量下限 3 是几何硬约束但实际业务里三角形热区几乎没法点我一般建议运营最少画四个点、面积别低于整个图面的 0.5%。3.3 归一化与序列化能存的那份数据长什么样// 落库前的归一化所有坐标除以原图宽高圆的半径只除以宽度 function toRatio(region, natural) { const rx (v) (v / natural.width).toFixed(6); const ry (v) (v / natural.height).toFixed(6); if (region.type rect) { return { ...region, x: rx(region.x), y: ry(region.y), width: rx(region.width), height: ry(region.height) }; } if (region.type circle) { // 半径按宽度比例存保证缩放后还是圆 return { ...region, cx: rx(region.cx), cy: ry(region.cy), r: rx(region.r) }; } return { ...region, points: region.points.map(([x, y]) [rx(x), ry(y)]) }; }圆形的半径是最反直觉的一处cx除以宽、cy除以高但r只能除以其中一个边通常除以宽。如果半径按“水平方向除宽、垂直方向除高”分别存那本质上是椭圆图片宽高比一变热区就从圆变成橄榄球。存的时候统一按宽度比例渲染时r ratio_r * viewWidth视觉上永远是正圆。存储格式我一般统一成points数组矩形存四个角、多边形存全部顶点、圆存圆心加半径字段前端渲染时再按type分支。好处是所有形状都能走同一套“点在多边形内”的兜底命中检测不依赖 DOM 命中重叠场景更好控。{ version: 1, image: { width: 1200, height: 1800, src: poster.png }, regions: [ { id: r1, type: rect, x: 0.100000, y: 0.050000, width: 0.250000, height: 0.111111, link: #rule, label: 活动规则 }, { id: r2, type: polygon, points: [[0.05,0.233333],[0.15,0.211111],[0.2,0.261111],[0.1,0.288889]], link: #faq, label: 常见问题 }, { id: r3, type: circle, cx: 0.4, cy: 0.088889, r: 0.05, link: #join, label: 报名 } ] }version字段别省。热区数据一旦进了数据库格式变更就是历史包袱留个版本号后面加hoverText、zIndex、targetType才有地方做兼容分支。3.4 渲染态与编辑态分离只读页别把编辑器打包进去编辑器体积通常是纯渲染的三到五倍用户端只需要画和点不需要画布拖拽、顶点吸附、撤销栈。我习惯把渲染抽成一个纯函数输入区域数组和容器尺寸输出 SVG 片段编辑器和只读页共用它。// 纯渲染不依赖编辑器实例只读页可以直接复用 function renderRegions(svgEl, regions, natural) { const NS http://www.w3.org/2000/svg; svgEl.setAttribute(viewBox, 0 0 ${natural.width} ${natural.height}); svgEl.innerHTML ; regions.forEach((r) { let el; if (r.type rect) { el document.createElementNS(NS, rect); el.setAttribute(x, r.x * natural.width); el.setAttribute(y, r.y * natural.height); el.setAttribute(width, r.width * natural.width); el.setAttribute(height, r.height * natural.height); } else if (r.type polygon) { el document.createElementNS(NS, polygon); el.setAttribute(points, r.points.map(([x, y]) ${x * natural.width},${y * natural.height}).join( )); } else { el document.createElementNS(NS, circle); el.setAttribute(cx, r.cx * natural.width); el.setAttribute(cy, r.cy * natural.height); el.setAttribute(r, r.r * natural.width); } el.dataset.id r.id; el.setAttribute(class, hot); svgEl.appendChild(el); }); }这里的natural必须是图片固有尺寸不是容器尺寸。用容器尺寸做viewBox会在图片加载前后尺寸跳变时出现热区抖动且object-fit非默认值时进一步错位。渲染和编辑两条链路共用这一个函数意味着编辑器里看到的和线上看到的是一份逻辑能省掉一整类“编辑器里对、上线就偏”的问题。4. 缩放、裁切与高 DPI热区不跑偏的坐标换算热区错位九成不是插件 bug是图片的显示尺寸和固有尺寸不一致而坐标换算没有经过统一收口。这一章把换算拆成四层容器适配、命中扩展、像素比、工具函数。4.1 object-fit 的三种取值三种换算公式fill是非等比拉伸x和y用不同缩放系数contain会在容器里留边需要算偏移cover会裁切超出部分热区虽然存在但用户看不到。// 计算图片在容器内的实际绘制区域供一切坐标换算使用 function computeFit(natural, box, mode contain) { const scaleX box.w / natural.w; const scaleY box.h / natural.h; if (mode fill) return { ox: 0, oy: 0, dw: box.w, dh: box.h, sx: scaleX, sy: scaleY }; const s mode cover ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY); const dw natural.w * s; const dh natural.h * s; // contain 居中留边cover 居中裁切偏移为负 return { ox: (box.w - dw) / 2, oy: (box.h - dh) / 2, dw, dh, sx: s, sy: s }; }contain模式下ox/oy是正数左右或上下黑边热区坐标要加上这两个偏移才不会整体偏向某一侧cover模式下是负数意味着部分热区被推到容器外虽然input之类的元素还能通过overflow:hidden裁掉但用户点不到那部分极端宽高比时甚至主按钮会被裁没。我做运营后台时一般禁止对热区图用cover宁可让容器撑高留白。fill最省心也最危险坐标换算简单但图片被非等比拉伸圆形热区视觉上就成椭圆运营会以为插件画错了。要圆就老老实实contain或cover。4.2 移动端最小命中区域与触摸判定iPhone 上实测热区可点宽度低于 40px 时误点率明显上升所以设计稿上的热区再小代码里也要做“命中扩展”视觉上按原尺寸画命中上包一层更大的透明元素或用点与线段距离、点在多边形内的判定加个容差。// 命中扩展点到多边形的最短距离小于 pad 也算命中 function hitPolygon(pt, points, pad 10) { let min Infinity; for (let i 0; i points.length; i) { const a points[i]; const b points[(i 1) % points.length]; min Math.min(min, distToSegment(pt, a, b)); } return min pad || pointInPolygon(pt, points); }触摸还要处理“点击还是滑动”直接用click事件用户在热区上开始滚动页面时会被误判为点击。常见做法是在pointerdown记下坐标pointerup时位移小于 8px 且时间小于 300ms 才算点击同时给覆盖层加touch-action: manipulation减少移动端的事件延迟与双击缩放干扰。4.3 devicePixelRatiocanvas 草稿层偏移的真正原因如果插件包的编辑态用 canvas 画草稿框高 DPI 屏上很容易出现“框比鼠标偏半个格”。原因不复杂canvas 有两套尺寸canvas.width是像素尺寸canvas.style.width是 CSS 尺寸两者不一致时浏览器会拉伸位图。function fitCanvas(canvas, cssW, cssH) { const dpr window.devicePixelRatio || 1; canvas.width Math.round(cssW * dpr); // 真实像素 canvas.height Math.round(cssH * dpr); canvas.style.width ${cssW}px; // CSS 尺寸 canvas.style.height ${cssH}px; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 让后续绘制按 CSS 像素思考 return ctx; }setTransform这一步漏了所有绘制坐标都会被放大 dpr 倍style尺寸漏了画面会被浏览器压回去看起来就是模糊加轻微偏移。窗口拖到外接显示器、系统缩放 125% 时还会触发dpr变化需要监听matchMedia的resolution变化重跑一次适配否则热区只在主屏上是准的。4.4 把换算收口成一套工具函数散落在各处的手写换算迟早会出现“某处少减了一个 offset”的问题。我一般把这些函数收进一个模块所有坐标进出都必须过它函数输入输出用在哪toNatural(clientPt, box, fit)鼠标事件的 clientX/Y原图像素坐标绘制、编辑toRatio(region, natural)像素区域0~1 区域保存toPixel(region, natural)0~1 区域像素区域渲染toClient(px, box, fit)原图像素屏幕坐标浮层定位、tooltiptoNatural是唯一允许读getBoundingClientRect的地方。它把clientX - rect.left减去fit.ox再除以fit.sx出来就是原图像素后面所有环节都只认原图像素和 0~1 比例两种表示不允许出现“渲染像素”这种第三种中间态。这条规矩定下来热区错位的问题会少一大半。5. 避坑与排查热区错位、点击穿透、重叠命中的五个现场下面五条是我在不同项目里真实踩过的现场按“现象 → 原因 → 解决”整理遇到问题先照单对号多半能命中。现象一图片一缩放所有热区整体往左上或右下偏偏移量随窗口大小变化。原因通常是坐标基于渲染像素而不是原图像素且换算时用了offsetX/offsetY而不是getBoundingClientRect。offsetX是相对事件目标元素的你点在覆盖层的rect上offsetX就是相对那个rect的拿到的是个位数除以图片宽度自然全错。解决统一用clientX/clientY减去容器rect.left/top再除fit的缩放系数编辑态给草稿层加pointer-events: none保证事件目标永远是容器而不是被点中的图元。现象二热区描边看得见鼠标移上去不变色、点了没反应。九成是fill: none。SVG 里fill: none的图形不参与命中测试pointer-events默认是visiblePainted没有可见填充就等于透明。解决改成fill: transparent或显式pointer-events: all。另一成是父层设了pointer-events: none但没给子元素恢复整层都不接收事件表现和上面一模一样排查时先在 Elements 面板看pointer-events的计算值。现象三两个热区重叠点上去总是命中下面那个或者上层热区完全点不到。DOM 里后出现的元素在上SVG 里后绘制的图形在上如果数据顺序和视觉顺序不一致命中的就是“看起来在下面”的那个。更麻烦的是绝对定位 div 方案里有人给热区加了z-index: -1想垫在图片下面结果整个热区到了图片下层永远点不到。解决渲染前按zIndex或面积从小到大排序小的叠在大的上面符合用户直觉点击回调里stopPropagation跨形状重叠用统一的“点在多边形内”兜底检测而不是依赖浏览器命中。现象四编辑器里画好的热区上线后在 4K 屏或浏览器缩放 125% 时整体偏了半个热区。这是 dpr 与viewBox双重作用的结果canvas 草稿层没按 dpr 缩放或者 SVG 层的viewBox用的是容器 CSS 尺寸而不是图片固有尺寸。前者按 4.3 的fitCanvas修后者把viewBox固定成0 0 naturalW naturalH绝不要动态写成容器宽高。还有一个隐蔽原因图片走 CDN 的?imageMogr2/...之类参数被裁过naturalWidth和你后台存的宽度对不上需要以后台存的宽高为准或加载后校验naturalWidth与配置值偏差超过 1% 就打日志告警。现象五移动端第一次点击没反应要连点两下才跳转。原因分两类一是热区太小手指没点中二是热区用了mouseover才显示跳转样式移动端没有 hover 态第一次点只触发了样式切换。解决热区最小命中扩展到 44px 见方见 4.2交互态用pointerdown而不是mouseover同时给页面根元素加touch-action: manipulation消掉双击缩放的等待窗口。上线前一定要在真机上点一遍模拟器的触摸事件和真机差别不小这个玄学我自己也吃过亏。排查顺序我固定成三步先在控制台打印当前fit的四个值ox/oy/sx/sy确认换算参数对不对再把鼠标坐标打到原图像素坐标看是否落在预期区域最后看命中元素是哪个document.elementFromPoint三步走完基本定位到具体环节。6. 进阶把热区数据当成配置来做校验和回归热区数据本质上是一份配置配置就该有校验。我现在的习惯是在保存接口前挂一层validate坐标必须在 0~1 之间、多边形顶点数不少于 3、单个热区面积不低于全图的 0.3%、link不能为空、id不能重复。这层校验能在运营手抖画出负宽高或者点到图片外面时直接拦下来比上线后被用户投诉强得多。function validateRegions(regions) { const errors []; const ids new Set(); regions.forEach((r, i) { const tag 第 ${i 1} 个热区; if (!r.id || ids.has(r.id)) errors.push(${tag}id 缺失或重复); ids.add(r.id); if (!r.link) errors.push(${tag}跳转地址为空); const nums r.type polygon ? r.points.flat() : [r.x, r.y, r.width, r.height].filter((v) v ! undefined); if (nums.some((v) v 0 || v 1 || Number.isNaN(v))) errors.push(${tag}坐标越界或非法); if (r.type polygon r.points.length 3) errors.push(${tag}顶点少于 3 个); }); return errors; }回归这块我用最土的办法但管用准备三张图1:1、16:9、9:16各存一份固定的热区数据页面加载后走一遍渲染用elementFromPoint在几个关键点位上断言命中的是预期热区 id。这套断言跑在 CI 里比人工点省事也能防住“改了渲染函数结果某个形状挂了”的回归。还有个小技巧值得单独拎出来说给热区加一个“调试模式”开启后在每个热区的几何中心画一个直径 6px 的实心点并在旁边打出 id。图片裁切、容器偏移、层叠顺序这三类问题肉眼看描边很难判断但看中心点位置一眼就知道是整体偏了还是只偏了某一个。这个开关我一般只留一个 URL 参数控制例如?hotdebug1上线默认关闭线上排查时让运营截个图就能定位。从那以后我每次接热区相关需求都会先把同一份数据在三个极端宽高比的容器里各渲染一遍、真机点一遍、再把fit的四个参数打出来对一遍才敢让人去配数据。热区这类东西不出问题则已一出问题就是“用户点了没反应”排查成本远高于预防成本。希望帮到你。本文还有配套的精品资源点击获取