在上一篇文章中,我们学习了 SMIL 动画的二大基石——<animate> 用于属性插值,<animateTransform> 用于变换动画。但有一个场景,它们都力不从心:
让一个物体沿着一条蜿蜒的曲线运动。
想象一下:一辆小车在盘山公路上行驶、一枚火箭沿着弹道轨迹飞行、一颗卫星在椭圆轨道上运转——这些动画的共同特征是:物体的位置随时间沿路径变化,且物体的方向需要与路径的切线方向对齐。
这就是 <animateMotion> 的用武之地。
<animateMotion> 是 SMIL 动画家族中最具视觉冲击力的成员。它不仅能让元素沿任意路径运动,还能自动计算路径的切线方向,让元素“顺滑地转弯”——就像一辆真正的汽车在弯道上自动打方向盘一样。
而要让动画具备“智能”的交互反馈,我们需要另一个重要的接口——TimeEvent。它让你能监听动画的开始、重复和结束事件,从而实现“动画结束后触发某个动作”“每次循环时更新状态”等高级交互。
本文将带你从零开始,掌握 <animateMotion> 的全部能力,并用 TimeEvent 为动画注入“生命信号”。
一、什么是 <animateMotion>?
<animateMotion> 是 SVG 中的一个动画元素,它的作用是:定义一个元素如何沿着一条运动路径移动。
与 <animate> 和 <animateTransform> 不同,<animateMotion> 专门处理“位置沿路径变化”这一特定场景。它同时控制元素的 x 和 y 坐标,让元素在二维空间中沿预定轨迹运动。
核心认知:<animateMotion> 的本质是在每一帧计算元素在路径上的位置,并将元素平移到该位置。如果你设置了 rotate="auto",它还会额外计算路径在该点的切线方向,并旋转元素与之对齐。
1.1 浏览器兼容性
<animateMotion> 自2020 年 1 月起已在主流浏览器中广泛可用。虽然 Chrome 45 曾短暂弃用 SMIL,但随后开发者中止了这一计划。如今,<animateMotion> 在 Chrome、Firefox、Safari 和 Edge 的最新版本中均得到良好支持。
二、<animateMotion> 的核心属性
2.1 path:定义运动轨迹
path 属性使用与 <path> 元素的 d 属性完全相同的语法来定义运动路径。
<svg viewBox="0 0 400 300">
<!-- 定义运动路径(可视化参考) -->
<path d="M 50 200 C 150 50 250 250 350 150" fill="none" stroke="#ddd" stroke-width="2" />
<!-- 沿路径运动的小球 -->
<circle r="10" fill="#4A90D9">
<animateMotion dur="4s" repeatCount="indefinite"
path="M 50 200 C 150 50 250 250 350 150" />
</circle>
</svg>重要提示:path 属性 不可动画化——你无法通过另一个动画来动态改变运动路径。如果需要动态切换路径,只能通过 JavaScript 移除并重新添加 <animateMotion> 元素。
2.2 rotate:控制方向跟随
rotate 属性是 <animateMotion> 的“灵魂”——它控制元素在运动过程中是否旋转以跟随路径方向。
值 | 效果 |
|---|---|
auto | 元素自动旋转,使其右侧对齐路径的当前运动方向 |
auto-reverse | 元素自动旋转,使其左侧对齐路径的当前运动方向 |
<number> | 固定旋转角度(度),不随路径方向变化 |
0(默认) | 保持原始方向不变 |
<svg viewBox="0 0 400 300">
<!-- 定义运动路径(可视化参考) -->
<path d="M 50 150 C 150 50 250 250 350 150" fill="none" stroke="#ddd" stroke-width="2" />
<!-- 小车跟随路径方向转弯 -->
<rect x="-20" y="-10" width="40" height="20" fill="#E67E22" rx="4">
<animateMotion dur="6s" repeatCount="indefinite" rotate="auto"
path="M 50 150 C 150 50 250 250 350 150" />
</rect>
<!-- 固定方向,不跟随路径 -->
<rect x="-20" y="-10" width="40" height="20" fill="#4A90D9" rx="4">
<animateMotion dur="6s" repeatCount="indefinite" rotate="0"
path="M 50 150 C 150 50 250 250 350 150" />
</rect>
</svg>记忆技巧:auto 就像一辆汽车在行驶——车头(右侧)始终指向前进方向。auto-reverse 则像倒车——车尾(左侧)指向前进方向。
2.3 keyPoints 与 keyTimes:精细控制进度
keyPoints 属性允许你精确控制元素在路径上的进度(0 到 1 之间的值),与 keyTimes 配合使用可以创建变速运动。
<animateMotion dur="6s" repeatCount="indefinite"
path="M 50 150 C 150 50 250 250 350 150"
keyPoints="0;0.2;0.8;1"
keyTimes="0;0.25;0.75;1"
calcMode="linear" />这个例子中:
在动画的 0% 时刻,元素在路径起点(进度 0)。
在 25% 时刻,元素走完路径的 20%。
在 75% 时刻,元素走完路径的 80%。
在 100% 时刻,元素到达路径终点(进度 1)。
2.4 calcMode:插值方式
对于 <animateMotion>,calcMode 属性的默认值是 paced(匀速运动),而不是通常的 linear。这意味着元素会以恒定速率沿路径运动,而不会在曲线的不同部分时快时慢。
三、<mpath>:复用已有路径
如果你已经在 SVG 中定义了一个 <path> 元素,不想在 path 属性中重复写一遍路径命令,可以使用 <mpath> 子元素来引用它。
<svg viewBox="0 0 500 300">
<!-- 定义路径(可被复用) -->
<path id="track" d="M 100 250 C 100 50 400 50 400 250"
fill="none" stroke="blue" stroke-width="2" />
<!-- 沿路径运动的小球 -->
<circle r="10" fill="#4A90D9">
<animateMotion dur="6s" repeatCount="indefinite" rotate="auto">
<mpath href="#track" />
</animateMotion>
</circle>
</svg><mpath> 元素是 <animateMotion> 的 唯一允许的子元素(除了描述性元素)。它通过 href 属性引用目标路径的 ID。
最佳实践:当同一个路径需要被多个动画元素复用时(比如多个物体沿同一路径运动),使用 <mpath> 可以避免重复书写路径命令,让代码更简洁、更易维护。
四、TimeEvent:动画的“生命信号”
TimeEvent 接口是 SVG SMIL 动画的一部分,它提供了与时间事件相关的特定上下文信息。
4.1 三种时间事件
SVGAnimationElement(所有动画元素的基类)会触发三种时间事件:
事件 | 触发时机 |
|---|---|
beginEvent | 动画开始播放时(每次重新开始时触发,不包括重复) |
repeatEvent | 每次重复时触发 |
endEvent | 动画的有效结束时触发(不包括每次重复的结束) |
4.2 TimeEvent 的实例属性
TimeEvent 接口提供了两个只读属性:
属性 | 类型 | 说明 |
|---|---|---|
detail | long | 对于repeatEvent,表示当前的重复次数(从 0 开始) |
view | WindowProxy | 标识生成事件的窗口 |
const anim = document.querySelector('animateMotion');
anim.addEventListener('beginEvent', () => {
console.log('动画开始了!');
});
anim.addEventListener('repeatEvent', (e) => {
console.log(`第 ${e.detail + 1} 次重复`); // detail 从 0 开始
});
anim.addEventListener('endEvent', () => {
console.log('动画结束了!');
});
// 也可以使用事件处理器属性
anim.onbegin = () => console.log('开始!');重要:
repeatEvent 的 detail 属性从 0 开始计数。第一次重复时 detail = 0,第二次重复时 detail = 1,以此类推。
detail存在浏览器兼容性问题:目前只有FireFox支持。
五、综合实战:自动驾驶小车
让我们综合运用所有知识,构建一个完整的自动驾驶演示——一辆小车沿着一条 S 形路径行驶,带有方向跟随、速度控制、进度显示和事件日志。
5.1 HTML 结构
<style>
body {margin: 0;display: flex;justify-content: center;align-items: center;min-height: 100vh;background: #f0f2f5;font-family: system-ui, sans-serif;}
.container {background: white;border-radius: 12px;box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);padding: 24px;max-width: 720px;width: 100%;margin: 16px;box-sizing: border-box;}
svg {border: 1px solid #ddd;border-radius: 8px;width: 100%;background: #fafafa;display: block;}
.controls {display: flex;gap: 10px;margin-top: 16px;flex-wrap: wrap;align-items: center;}
.controls button {padding: 8px 18px;border: none;border-radius: 6px;background: #4A90D9;color: white;cursor: pointer;font-size: 14px;transition: background 0.2s;}
.controls button:hover {background: #357ABD;}
.controls button.danger {background: #E74C3C;}
.controls button.danger:hover {background: #C0392B;}
.controls button.success {background: #27AE60;}
.controls button.success:hover {background: #1E8449;}
.controls button.outline {background: transparent;color: #4A90D9;border: 1.5px solid #4A90D9;}
.controls button.outline:hover {background: #eef4fb;}
.status {display: flex;gap: 20px;margin-top: 12px;font-size: 14px;color: #555;flex-wrap: wrap;}
.status span {font-family: 'SF Mono', 'Fira Code', monospace;background: #f8f9fa;padding: 2px 12px;border-radius: 4px;}
#log {margin-top: 12px;padding: 10px 12px;background: #f8f9fa;border-radius: 6px;font-size: 13px;font-family: 'SF Mono', 'Fira Code', monospace;max-height: 110px;overflow-y: auto;color: #333;border: 1px solid #eee;line-height: 1.6;}
#log div {border-bottom: 1px solid rgba(0, 0, 0, 0.04);padding: 2px 0;}
#log div:last-child {border-bottom: none;}
.label {fill: #666;font-size: 13px;font-family: system-ui, sans-serif;}
.legend {display: flex;gap: 16px;font-size: 13px;color: #555;margin-top: 8px;flex-wrap: wrap;}
.legend-item {display: flex;align-items: center;gap: 6px;}
.legend-dot {display: inline-block;width: 12px;height: 12px;border-radius: 50%;}
</style><div class="container">
<svg viewBox="0 0 600 400" id="mainSvg">
<!-- 运动路径 -->
<path id="route" d="M 80 300 C 150 50 300 350 450 150 C 500 80 550 120 520 200"
fill="none" stroke="#4A90D9" stroke-width="3" stroke-dasharray="8,4" />
<!-- 起点 -->
<circle cx="80" cy="300" r="6" fill="#27AE60" />
<text x="65" y="322" class="label" font-size="12">起点</text>
<!-- 终点 -->
<circle cx="520" cy="200" r="6" fill="#E74C3C" />
<text x="508" y="188" class="label" font-size="12">终点</text>
<!-- 小车 -->
<g id="carGroup">
<g id="car">
<rect x="-30" y="-15" width="60" height="30" fill="#E67E22" rx="6" stroke="#D35400" stroke-width="2" />
<rect x="-15" y="-10" width="15" height="10" fill="rgba(255,255,255,0.6)" rx="2" />
<circle cx="28" cy="-6" r="4" fill="#F1C40F" />
<circle cx="28" cy="6" r="4" fill="#F1C40F" />
<animateMotion id="motion" dur="8s" repeatCount="indefinite" rotate="auto">
<mpath href="#route" />
</animateMotion>
</g>
<circle id="progressDot" r="9" fill="rgba(74,144,217,0.35)" stroke="#4A90D9" stroke-width="2.5">
<animateMotion id="progressMotion" dur="8s" repeatCount="indefinite" rotate="0">
<mpath href="#route" />
</animateMotion>
</circle>
</g>
</svg>
<div class="controls">
<button id="playBtn" class="success">▶ 播放</button>
<button id="pauseBtn">⏸ 暂停</button>
<button id="resetBtn" class="danger">⏹ 重置</button>
<button id="toggleRotate" class="outline">🔄 方向模式</button>
</div>
<div class="status">
<span>状态: <strong id="statusText">运行中</strong></span>
<span>循环: <strong id="repeatCount">0</strong> 次</span>
<span>方向: <strong id="rotateMode">auto</strong></span>
<span>进度: <strong id="progressDisplay">0.00s</strong></span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-dot" style="background:#E67E22;"></span> 小车</span>
<span class="legend-item"><span class="legend-dot" style="background:rgba(74,144,217,0.5);"></span> 进度指示器</span>
<span class="legend-item" style="color:#999;">⏱ 8 秒 / 圈</span>
</div>
<div id="log">📋 事件日志:等待动画开始...</div>
</div>5.2 JavaScript 交互逻辑
<script>
(function() {
'use strict';
var SVG_NS = 'http://www.w3.org/2000/svg';
var DURATION = 8;
// ---------- DOM 引用 ----------
var svg = document.getElementById('mainSvg');
var carGroup = document.getElementById('carGroup');
var statusText = document.getElementById('statusText');
var repeatDisplay = document.getElementById('repeatCount');
var rotateModeDisplay = document.getElementById('rotateMode');
var progressDisplay = document.getElementById('progressDisplay');
var log = document.getElementById('log');
// motion / progressMotion 用 let,因为方向切换时需要重建
var motion = document.getElementById('motion');
var progressMotion = document.getElementById('progressMotion');
// ---------- 状态管理 ----------
var isPaused = false;
var pausedPosition = 0; // 暂停时小车在路径上的位置(0 ~ DURATION)
var animStartTime = 0; // 当前动画实例对应的 SVG 文档时间起点
var repeatCount = 0;
var rotateMode = 'auto';
var ROTATE_MODES = ['auto', 'auto-reverse', '0'];
var rotateIndex = 0;
// ---------- 日志工具 ----------
function addLog(message) {
var entry = document.createElement('div');
var ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
entry.textContent = '[' + ts + '] ' + message;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
while (log.children.length > 40) {
log.removeChild(log.firstChild);
}
}
// ---------- 获取当前动画位置 ----------
// 通过 SVG 文档时间与 animStartTime 的差值计算
function getCurrentPosition() {
if (isPaused) return pausedPosition;
try {
var docTime = svg.getCurrentTime();
var pos = (docTime - animStartTime) % DURATION;
if (pos < 0) pos += DURATION;
return pos;
} catch (e) {
return 0;
}
}
// ---------- 更新进度显示 ----------
function updateProgressDisplay() {
progressDisplay.textContent = getCurrentPosition().toFixed(2) + 's';
}
setInterval(updateProgressDisplay, 100);
// ---------- 重建动画元素 ----------
// 核心方法:通过 begin 属性的偏移量让新动画从指定位置开始
// begin = docTime - position → 在 docTime 时刻动画已运行 position 秒
function rebuildAnimations(rotate, position) {
// ...
}
// ---------- 暂停 ----------
function pauseAnim() {
// ...
}
// ---------- 恢复播放 ----------
function resumeFromPause() {
// ...
}
// ---------- 播放按钮 ----------
function play() {
// ...
}
// ---------- 重置 ----------
function resetAnim() {
// ...
}
// ---------- 方向切换 ----------
function toggleRotateMode() {
// ...
}
// ---------- 事件绑定 ----------
function bindMotionEvents(motionEl) {
// ...
}
function onBegin() {
// ...
}
function onRepeat(e) {
// ...
}
function onEnd() {
// 忽略
}
// ---------- UI 绑定 ----------
document.getElementById('playBtn').addEventListener('click', play);
document.getElementById('pauseBtn').addEventListener('click', pauseAnim);
document.getElementById('resetBtn').addEventListener('click', resetAnim);
document.getElementById('toggleRotate').addEventListener('click', toggleRotateMode);
// ---------- 键盘快捷键 ----------
document.addEventListener('keydown', function(e) {
// ...
});
// ---------- 初始化 ----------
// 记录初始动画起点
try {
animStartTime = svg.getCurrentTime();
} catch (e) {
animStartTime = 0;
}
bindMotionEvents(motion);
window.addEventListener('load', function() {
addLog('🚗 小车已就绪,按 空格键 暂停/继续,R 重置,D 切换方向');
});
})();
</script>需要完整交互代码的小伙伴,请评论区留言!!!
5.3 运行效果
打开页面后,你会看到一辆橙色的小车沿着一条 S 形蓝色虚线路径行驶:
方向跟随:小车在转弯时自动旋转,车头始终指向前进方向(rotate="auto")。
播放控制:播放、暂停、重置,完整控制动画生命周期。
循环计数:实时显示小车完成了多少次全程行驶(通过 repeatEvent 监听)。
方向切换:点击按钮可在 auto(正向跟随)、auto-reverse(反向跟随)和 0(固定方向)三种模式间切换。
事件日志:记录所有动画事件,方便调试和理解。

六、SMIL vs CSS offset-path:如何选择?
CSS 动画规范中也有一个实现路径动画的特性——offset-path。它和 SMIL 的 <animateMotion> 功能重叠,两者如何选择?
对比维度 | SMIL (<animateMotion>) | CSS (offset-path) |
|---|---|---|
声明位置 | SVG 内部 | CSS 样式表 |
路径定义 | path 属性或 <mpath> | offset-path 属性 |
方向控制 | rotate="auto" | offset-rotate="auto" |
JS 控制 | beginElement()、pauseElement()、setCurrentTime() | 通过 animation-play-state、animation-delay 等控制 |
事件监听 | beginEvent、repeatEvent、endEvent | animationstart、animationiteration、animationend |
与 HTML 元素配合 | 仅 SVG 元素 | 所有元素(包括 HTML) |
浏览器支持 | 2020 年起广泛支持 | 现代浏览器支持 |
选择建议:
纯 SVG 场景 + 需要精细的 JS 控制(进度跳转、暂停/恢复)→ 选 SMIL。
需要与 HTML 元素统一动画 → 选 CSS offset-path。
需要复用已有 <path> 元素 → SMIL 的 <mpath> 更自然。
需要 keyPoints 精细控制进度 → SMIL 原生支持。
七、常见错误与避坑指南
错误一:同时使用 path 属性和 <mpath>
<!-- 错误:path 属性和 mpath 同时存在,mpath 会被忽略 -->
<animateMotion path="M 0 0 L 100 100">
<mpath href="#route" />
</animateMotion>正确做法:二选一。复用路径用 <mpath>,独立定义用 path 属性。
错误二:忘记 rotate="auto" 导致方向不跟随
<!-- 小车在转弯时不会转向,看起来像“侧滑” -->
<animateMotion dur="6s" repeatCount="indefinite" path="..." />正确做法:添加 rotate="auto" 让元素自动跟随路径方向。
错误三:在动画运行中修改 rotate 属性
rotate 属性不可动画化,且在动画运行中修改可能不会生效。
正确做法:如果需要动态切换方向模式,移除并重新创建 <animateMotion> 元素(如综合实战中的做法)。
错误四:混淆 repeatEvent 的 detail 起始值
repeatEvent 的 detail 从 0 开始计数,而不是 1。
// 错误:第一次重复时 detail = 0,输出 "第 0 次"
anim.addEventListener('repeatEvent', (e) => {
console.log(`第 ${e.detail} 次重复`);
});
// 正确:加 1
anim.addEventListener('repeatEvent', (e) => {
console.log(`第 ${e.detail + 1} 次重复`);
});错误五:使用 endEvent 监听每次循环结束
endEvent 只在动画的有效结束时触发, 不会在每次重复结束时触发。
正确做法:监听每次循环结束应使用 repeatEvent。
八、本章总结
概念 | 说明 |
|---|---|
<animateMotion> | 让元素沿路径运动的动画元素 |
path 属性 | 使用与 <path> 的 d 属性相同的语法定义路径 |
rotate 属性 | 控制元素方向是否跟随路径切线 |
<mpath> | 引用外部 <path> 元素作为运动路径 |
keyPoints | 精细控制元素在路径上的进度 |
TimeEvent | 动画时间事件的上下文信息 |
beginEvent | 动画开始时触发 |
repeatEvent | 每次重复时触发,detail 从 0 开始 |
endEvent | 动画有效结束时触发(不包括每次重复的结束) |
三个核心认知:
<animateMotion> 是路径动画的“专用引擎”——它同时处理位置和方向,让元素沿路径行驶的同时自动转弯。rotate="auto" 是实现“顺滑转弯”的关键。
<mpath> 让路径复用成为可能——当多个元素需要沿同一条路径运动时,<mpath> 避免了重复书写路径命令,让代码更干净、更易维护。
TimeEvent 让动画“会说话”——通过 beginEvent、repeatEvent 和 endEvent,你可以让动画在关键时刻触发业务逻辑,实现“动画结束后执行某操作”,“每次循环更新状态”等高级交互。
下篇预告
在下一篇文章中,我们将进入本系列的最终章—— 从零打造一个简易版 Figma 画板。我们将综合运用前 11 篇文章的所有知识点:
用 SVGRectElement、SVGCircleElement、SVGPathElement 创建图形。
用 <g> 分组管理图层。
用 DOMMatrix 处理变换。
用 SVGPointList 操作多边形顶点。
用 SMIL 动画添加交互动效。
实现选中/高亮、拖拽移动、属性面板、导出 PNG/SVG 等完整功能。
这将是一场从“会写 SVG”到“能构建 SVG 应用”的终极蜕变。
互动话题:尝试用 <animateMotion> 让一个物体沿你喜欢的路径运动——可以是心形、∞ 符号,或者你名字的字母轮廓。你用了 rotate="auto" 吗?效果如何?欢迎在评论区分享你的 SVG 代码和效果截图!