CSS & JS Effect – 用 SVG 画曲线和箭头
HTML + CSS 画箭头的局限
上上一篇《画箭头》介绍了如何用纯 HTML + CSS 手法来画箭头,它可以做出这类设计:

但是!
遇到这种设计需求,它就不管用了。

这款箭头最大的特色是它那弯曲的箭身。
术语叫 -- 二次贝塞尔曲线(Quadratic Bézier Curve)

CSS 完全不支持这个东东,我们只能靠 SVG 或 Canvas 来实现。
SVG 基础
SVG 是一种特殊的 XML 格式,我们可以通过 elements、attributes 来描述点、线、面、颜色等等。
它和 HTML + Styles 有异曲同工之妙,但更像是在作画,而不是布局。
new SVG
注:一般制作 SVG 会透过 Inkscape 等绘画工具,但我们是学基础,而且只是想画曲线和箭头(太简单),因此不需要劳师动众,直接写代码就可以了。
<svg width="500" height="500" style="outline: 4px solid black;"></svg>
一个 svg element,设置 width height,它就是一个画布(outline 是为了让我们看得见它)。
注:svg 无法像 div 那样依据内容决定高度宽度,它的玩法是先确定画布大小,然后按坐标作画,最后外部可以缩放整个 svg。
效果

我们什么都还没画,因此画布是一片空白。
画矩形 rectangle
<svg width="300" height="300" style="outline: 4px solid black;">
<rect width="80" height="100" fill="pink" x="50" y="100" />
</svg>
<rect> element 表示画一个 rectangle 矩形。
width height 属性定义矩形的宽度和高度。
fill 相当于 CSS 的 background-color,也就是整个矩形的颜色。
x, y 则是它的坐标。
用 CSS 理解的话:<svg> 是 position: relative,<rect> 是 position: ablsolute
x, y 相当于 top 和 left。
效果

画横线 horizontal line
除了 <rect> 以外,还有许多画形状的 elements,比如:<circle> 圆形、<ellipse> 椭圆、<polygon> 多边形等等。
这些就不一一介绍了。
我们聚焦在 <path> 这个 element 就好。
它是最抽象的,什么形状都画的出来。
先试试画一条简单的横线:
<svg width="300" height="300" style="outline: 4px solid black;">
<path d="
m 50 50
h 100
" stroke-width="10" stroke="red" />
</svg>
d 属性是绘画命令(有点类型 Canvas)
m 50 50 意思是把画笔移动到 x:50, y:50 坐标,但它只是移动画笔,还没下笔画哦。
下一个命令 h 100 意思是画一条横线(horizontal line),从当前的 50,50 坐标往右画 100px,这就是横线的长度。
仅靠 d 属性还无法让横线呈现出来,因为它只画了长度,并没有画高度。
接着添加 stroke-width 属性,它负责横线的高度。
我们可以先把它看作是 CSS 的 border-width(当然,有很多不同之处,这个我们接下来慢慢讲)。
stroke 属性则是 border-color -- 边框颜色。
好,总结一下全部绘画命令:
-
移动画笔到 50,50 坐标
-
往右画 100px 横线(横线有了长度)
-
给这条横线添加 border(横向有了高度)
-
再给这个 border 上色
效果

stroke 是左右各一半
仔细看我们会发现

这段白色区域只有 45px,可我们的第一道命令明明是移动到 50,50 坐标,怎么少了 5px 呢?
原因是 SVG stroke 的逻辑和 CSS border 不同。
CSS 的 border 是往内,outline 是往外;而 SVG 的 stroke 则是一半一半。
想象一下,一开始画笔移动到 y: 50 这个位置,这时白色区域有 50px。
接着画横线,这没影响,然后画 stroke 10px,这个 stroke 不是从第 51px 开始往下画 10px。
而是往下画 5px(51 到 55),往上画 5px(50 到 46)。
因此,我们最终看到的结果是 1 到 45 是白色区域,46 到 55 才是红色的线。

