「✍ SVG」

1,038 阅读8分钟

矢量图形

不同与传统的位图(PNG、JPG ...), 矢量图是一种面向对象的图像。矢量文件中的图形元素称为对象。每个对象都是一个自成一体的实体,它具有颜色、形状、轮廓、大小和屏幕位置等属性。

SVG

基本结构

下图是一个svg的基本结构

<svg width='100' heiight='100' xmlns='http://wwww.w3.org/2000/svg'>
  <title>SVG</title>
  <desc>Desc of SVG</desc>
  <!-- ...draw sth !-->
</svg>

我们可以利用图形属性来组合成一个矢量图

基本图形:<rect><circle><ellipse><line><polyline><polygon>

基本属性:fillstrokestroke-widthtransform

具体可参考 MDN-SVG

接下来我们就可以随意组合图形了!

<svg width="500" height="500" xmlns="http://wwww.w3.org/2000/svg">
  <circle
    cx="250"
    cy="80"
    r="50"
    style="stroke: black; fill: aquamarine"
  />
  <rect
    x="200"
    y="130"
    width="100"
    height="100"
    style="stroke: black; fill: blue"
  />
</svg>

分组

除了直接使用基础元素,还可以用<g>标签来定义自己的分组,比如我们想给上面的图形加个眼睛。

<svg width="500" height="500" xmlns="http://wwww.w3.org/2000/svg">
  <circle
    cx="250"
    cy="80"
    r="50"
    style="stroke: black; fill: aquamarine"
  />
  <rect
    x="200"
    y="130"
    width="100"
    height="100"
    style="stroke: black; fill: blue"
  />
  <g id="eye">
    <circle cx="230" cy="70" r="5" style="stroke: black; fill: black" />
  </g>
  <use xlink:href="#eye" transform="translate(40 0)"></use>
</svg>

<g>定义了一个可复用的分组,用<use>可以深度克隆一份分组元素,并且可以重新定义元素样式

路径

<path>大致可以理解成通过描述一个点的运动路径,来绘制图像。首先需要了解以下命令

  • M = moveto - 移动到新的位置
  • L = lineto - 从当前坐标画一条直线到一个新坐标
  • H = horizontal lineto - 画一条水平线到新坐标
  • V = vertical lineto - 画一条垂直线到新坐标
  • C = curveto - 贝塞尔曲线
  • S = smooth curveto
  • Q = quadratic Bézier curve
  • T = smooth quadratic Bézier curveto
  • A = elliptical Arc
  • Z = closepath - 关闭当前路径

:大写为绝对路径,小写为相对路径(相对于上一个M/m)

尝试给上面的小人加个鼻子

<svg width="500" height="500" xmlns="http://wwww.w3.org/2000/svg">
  <circle
    cx="250"
    cy="80"
    r="50"
    style="stroke: black; fill: aquamarine"
  />
  <rect
    x="200"
    y="130"
    width="100"
    height="100"
    style="stroke: black; fill: blue"
  />
  <g id="eye">
    <circle cx="230" cy="70" r="5" style="stroke: black; fill: black" />
  </g>
  <use xlink:href="#eye" transform="translate(40 0)"></use>
  
  <!-- 加个鼻子 !-->
  <path style="fill: #000" d="M 250 85 l -5 10 l 10 0 Z" />
</svg>

来解读一下 M 250 85 l -5 10 l 10 0 Z

M 250 85:绝对路径,标识起点是绝对坐标(250,85)

l -5 10: 相对于上一个坐标的路径,即(245,95)

l 10 0: 相对于上一个坐标的路径,即(255,95)

效果:

defs

上面我们说到<g><use>搭配可以做到元素的复用,但在定义<g>时,就已经完成了一次绘制,如果我们想仅仅定义而不马上使用,可以使用<defs>

