这一切要从我决定重构 https://cytrogen.icu 开始(当你看到这篇文章时,访问该链接就能看到雏形了)。原本它是我的博客网站的地址,后来我想要一个更像是个人网络的设计,即根域名为个人主页,博客网站放在blog子域名上。
入门编写 SVG
这一切要从我决定重构 https://cytrogen.icu 开始(当你看到这篇文章时,访问该链接就能看到雏形了)。原本它是我的博客网站的地址,后来我想要一个更像是个人网络的设计,即根域名为个人主页,博客网站放在 blog 子域名上。
个人主页、个人主页…… 怎么样的个人主页更能展示出我的「个性」呢?除了 先前 介绍的 ellesho.me 外,我还看了 Hacker News 的这个 帖子,里面有许多设计非常有趣的个人主页,不过博客网站依然偏多。不是对博客网站有任何意见,只是我认为,我不需要另一个以文本为主的网络载体:我已经有了博客网站,还有了只能显示文字的胶囊。
这就需要说到 Web 最为特殊的东西了:HTML、CSS 和 JavaScript。既然是 Web 上搭建的站点,不玩玩这三剑客又怎么行呢?当然,这也不意味着我的个人主页就会充满了 JavaScript、把访问者的设备卡到无法动弹。我依然会遵循「极简 JavaScript」的原则,来完成它。
小时候游玩过的 Flash 小游戏,让我想要实现类似的内容。不知道读者们有没有玩过 Reincarnation 这款点击解密 Flash 游戏呢?4399 游戏平台将其翻译为《地狱使者》,不过我认为失去了原标题「轮回」的意味。总之它是一款原先发布在 NEWGROUNDS 平台 上的游戏,由 Chris Gianelloni 开发。故事讲的是地狱中逃出去了许多鬼魂,主角小恶魔受地狱之王撒旦指使,要去人间找到这些死人、定他们的罪,最后靠解密让他们「意外」身亡 —— 为什么小时候的我喜欢这种血腥的游戏啊?不过主角小恶魔我觉得很可爱,还是个被迫工作的打工人,经常光着身子撅着个腚走来走去。
当时的 Flash 游戏,这种玩法单纯依赖于鼠标点击的有很多,比方说各种类宝可梦的网游、《华纳史诗冒险系列》(Steppenwolf: The X-Creatures Project)都是如此。我也想一个类似的个人主页!
使用 Flash 技术肯定是不可能的了。我也不打算将个人主页做成一款网页游戏,不过一些游戏元素还是可以接受的…… 但这篇文章并不会涉及到这一方面,为什么呢,因为我还没有开始设计和编写。就像标题所说的,该文章的重点在于 SVG 这项技术。
了解 SVG
SVG 的全称叫作 Scalable Vector Graphics,即 可缩放矢量图形。它是基于 XML 格式的图像格式,用于描述 二维矢量图形。
说这么多术语以及模糊的概念,大家可能无法很好地明白,我就先来讲解一下计算机里、图形到底是什么吧。图形本质上是数据的可视化量现。为了在屏幕上准确呈现这些视觉信息,计算机需要依赖一套特定的编码规则或者说数据格式来解析信息,而这一切大致可以分为两个分支:位图与矢量。
位图图形(bitmap),也被称为 栅格图形(raster graphics),核心结构是由像素点(pixel)排列而成的矩形网格,而像素是数字显示设备上能够被独立控制色彩与亮度的最小物理发光单元。大家熟知的 JPEG、PNG 等,都是位图图形的格式。当计算机系统渲染一张 JPEG 文件时,它读取的其实是二维数据表 —— 表会记录该特定尺寸网格中,每一个坐标位置上像素的具体色彩值。
不过位图有个大问题:依赖分辨率。因为图像的信息由固定数量的像素点强行绑定,一旦图像被放大、网格被拉伸,计算机就必须运行插值算法,根据原有相邻像素的颜色去推算并凭空生成新的像素来填补填充后的物理面积。作为一个例子,你可以打开任何一个位图编辑器,比方说 Windows 系统的 Paint 软件,在上面随便画点什么,接着将其选中、拉伸,你就能看到许多突然多出的锯齿状像素方块。
而 矢量图(vector)抛弃了像素网格的概念,转而使用数学公式和几何图元来描述图像。一组由起点坐标、终点坐标、线条粗细和颜色属性共同构成的数学指令,就能在矢量体系中画出一条直线。当矢量图被放大后,计算机会将新的缩放比例代入原有的数学公式中,重新计算并在屏幕上实时绘制出新的图像,从而让矢量图无论怎么放大,都能让边缘保持锐利。
在互联网普及之前,计算机图形学领域便已经存在成熟的矢量技术:用于打印排版的 PostScript、CAD 软件和 Adobe Illustrator 等专业绘图工具使用的专用格式…… 二十世纪九十年代末,万维网面临着严重的带宽限制和多样的终端显示器分辨率。位图格式在传输高质量大尺寸图像时,会导致文件体积急剧膨胀,且有着先前提到过的、极差的不同分辨率下的缩放表现。矢量图本应该是解决这一问题的完美方案,但是当时的矢量格式大多是编译后的二进制数据,且被各家商业公司作为技术壁垒严格封闭。
如果将早期 Macromedia Flash 或各类专有矢量文件直接嵌入网页,浏览器无法原生解析这些数据,用户必须下载并安装特定的第三方插件。这些二进制文件对于网页的底层结构而言,也有着许多问题:搜索引擎的爬虫无法读取其中的文本内容,网页开发者也无法使用 JavaScript 去捕获图形内部的元素并产生交互。
W3C(万维网联盟)认为,互联网需要一种属于自己的、非专有的、且与现有 Web 技术栈深度融合的矢量图形标准。业界纷纷向 W3C 提交关于 Web 矢量图形的提案,其中最具代表性的分别是 VML(Vector Markup Language)和 PGML(Precision Graphics Markup Language)。它们都试图建立基于 XML 的图形描述规范。
而 W3C…… 哪个都没选,反而成立专门的图形工作组,吸收了这两种方案的优势、想出了 SVG 规范。
因为 SVG 被设计为纯粹的 XML 文本,因此顺理成章成为了 DOM 树的一部分。网页浏览器不需要插件也能利用内置的渲染引擎对其进行解析,同时图形中的每一条路径、形状都成为了可被精确寻址的 DOM 节点。可喜可贺,可喜可贺。
写 SVG
在我的计划里,我的个人主页所会用到的矢量图形并不复杂。没有描边的大色块有着自己的魅力。
用 SVG 来写只需要知道这五个元素:
<rect><circle><polygon><path><text>
SVG 应用的是直角坐标系,原点 位于画布的左上角,X 轴向右延伸,Y 轴向下延伸。所有的几何图元都基于这个网格定位与绘制。
对于矩形 <rect> 元素,核心属性分别是:
x和y,用于确定矩形左上角顶点的精确坐标width和height,分别定义矩形在水平方向和垂直方向上的标量跨度rx和ry,指定圆角的水平和垂直半径
圆形 <circle> 元素的逻辑基于圆心的半径:
cx和cy,标记圆心在坐标系中的绝对位置r,设定原的半径长度
多边形 <polygon> 元素用于绘制由直线段连接而成的闭合形状:
- 核心
points属性,在接收一系列坐标对,每个坐标对代表多边形的一个几何顶点。渲染引擎会按照坐标的书写顺序,依次用直线将这些点连接起来,并自动将最后一个点与第一个点连接以闭合图形
作为一个例子:<polygon points="50,15 100,100 0,100" /> 画出的是三角形。
文本 <text> 元素将字符数据引入矢量画布。与常规 HTML 中的文本排版不同,SVG 文本需要严格的空间绝对定位:
x和y属性并不代表文本块的左上角,而是文本基线的起点位置。字号、字体族等排版属性需要通过 CSS 或内联属性进行声明
<path> 元素很常用:
d属性是一系列微型绘图指令的集合:M x y指令负责抬起虚拟画笔并移动到指定的 坐标,通常作为路径的初始起笔指令L x y指令表示从当前画笔位置绘制一条绝对直线到新的 坐标Q x1 y1, x y指令绘制二次贝塞尔曲线。其中 是控制点, 是整段曲线的最终物理端点Z指令没有参数。当它出现在路径数据的末尾时,渲染引擎会自动从当前所在坐标绘制一条直线,径直连回当前子路径的起始点,即上一个M指令所在的坐标
知道了这些后,我便可以一步步绘制出,我的个人主页的内容了!
首先,我希望我的个人主页有一个开场动画:一面山羊头骨为中心的墙,短暂呈现一段时间后,从中间垂直地打开,展示后面的城堡。那么这个墙要怎么画呢?
先定义一下结构:
1 | <div class="skull-overlay" aria-hidden="true"> |
在最外层的 <svg> 容器中(实际最外层的 div 仅作动画用,在写 SVG 的情况下并不需要管),视口属性 viewBox="-200 -250 800 1000" 意味着起始点在于 X 轴的 和 Y 轴的 ,整体的宽度设定为 ,高度设定为 。
preserveAspectRatio="xMidYMidslice" 属性是适配规则,强制 SVG 在容器尺寸变化时保持自身比例缩放,且会裁剪掉超出容器边缘的部分,以确保画面始终完全填满视口、不发生形变。
由于 SVG 遵循着从上到下的文档流渲染顺序,实际带有山羊头骨的上半部分墙需要在下半部分墙之后定义。现在让我们先完成下半部分墙的内容。
<g> 是群组元素,可以在内部添加数个 SVG 元素来组合起来。对于下半部分墙,我们只需要一个 <rect> 元素,但为了统一性,还是放入群组内:
1 | <g class="skull-buttom"> |
除了高度外,其他内容都和视口属性相同,因为需要填满整个画面。高度则是视口高度的一半,毕竟是「下半部分」。
接下来让我们绘制上半部分墙。在我的构造里,这个墙在画面中心还会有一个山羊头骨,在绘制时需要先画墙、山羊头骨、头骨细节。先画墙:
1 | <g class="skull-top"> |
接着绘制一个山羊头骨的剪影:
1 | <path class="fill-skull" d=" |
起初点 M175 380 是头骨吻部的左侧底端,随后用 L 指令向上描绘头骨的左侧边缘:L158 340 L138 290 L125 245 L130 200 L155 170。可以观察出,它们的 X 坐标先减小后增大,因为我想先画出从吻部向外扩张到颧骨,再到头顶时向内收缩。而 Y 坐标一直在减少,因为我们是从左下角开始画、要画到头顶的。
跨过中心点 L200 158 后,再描绘头骨的右侧边缘:L245 170 L270 200 L275 245 L262 290 L242 340 L225 380。和画头骨左侧的路线相反,Y 坐标一直在变大,X 坐标则是先增大再减少,直至吻部的右侧底端(看 Y 坐标,和起始点的 Y 坐标一致)。最终用一个 Z 指令将它们两个点用一条直线相连。
想要填充头骨颜色的话,在 CSS 类 .fill-skull 里设置即可。
山羊头骨的大致形状写完后,我们还需要写两个眼洞和角。其中眼洞的写法和头骨剪影一致,都是用直线勾出形状,这里只说角的写法吧。
我想要这个角具有弧度,因此需要引入二次贝塞尔曲线。二次贝塞尔曲线需要三个关键点:
- 起点
- 控制点
- 终点
控制点本身不位于最终渲染的曲线上,但起到了类似于引力的作用,将起点到终点的连线向自身方向拉扯,从而形成特定的弧度。

