1. 为什么用SVG做动画:先把技术选型这关过了
做前端或独立开发的朋友,遇到"画一个2D动画"的需求时,第一反应多半是Canvas、CSS动画甚至直接上WebGL。但如果你只想做一个轻量、清晰、可缩放的矢量图形动画——比如一个鹈鹕骑着自行车在页面上跑——SVG反而是性价比最高的选择。它不需要任何第三方库,浏览器原生支持,代码写出来就是文本文件,随处可改,缩放不失真。
SVG全称Scalable Vector Graphics,本质是XML描述的矢量图形。跟Canvas那种"画完就变成像素矩阵"的思路不同,SVG里的每个元素都是独立的DOM节点。这意味着你可以用CSS、JavaScript甚至SVG自带的SMIL动画系统,直接对某个圆、某条路径做变换操作。比如想让自行车轮子转起来,只需要给轮子包一个<animateTransform>标签,指定从0度旋转到360度就行。不需要自己算坐标、不需要重绘、不需要requestAnimationFrame,浏览器帮你把每个中间帧都渲染好了。
这次我以一个"鹈鹕骑自行车"的动画为例子,把SVG的基础知识串一遍。鹈鹕元素多——大嘴、长脖子、圆滚滚的身体、翅膀,再加上一辆自行车,正好覆盖路径绘制、图形组合、坐标系统、动画控制这些核心知识点。适合刚入门前端、想系统学一下SVG的同学,也适合那些做过一点SVG但总搞不清viewBox和transform关系的朋友。
要提醒的是,我下面讲的方案不一定是最简方案,但一定是维护性和可读性比较好的方案。SVG写起来容易,写烂了更容易——一堆写死的坐标、嵌套混乱的<g>标签、动画硬编码,到最后想改一个小部件都像拆炸弹。这个案例会把拆解思路一并讲清楚,你可以直接照着敲一遍,再去发挥自己的创意。
2. 先搞懂SVG的地基:坐标系、viewBox与基础图形
2.1 viewBox才是真正的"画布",不是width和height
很多初学者第一个迷惑的地方就在这里。<svg width="800" height="500">只决定SVG在页面上的显示尺寸,真正决定你画图坐标基准的是viewBox。可以这样理解:width和height是窗口尺寸,viewBox是窗口里的世界坐标范围。
举个例子:
<svg width="800" height="500" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg"> </svg>viewBox="0 0 800 500"的意思是:把坐标原点(0,0)放在SVG区域的左上角,横向范围0到800,纵向范围0到500。在内部绘制的所有图形,都按这个虚拟坐标系定位。窗口被拉伸或缩小时,坐标系会自动缩放适配。
这个特性日常特别有用。比如你在手机上想缩小展示整个动画,不需要改任何图形坐标,只需要调整外层容器的CSS宽度,viewBox会自动等比缩放。反过来,如果你想"放大"画面中的某一部分作为聚焦镜头,直接改viewBox的前两个值即可——把原点偏移到想要的位置,把宽高范围缩小,视觉上就像镜头拉近了。
我一般习惯先定viewBox范围,再反推width和height。这个案例里,我把viewBox设为0 0 900 600,宽度留足给鹈鹕和自行车移动的空间。
2.2 基础图形:圆、椭圆、直线、折线的坐标法则
SVG的基础图形很直观,每个都有自己的关键属性。简单梳理一下:
- 圆形:
<circle cx="150" cy="400" r="40">,cx和cy是圆心坐标,r是半径。 - 椭圆:
<ellipse cx="300" cy="200" rx="80" ry="50">,rx是横向半径,ry是纵向半径。 - 直线:
<line x1="50" y1="100" x2="200" y2="100">,两个端点坐标。 - 折线:
<polyline points="10,10 50,30 80,20">,一串坐标点。 - 多边形:
<polygon points="...">,自动闭合。 - 矩形:
<rect x="100" y="100" width="60" height="40" rx="5">,rx是圆角半径。
画鹈鹕和自行车时,圆、椭圆、直线就够用了。车圈用圆,车架用直线,车座用椭圆,鹈鹕身体用椭圆加路径。坐标的规则跟数学里的直角坐标系有一个关键区别:y轴向下是正方向。也就是说,数值越大越往下。初学者经常把图形画到"天上"去,就是因为没绕过这个弯——想往上移动某个部件,应该减小y值。
<circle cx="450" cy="430" r="55" fill="none" stroke="#333" stroke-width="6"/> <circle cx="270" cy="430" r="55" fill="none" stroke="#333" stroke-width="6"/>这两行是自行车两个轮子。前轮在右,横坐标450;后轮在左,横坐标270;共同高度在430。这个案例里地面在y=485,轮子半径55,轮心在430,正好贴合地面。
2.3 路径path:SVG里最强大也最容易劝退的东西
基础图形之外的任意形状,基本都得靠<path>来完成。鹈鹕的大嘴、弯曲的脖子、翅膀的弧度,用圆和直线拼出来会很僵硬,但path可以用贝塞尔曲线轻松搞定。
path的d属性是一串命令加坐标:
M x y // move to,把画笔移到某点 L x y // line to,从当前点画直线到某点 C x1 y1 x2 y2 x y // 三次贝塞尔曲线,两个控制点加一个终点 Q x1 y1 x y // 二次贝塞尔曲线,一个控制点加终点 Z // 闭合路径,连回起点所有命令都区分大小写。大写命令是绝对坐标,小写命令是相对坐标。对于新手,我建议先用绝对坐标,因为画的时候脑子里是"某个点在哪"的思路,相对坐标容易越画越乱。
鹈鹕的嘴巴我这样写:
<path d="M 400 180 Q 520 170 560 220 Q 530 220 500 200 Q 450 205 400 205 Z" fill="#f0a030" stroke="#c47720" stroke-width="3"/>我来拆解一下:起点在鹈鹕头部的下缘(400,180),第一段贝塞尔曲线从嘴角向左上方延伸到喙尖(560,220),第二段从喙尖收回上喙下边缘,第三段回到嘴基底部。最终闭合,形成一个鸭梨形的长喙。控制点的位置是反复试出来的——画路径没有一次成功的,都是改两三次坐标才顺眼。
3. 图形画出来了,怎么让它们"组织"起来:分组与transform
3.1 用<g>做逻辑分组,能救你的命
一个动画场景里有十几二十个元素,如果不分组,后面改起来会崩溃。<g>标签就是一个容器,可以把多个图形包在一起,统一的样式、统一的变换都能直接作用于整组。
这个鹈鹕骑自行车的案例,我用<g>做三层组织:
- 最外层:整个自行车+鹈鹕作为一个整体(用于整体位移,比如让画面往右移动)。
- 中层:自行车是一组,鹈鹕是一组,背景元素(太阳、云朵、地面线)是一组。
- 内层:自行车内部,两个轮子各自再单独分组——因为轮子要独立旋转。
代码结构长这样:
<g id="bike-and-pelican"> <g id="background">...</g> <g id="bike"> <g id="rear-wheel">...</g> <g id="front-wheel">...</g> <line id="frame-top" .../> ... </g> <g id="pelican"> <ellipse id="body" .../> <path id="neck" .../> <path id="beak" .../> <g id="wing">...</g> </g> </g>有了id和分组,动画只管引用对应的id就行。比如给轮子加旋转动画,只需要<animateTransform>指向rear-wheel这个分组。分组不理想、层级错乱带来的最大问题是:动画作用在错误的元素上,或者一个元素的样式被另一个容器的属性干扰。
3.2 transform的叠加顺序:真的要较真
SVG的transform属性支持translate(平移)、rotate(旋转)、scale(缩放)、skew(倾斜)。这里有一个非常容易踩坑的细节:多个变换写在一起时,从右往左生效。
比如:
transform="translate(100, 0) rotate(45)"实际执行顺序是先旋转45度,再向右平移100。反过来的话,旋转中心的位置会不一样,画出来的结果完全不是你想要的。更安全的做法是分开写:
transform="translate(100, 0)"然后旋转另开一个标签写,或在同一个transform里按预期顺序排列。初学者建议先只用translate,把旋转和缩放独立到内层分组里——这样内层旋转不会影响外层平移。
3.3 transform-origin是旋转动画的"定海神针"
在CSS里用transform做动画时,transform-origin控制旋转中心。SMIL动画(后面详说)也一样,但SMIL中变化的是rot角的含义:rotate(角度, cx, cy)可以直接指定旋转中心。
更通用的做法是用CSS:
#rear-wheel { transform-origin: 270px 430px; animation: spin 2s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }这个例子里,后轮圆心在(270,430),旋转中心必须也是(270,430),轮子才会原地转动。如果写成默认的中心点(0,0),轮子就会沿着大圆弧甩出去,像一个失控的回旋镖。我第一天写这个案例时就犯了这个错——轮子画出来后一旋转,整个轮子跑到画面右上角去了。最后发现就是transform-origin的问题。
4. 逐步实操:鹈鹕骑自行车的完整绘制过程
4.1 先把场景布局在脑子里过一遍
动手写代码之前,我一般在纸上画个简略的布局图。比例很重要,不然东挪西挪半天都拼不成一个整体。这个案例的布局大致是:
- 背景:宽度900,高度600,天空用浅蓝色填充一个矩形,地面用一条浅绿色矩形表示,右侧加一个太阳、两朵云。
- 自行车:后轮圆心在(270,430),前轮圆心在(450,430),轮距180。车架用直线连接轮心到中轴(360,430),再到车座(350,340)、车把(470,300)。
- 鹈鹕:骑在车座上,身体底部大概在(340,300)附近,整体中心略微偏后。脖子向上并向左侧弯,头部在(400,180)附近,大嘴向外伸到(560,220)附近。
- 动画:两个轮子绕各自圆心旋转,中轴脚蹬绕(360,430)旋转,鹈鹕身体做小幅度的上下颠簸,翅膀做扇动,云朵缓慢水平漂移。
草图画完,坐标心里就有谱了。不用精确到像素,先把相对位置关系定死——轮距多少、鹈鹕坐姿高度多少、嘴伸多长,这些决定了整个画面是否协调。
4.2 搭建SVG骨架并绘制自行车
自行车部分是整个场景中最"工程化"的部分,需要靠直线和圆形精确拼接。我给出完整代码:
<svg width="900" height="600" viewBox="0 0 900 600" xmlns="http://www.w3.org/2000/svg"> <!-- 背景 --> <rect x="0" y="0" width="900" height="600" fill="#dff0fa"/> <rect x="0" y="485" width="900" height="115" fill="#cde6c3"/> <!-- 后轮和外胎 --> <g id="rear-wheel"> <circle cx="270" cy="430" r="55" fill="none" stroke="#333" stroke-width="8"/> <circle cx="270" cy="430" r="48" fill="none" stroke="#666" stroke-width="3" stroke-dasharray="8 8"/> <line x1="270" y1="430" x2="270" y2="382" stroke="#888" stroke-width="4"/> <circle cx="270" cy="430" r="5" fill="#333"/> </g> <!-- 前轮 --> <g id="front-wheel"> <circle cx="450" cy="430" r="55" fill="none" stroke="#333" stroke-width="8"/> <circle cx="450" cy="430" r="48" fill="none" stroke="#666" stroke-width="3" stroke-dasharray="8 8"/> <line x1="450" y1="430" x2="450" y2="382" stroke="#888" stroke-width="4"/> <circle cx="450" cy="430" r="5" fill="#333"/> </g> <!-- 车架 --> <g stroke="#e04a2f" stroke-width="5" stroke-linecap="round"> <line x1="270" y1="430" x2="360" y2="430"/> <line x1="360" y1="430" x2="350" y2="340"/> <line x1="350" y1="340" x2="270" y2="430"/> <line x1="360" y1="430" x2="450" y2="430"/> <line x1="450" y1="430" x2="470" y2="300"/> <line x1="470" y1="300" x2="350" y2="340"/> </g> <!-- 车座和车把 --> <ellipse cx="350" cy="332" rx="22" ry="8" fill="#333"/> <line x1="470" y1="300" x2="450" y2="275" stroke="#555" stroke-width="4" stroke-linecap="round"/> <!-- 脚蹬和链条 --> <g id="crank"> <circle cx="360" cy="430" r="18" fill="none" stroke="#555" stroke-width="4"/> <line x1="360" y1="430" x2="340" y2="410" stroke="#777" stroke-width="4" stroke-linecap="round"/> <line x1="360" y1="430" x2="380" y2="450" stroke="#777" stroke-width="4" stroke-linecap="round"/> </g> </svg>注意车架那几根线,我故意用三角形结构——自行车车架本来就是多个三角形拼接,这种结构在视觉上最稳定。车把和车座的坐标要根据坐姿调整,多试几次就有手感了。脚蹬的crank分组画了两个相对180度的踏杆,动画时旋转这个分组,就会有踩踏板的错觉。
4.3 绘制鹈鹕:从哪下笔都不会崩的分步法
鹈鹕的整体可以拆成:身体、尾巴、脖子、头、嘴、眼睛、翅膀、腿。每画一个部分就换一种颜色,先用大形状占位,再精修小部件。我的经验是:先画最大的身体,再画脖子连接身体和头,最后补嘴和翅膀。
<!-- 鹈鹕主体 --> <g id="pelican"> <!-- 尾巴 --> <path d="M 245 290 Q 210 270 230 310 Z" fill="#f5f0e0" stroke="#d0c8b0" stroke-width="2"/> <!-- 身体 --> <ellipse cx="315" cy="285" rx="75" ry="45" fill="#f5f0e0" stroke="#d0c8b0" stroke-width="3"/> <!-- 腿 --> <line x1="330" y1="330" x2="330" y2="365" stroke="#e8a040" stroke-width="5" stroke-linecap="round"/> <line x1="360" y1="330" x2="360" y2="360" stroke="#e8a040" stroke-width="5" stroke-linecap="round"/> <!-- 脖子和头 --> <path d="M 360 270 Q 390 200 400 180" fill="none" stroke="#f5f0e0" stroke-width="28" stroke-linecap="round"/> <circle cx="400" cy="175" r="28" fill="#f5f0e0" stroke="#d0c8b0" stroke-width="3"/> <!-- 嘴 --> <path d="M 400 180 Q 520 170 560 220 Q 530 220 500 200 Q 450 205 400 205 Z" fill="#f0a030" stroke="#c47720" stroke-width="3"/> <!-- 眼睛 --> <circle cx="410" cy="162" r="4" fill="#222"/> <circle cx="414" cy="162" r="2" fill="#fff"/> <!-- 翅膀 --> <g id="wing"> <path d="M 300 260 Q 350 240 380 275 Q 340 285 300 280 Z" fill="#e8dcc8" stroke="#d0c0a0" stroke-width="2"/> </g> </g>脖子我直接用粗线条加圆角端点来画,比用路径拼轮廓省事得多。你想要更真实的脖子形状,可以把stroke加宽到28像素,然后让路径自然弯曲——这是偷懒但很实用的技巧。
注意身体的y坐标和车座的关系:车座在(350,332),鹈鹕身体圆心在(315,285),身体下缘在330附近,刚好落在车座上。视觉上就是鹈鹕稳稳坐在车上。
4.4 用SMIL让画面动起来:轮子、脚蹬、颠簸和飞行的翅膀
静态的SVG画好之后,动画就有地方施展了。SMIL全称Synchronized Multimedia Integration Language,已经内置于浏览器中,不需要额外JS库。
最基础的动画标签是<animate>,用于驱动数字属性,比如坐标、透明度、颜色。还有<animateTransform>,驱动transform变化。再看轮子的旋转:
<g id="rear-wheel"> ... <animateTransform attributeName="transform" type="rotate" from="0 270 430" to="360 270 430" dur="2s" repeatCount="indefinite"/> </g>from="0 270 430"表示从0度开始,旋转中心是(270,430);to="360 270 430"表示转到360度。dur="2s"是一圈2秒,repeatCount="indefinite"表示无限循环。这样轮子就连续转起来了。前轮的写法完全一样,只是中心点换成(450,430)。
脚蹬旋转:
<animateTransform attributeName="transform" type="rotate" from="0 360 430" to="360 360 430" dur="2s" repeatCount="indefinite"/>为了让鹈鹕有骑车颠簸的感觉,我把整个鹈鹕组做一个上下小幅度位移:
<animateTransform attributeName="transform" type="translate" values="0 0; 0 -8; 0 0" dur="0.5s" repeatCount="indefinite"/>values可以是分号分隔的多个值,SVG会自动插值。这个动画让鹈鹕每0.5秒完成"原位-上移8像素-回原位"的循环,模拟被颠起来的节奏。
翅膀扇动我选了在wing组上做旋转,但旋转中心要放在翅根处,我用CSS的transform-origin来控制:
<g id="wing" style="transform-origin: 300px 260px;"> <path d="M 300 260 Q 350 240 380 275 Q 340 285 300 280 Z" .../> <animateTransform attributeName="transform" type="rotate" values="0 300 260; -25 300 260; 0 300 260" dur="0.8s" repeatCount="indefinite"/> </g>翅膀扇动比旋转更复杂,因为它在"上下拍打"——我用了values序列:初始角度0度,然后向上翻到-25度,再回0度。这个往复运动就是拍翅膀的效果。
云朵水平漂移用<animateTransform>的translate类型:
<g id="cloud-left"> <ellipse cx="150" cy="100" rx="45" ry="22" fill="#fff" opacity="0.8"/> <ellipse cx="190" cy="90" rx="35" ry="20" fill="#fff" opacity="0.8"/> <animateTransform attributeName="transform" type="translate" values="0 0; 120 0; 0 0" dur="12s" repeatCount="indefinite"/> </g>云向右漂120像素之后回到原位,重复循环。如果想让云一直往一个方向走,到头后重新出现,可以把values改成-100 0; 900 0,然后再配合第二朵云交替出现,视觉上就会衔接自然。
5. 常见问题与排查技巧实录
5.1 动画完全不动:八成是引号或单位写错了
SMIL动画的dur值如果忘了写s,比如写成dur="2",某些浏览器会直接忽略这个动画。repeatCount="indefinite"这个单词也容易拼错——我在本地调试时就拼成过indefinites,结果动画只播一遍就停了。这种问题基本不在控制台报错,只能靠肉眼排查。
更隐秘的问题是from和to里的坐标没带空格,例如from="0 270 430"写成了from="0,270,430"——大部分浏览器能容忍逗号分隔,但有部分老版本只认空格。为了保险,我统一用空格分隔。
5.2 旋转动画把图形甩飞了:问题永远在中心点
所有诡异的旋转错位,99%的原因都是旋转中心不对。检查顺序是:先确认被旋转元素的实际坐标包围盒,再确认from里的第三个参数是不是这个包围盒的几何中心。
还有一种情况是:元素位于某个外层<g>内部,而外层<g>有一个translate位移。此时SMIL里的旋转中心应该使用该元素相对于整个SVG画布的坐标,而不是它在内层<g>中的相对坐标。如果你不确定,直接在浏览器开发者工具中查看该元素渲染后的getBoundingClientRect()值,反推它在SVG坐标系中的位置,再去写旋转中心。这个排查方法屡试不爽。
5.3 路径形状扭曲:多半是M命令的起点没选好
画鹈鹕嘴的路径时,我一开始把起点设在了上喙的中间,结果整个形状像被人拧过一样。路径的起点最好选在形状"最平实"的低点,比如嘴的根部,然后沿一个方向画一圈。闭合路径Z会从终点自动连回起点,如果起点和终点距离太远,中间会拉出一条刺眼的直线。
实操建议:一定要给path加fill,哪怕只是调试用的半透明颜色。路径默认填充是黑色,如果你只画了描边没设置fill,闭合后不明显的异常会被扩大化。先填充、后描边的顺序调试,能快速看出路径围成的区域是否正确。
5.4 不同浏览器效果不一致:尽量避开SMIL盲区
SMIL的兼容性在主流现代浏览器中已经非常稳定,但如果你在旧版Edge(非Chromium内核)或某些移动端WebView上遇到动画不生效,备选方案是用CSS动画替代。CSS动画的transform和transform-origin可以做到同样效果,而且兼容性更好。反过来,CSS动画无法直接驱动d属性(路径的坐标数据),这时SMIL就有独特优势——不过驱动d属性本身就是进阶玩法,新手可以先避开。
如果遇到"动画在浏览器里正常,但在某些SVG编辑器/导出工具里失效",原因可能是工具把<animateTransform>当成不认识的标签给过滤了。解决办法是导出后再手工检查代码,或者直接用文本编辑器保留SMIL标签。
5.5 性能优化经验:元素多了一定要做这三件事
SVG动画卡顿,最常见的原因不是元素太多,而是触发了大量无谓的重绘。我的优化清单:
- 动画尽量加在
<g>分组上,而不是几十个独立子元素上。 - 使用
transform做位移和旋转,不要直接修改cx、cy、x、y属性——transform的变化由合成器单独处理,不强制整页重绘。 - 设置
will-change: transform或contain: layout paint(CSS样式),可以提前告诉浏览器这些区域会独立变化。但这个属性不要滥用,给一两组关键动画加就行。
实测下来,同样的鹈鹕动画,如果每个轮子的8根辐条都单独动画,帧率会掉一半;改成整组旋转后,基本全程60帧。
5.6 坐标画到画布外面去了:善用开发者工具的可视化调试
当你发现某个图形就是画不出来,或者画出来半截,但代码看起来没问题,一定先看看它是否超出了viewBox的范围。SVG默认不会裁剪超出部分的可见性(除非外层容器有overflow:hidden),但隐藏的图形很容易被忽略。
我调试时有一个习惯:先给所有元素加一个高亮的stroke="#f00"或fill="rgba(255,0,0,0.3)",然后在浏览器里逐个检查坐标。确认无误后,再改回正常配色。这个笨办法对新手特别友好,可以避免在白白猜测中浪费时间。
6. 让这个案例变得更丰富:从静态动画到"可交互"的升级思路
6.1 让SVG动的是动画,让它活的是交互
动画做出来只是第一步。你可以给轮子加一个点击事件,点击后加速;也可以监听页面滚动位置,让鹈鹕的位移跟着滚动走。SVG是DOM的一部分,所有DOM事件都能直接挂。
一个简单而实用的交互:点击鹈鹕触发叫声和跳跃。跳跃的本质是让整个鹈鹕组做一段更大幅度的位移动画,同时嘴巴路径切换成一张一合的状态。我通常用JavaScript动态插入/移除<animateTransform>标签来实现一次性动画:
const pelican = document.getElementById('pelican'); pelican.addEventListener('click', () => { const jump = document.createElementNS('http://www.w3.org/2000/svg', 'animateTransform'); jump.setAttribute('attributeName', 'transform'); jump.setAttribute('type', 'translate'); jump.setAttribute('values', '0 0; 0 -40; 0 0'); jump.setAttribute('dur', '0.6s'); jump.setAttribute('fill', 'freeze'); pelican.appendChild(jump); });这里注意,动态创建的SVG元素必须用document.createElementNS加上SVG命名空间创建,否则浏览器会当成普通HTML标签处理,完全不生效。这个坑我踩过不止一次。
6.2 用JavaScript驱动更复杂的运动轨迹
SMIL的动画适合简单的循环运动,但如果鹈鹕要沿一条曲线从画面左边驶入、中间绕过一棵树、最后停在右边,用SMIL写会比较笨拙。更通用的方案是用JavaScript直接修改transform:
let t = 0; function animatePath() { t += 0.02; const x = 100 + t * 300; // 匀速前进 const y = 300 + Math.sin(t * 3) * 20; // 上下颠簸 bikeGroup.setAttribute('transform', `translate(${x}, ${y})`); requestAnimationFrame(animatePath); } animatePath();这是最原始但最灵活的做法。你需要自己控制帧循环、缓动、插值——相当于亲手实现一个微型动画引擎。好处是运动轨迹完全由代码掌控,想怎么变态都行;坏处是代码量明显增加,而且不如SMIL那样"声明式"好维护。我的建议是:简单循环用SMIL,复杂轨迹用JavaScript,两种方式互补,不是谁替代谁的关系。
6.3 顺便聊聊SVG的可访问性
SVG毕竟是XML,屏幕阅读器可以读里面的元素,但默认可读性很差。给每个关键分组加一个<title>或role="img"是很好的习惯:
<g id="pelican" role="img" aria-label="一只鹈鹕正骑着自行车"> <title>鹈鹕</title> ... </g>这样做不仅对屏幕阅读器友好,对SEO也有帮助——搜索引擎能更好理解你页面上图形的内容。千万不要在一大段SVG里裸奔几十个形状,却连个说明都没有。
7. 一份可直接运行的完整SVG代码
到这里,一个可运行的完整示例就拼好了。把下面整个代码块保存成html文件,用浏览器打开就能看到鹈鹕骑着自行车在页面里跑。代码我把动画和结构都保留,方便你逐段对照、修改坐标练手。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>鹈鹕骑自行车 - SVG动画示例</title> <style> body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f7f3e9; } svg { max-width: 900px; width: 100%; height: auto; box-shadow: 0 15px 40px rgba(0,0,0,0.12); border-radius: 14px; } </style> </head> <body> <svg width="900" height="600" viewBox="0 0 900 600" xmlns="http://www.w3.org/2000/svg"> <!-- 背景 --> <rect x="0" y="0" width="900" height="600" fill="#dff0fa"/> <rect x="0" y="485" width="900" height="115" fill="#cde6c3"/> <!-- 太阳 --> <circle cx="800" cy="90" r="45" fill="#ffd166"/> <!-- 云朵 --> <g id="cloud-left"> <ellipse cx="120" cy="100" rx="45" ry="22" fill="#fff" opacity="0.85"/> <ellipse cx="155" cy="90" rx="33" ry="18" fill="#fff" opacity="0.85"/> <ellipse cx="190" cy="100" rx="38" ry="20" fill="#fff" opacity="0.85"/> <animateTransform attributeName="transform" type="translate" values="0 0; 110 0; 0 0" dur="14s" repeatCount="indefinite"/> </g> <!-- 地面点缀 --> <line x1="80" y1="520" x2="130" y2="520" stroke="#91b882" stroke-width="4" stroke-linecap="round"/> <line x1="600" y1="510" x2="660" y2="510" stroke="#91b882" stroke-width="4" stroke-linecap="round"/> <line x1="750" y1="540" x2="820" y2="540" stroke="#91b882" stroke-width="4" stroke-linecap="round"/> <!-- 后轮 --> <g id="rear-wheel"> <circle cx="270" cy="430" r="55" fill="none" stroke="#333" stroke-width="8"/> <circle cx="270" cy="430" r="48" fill="none" stroke="#666" stroke-width="3" stroke-dasharray="8 8"/> <line x1="270" y1="430" x2="270" y2="382" stroke="#888" stroke-width="4"/> <line x1="270" y1="430" x2="320" y2="430" stroke="#888" stroke-width="4"/> <line x1="270" y1="430" x2="270" y2="478" stroke="#888" stroke-width="4"/> <line x1="270" y1="430" x2="220" y2="430" stroke="#888" stroke-width="4"/> <circle cx="270" cy="430" r="5" fill="#333"/> <animateTransform attributeName="transform" type="rotate" from="0 270 430" to="360 270 430" dur="2s" repeatCount="indefinite"/> </g> <!-- 前轮 --> <g id="front-wheel"> <circle cx="450" cy="430" r="55" fill="none" stroke="#333" stroke-width="8"/> <circle cx="450" cy="430" r="48" fill="none" stroke="#666" stroke-width="3" stroke-dasharray="8 8"/> <line x1="450" y1="430" x2="450" y2="382" stroke="#888" stroke-width="4"/> <line x1="450" y1="430" x2="500" y2="430" stroke="#888" stroke-width="4"/> <line x1="450" y1="430" x2="450" y2="478" stroke="#888" stroke-width="4"/> <circle cx="450" cy="430" r="5" fill="#333"/> <animateTransform attributeName="transform" type="rotate" from="0 450 430" to="360 450 430" dur="2s" repeatCount="indefinite"/> </g> <!-- 车架 --> <g stroke="#e04a2f" stroke-width="5" stroke-linecap="round" fill="none"> <line x1="270" y1="430" x2="360" y2="430"/> <line x1="360" y1="430" x2="350" y2="340"/> <line x1="350" y1="340" x2="270" y2="430"/> <line x1="360" y1="430" x2="450" y2="430"/> <line x1="450" y1="430" x2="470" y2="300"/> <line x1="470" y1="300" x2="350" y2="340"/> </g> <!-- 车座 --> <ellipse cx="350" cy="328" rx="24" ry="9" fill="#333"/> <!-- 车把 --> <line x1="470" y1="300" x2="450" y2="272" stroke="#555" stroke-width="5" stroke-linecap="round"/> <!-- 脚蹬中轴 --> <circle cx="360" cy="430" r="20" fill="none" stroke="#555" stroke-width="4"/> <line x1="360" y1="430" x2="340" y2="410" stroke="#777" stroke-width="5" stroke-linecap="round"/> <line x1="360" y1="430" x2="380" y2="450" stroke="#777" stroke-width="5" stroke-linecap="round"/> <animateTransform attributeName="transform" type="rotate" from="0 360 430" to="360 360 430" dur="2s" repeatCount="indefinite"/> <!-- 鹈鹕整体分组,做上下颠簸 --> <g id="pelican"> <animateTransform attributeName="transform" type="translate" values="0 0; 0 -8; 0 0" dur="0.5s" repeatCount="indefinite"/> <!-- 尾巴 --> <path d="M 245 290 Q 210 265 230 310 Z" fill="#f5f0e0" stroke="#d0c8b0" stroke-width="2"/> <!-- 身体 --> <ellipse cx="315" cy="285" rx="75" ry="45" fill="#f5f0e0" stroke="#d0c8b0" stroke-width="3"/> <!-- 腿 --> <line x1="330" y1="330" x2="330" y2="365" stroke="#e8a040" stroke-width="6" stroke-linecap="round"/> <line x1="372" y1="330" x2="370" y2="360" stroke="#e8a040" stroke-width="6" stroke-linecap="round"/> <!-- 脖子 --> <path d="M 360 270 Q 390 200 400 180" fill="none" stroke="#f5f0e0" stroke-width="28" stroke-linecap="round"/> <!-- 头 --> <circle cx="400" cy="175" r="28" fill="#f5f0e0" stroke="#d0c8b0" stroke-width="3"/> <!-- 嘴 --> <path d="M 400 180 Q 520 170 560 220 Q 530 220 500 200 Q 450 205 400 205 Z" fill="#f0a030" stroke="#c47720" stroke-width="3"/> <!-- 眼睛 --> <circle cx="410" cy="162" r="4" fill="#222"/> <circle cx="413" cy="161" r="2" fill="#fff"/> <!-- 翅膀 --> <g id="wing"> <path d="M 300 260 Q 350 235 385 270 Q 340 285 300 280 Z" fill="#e8dcc8" stroke="#d0c0a0" stroke-width="2"/> <animateTransform attributeName="transform" type="rotate" values="0 300 260; -25 300 260; 0 300 260" dur="0.8s" repeatCount="indefinite"/> </g> </g> </svg> </body> </html>这段代码可以直接运行。如果复制到本地后看不到动画,先检查浏览器版本或换一个现代浏览器试一下。
我实际运行这个文件的感觉是:轮子转动和脚蹬旋转同步进行,鹈鹕身体轻微颠簸,翅膀持续扇动,云慢慢飘过——整个画面就活起来了。虽然算不上精致,但作为学习案例,它把SVG从绘图到动画的关键点都串起来了。
8. 一些发自肺腑的实操总结
把这一整套SVG案例做下来,我最想分享的经验其实就三条:
第一,先画静态图,再做动画。这句话听起来像废话,但我见过太多新手一上来就打算让轮子转起来,结果连车架都还没画就卡住了。静态图和动画是两个独立的问题,分开解决每个都容易,混在一起会互相干扰。我写这个案例时,先把整个画面完整画出来,确认每个坐标、每个颜色都满意了,才开始加动画标签。
第二,坐标系的数学化思维很重要。SVG初看只要填坐标就行,但真正写起来,你会发现如果随机给坐标,画面永远乱糟糟。先确定关键基准点(比如轮心、地面线),再从基准点推演其他坐标,是让画面整齐的最有效方法。它不考验美术功底,只考验你有没有耐心去算。
第三,动画的关键不是动,而是"节奏"。轮子转得太快像电风扇,鹈鹕颠簸幅度太大像在坐过山车,翅膀扇得太慢像濒死挣扎。这些都需要根据画面内容反复调参数,没有一步到位的方案。我建议把动画参数集中放在文件头部或末尾注释里,方便统一调整——比如dur="2s"、values中的偏移量,都记在一个地方,后面调参效率会高很多。
如果你对着这个案例刷完了,下一步可以试着改变鹈鹕的颜色、换成不同地形背景、给自行车加个车筐,或者干脆换一只动物——画一只鸭子骑滑板车也行。SVG就是这样,学会了基础图形和坐标变换这两个核心,剩下的就是组合和创意了。