<svg width="500" height="500" xmlns="http://wwww.w3.org/2000/svg">
  <circle
    cx="250"
    cy="80"
    r="50"
    style="stroke: black; fill: aquamarine"
  />
  <rect
    x="200"
    y="130"
    width="100"
    height="100"
    style="stroke: black; fill: blue"
  />
  
  <!-- 定义模板 !-->
  <defs>
    <g id="eye">
      <circle r="5" cx="5" cy="5" style="stroke: black; fill: black" />
    </g>
  </defs>
  
  <!-- 使用模板模板 !-->
  <use x="225" y="70" xlink:href="#eye"></use>
  <use x="265" y="70" xlink:href="#eye"></use>
  
  <!-- 加个鼻子 !-->
  <path style="fill: #000" d="M 250 85 l -5 10 l 10 0 Z" />
</svg>

symbol

<symbol>有点像<g>+ <defs>的结合体

<svg width="500" height="500" xmlns="http://wwww.w3.org/2000/svg">
  <circle
    cx="250"
    cy="80"
    r="50"
    style="stroke: black; fill: aquamarine"
  />
  <rect
    x="200"
    y="130"
    width="100"
    height="100"
    style="stroke: black; fill: blue"
  />
  
  <!-- symbol 定义模板 !-->
  <symbol id="eye">
    <circle r="5" cx="5" cy="5" style="stroke: black; fill: black" />
  </symbol>
  
  <!-- 使用模板模板 !-->
  <use x="225" y="70" xlink:href="#eye"></use>
  <use x="265" y="70" xlink:href="#eye"></use>
  
  <!-- 加个鼻子 !-->
  <path style="fill: #000" d="M 250 85 l -5 10 l 10 0 Z" />
</svg>

viewBox

segmentfault.com/a/119000000…

viewBox 简单来说,可以看作一个虚拟的画布

先看常规的svg

<svg style="width:200px; height:400px">
    <circle cx="200" cy="200" r="200" fill="#fdd" stroke="none"></circle>
</svg>

这时cx、cy、r的单位都是绝对像素px,为了装下这个circle,svg的宽高必须不小于400px, 像上面宽度只有200px,就会出现显示不全的情况。这只是一个circle,当svg里写了一堆path,结果发现因为外部宽度不足而导致展示不全,这问题就麻烦了。

因此有了viewBox

<svg style="width:200px; height:400px" viewBox="0 0 400 400">
	<circle cx="200" cy="200" r="200" fill="#fdd" stroke="none"></circle>
</svg>

viewBox有四个参数,一般只用到后两个,即虚拟画布的宽高

添加了viewBox后,cx、cy、r的单位就变成了相对viewBox的相对单位,只要保证不超过viewBox的宽高即可,而svg实际的宽高,我们可以自行在外部定义或给svg写内联style。

<div class="icon-class">
   <svg viewBox="0 0 400 400">
      <circle cx="200" cy="200" r="200" fill="#fdd" stroke="none"></circle>
  </svg>
</div>
<style type="text/css">
  .icon-class{
      width: 1em;
      height: 1em;
  }
</style>

svg变色

color方案

  1. 修改svg的fill属性 fill: currentColor(currentColor会取当前color颜色)
  2. 通过修改color属性即可
svg {
  fill: currentColor;
  color: red;
}

实现自定义svg组件

这一部分我们将参考antd-mobile的icon组件实现,来理解一下如何使用svg来封装icon

基础实现

首先来看看icon组件的核心代码,掐去不重要的部分

const Icon = (props) => {
  useEffect(() => {
    loadSprite(); // important
  }, []);
  const { type, className, size, ...restProps } = props;
  const cls = classnames(
    className,
    'au-icon',
    `au-icon-${type}`,
    `au-icon-${size}`
  );
  return (
    <svg className={cls} {...(restProps as any)}>
      <use xlinkHref={`#${type}`} />
    </svg>
  );
};

最重要的部分应该就是loadSprite方法的执行了,继续看

loadSprite


// svg 列表
const icons: { [key: string]: string } = {
  check:
    '<svg viewBox="0 0 44 44"><path fill-rule="evenodd" d="M34.538 8L38 11.518 17.808 32 8 22.033l3.462-3.518 6.346 6.45z"/></svg>',
    plus:
    '<svg viewBox="0 0 30 30"><path d="M14 14H0v2h14v14h2V16h14v-2H16V0h-2v14z" fill-rule="evenodd"/></svg>',
  minus:
    '<svg viewBox="0 0 30 2"><path d="M0 0h30v2H0z" fill-rule="evenodd"/></svg>',
}

