在 SVG 中创建 UI 组件

Avatar of Sarah Drasner
Sarah Drasner

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费积分!

我坚信 SVG 为在 Web 上构建界面开辟了全新的世界。乍一看学习 SVG 似乎令人生畏,但您有一个专为创建形状而设计的规范,同时还提供文本、链接和 aria 标签等元素。您可以使用 CSS 达到一些相同的效果,但要使定位恰到好处则需要更加细致,尤其是在不同视窗和响应式开发中。

SVG 的独特之处在于所有定位都基于坐标系,有点像游戏 战舰。这意味着决定所有内容的位置和绘制方式,以及它们彼此之间的相对位置,可以非常直观地理解。CSS 定位用于布局,这很好,因为您拥有在文档流方面彼此对应的元素。如果您要制作一个非常特别的组件,其中包含重叠且精确放置的元素,那么这种原本积极的特性就难以处理。

真正地,一旦您学会了 SVG,您就可以绘制任何东西,并且可以在任何设备上进行缩放。即使是这个网站也使用 SVG 来制作自定义 UI 元素,例如我上面的头像(元数据!)。

作者图像下方的小半圆只是 SVG 标记。

我们不会在本篇文章中介绍有关 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 内部,我们要创建一条与任务列表长度相同的垂直线。线条相当简单。我们有 x1x2 值(线条在 x 轴上的绘制位置),类似地,还有 y1y2

<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 中的圆圈由三个属性组成。圆圈的中心点绘制在 cxcy, 上,然后我们使用 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 元素开辟一些新的思路。