另外,也因为这个原因,stroke 最好设置成双数,否则一半一半就会出现小数:stroke 1px 会被渲染成 2px 但是颜色会从红色变成浅红色。
画直线
横线的命令是 h(stand for horizontal),直线则是 v(stand for vertial)
m 50 50
v 100
效果

同样的,换成 x 是 45px,因为 stroke 是一半一半的缘故。
画斜线
<path d="
m 50 50
l 100 80
" stroke-width="10" stroke="red" />
斜线的命令是 l(stand for line)
我们需要提供两个参数,或者可以理解为提供一个点坐标。
l 100 80 的意思是从当前的 50,50 往右 100px,往下 80px,把这个点和 50,50 这个点连起来成为一条斜线。

l 100 80 是 "相对" 写法,换成 "绝对" 写法是 L 150 130 L 变成大写,坐标本来是相对于 50,50 变成绝对 0,0。
stroke の 样式
stroke 可以做一些基本样式,比如圆角、dash 等等:
stroke-linecap
<path d="
m 50 50
h 100
" stroke-width="10" stroke="red" stroke-linecap="butt" />
stroke-linecap 可以填三种值:butt、round、square。
默认是 butt,也就是我们上面看到的样子。
round 长这样:

左右两边变圆润了,同时整条线也边长了。
左边多了 5px,右边也多了 5px。
这 5px 便是 stroke-width 10px 的一半。
stroke-linecap: square 长这样:

它也是变长了,但没有圆润,依然是方形。
stroke-dasharray
stroke-dasharray 用来让线条变成 dash dash dash(相当于 CSS 的 border-style: dashed)
<svg width="300" height="300" style="outline: 4px solid black;">
<path d="
m 50 50
h 200
" stroke-width="10" stroke="red" />
<path d="
m 50 70
h 200
" stroke-width="10" stroke="red" stroke-dasharray="20, 5" />
</svg>
stroke-dasharray="20, 5" 20 是指 dash 的长度,5 是指 dash 的间隔。

画折叠线
<svg width="300" height="300" style="outline: 4px solid black;">
<path d="
m 10 10
l 100 50
" stroke-width="10" stroke="red" />
<path d="
m 10 110
l 100 -50
" stroke-width="10" stroke="blue" />
</svg>
这里画了两条斜线,长这样

它的特色是

两条线交会在同一个点上。
假如我们用一条折叠线来画,它会变成这样:
<svg width="300" height="300" style="outline: 4px solid black;">
<path d="
m 10 10
l 100 50
l -100 50
" stroke-width="10" stroke="red" />
</svg>
l 100 50 之后再接一道命令 l -100 50,它就会继续画下去。
效果

绿线是我自己加上的,它代表我们画线的左边,红色则是 stroke。
黑色的区域是它默认的 fill。
虽然我们只花了两条线,但它已经可以形成一个 "面" 了。
<path fill="none" />
给 <path> 加上属性 fill="none" 把黑色去掉,它就变成了一个箭头

stroke-linejoin
折叠线的交会处要如何画,是由 linejoin 负责。
默认是 stroke-linejoin="miter" 也就是把它画成尖角。
我们对比上一个两条线就一清二楚了

绿线是我加上去的,看出尖角了吗?
stroke-linejoin="round" 是画圆角

它的原理是这样

stroke-linejoin="bevel" 是平角

画二次贝塞尔曲线

关键就是需要提供一个拉力点(或者叫控制点 control point),图中的 P1。
<path d="
m 10 50
q 25 -50 100 0
" stroke-width="5" stroke="red" fill="none" />
q stand for Quadratic Bézier curve
25 -50 是拉力点的坐标
100 0 则是线结尾的坐标
效果

拉力点大概是在绿点那个位置,依照弧度把直线拉弯。
好,SVG 基础会被使用到的,大概就是这些了。接下来,我们准备开始作画吧 🚀。
缩放 – viewBox
<svg width="100" height="100" style="outline: 4px solid black;">
<rect x="10" y="10" width="50" height="50" fill="red" />
</svg>
这是一个 svg 里面有一个 rectangle,它长这样