// 装载svg
const svgSprite = (contents: string) => `
  <svg
    xmlns="http://www.w3.org/2000/svg"
    xmlns:xlink="http://www.w3.org/1999/xlink"
    id="__ANTD_MOBILE_SVG_SPRITE_NODE__"
    style="display:none;overflow:hidden;width:0;height:0"
  >
    <defs>
      ${contents}
    </defs>
  </svg>
`;

// 渲染svg
const renderSvgSprite = () => {
  const symbols = Object.keys(icons)
    .map(iconName => {
      const svgContent = icons[iconName].split('svg')[1];
      return `<symbol id=${iconName}${svgContent}symbol>`;
    })
    .join('');
  return svgSprite(symbols);
};

// 加载svg
const loadSprite = () => {
  if (!document) {
    return;
  }
  const existing = document.getElementById('__ANTD_MOBILE_SVG_SPRITE_NODE__');
  const mountNode = document.body;

  if (!existing) {
    mountNode.insertAdjacentHTML('afterbegin', renderSvgSprite());
  }
};

简单来说,loadSprite 就是定义好一堆<symbol>

 <svg
    xmlns="http://www.w3.org/2000/svg"
    xmlns:xlink="http://www.w3.org/1999/xlink"
    id="__ANTD_MOBILE_SVG_SPRITE_NODE__"
    style="display:none;overflow:hidden;width:0;height:0"
  >
    <defs>
      <symbol class="check" viewBox="0 0 1024 1024" id="icon名">
      	<path d="...."></path>
      </symbol>
      <symbol class="plus" viewBox="0 0 1024 1024" id="icon名">
      	<path d="...."></path>
      </symbol>
      <symbol class="minus" viewBox="0 0 1024 1024" id="icon名">
      	<path d="...."></path>
      </symbol>
    </defs>
  </svg>

并插入到html中,外部使用<use>即可调用

这个Icon组件的缺点就很明显了,所有的icon都是手动写到代码中的,扩展性差,事实上,利用webpack我们可以很简单的引入自定义icon并使用。

svg-sprite-loader

svg-sprite-loader 的作用就是把所有svg塞到一个个symbol中

大致步骤:webpack批量读取svg => loader将svg文件塞到symbol中 => 组件引用

1.添加依赖

yarn add svg-sprite-loader

2.添加icons目录, 添加svg文件(icons/svg)和入口(icons/index)

入口inedx

// @ts-ignore
const req = require.context('./svg', true, /\.svg$/);
const requireAll = (requireContext) => requireContext.keys().map(requireContext);
requireAll(req);

这是为了读取所有svg文件到webpack中。

3.添加webpack配置,在config-overrides.js中

module.exports = override(
addWebpackModuleRule({
    test: /\.svg$/,
    include: [resolve('src/icons')], // svg文件目录
    use: [
      {
        loader: 'svg-sprite-loader',
        options: { symbolId: 'icon-[name]' }, // 引用的时候<use xlink:href="#icon-文件名"></use>
      },
    ],
  })
)

这时,loader已经把读取到的svg文件都塞到symbol中了,这时候已经可以这样引用了

<svg>
    <use xlink:href="#icon-check"></use>
</svg>

但是每次都要写use,依然不够优雅,且拓展性不好,因此需要封装一个组件

4.封装组件

import React from 'react';
import PropTypes from 'prop-types';
import './svg-icon.less';

const SvgIcon = ({ iconClass, className, color }) => {
  const styleExternalIcon = {
    mask: `url(${iconClass}) no-repeat 50% 50%`,
    WebkitMask: `url(${iconClass}) no-repeat 50% 50%`,
  };

  const isExternal = (path) => /^(https?:|mailto:|tel:)/.test(path);

  const svgClass = className ? 'svg-icon ' + className : 'svg-icon';

  const iconName = `#icon-${iconClass}`;

  return (
    <div>
      {isExternal(iconClass) ? (
        <div style={styleExternalIcon} className={`svg-external-icon ${svgClass}`} />
      ) : (
        <svg color={color} className={svgClass} aria-hidden="true">
          <use xlinkHref={iconName} />
        </svg>
      )}
    </div>
  );
};

