2026年5月27日 14:36

SVG 怎样实现点击、拖拽、动画和无障碍交互?

从一个静态图标说起

你在一个管理后台里放了一个 SVG 图标,产品说"点它能不能切换状态?"——于是你开始搜索 SVG 到底怎么绑定事件。接着设计说"hover 时能不能有个动画过渡?"——你发现 SVG 的动画方案不止一种。再后来需求升级到"能不能拖拽元素""能不能缩放平移画布"——你意识到 SVG 交互远比想象中复杂。这篇文章把 SVG 交互开发的核心技术一次性梳理清楚,从事件处理到动画方案,从拖拽缩放到无障碍支持,最后看 D3.js 如何把这些能力封装成数据驱动的交互模式。

SVG 事件处理:click、hover、mousemove

SVG 元素是合法的 DOM 节点,所以你可以像操作 HTML 一样给它绑定事件。内联 SVG 直接挂在标签上就行:

html
<svg width="200" height="200"> <rect id="box" x="10" y="10" width="80" height="80" fill="#4A90D9" /> </svg> <script> const rect = document.getElementById('box'); rect.addEventListener('click', () => { rect.setAttribute('fill', '#E74C3C'); }); rect.addEventListener('mouseenter', () => { rect.setAttribute('opacity', '0.8'); }); rect.addEventListener('mouseleave', () => { rect.setAttribute('opacity', '1'); }); rect.addEventListener('mousemove', (e) => { // 获取鼠标在 SVG 坐标系中的位置 const svg = rect.closest('svg'); const point = svg.createSVGPoint(); point.x = e.clientX; point.y = e.clientY; const svgPoint = point.matrixTransform(svg.getScreenCTM().inverse()); console.log(`x: ${svgPoint.x}, y: ${svgPoint.y}`); }); </script>

几个要点:

  • mouseenter / mouseleave 不冒泡,mouseover / mouseout 会冒泡。如果 SVG 内有子元素(比如 <g> 里嵌了多个形状),用不冒泡版本避免反复触发。
  • mousemove 中拿 e.clientX/Y 是屏幕坐标,要通过 getScreenCTM().inverse() 转换为 SVG 坐标,否则 SVG 做过缩放或位移后坐标会偏移。
  • 也可以用 SVG 属性写法 onclick="handler(evt)",但这种方式和 HTML 内联事件一样,不利于维护,推荐 addEventListener

如果 SVG 通过 <img> 标签引入,JavaScript 无法访问内部元素。需要交互的 SVG 必须内联或使用 <object> / <embed> 标签,再通过 contentDocument 访问内部 DOM。

CSS 动画:transition 与 keyframes

SVG 元素支持 CSS transition 和 animation,这是最轻量的动画方案。

transition 处理状态变化

css
.circle-btn { fill: #4A90D9; transition: fill 0.3s ease, r 0.3s ease, transform 0.3s ease; transform-origin: center; cursor: pointer; } .circle-btn:hover { fill: #E74C3C; transform: scale(1.15); }

注意 SVG 的 transform-origin 默认是 SVG 画布的 (0, 0),不是元素自身中心。需要显式设置 transform-origin: center 或用具体的坐标值。Safari 对 SVG 的 transform-origin 处理曾有问题,可以用 transform-box: fill-box 让浏览器以元素的填充框为参考:

css
.circle-btn { transform-box: fill-box; transform-origin: center; }

keyframes 做持续动画

描边动画是 SVG 最经典的 CSS 动画效果:

css
.draw-path { stroke-dasharray: 1000; stroke-dashoffset: 1000; animation: draw 2s ease forwards; } @keyframes draw { to { stroke-dashoffset: 0; } }

stroke-dasharray 定义虚线长度,stroke-dashoffset 控制偏移。把 offset 从总长度拉到 0,就是一条逐渐画出的线。总长度可以用 getTotalLength() 在 JavaScript 里获取后精确设置。

CSS 动画的优势是不依赖 JavaScript,浏览器可以硬件加速。缺点是无法操作 SVG 特有的属性(比如 <path>d 属性),也不能基于数据驱动动画。

SMIL 动画:animate 与 animateTransform

SMIL 是 SVG 原生的动画方案,直接写在 SVG 标签内部,不需要 CSS 也不需要 JavaScript。

animate 属性变化

xml
<rect x="10" y="10" width="40" height="40" fill="#4A90D9"> <animate attributeName="width" from="40" to="120" dur="1s" begin="click" fill="freeze" /> </rect>

begin="click" 表示点击触发,fill="freeze" 让动画停在终态。begin 支持丰富的时间语法,比如 click + 0.5s(点击后 0.5 秒开始)、rect1.click(另一个元素点击时开始)。

animateTransform 做变换

xml
<rect x="50" y="50" width="40" height="40" fill="#E74C3C"> <animateTransform attributeName="transform" type="rotate" from="0 70 70" to="360 70 70" dur="2s" repeatCount="indefinite" /> </rect>

type 支持 translatescalerotateskewXskewYfromto 的值格式与对应的 transform 函数一致。

SMIL 的优点是声明式、无需额外代码,且能精确控制动画时序关系。Chrome 曾在 2015 年宣布弃用 SMIL,后来撤回了决定,目前主流浏览器均支持。但如果你的项目需要 IE 兼容,SMIL 不可用。

JavaScript 操作 SVG:DOM API 详解

JavaScript 通过 DOM API 可以精确控制 SVG 的每个细节。

创建和修改元素

javascript
const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.querySelector('svg'); // 创建元素必须用 createElementNS,不是 createElement const circle = document.createElementNS(svgNS, 'circle'); circle.setAttribute('cx', '100'); circle.setAttribute('cy', '100'); circle.setAttribute('r', '30'); circle.setAttribute('fill', '#27AE60'); svg.appendChild(circle); // 修改属性 circle.setAttribute('r', '50'); // 读取属性 const radius = circle.getAttribute('r'); // "50" // 删除元素 circle.remove();

关键点:SVG 元素在 XML 命名空间下,创建时必须用 createElementNS,传入 'http://www.w3.org/2000/svg'。用 createElement('circle') 创建的元素浏览器不会识别为 SVG 元素,只会当作未知 HTML 标签。

操作 transform

javascript
// 获取当前变换矩阵 const ctm = circle.getCTM(); // 相对于最近 SVG 容器 const screenCtm = circle.getScreenCTM(); // 相对于屏幕 // 通过 SVGTransform 接口设置变换 const transform = svg.createSVGTransform(); transform.setTranslate(50, 30); circle.transform.baseVal.appendItem(transform);

getCTM() 返回的是元素相对于 SVG 视口的变换矩阵,包含了所有祖先元素的变换叠加。这在坐标转换时非常有用。

用 requestAnimationFrame 做帧动画

javascript
let angle = 0; const el = document.getElementById('rotating-rect'); function animate() { angle += 1; el.setAttribute('transform', `rotate(${angle} 100 100)`); if (angle < 360) { requestAnimationFrame(animate); } } animate();

requestAnimationFrame 在每帧重绘前调用回调,比 setInterval 更流畅且节能(标签页不可见时自动暂停)。

拖拽实现:从 mousedown 到 drop

SVG 拖拽比 HTML 拖拽多了坐标转换这一步。

javascript
let dragging = null; let offset = { x: 0, y: 0 }; function getClientToSVG(svg) { return (clientX, clientY) => { const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY; return pt.matrixTransform(svg.getScreenCTM().inverse()); }; } const svg = document.querySelector('svg'); const toSVG = getClientToSVG(svg); svg.addEventListener('mousedown', (e) => { if (e.target.classList.contains('draggable')) { dragging = e.target; const svgPt = toSVG(e.clientX, e.clientY); // 记录点击位置与元素位置的偏移 const cx = +dragging.getAttribute('cx') || 0; const cy = +dragging.getAttribute('cy') || 0; offset.x = svgPt.x - cx; offset.y = svgPt.y - cy; } }); svg.addEventListener('mousemove', (e) => { if (!dragging) return; const svgPt = toSVG(e.clientX, e.clientY); dragging.setAttribute('cx', svgPt.x - offset.x); dragging.setAttribute('cy', svgPt.y - offset.y); }); svg.addEventListener('mouseup', () => { dragging = null; });

核心逻辑:把屏幕坐标转换为 SVG 坐标,减去初始偏移,设置为元素新位置。对于 <rect> 等用 x/y 定位的元素,修改 x/y 属性即可;对于 <g> 容器,修改 transformtranslate 值。

触摸设备需要额外监听 touchstarttouchmovetouchend,从 e.touches[0].clientX/Y 取坐标。

缩放平移:SVG viewport 变换

SVG 的缩放和平移有两种实现路径:修改 viewBox 或修改 transform

方案一:修改 viewBox

javascript
const svg = document.querySelector('svg'); let vb = { x: 0, y: 0, w: 800, h: 600 }; const scale = 1.1; svg.addEventListener('wheel', (e) => { e.preventDefault(); const factor = e.deltaY > 0 ? scale : 1 / scale; // 以鼠标位置为中心缩放 const pt = svg.createSVGPoint(); pt.x = e.clientX; pt.y = e.clientY; const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse()); vb.x = svgPt.x - (svgPt.x - vb.x) / factor; vb.y = svgPt.y - (svgPt.y - vb.y) / factor; vb.w /= factor; vb.h /= factor; svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`); });

修改 viewBox 相当于改变"相机"的位置和焦距,画布上的元素本身不变。适合做整个画布的缩放平移,类似地图交互。

方案二:transform 变换

对单个元素或 <g> 容器使用 transform 做缩放平移,不影响其他元素:

javascript
const group = document.getElementById('layer'); group.setAttribute('transform', `translate(${tx}, ${ty}) scale(${s})`);

两种方案的选择:画布级操作用 viewBox,元素级操作用 transform。实际项目中经常组合使用——viewBox 控制全局视口,transform 控制图层独立变换。

无障碍交互:ARIA 与键盘支持

SVG 交互不是视觉用户的专属,屏幕阅读器和键盘用户也需要能操作。

让 SVG 可聚焦

html
<svg width="200" height="200" role="img" aria-labelledby="chart-title chart-desc"> <title id="chart-title">季度销售额柱状图</title> <desc id="chart-desc">展示了2024年四个季度的销售额变化趋势</desc> <g tabindex="0" role="button" aria-label="第一季度:销售额120万" class="bar-interactive"> <rect x="20" y="80" width="30" height="120" fill="#4A90D9" /> </g> <g tabindex="0" role="button" aria-label="第二季度:销售额150万" class="bar-interactive"> <rect x="70" y="50" width="30" height="150" fill="#27AE60" /> </g> </svg>

关键点:

  • tabindex="0" 让元素进入 Tab 导航序列。不要用已废弃的 focusable 属性。
  • 每个可聚焦元素必须有可访问名称,通过 aria-label<title> 提供。
  • 非交互 SVG 用 role="img",交互部分用对应的 role(如 buttonslider)。

键盘事件处理

javascript
document.querySelectorAll('.bar-interactive').forEach(bar => { bar.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); // 触发和 click 相同的逻辑 bar.dispatchEvent(new Event('click')); } }); bar.addEventListener('focus', () => { bar.querySelector('rect').setAttribute('stroke', '#333'); bar.querySelector('rect').setAttribute('stroke-width', '2'); }); bar.addEventListener('blur', () => { bar.querySelector('rect').removeAttribute('stroke'); }); });

focusblur 事件提供可见的焦点指示器,这是 WCAG 2.1 的硬性要求。不要只用颜色区分焦点状态,高对比度模式下颜色差异可能消失。

常见陷阱

  • <img src="chart.svg"> 里的 SVG 对屏幕阅读器不可见,需要用 alt 文本或 aria-label 补充。
  • 如果 SVG 作为装饰图使用,加 aria-hidden="true" 让屏幕阅读器跳过。
  • 避免单独用颜色传达信息,补充形状或文字标记。

D3.js 交互:数据驱动的 SVG 操作

D3.js 把上面这些底层能力封装成了数据驱动的 API,是做 SVG 数据可视化的首选工具。

事件绑定

javascript
d3.selectAll('.data-point') .on('click', function(event, d) { d3.select(this) .attr('fill', '#E74C3C') .transition() .duration(300) .attr('r', d.value * 2); }) .on('mouseenter', function(event, d) { d3.select('#tooltip') .style('visibility', 'visible') .text(`${d.label}: ${d.value}`); }) .on('mouseleave', function() { d3.select('#tooltip').style('visibility', 'hidden'); });

D3 的 on 方法会自动把绑定的数据 d 传给回调函数,不需要手动从 DOM 上取数据。this 指向当前 DOM 元素,event 是原生事件对象。

数据驱动更新

javascript
function updateChart(data) { const bars = d3.select('#chart') .selectAll('rect') .data(data, d => d.id); // 进入:新数据创建元素 bars.enter() .append('rect') .attr('x', (d, i) => i * 35) .attr('y', d => 300 - d.value) .attr('width', 30) .attr('height', d => d.value) .attr('fill', '#4A90D9') .attr('opacity', 0) .transition() .duration(500) .attr('opacity', 1); // 更新:已有元素过渡到新状态 bars.transition() .duration(500) .attr('y', d => 300 - d.value) .attr('height', d => d.value); // 退出:多余元素移除 bars.exit() .transition() .duration(300) .attr('opacity', 0) .remove(); }

这就是 D3 的 Enter-Update-Exit 模式。数据变化时,新增的元素做进入动画,更新的元素做过渡动画,删除的元素做淡出动画。整个过程不需要手动管理 DOM 状态。

拖拽与缩放

D3 提供了 d3.drag()d3.zoom() 模块,封装了坐标转换和事件处理:

javascript
// 拖拽 d3.selectAll('.draggable') .call(d3.drag() .on('drag', function(event, d) { d.x = event.x; d.y = event.y; d3.select(this).attr('cx', d.x).attr('cy', d.y); }) ); // 缩放平移 const zoom = d3.zoom() .scaleExtent([0.5, 5]) .on('zoom', (event) => { d3.select('#canvas').attr('transform', event.transform); }); d3.select('svg').call(zoom);

d3.drag() 自动处理屏幕坐标到 SVG 坐标的转换,d3.zoom() 封装了滚轮缩放和拖拽平移,并维护 event.transform 对象(包含 xyk 三个分量)。比手动实现省去大量边界处理代码。

方案选型速查

场景推荐方案理由
简单 hover 状态变化CSS transition零 JS、硬件加速
持续循环动画(描边、旋转)CSS keyframes声明式、性能好
点击触发的属性动画SMIL animate内嵌 SVG、无需外部代码
基于数据的图表交互D3.js数据驱动、Enter-Update-Exit
自定义拖拽原生 JS + 坐标转换完全控制、无依赖
画布级缩放平移viewBox 操作改变视口而非元素
需要兼容 IE 的动画JS + requestAnimationFrameSMIL 和部分 CSS 动画 IE 不支持

选择的核心原则:能用 CSS 就不用 SMIL,能用 SMIL 就不用 JS,数据可视化直接上 D3.js。每多引入一层复杂度,就要多承担一层维护成本。不过这个原则有个前提——如果你需要根据数据动态更新,CSS 和 SMIL 的声明式语法反而会成为障碍,这时候 JS 方案更合适。

写在最后

SVG 交互的本质并不神秘:它是 DOM 元素,所以有 DOM 事件;它有视觉属性,所以能做 CSS 动画;它有 XML 命名空间,所以要用 createElementNS;它有独立的坐标系,所以要做坐标转换。理解了这几条线,剩下的就是根据场景选工具。CSS 处理简单状态过渡,SMIL 做声明式属性动画,JavaScript 处理复杂逻辑,D3.js 做数据驱动可视化——它们各有擅长的边界,拼在一起就是完整的 SVG 交互开发能力。

标签:SVG