主题
SVG 概览
<svg> 是浏览器里唯一的矢量场景图:你用标记描述图形,浏览器保存这棵对象树,并在任意缩放下重新光栅化。它既不是位图,也不是 Canvas 那样的绘制 API —— 每个图形都是能被 CSS 选中、被 JS 改属性、被读屏读取的 DOM 节点。
本页先把“SVG 是什么、怎么放进页面、坐标系长什么样”建立起来,再给出整节的地图。
1. 矢量 vs 位图
| 维度 | 矢量(SVG) | 位图(PNG/JPEG/WebP) |
|---|---|---|
| 缩放 | 无损,永远锐利 | 放大出现锯齿/模糊 |
| 体积 | 与图形复杂度相关,与显示尺寸无关 | 与像素数量相关 |
| 内容 | 文本可选中、可搜索、可被 CSS/JS 修改 | 一坨像素,无法局部修改 |
| 动画 | 元素属性/形状可动画(SMIL、CSS、JS) | 只能整图变换 |
| 适合 | 图标、图表、地图、插画、线框 | 照片、复杂纹理、截图 |
一句话选型
“要缩放不失真、要逐元素交互”选 SVG;“像素很多、每帧都在变”选 Canvas。 图标这类小而需要主题换色的图形,几乎没有理由不用 SVG。
2. 保留模式:改属性,而不是重绘
SVG 是保留模式(retained mode):浏览器记住整棵场景树,你只负责改属性。
js
circle.setAttribute('r', '40') // 浏览器自己决定重绘哪些像素
circle.style.fill = 'currentColor' // 也能走 CSS1
2
2
对比 Canvas 的立即模式:那边画完即忘,想改一个圆必须清屏重画。代价是 SVG 节点数一多,样式与布局的计算成本会明显上升(经验值:几千个节点仍然流畅,上万就开始吃紧)。
3. 三种引入方式与能力边界
| 方式 | 写法 | 内部能被 CSS 控制 | JS 能访问内部节点 | 能继承 currentColor | 备注 |
|---|---|---|---|---|---|
| 内联 | <svg>…</svg> 直接写进 HTML | ✅ | ✅ | ✅ | 唯一能被完全控制的方式 |
<img> | <img src="icon.svg"> | ❌ | ❌ | ❌ | 会被浏览器缓存;不能脚本化 |
| CSS 背景 | background-image: url(icon.svg) | ❌ | ❌ | ❌ | 可配合 mask-image 做换色 |
最常见的“为什么改不动颜色”
把 SVG 当图片引入(<img> / background-image)后,它就是一个不透明的替换元素,外部 CSS 无法进入其内部,JS 也拿不到它的 contentDocument(跨源时更严格)。需要 .icon:hover { fill: red } 这种能力,就必须内联。
下面这段就是最朴素的“内联 + CSS 变量”:内联 SVG 会继承 color,因此只需要换掉文字颜色,图形就跟着换色。
4. 命名空间:HTML 里看不见的那一层
HTML 解析器遇到内联 <svg> 时会自动切换到 SVG 命名空间,所以你手写标记不需要写 xmlns。但用 JS 创建元素时必须显式指定:
js
const SVG_NS = 'http://www.w3.org/2000/svg'
const circle = document.createElementNS(SVG_NS, 'circle') // ✅ 会渲染
const wrong = document.createElement('circle') // ❌ HTMLUnknownElement,不渲染1
2
3
2
3
下面这个示例把两种写法并排放在同一个 <svg> 里,并用 namespaceURI 与 getBBox() 给出证据。
用 JS 创建 SVG:命名空间对照左右两边 DOM 结构相同,只有命名空间不同
examples/svg/hello-svg.jsjs
/**
* hello-svg.js —— 用 JavaScript 创建 SVG
* 演示:createElementNS 命名空间、createElement 的失效对照、图形追加与属性修改、
* 以及用 getBBox()/namespaceURI 判断“这个节点到底渲染了吗”。
*/
import { createPanel } from '../shared/ui.js'
const SVG_NS = 'http://www.w3.org/2000/svg'
export default function (container) {
const wrap = document.createElement('div')
wrap.className = 'demo-dom'
wrap.innerHTML = `<svg viewBox="0 0 460 230" style="width:100%;max-width:460px;height:auto;display:block;margin:0 auto"></svg>`
const svg = wrap.querySelector('svg')
container.appendChild(wrap)
/** 正确的写法:createElementNS + 完整命名空间。 */
const ns = (tag, attrs = {}) => {
const el = document.createElementNS(SVG_NS, tag)
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value))
return el
}
svg.appendChild(ns('rect', { width: 460, height: 230, fill: '#0b1220' }))
svg.appendChild(ns('line', { x1: 230, y1: 20, x2: 230, y2: 210, stroke: '#1e293b', 'stroke-dasharray': '4 6' }))
svg.appendChild(ns('text', { x: 16, y: 26, fill: '#94a3b8', 'font-size': 13 })).textContent =
'createElementNS(SVG_NS, …) → 正常渲染'
svg.appendChild(ns('text', { x: 246, y: 26, fill: '#94a3b8', 'font-size': 13 })).textContent =
"createElement('rect') → 不渲染"
// 左半边:用命名空间 API 创建的一组图形
const good = ns('g', { transform: 'translate(20 60)' })
good.appendChild(ns('rect', { x: 0, y: 0, width: 70, height: 70, rx: 12, fill: '#7aa2ff' }))
good.appendChild(ns('circle', { cx: 130, cy: 35, r: 30, fill: 'none', stroke: '#22d3ee', 'stroke-width': 6 }))
good.appendChild(
ns('path', { d: 'M170 70 L200 5 L230 70 Z', fill: '#f472b6', 'fill-opacity': 0.85 })
)
svg.appendChild(good)
// 右半边:故意用 HTML 的 createElement —— 节点进了 DOM,但没有 SVG 命名空间
const bad = ns('g', { transform: 'translate(250 60)', opacity: 0.9 })
for (const [tag, attrs] of [
['rect', { x: 0, y: 0, width: 70, height: 70, rx: 12, fill: '#7aa2ff' }],
['circle', { cx: 130, cy: 35, r: 30, fill: 'none', stroke: '#22d3ee', 'stroke-width': 6 }]
]) {
const el = document.createElement(tag) // ← 错误示范:HTMLUnknownElement
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value))
bad.appendChild(el)
}
svg.appendChild(bad)
const note = ns('text', { x: 230, y: 222, fill: '#64748b', 'font-size': 12, 'text-anchor': 'middle' })
note.textContent = '两边的 DOM 结构一样,只有命名空间不同'
svg.appendChild(note)
const panel = createPanel(container, { title: 'DOM 与命名空间', position: 'below' })
const readout = panel.readout('')
const bbox = (el) => {
try {
const box = el.getBBox()
return `getBBox() = ${box.width.toFixed(0)}×${box.height.toFixed(0)}`
} catch (err) {
return 'getBBox() 抛错(未渲染)'
}
}
const describe = (label, el) => `${label}: <${el.tagName}> ns=${el.namespaceURI} · ${bbox(el)}`
function report() {
const leftRect = good.firstChild
const rightRect = bad.firstChild
readout.set([describe('左 rect', leftRect), describe('右 rect', rightRect), `svg 子节点数:${svg.childNodes.length}`].join('\n'))
}
report()
panel.note('打开 DevTools 的 Elements 面板对比两边:右边虽然也在 <svg> 里,但没有 SVG 命名空间,所以不参与绘制。')
panel.button({ label: '再追加一个圆(左)', onClick: () => {
const r = 12 + Math.random() * 8
good.appendChild(ns('circle', { cx: 60 + Math.random() * 150, cy: 20 + Math.random() * 40, r, fill: '#a3e635', 'fill-opacity': 0.7 }))
report()
} })
panel.button({ label: '刷新统计', onClick: report })
return () => {
panel.dispose()
wrap.remove()
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
5. 坐标系:y 轴向下,viewBox 才是主角
SVG 的用户坐标系与 Canvas 一致:原点在左上角,x 向右,y 向下(这一点和数学课本相反,画折线图时尤其要注意取负号)。
<svg> 上有两个互相独立的尺寸概念,务必分清:
| 属性 | 作用 | 单位 |
|---|---|---|
width / height | 视口(viewport)在页面里占多大 | 长度:px、%、em |
viewBox="minX minY width height" | 内部坐标系的范围,以及如何映射到视口 | 无单位的用户单位 |
width="300" height="150" viewBox="0 0 24 24" 的含义是:内部用 24×24 的坐标系作画,最终缩放 12.5 倍画进 300×150 的框里。没有 viewBox 时用户单位就等于 CSS 像素,缩放会变成“裁剪”而不是“缩放”。
6. 一个最小可用骨架
把下面这段贴进任何 HTML 文件就能看到结果 —— 它包含了后面每一页都会反复用到的四件事:viewBox、内联命名空间、分组、currentColor。
html
<svg viewBox="0 0 120 120" width="120" height="120" role="img" aria-labelledby="t1">
<title id="t1">一个圆环与一段弧</title>
<g fill="none" stroke-width="10" stroke-linecap="round">
<circle cx="60" cy="60" r="44" stroke="rgba(127,161,255,0.25)" />
<path d="M60 16 A44 44 0 0 1 100 84" stroke="currentColor" />
</g>
<text x="60" y="66" text-anchor="middle" font-size="22" fill="currentColor">75%</text>
</svg>1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
用 JS 生成同样的内容时,唯一的变化是必须带命名空间:
js
const NS = 'http://www.w3.org/2000/svg'
const circle = document.createElementNS(NS, 'circle')
circle.setAttribute('cx', 60)
circle.setAttribute('cy', 60)
circle.setAttribute('r', 44)
svg.appendChild(circle)1
2
3
4
5
6
2
3
4
5
6
7. 本节地图
| 页面 | 内容 | 关键示例 |
|---|---|---|
| 基础图形 | rect / circle / ellipse / line / polyline / polygon / path 与描边填充属性 | 描边虚线动画 |
| 路径 path 详解 | M L H V Z C S Q T A 全部命令、贝塞尔控制点直觉 | 控制点探索器 |
| 视口、viewBox 与坐标 | preserveAspectRatio、嵌套 svg、坐标映射换算 | 映射对比面板 |
| 文本与图像 | text/tspan/textPath、image、foreignObject | 文本排版与图像适配 |
| 渐变与图案 | linearGradient / radialGradient / pattern | 渐变参数面板 |
| 变换与分组 | transform 顺序、嵌套 g、getCTM | 变换顺序对比 |
| 滤镜与特效 | filter 图元、feGaussianBlur、feColorMatrix、region 裁剪 | 滤镜参数面板 |
| SVG 动画 | SMIL / CSS / WAAPI / rAF 四种方案对比 | 同一图形三种驱动 |
| 交互、样式与脚本 | pointer-events、use+symbol、路径采样、可访问性 | 图标复用与路径采样 |
8. API 速查(最常用的一小撮)
| 元素/属性 | 说明 |
|---|---|
viewBox="x y w h" | 定义用户坐标系;缺省时用户单位 = CSS 像素 |
preserveAspectRatio | 宽高比不一致时的对齐与缩放方式,默认 xMidYMid meet |
fill / stroke | 填充与描边;值为 none / 颜色 / url(#id) 引用 paint server |
stroke-width / stroke-linecap / stroke-linejoin | 描边宽度、端点、拐角样式 |
stroke-dasharray / stroke-dashoffset | 虚线模式与相位(虚线动画的核心) |
opacity / fill-opacity / stroke-opacity | 整体透明度 vs 分别控制 |
transform="translate() rotate() scale()" | 元素级变换,原点默认是用户空间原点 (0,0) |
<defs> + <use href="#id"> | 定义一次、多处复用 |
<g> | 分组:共享变换与样式,但不产生自己的裁剪 |
role="img" + <title> | 可访问性所需的替代文本 |
9. 常见坑与排查
坑 1:以为 width/height 会缩放内部坐标系
现象:改了 width 图形没变大,只是被裁掉/留白。 原因:没有 viewBox 时,用户单位就等于 CSS 像素,改 width 只是改了可见窗口大小。 解决:想缩放就加 viewBox="0 0 原始宽 原始高",再用 CSS 控制 width。
坑 2:忘了 y 轴向下
现象:折线图上下颠倒、text 的 y 设成 0 结果文字跑到画布外。 原因:SVG 的 y 轴向下,y=0 是顶边;文字基线也遵循同一规则。 解决:要么在数据映射时取反(y = height - value),要么给内层 <g transform="scale(1,-1)">(此时文字也要再翻回来)。
坑 3:<use> 的 xlink:href 是历史包袱
xlink:href 属于 XML 时代写法,需要额外的 xmlns:xlink="http://www.w3.org/1999/xlink" 声明。现代浏览器都支持 SVG 2 的裸 href,新代码直接写 href="#id" 即可;只有在支持 IE/老 Safari 时才需要两个都写(href 在前,xlink:href 兜底)。
排查顺序
图形不显示时按这个顺序查:① 元素是否在 SVG 命名空间里?② fill/stroke 是否恰好都是 none?③ 坐标是否落在 viewBox 之外?④ 是否被 filter/clip-path 的 region 裁掉了?⑤ 祖先元素是否 display:none 或 visibility:hidden?
10. 工作流与工具
| 环节 | 常见选择 | 注意 |
|---|---|---|
| 设计导出 | Figma / Illustrator / Inkscape | 导出后清掉编辑器私有属性(sodipodi:*、inkscape:*)与无用的 <metadata> |
| 体积优化 | SVGO 等优化器 | 默认会删 viewBox(危险)、把 id 改短(会破坏 url(#id) 引用),按项目配置 |
| 图标管理 | 内联 sprite(<symbol> 集合) | 跨文件 <use href="icons.svg#id"> 在 Chromium 不可用,需构建期注入 |
| 交付形态 | 图标用内联、插画用 <img> | 需要 CSS/JS 控制就必须内联,见第 3 节 |
| 调试 | 浏览器 DevTools 的 Elements 面板 | 直接改属性/样式看效果最快;"Computed" 面板能看出哪条规则赢了 |
先用 DevTools 做实验,再写进代码
SVG 的所有属性与 CSS 规则都能在 Elements 面板里实时改。调坐标、试 preserveAspectRatio、验证 fill-rule,比反复改文件刷新快得多。
11. 小结
- SVG = 矢量 + 保留模式 + DOM:三个特点决定了它“适合图标图表、不适合百万像素”。
- 要能换色、要能交互,就必须内联引入。
- JS 创建节点一律
createElementNS,createElement造出来的东西不会渲染。 - 先把
viewBox想清楚,再谈缩放与适配 —— 这是后面几页所有内容的基础。