SvgIcon.propTypes = {
  iconClass: PropTypes.string.isRequired,
  className: PropTypes.string,
};

export default SvgIcon;

然后我们就可以这样用了

<SvgIcon color="red" iconClass="check"></SvgIcon>

注:想要实现传递color变色需要删掉svg中的fill属性

用SVG玩一玩动画

一个圆形进度条

首先了解两个属性

stroke-dasharray

用于描述一段虚线的排列方式

stroke-dasharray 只取一个值时,标识每段虚线之间的间隔

image.png

上图就是长度为600的直线stroke-dashoffset分别取20,30,40,60时的表现

当然也可以取多个值

stroke-dasharray = '20, 10' :虚线长20, 间距10,=> 重复 虚线长20, 间距10

stroke-dasharray = '20, 10, 5' :虚线长20, 间距10 => 虚线长5, 间距20 => 虚线10, 间距5 => 循环

然后我们要记住一种特殊情况,stroke-dasharray等于线长的情况,即一段虚线就等于线长,这看起来就是一条实线(下面会用到)

<line
  x1="0" y1="0" x2="50" y2="0"
  stroke="#000"
  stroke-width="5"
  stroke-dasharray="50"
/>

stroke-dashoffset

即虚线开始的偏移量,不管往哪边便宜,由于stroke-dasharray是循环的,所以超过边界的部分都会循环到头/尾部

直线进度

我们将实现一个这样的效果

svg line step.gif

<style>
    .progress {
      animation: rotate 1500ms linear both;
    }
    @keyframes rotate {
      from {
        stroke-dashoffset: 150;
      }
      to {
        stroke-dashoffset: 0;
      }
    }
</style>
<div style="width: 300px;">
    <svg viewBox="0 0 150 150">
        <line
        class="progress"
        x1="0"
        y1="0"
        x2="150"
        y2="0"
        stroke="red"
        stroke-width="20"
        stroke-dasharray="150"
        stroke-dashoffset="150"
        />
    </svg>
</div>

关键点:

1.stroke-dasharray="150" x2="150":上面已经说到,让虚线间隔和线长相等,看起来就像一个实线,但这个实线后面的150,是一段空白,因为间隔是150

2.stroke-dashoffset="150":让线段在开始时向左偏移150,这看起来线段是不见了,是因为看到的是后面的150空白间隔

3.通过动画让.stroke-dashoffset 从150回到0,线段就慢慢出现了

让进度条变成圆形

上面所说的技巧在<circle>中同样适用,stroke-dashoffset的初始值设为周长

<style>
    .progress {
      animation: rotate 1500ms linear both;
      animation-delay: 2s;
    }
    @keyframes rotate {
      from {
        stroke-dashoffset: 471;
      }
      to {
        stroke-dashoffset: 0;
      }
    }
</style>
    <div style="width: 300px;">
        <svg class="circle-step" viewBox="0 0 150 150">
            <circle
              class="progress"
              r="70"
              cy="75"
              cx="75"
              stroke-width="8"
              stroke="#1593FF"
              stroke-linejoin="round"
              stroke-linecap="round"
              fill="none"
              transform = "rotate(-90,75,75)"
              stroke-dashoffset="0px"
              stroke-dasharray="471"   // 471 = 75 * 2 * 3.14
            />
        </svg>
    </div>

transform = "rotate(-90,75,75)" :由于circle中stroke的起始点在3点钟方向,因此如果需要从12点开始,需要做一个90°的逆时针翻转, (75,75)是指定旋转中心坐标为圆心

效果:

svg circle step.gif

把 keyframes 稍微改改也可以变成一个loading !

@keyframes rotate {
  from {
    stroke-dashoffset: 471;
  }
  to {
    stroke-dashoffset: -471;
  }
}

更简单的写法 有没有发现用css3写麻烦得很,用svg自带的animate可以更方便的完成

