我坚信 SVG 为在 Web 上构建界面开辟了全新的世界。乍一看学习 SVG 似乎令人生畏,但您有一个专为创建形状而设计的规范,同时还提供文本、链接和 aria 标签等元素。您可以使用 CSS 达到一些相同的效果,但要使定位恰到好处则需要更加细致,尤其是在不同视窗和响应式开发中。
SVG 的独特之处在于所有定位都基于坐标系,有点像游戏 战舰。这意味着决定所有内容的位置和绘制方式,以及它们彼此之间的相对位置,可以非常直观地理解。CSS 定位用于布局,这很好,因为您拥有在文档流方面彼此对应的元素。如果您要制作一个非常特别的组件,其中包含重叠且精确放置的元素,那么这种原本积极的特性就难以处理。
真正地,一旦您学会了 SVG,您就可以绘制任何东西,并且可以在任何设备上进行缩放。即使是这个网站也使用 SVG 来制作自定义 UI 元素,例如我上面的头像(元数据!)。

我们不会在本篇文章中介绍有关 SVG 的 所有内容(您可以在 这里、这里、这里 和 这里 学习一些基本知识),但为了说明 SVG 为 UI 组件开发打开的可能性,让我们讨论一个具体的用例,并分解一下我们如何考虑构建自定义组件。
时间轴任务列表组件
最近,我与 Netlify 团队合作 参与了一个项目。我们想要向观众展示他们在课程中正在观看的一系列视频中的哪一个视频。换句话说,我们想要制作一个类似待办事项列表的东西,但会随着事项的完成而显示总体进度。(我们制作了一个免费的太空主题学习平台,它非常酷。是的,我说的是 非常酷。)
以下是外观