如果我们把 width height 放大一倍,变成 200,它会变成这样

除了整个 svg 框变大了之外,里面的 rectangle 原封不动。
我们加上 viewBox 属性
<svg width="200" height="200" style="outline: 4px solid black;" viewBox="0 0 100 100">
<rect x="10" y="10" width="50" height="50" fill="red" />
</svg>
viewBox 定义 width 是 100,可 svg 是 200,这意味着放大了一倍。
所有内容的定位和尺寸都要长一倍,等同于我们自己写
<rect x="20" y="20" width="100" height="100" fill="red" />
效果

另外,viewBox 前面两个参数是坐标 x, y,它和缩放没有直接关系,它的用途是限制展现区域。
比如说,viewBox="10 10 100 100",出来的效果是

rectangle 的 x, y 偏移没了,因为被 viewBox clip 掉了。
用 SVG 画箭头
上一 part 我们在介绍折叠线时,粗糙的画了一个箭头做例子。
但,箭头其实有许多细节,这一 part 我们来补上。
用 rect 画箭头
在《画箭头》这篇中,我用了两个 div + transform 的方式来实现箭头。
SVG 也可以采用相同方案,用两个 rectangle + transform(对,SVG 支持 transform)来实现。
<svg width="375" height="250" style="outline: 4px solid black;">
<rect x="125" y="45" width="250" height="80" fill="green" opacity="0.5"
rx="40"
transform="translate(14.641, 65.359) rotate(30, 375, 45)"
/>
<rect x="0" y="95" width="375" height="60" fill="red" rx="30" opacity="0.5" />
<rect x="125" y="125" width="250" height="80" fill="blue" opacity="0.5"
rx="40"
transform="translate(14.641, -65.359) rotate(-30, 375, 205)"
/>
</svg>
效果

虽然是画出来了,但它比 div 版复杂多了。
需要计算 svg width height、rect 的定位 x y、又不能写 variable、calc,总之非常别扭。
这些数具体怎样算我就不讲解了,总之在 svg 就用 svg way,不要试图模仿 div way。
用折叠线画箭头
我们只画箭头就好,箭身就不画了。

红框是整个 svg,红线是 svg 线条。
首先起笔坐标是 40, 40。因为箭头的厚度是 80,中心就是 40, 40。

接着往右下角画斜线,长度是 250,角度是 30°。

这时我们就需要算坐标 x, y 了。
首先,调整一下斜线的长度。
上面我们说是 250,但由于需要做 stroke-linecap="round",这会导致线变长(原理上面讲解过了),因此需要扣掉变长的 80,最后线长是 170。
接着想象一个 right triangle

hypot 是 170,角度是 30°,求 adjacent(也就是 x 坐标) 和 opposite(也就是 y 坐标)。
算式
x = cos(30°) * 170 = 147.224...
y = sin(30°) * 170 = 84.999...
接下来是画回去

角度一样,斜线长度一样,无需再算,用回 147.244... 和 84.999... 就可以了。
<path d="
m 40 40
l 147 85
l -147 85
" stroke-width="80" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
起笔 m 40 40
斜线画到相对坐标 147, 85(我进位了)
继续画斜线到相对坐标 -147 85(往左画,所以是 native)
最后,我们算 svg 的 width height。

40 是箭头厚度 80 的一半
147, 85 是斜线坐标。
全部加一加得出:width 227,height 250。
最终代码
<svg width="227" height="250" style="outline: 4px solid black;">
<path d="
m 40 40
l 147 85
l -147 85
" stroke-width="80" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>
效果

是不是觉得挺简单的?
SVG marker 基础
回到我们的设计需求

