在上一篇文章中,我们学习了 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('开始!');

重要:

  1. repeatEvent 的 detail 属性从 0 开始计数。第一次重复时 detail = 0,第二次重复时 detail = 1,以此类推。

  2. 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

动画有效结束时触发(不包括每次重复的结束)

三个核心认知:

  1. <animateMotion> 是路径动画的“专用引擎”——它同时处理位置和方向,让元素沿路径行驶的同时自动转弯。rotate="auto" 是实现“顺滑转弯”的关键。

  2. <mpath> 让路径复用成为可能——当多个元素需要沿同一条路径运动时,<mpath> 避免了重复书写路径命令,让代码更干净、更易维护。

  3. TimeEvent 让动画“会说话”——通过 beginEvent、repeatEvent 和 endEvent,你可以让动画在关键时刻触发业务逻辑,实现“动画结束后执行某操作”,“每次循环更新状态”等高级交互。

下篇预告

在下一篇文章中,我们将进入本系列的最终章—— 从零打造一个简易版 Figma 画板。我们将综合运用前 11 篇文章的所有知识点:

  • 用 SVGRectElement、SVGCircleElement、SVGPathElement 创建图形。

  • 用 <g> 分组管理图层。

  • 用 DOMMatrix 处理变换。

  • 用 SVGPointList 操作多边形顶点。

  • 用 SMIL 动画添加交互动效。

  • 实现选中/高亮、拖拽移动、属性面板、导出 PNG/SVG 等完整功能。

这将是一场从“会写 SVG”到“能构建 SVG 应用”的终极蜕变。

互动话题:尝试用 <animateMotion> 让一个物体沿你喜欢的路径运动——可以是心形、∞ 符号,或者你名字的字母轮廓。你用了 rotate="auto" 吗?效果如何?欢迎在评论区分享你的 SVG 代码和效果截图!