图片来自 Pocket Guide to Writing SVG。
用这张图片来看,会更直观些吧。
左侧角的绘制从 M130 205 开始,确立了角与颅骨左侧的连接基点。随后 Q70 170 35 110 勾勒角的外侧边缘。这里的曲线从坐标 开始,向 延伸,同时受到位于左上方的控制点 的牵引。结果便是曲线先向左侧外扩,随后向左上方收束。到达角尖最高点附近时,L65 95 封闭角的顶端,再用 Q85 145 145 180 画角的内侧边缘。右侧角就不用说了,是完全镜像的。
更复杂的 SVG
到这里,你或许会想,这样写 SVG 好累。我不否认。人类的大脑并不擅长于直观构建二维坐标系内的形状,尤其是过于复杂的形状。一个解决方案是使用更抽象的工具:可视化矢量图形编辑器。
我高中时学习了 Adobe Illustrator 的使用方式,或许其他的编辑器也是一样的。绘制路径时,往往都会有操作锚点、控制手柄等工具。底层图形引擎会实时计算这些交互动作对应的绝对,或相对的坐标,并能够精准地生成所需的二次,甚至是三次贝塞尔曲线指令。
不过 Adobe 的东西都很昂贵。Inkscape 是开源的替代品,可以尝试。据说 Figma 也能够绘画矢量图形。它们都可以将结果导出为标准的 SVG 代码。
如果想要画重复性高的 SVG 图形,也可以通过编写脚本来动态连接路径数据。比方说 d3.js 这个项目,就能实现一个很有意思的 例子。
再就是,在传统开发模式中,SVG 往往被视为等同于 PNG 或 JPEG 的静态外部资源。一旦在矢量编辑器中完成绘制并导出为 XML 代码,其内部的节点结构、填充颜色、描边参数以及路径坐标种种,都被硬编码锁定了。
怎么解决呢?现代前端开发体系,比如 React 和 Vue,引入了组件化封装与声明式抽象,本质上是将静态的 XML 标记解析为虚拟 DOM 树上的节点。这样的话,SVG 就被包装成了个能够接收外部参数的函数或类组件。