SVG + react: respinner 的诞生

Slide: https://speakerdeck.com/huozhi/cool-staff-of-svg

计划开始之前

很早之前看到过许多诸如 spinkit、 loader.css、 spin.js 一大堆实现 loading 效果的库,思路基本上是用各种 div 定位到一个icon 大小的 grid 内,再对他们进行不同的 css animation 动画设置。有的用 js 设置,比如 spin.js 算好差不多的动画,插一段 style 到 html 里,而一些 loader.css 之类的东西,则很多写死了 width,position,颜色,包括动画参数等,不易于扩展。
突然就想到可不可以用 react 做一般用 props 来控制 loading 动画效果的组件。(以下都称为 spinner

那么独立组件需要什么属性?(考虑到有含有多个子元素的 spinner)

  • 子元素个数
  • 子元素大小、间距、颜色
  • 单个元素动画,总时长和每个间隔时长

其实基本上能看出我们需要 count, size, gap, color, animation time 这些属性。先用一个空数组 map 出一堆 div 元素,再把 style 赋上去就好了?

// loader 1
<div>
  {repeat(3).map(() => (
    <div style={someAnimationStyle} className="Dot" />
  ))}
</div>

看起来就好了?有点太糙了,本质其实和 loader.css 那样的 demo 差不多,没有改变什么。子元素的形状多种多样,用 div 来模拟真的合适么?突然想到了 svg。

SVG + React

2003 年诞生,SVG 历史也比较悠久了,基本的元素类型有:Rectangles, Circle, Ellipse, Line, Polyline, Polygon, Path。这些东西控制起来要好很多,比如有 Circle 的时候你就不用写 div + border-radius 来模拟圆,看起来也比较直观。

SVG 生成的也是 dom,只不过很多需要设置 props 比如 x, y, r, fill, stroke, stroke-width...,在我们用 css 解决各种样式、大小的时候,确实有点不方便,但当你用了 react props,是不是突然感觉这种书写方式就是你需要的?

respinner demo

我提供了 6 种比较基础的 spinner,我们对每个进行分析一下实现思路:

  1. 点用 <circle> 来做,有数量,间距大小,每个元素动画是 scale
  2. 1 个 circle,改变 dash-array 和 dash-offset
  3. 几个 <rect>,动画控制 translateY
  4. 2 个 circle,rotate 就行了
  5. 这个有趣了,有点像 iOS 上的加载动画,看起来是几个 rect 做成的粗线,每个 rect 其实有一个中心点,依靠 x, y 属性定位。因为都在圆上,用三角函数可以解决这个问题,先全部移动到圆的中心,然后在这个基础上,x + sin(Θ) r,y + cos(Θ) r,移动到对应位置,然后对每个 rect 旋转一定角度。动画是轮流 fade in。
  6. 多个 circle 不断 scale

在第 5 个形状的制作上,svg 可以用 x, y 方便地进行定位,不需要 position,不需要计算 top, left, 个人觉得比 div 方便不少。
对于每个不同的形状,你还可以用 fill、stroke 等去改变颜色,以及 strokeWidth 可以改变粗细,linecap、rx、ry 可以帮你控制圆角,全部都依赖 props,定义更加自由。

SVG 另一种动画实现方式

animateTransform 可以帮你做 svg 动画,在 <svg> 里写一个 <animateTransform> 元素,用属性控制动画,如下:

<polygon points="60,30 90,90 30,90">
  <animateTransform attributeName="transform"
                    attributeType="XML"
                    type="rotate"
                    from="0 60 70"
                    to="360 60 70"
                    dur="10s"
                    repeatCount="indefinite"/>
</polygon>

不过这种动画有点奇怪,也并不是很灵活,像 google 那种旋转的 loading 效果,stroke-dasharray 和 stroke-dashoffset 就比这个更容易完成操作。mdn 上 svg animation 相关的文档也不是很全,最终还是选了 css animation,感觉写起来更简单。唯一没想好的是,每个 animation-delay 还是要写到 style 里,看上去略丑。

手动拼图

大多时候当我们创建 svg icon 搭配动画的时候,我们可能会选择使用设计提供的 svg, 然后自己给他们加上动画。loading 其实一般情况下都不需要特别复杂的设计,大多用于内容显示的过渡,能控制形状、颜色、动画时间、大小,其实基本满足需求,respinner 的初期版本就是这样的目标。

也引出了一些思考,比如 x 这种用于表达 close、cancel 的 svg,是不是也可以前端直接尝试自己做,而不需要设计提供。复杂图像组合为单个 path 是有利于上色,作为整体交互,而比较简单的 icon, 如果能保留里面多个 path 的 dom 结构不进行合并,反而也很容易对更细节处控制,比如一个圈里面包裹一个 check 符号,希望外层内层颜色不同,当然就要分别控制两个 dom 节点。

以后会尝试画画别的有趣的东西。留一下 respinner 的 demo
repo: https://github.com/huozhi/respinner

其他扩展

之前 yingwei 有一篇 wiki,讲如何画 SVG Icon,有哪些注意的,老主站 Icon 替换。所有的 Icon 最终都会画成一个 <path>,线条简单,便于生成体积小,可以缩放的 SVG。

接着我们的设计团队出了一套 SVG Icon,包括 logo、close、arrow 等 Icon,前端用 React 包装一下,使用起来更加容易。但在使用中,我遇到了一些问题,如 X 这样代表的 close Icon,做成一个 path 后,stroke-width 并不能直接控制粗细,在 sketch 里它已经不是两条线或者两个 <rect>,只是一个 combined shape。放大缩小后,粗细不变都会显得略微尴尬。

这类 Icon 还有许多,如 + 等,都是图形简单,需求希望控制细节。那是不是可以用 svg 直接拼出来这样的简单图形呢?

拿 close 举例,可以用两条粗线交叉画出来,如果用 <rect>,粗细只能用 width 控制,svg width 只会控制大小,不会直接作用在它的粗细上面,用 <line> + stroke-width 更加符合语义,也易于控制。

那最终的 SVG 其实就是

<svg>
  <g transform=...> <!-- transform 属性可以用来做一些如 rotate 之类的转换 -->
    <line x1=.. y1=.. x2=.. y2=..>
    <line x1=.. y1=.. x2=.. y2=..>
  </g>
</svg>

我在 reicons 项目里给出了一种 Cross 组件的实现

具体要用什么样式(包括粗细、颜色等)的 Icon,用属性来操控

有一种理想的情况,就是一个 Icon 是一个文件,这样用起来会比较容易,对一些希望进行扩展的 SVG Icon 可以在对应文件里单独做,如 responsive、animation 效果等。

不过打包起来可能就会有两种情况

情况 1

- Icon
| - index.js # 入口文件
| - components/ # Icon 组件
  | - Cross.js
  | - ...
// index.js

const Icon = ({name, ...props}) => (
  <SVG src={require(`./${name}`)} {...props} />
)

使用:<Icon name="xxx" />

情况 2

直接饮用文件,使用:

import CrossIcon from 'lib/Icon/Cross'

<CrossIcon ... />

后者更加灵活,可以有诸多细节控制,但前者更加容易,如果你的场景本身就比较简单。当然,前者还可以想办法把 svg 和 js 进行分离,比如有某个仓库只放 svg,你可以引用不同的 svg 集合,统一入口引用。

Welcome to discuss!