<div style="width: 300px;">
  <svg class="circle-step" viewBox="0 0 150 150">
    <circle
      r="70"
      cy="75"
      cx="75"
      stroke-width="8"
      stroke="#EAEFF4"
      stroke-linejoin="round"
      stroke-linecap="round"
      fill="none"
    />
    <circle
      class="progress"
      r="70"
      cy="75"
      cx="75"
      stroke-width="8"
      stroke="#1593FF"
      stroke-linejoin="round"
      stroke-linecap="round"
      fill="none"
      transform="rotate(-90,75,75)"
      stroke-dashoffset="471"
      stroke-dasharray="471"
    >
      <animate
        attributeName="stroke-dashoffset"
        attributeType="XML"
        from="471"
        to="0"
        begin="500ms"
        dur="2000ms"
        fill="freeze"
      />
    </circle>
  </svg>
</div>

animate在下面会详细介绍

做成组件

import React, { useRef, useImperativeHandle } from 'react';

export interface IProps {
  process: number; // 进度 0 - 100
  color: string; // 进度条颜色
  duration: number; // 持续时间
  delay: number; // 延迟开始时间
  auto?: boolean; //是否自动执行
}

interface animateRefProps extends SVGAnimateElement {
  beginElement: () => void;
}

export interface forwardRefProps {
  excute: () => void;
}

/**
 *  React.forwardRef<useImperativeHandle Props, 接收的props>
 */
const CircleStep = React.forwardRef<forwardRefProps, IProps>((props, ref) => {
  const {
    process,
    color = '#1593FF',
    delay = 1500,
    duration = 1000,
    auto = true,
  } = props;

  // animate DOM
  const animateRef = useRef<animateRefProps>();

  /**
   * 手动触发
   */
  const excute = () => {
    animateRef.current.beginElement();
  };

  /**
   * 通过ref暴露
   */
  useImperativeHandle(ref, () => ({
    excute: excute,
  }));

  return (
    <div className="cs-wrap">
      <svg className="circle-step" viewBox="0 0 150 150">
        <circle
          r="70"
          cy="75"
          cx="75"
          stroke="#EAEFF4"
          style={{
            strokeWidth: 8,
            strokeLinejoin: 'round',
            strokeLinecap: 'round',
          }}
          fill="none"
        />
        <circle
          className="progress"
          r="70"
          cy="75"
          cx="75"
          style={{
            strokeWidth: 8,
            strokeLinejoin: 'round',
            strokeLinecap: 'round',
            strokeDashoffset: 471,
            strokeDasharray: 471,
          }}
          stroke={color}
          fill="none"
          transform="rotate(-90,75,75)"
        >
          <animate
            ref={animateRef}
            attributeName="stroke-dashoffset"
            attributeType="XML"
            from="471"
            to={`${471 - (process / 100) * 471}`}
            begin={auto ? `${delay}ms` : `indefinite`}
            dur={`${duration}ms`}
            fill="freeze"
          />
        </circle>
      </svg>
    </div>
  );
});

export default CircleStep;

该组件中,begin若设为indefinite, 该animate不会执行,通过animate dombeginElement方法可触发执行,并且可以重复触发。

animate

举个简单的例子

<div style="width: 500px; height: 500px">
    <svg viewBox="0 0 400 400">
        <circle cx="200" cy="200" r="200"  stroke="black" fill="none">
          <animate attributeName="r" attributeType="XML" from="200" to="50" begin="0s" dur="3s" fill="freeze" />
        </rect>
    </svg>
</div>

上图展示了如何描述一个缩小的circle, 下面是常用的animate属性

attributeName

定义发生变化的元素属性名

attributeType

当attributeType="XML"时,attributeName被认为是XML的属性;当attributeType="CSS"时,attributeName被认为是css的属性;不指定attributeType时,默认为"auto",会先将attributeName作为css的属性,如果无效,再将attributeName作为XML的属性。

from & to & by

from和to分别定义发生变化的属性的初始值和终止值。from可缺省,表示初始值即为父元素相应的属性值。可用by替换to,表示变化偏移量。可以理解为to = from + by。

begin & dur & end

begin定义动画开始时间;dur定义动画所需时间;end定义动画终止时间。时间单位h:小时;min:分钟;s:秒;ms:毫秒。默认时间单位为s

fill

当fill="freeze"时,动画终止时,发生变化的元素属性值停留在动画终止时的状态;当fill="remove"时,动画终止时,发生变化的元素属性值回复到动画起始时的状态。fill属性默认值为remove。 允许在同一个元素内嵌入多个