它有一条线,这条线是 dash 样式,有二次贝塞尔曲线弯曲。
它有一个箭头,
画线、画箭头,我们都掌握了。
可,线(箭身)和箭头的关系如何绑定,这部分我们还没有学到。
箭头会在线(箭身)的结尾,并且箭头的方向还会配合箭身的曲线调整,让它始终指向一个正确的方向。
这就是箭头与箭身的逻辑关系。
SVG 有一个叫 marker 的东东,专门用来处理这层关系。
<svg width="300" height="300" style="outline: 4px solid black;">
<defs>
<marker id="arrow" markerWidth="2" markerHeight="2">
<rect width="2" height="2" fill="blue" />
</marker>
</defs>
</svg>
<defs> stand for definition,在 <defs> 里的 elements 不会被直接绘画出来。
我们可以把 <marker> 当作是一个局部的 svg 区块 -- 画布里的小画布。
它里面有一个 <rect> 矩形 。
接着我们画一条线,并且把 marker 放到这条线的结尾上。
<path d="
m 10 10
h 100
" stroke-width="10" stroke="red" marker-end="url(#arrow)" />
透过 marker-end="url(#arrow)" 让线和 marker 产生关联。
效果

注:绿线是我们实际画的线条,红色是它的 stroke。
marker 尺寸
先看 <marker> 和 <rect> 的尺寸。
<marker id="arrow" markerWidth="2" markerHeight="2">
<rect width="2" height="2" fill="blue" />
写是写 2,但最终绘画出来却是 20px。
这是因为,默认情况下,<marker> 有自带一个属性 markerUnits="strokeWidth"。
它的意思是,<marker> 里 1 unit 等于它的关联者(线条)的 stroke width。
线条 stroke width 是 10px,<rect width="2" 代表 2 x 10px = 20px,因此最终 rectangle 的宽度就变成了 20px。
如果我们不希望它关联 stroke width,可以设置 markerUnits="userSpaceOnUse",这样 <rect width="2"> 就是单纯的 2px。
marker 定位

marker 的定位规则是这样:marker 的左上角,会对着线(绿线)的结尾。
如上图,线 y 是 10,marker y 也是 10,第 11px 开始是 marker 的内容。
线 x 是 10,线长是 100,marker x 是 10 + 100 = 110,第 111px 开始是 marker 的内容。
注:marker 的定位不受 stroke 影响,它对的是绿线,比如 linecap 会导致红线变长,但这不影响 marker。
透过 refX,refY 属性可以修改 marker 的位置。
<marker id="arrow" markerWidth="2" markerHeight="2" refY="1">

refY="1" 不是 translateY(10px) 哦。
它是改变了 marker 的定位点,从本来的左上角(0, 0)变成了(0,1)。
因此,最后的效果是 marker 往上移了 10px。
context-stroke
除了尺寸、定位,颜色也可以关联 <rect fill="context-stroke" /> 这样 <rect> 的颜色就会和它的关联者(线条)一样。

SVG marker + 箭头
上一 part 为了聚焦 <marker> 我刻意采用了较为简单的 <rect>,这里我们把它换成正式场景需要的箭头。
这里我用 JavaScript 生成箭头 <marker>,这样大家比较容易看得懂它的计算方式
const thickness = 1; // 和线的 stroke 一样厚
const length = thickness * 4; // 随便一个比例,有个长方形就可以了
const rotate = 30;
// 起笔坐标
const start = { x: thickness / 2, y: thickness / 2 };
// 画斜线的相对坐标
const move = {
x: Math.cos(degreeToRadian(rotate)) * (length - thickness),
y : Math.sin(degreeToRadian(rotate)) * (length - thickness)
}
// 箭头 svg 的尺寸
const width = (thickness / 2) + move.x + (thickness / 2);
const height = (thickness / 2) + move.y + move.y + (thickness / 2);
console.log(`
<marker id="arrow" markerWidth="${ width }" markerHeight="${ height }">
<path d="
m ${ start.x } ${ start.y }
l ${ move.x } ${ move.y }
l -${ move.x } ${ move.y }
" stroke-width="1" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</marker>
`);
function degreeToRadian(degree: number): number {
return degree * Math.PI / 180;
}
出来的 <marker> 长这样
<marker id="arrow" markerWidth="3.598076211353316" markerHeight="3.9999999999999996">
<path d="
m 0.5 0.5
l 2.598076211353316 1.4999999999999998
l -2.598076211353316 1.4999999999999998
" stroke-width="1" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</marker>
效果

位置不对,因为默认对接位置是 marker 的左上角对线的结尾,我们需要调 refX 和 refY。
箭头要居中,因此 refY 应是 markerHeight 的一半。
<marker id="arrow" markerWidth="${ width }" markerHeight="${ height }" refY="${ height / 2 }">
<marker id="arrow" markerWidth="3.598076211353316" markerHeight="3.9999999999999996" refY="1.9999999999999998">
效果

我们要确保整个 svg 有足够的空间,线的位置也要正确。
marker 的高度是 4,线 stroke 是 10,乘起来就是 40,这就是 svg 要有的高度
<svg width="300" height="40" style="outline: 4px solid black;">
线在中心,因此线的起笔 y 坐标应该要是 20 -- m 10 20。
效果

接着是算 refX。
如果我们把它的对接点设在最尾巴 -- refX = markerWidth,会长这样

如果我们往前一点点,那箭头就会超出箭身。
总之它们息息相关,我们在调的时候要思考整体。
一个比较日常会用的设置是这样:
给线条加上 stroke-linecap="round",此时箭身会增加长度左右 5px(因为 stroke 是 10px)。
接着 refX = markerWidth - arrow-thickness / 2
最终效果

箭头最前方凸出的圆角刚好和线凸出的圆角相合。
最终 JS 代码
const thickness = 1; // 和线的 stroke 一样厚 const length = thickness * 4; // 随便一个比例,有个长方形就可以了 const rotate = 30; // 起笔坐标 const start = { x: thickness / 2, y: thickness / 2 }; // 画斜线的相对坐标 const move = { x: Math.cos(degreeToRadian(rotate)) * (length - thickness), y : Math.sin(degreeToRadian(rotate)) * (length - thickness) } // 箭头 svg 的尺寸 const width = (thickness / 2) + move.x + (thickness / 2); const height = (thickness / 2) + move.y + move.y + (thickness / 2); console.log(` <marker id="arrow" markerWidth="${ width }" markerHeight="${ height }" refY="${ height / 2 }" refX="${ width - thickness / 2 }"> <path d=" m ${ start.x } ${ start.y } l ${ move.x } ${ move.y } l -${ move.x } ${ move.y } " stroke-width="1" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" /> </marker> `);
最终 HTML 代码
<!-- width = line-length + stroke-width(因为 stroke-linecap) height = markerHeight * stroke-width --> <svg width="110" height="40" style="outline: 4px solid black;"> <defs> <marker id="arrow" markerWidth="3.598076211353316" markerHeight="3.9999999999999996" refY="1.9999999999999998" refX="3.098076211353316"> <path d=" m 0.5 0.5 l 2.598076211353316 1.4999999999999998 l -2.598076211353316 1.4999999999999998 " stroke-width="1" stroke="red" fill="none" stroke-linecap="round" stroke-linejoin="round" /> </marker> </defs> <!-- m (stroke-width / 2) (markerHeight * stroke-width / 2) --> <path d=" m 5 20 h 100 " stroke-width="10" stroke="red" stroke-linecap="round" marker-end="url(#arrow)" /> </svg>
二次贝塞尔曲线
我们把线条弯曲,看看它对箭头有何影响。
<path d="
m 80 80
q 25 -100 200 0
" stroke-width="10" stroke="red" stroke-linecap="round" marker-end="url(#arrow)" fill="none" />
效果

完全没有影响,箭头原封不动。但,这样不行啊,超丑的。
解决方式很简单,给 <marker> 加上属性 orient="auto" 就可以了,它会依据线条的弧度自行调整。

总结
本篇讲解了如何使用 svg 画曲线和箭头。
要实现这个设计

我们需要使用 JavaScript,获取特定点对点的坐标(用 getBoundingClientRect),然后计算动态生成 svg 即可。

浙公网安备 33010602011771号