那么我们该如何进行呢?我将展示 Vue 和 React 中的示例,以便您了解它在两种框架中的工作原理。
Vue 版本
我们决定使用 Next.js 来制作平台,以便进行测试(即尝试我们自己的 Next on Netlify 构建插件),但我更熟悉 Vue,所以我用 Vue 编写了初始原型,并将其移植到 React 中。
以下是完整的 CodePen 演示
让我们简要介绍一下这段代码。首先,这是一个单文件组件 (SFC),因此模板 HTML、响应式脚本和作用域样式都封装在这个文件中。
我们将在 data
中存储一些虚拟任务,包括每个任务是否已完成。我们还将创建一个方法,我们可以在点击指令上调用该方法,以便我们可以切换状态是完成还是未完成。
<script>
export default {
data() {
return {
tasks: [
{
name: 'thing',
done: false
},
// ...
]
};
},
methods: {
selectThis(index) {
this.tasks[index].done = !this.tasks[index].done
}
}
};
</script>
现在,我们要做的是创建一个 SVG,它具有根据元素数量灵活调整的 viewBox
。我们还希望告诉屏幕阅读器这是一个表示性元素,并且我们将提供一个标题,其唯一 ID 为 timeline
。(获取有关创建 无障碍 SVG 的更多信息。)
<template>
<div id="app">
<div>
<svg :viewBox="`0 0 30 ${tasks.length * 50}`"
xmlns="http://www.w3.org/2000/svg"
width="30"
stroke="currentColor"
fill="white"
aria-labelledby="timeline"
role="presentation">
<title id="timeline">timeline element</title>
<!-- ... -->
</svg>
</div>
</div>
</template>
stroke
设置为 currentColor
以便灵活使用 - 如果我们想要在多个地方重用该组件,它将继承封装 div 上使用的任何 color
。
接下来,在 SVG 内部,我们要创建一条与任务列表长度相同的垂直线。线条相当简单。我们有 x1
和 x2
值(线条在 x 轴上的绘制位置),类似地,还有 y1
和 y2
。
<line x1="10" x2="10" :y1="num2" :y2="tasks.length * num1 - num2" />
x 轴始终保持在 10,因为我们要向下绘制一条线,而不是从左到右绘制。我们将在数据中存储两个数字:我们要使用的间距量,这将是 num1
,以及我们要使用的边距量,这将是 num2
。
data() {
return {
num1: 32,
num2: 15,
// ...
}
}
y 轴从 num2
开始,从末尾减去,以及边距。tasks.length
乘以间距,即 num1
。
现在,我们需要位于这条线上的圆圈。每个圆圈都指示一个任务是否已完成。我们需要每个任务一个圆圈,因此我们将使用 v-for
以及唯一的 key
,即索引(这里可以使用它,因为它们永远不会重新排序)。我们将把 click
指令与我们的方法连接起来,并将索引作为参数传递进去。
SVG 中的圆圈由三个属性组成。圆圈的中心点绘制在 cx
和 cy,
上,然后我们使用 r.
绘制半径。与线条类似,cx
从 10 开始。半径为 4,因为在这个比例尺上它是可读的。cy
将像线条一样进行间隔:索引乘以间距 (num1
),加上边距 (num2
)。
最后,我们将使用三元运算符来设置 fill
。如果任务已完成,它将使用 currentColor
填充。如果没有,它将使用 white
填充(或任何背景颜色)。例如,可以使用一个传递给背景的道具来填充它,这样您就可以使用浅色和深色的圆圈。
<circle
@click="selectThis(i)"
v-for="(task, i) in tasks"
:key="task.name"
cx="10"
r="4"
:cy="i * num1 + num2"
:fill="task.done ? 'currentColor' : 'white'"
class="select"/>
最后,我们使用 CSS 网格来对齐包含任务名称的 div。布局方式与之前相同,我们遍历任务,并且还与相同的点击事件绑定,以切换完成状态。
<template>
<div>
<div
@click="selectThis(i)"
v-for="(task, i) in tasks"
:key="task.name"
class="select">
{{ task.name }}
</div>
</div>
</template>
React 版本
以下是我们在 React 版本中最终得到的结果。我们正在努力将其开源,这样您就可以查看完整的代码及其历史。以下是几个修改内容
- 我们使用的是 CSS 模块,而不是 Vue 中的 SFC
- 我们导入的是 Next.js 链接,因此我们不是切换“完成”状态,而是将用户带到 Next.js 中的动态页面
- 我们使用的任务实际上是课程的阶段 - 或我们所谓的“任务” - 它们在这里传递进来,而不是由组件保存。
大多数其他功能相同 :)
import styles from './MissionTracker.module.css';
import React, { useState } from 'react';
import Link from 'next/link';
function MissionTracker({ currentMission, currentStage, stages }) {
const [tasks, setTasks] = useState([...stages]);
const num1 = 32;
const num2 = 15;
const updateDoneTasks = (index) => () => {
let tasksCopy = [...tasks];
tasksCopy[index].done = !tasksCopy[index].done;
setTasks(tasksCopy);
};
const taskTextStyles = (task) => {
const baseStyles = `${styles['tracker-select']} ${styles['task-label']}`;
if (currentStage === task.slug.current) {
return baseStyles + ` ${styles['is-current-task']}`;
} else {
return baseStyles;
}
};
return (
<div className={styles.container}>
<section>
{tasks.map((task, index) => (
<div
key={`mt-${task.slug}-${index}`}
className={taskTextStyles(task)}
>
<Link href={`/learn/${currentMission}/${task.slug.current}`}>
{task.title}
</Link>
</div>
))}
</section>
<section>
<svg
viewBox={`0 0 30 ${tasks.length * 50}`}
className={styles['tracker-svg']}
xmlns="http://www.w3.org/2000/svg"
width="30"
stroke="currentColor"
fill="white"
aria-labelledby="timeline"
role="presentation"
>
<title id="timeline">timeline element</title>
<line x1="10" x2="10" y1={num2} y2={tasks.length * num1 - num2} />
{tasks.map((task, index) => (
<circle
key={`mt-circle-${task.name}-${index}`}
onClick={updateDoneTasks(index)}
cx="10"
r="4"
cy={index * +num1 + +num2}
fill={
task.slug.current === currentStage ? 'currentColor' : 'black'
}
className={styles['tracker-select']}
/>
))}
</svg>
</section>
</div>
);
}
export default MissionTracker;
最终版本
您可以在此处查看最终的运行版本
此组件足够灵活,可以适应大小列表、多种浏览器和响应式大小。它还使用户能够更好地了解他们在课程中的进度。
但这只是一个组件。您可以制作任意数量的 UI 元素:旋钮、控件、进度指示器、加载程序……无所不能。您可以使用 CSS 或内联样式对其进行样式设置,您可以根据道具、上下文、响应式数据等进行更新,无所不能!我希望这能为您的 Web 开发更具吸引力的 UI 元素开辟一些新的思路。
谢谢,Sarah!
我使用 SVG 已经有一段时间了。
以下是我的想法 -
一个更好的方法是先使用 Adobe Illustrator 或 Inkscape 等可视化工具设计组件,然后使用 JS 对其进行操作或添加交互性。
这样会更方便。
Sarah 的文章很棒,我非常喜欢,并感谢您的团队为此付出的努力。
@vishal:我使用 SVG 创建组件也已经好几年了,你说得没错,有时在 Illustrator 中创建东西会更快。但是,在这种情况下,您需要组件能够适应许多项目,因此您需要使用 JS 动态创建和绑定长度,因此这种方法对于许多用例来说不够灵活。您还需要手动编写一些代码才能使其无障碍。我想明确一点,我的团队没有编写这个组件,是我编写的,因此这不仅仅是对其他人的工作的介绍,这篇文章还包括我对选择这种方法的原因的理解。干杯!
很棒的文章。我之前开始阅读您的 SVG 书籍,并一直在探索它带来的可能性。很高兴看到这篇文章,并且有人用它来教我一些 Vue 知识。
title
在内容内部 - 我是否错过了有关使用此元素的内容?SVG 元素有它自己的标题标签,而不是您可能想到的文档标题标签。
这太棒了!
我对您示例代码中的一些内容很好奇,让我挠头
而不是
我预计后面的算术运算在给定数组时会以奇怪的方式起作用,但它们完全有效!只要数组中只有一个元素。我想有些事情正在发生,比如将类型强制转换为字符串,然后又转换为数字。
使用带数字的单元素数组与使用数字相比,是否更容易或有其他好处,还是仅仅是风格选择?(或者其他?)
嘿!实际上,这是来自旧版本的遗留问题,我已经更新了,但它不知何故被恢复了,感谢您提到它!现在应该修复了 :)
感谢您的文章,Sarah!
在 HTML/CSS 中创建此组件与在 SVG 中创建此组件的优缺点是什么?
谢谢!
很棒的文章 Sarah!:) 只是一个想法:注意 React 中的突变状态。它应该*始终*被避免。当您这样做时
您实际上是在改变
tasks[index]
(因为[...tasks]
只是数组的浅拷贝)。您应该改为做这样的事情tasksCopy[index] = {...tasksCopy[index], done: tasksCopy[index].done}
也许使用
spacing
和margin
比使用num1
和num2
更好。我发现代码的那部分很令人困惑。我也不知道这两个有什么区别,因为spacing
和margin
在语义方面听起来非常相似。否则,酷教程!问题是,各种 Vue 和 React 扩展使代码几乎无法识别为标准 JS。
看起来有些人在这里对您进行自行车棚,我只想说,谢谢,不错的例子,我发现这很有启发!我最近一直在研究 React 中的 SVG,我同意它们是 UI 天堂中的天作之合。期待您的更多作品!