ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用tiny-svg实现SVG批量优化与前端组件代码生成

2026/10/7 16:08:45 拓冰建站 浏览量
用tiny-svg实现SVG批量优化与前端组件代码生成 设计师给了一套数据可视化项目的图标几十个 SVG 文件全部是 Figma 直接导出的。打开一看fillnone、stroke-linecapround、空g组、一堆没用的命名空间声明还有一个 800 多行的文件实际画的就是一个简单的折线图。直接在项目里引用这些文件包体积先不谈浏览器解析和渲染阶段的开销也够喝一壶的。我需要一个能批量处理 SVG、顺手还能生成前端组件代码的工具。tiny-svg 作为解析核心配合我自己写的优化规则搭了一套命令行管线整个过程从最初的手工清理到现在一条命令搞定值得完整记录下来。这个工具解决的是前端日常都会撞到的问题拿到手的 SVG 往往带着设计工具的冗余产物而手写正则去洗数据又极容易翻车。tiny-svg 提供了稳定的解析与信息提取能力让我可以在 DOM 层面做精确操作再配合模板生成 React 或 Vue 组件代码。适合所有需要批量管理 SVG 图标、需要把 SVG 转化为前端代码的前端开发者参考。下面我把选型逻辑、核心 API、工具搭建、踩坑记录和工程化接入一次讲清楚。1. 为什么我选了 tiny-svg 而不是继续用 SVGO1.1 两条路线的分岔点先说结论SVGO 和 tiny-svg 不是同一个层面的东西选型之前必须分清楚。SVGO 是重型优化器它做的事是深度压缩合并路径、精简属性、调整precision精度、移除metadata插件体系庞大配置项多得能写一本书。它的默认配置是为了追求极致体积所以在很多场景下行为是激进的。我之前用一个默认配置跑一套图标结果把几个动画 SVG 的时间轴属性全删了动画直接失效排查了半个下午才发现是cleanupIds和removeUnknownsAndDefaults这两个插件的组合拳。tiny-svg 正好相反。它是 svg.js 生态里做基础解析的小工具定位非常克制把 SVG 字符串变成可操作的 XML DOM同时提取出宽度、高度、viewBox 这类关键信息。它不负责压到最小而是负责把数据洗干净、把结构搞清楚。我需要的恰恰是这种可控性——我希望优化规则由我自己定义而不是被某个工具的默认策略绑架。1.2 手写解析函数的痛tiny-svg 恰好补上了在决定用 tiny-svg 之前我试过自己写正则去提取 SVG 的 viewBox、宽度、高度。一开始觉得挺简单不就是几个正则吗实际跑起来全是边界情况viewBox可能出现单引号、可能出现多余空格、可能大小写混合、可能根本没有、还可能藏在symbol上而不是svg上。宽度width100%和width100的处理逻辑完全不同。tiny-svg 的extract接口把这些脏活全包了。它内部对 SVG 字符串做了标准化解析返回的 viewBox 信息是处理过的数值数组宽度高度也有对应的类型判断。我后面搭工具时所有依赖尺寸信息的逻辑都建立在extract的返回值上再也没为这个 SVG 到底多大这种事操过心。1.3 两者怎么配合才合理我现在的工作流是双轨制。日常维护的图标库优先用 tiny-svg 跑我自己的优化管线因为规则透明、行为可预期、出了问题能快速定位到某一条规则。只有遇到那种几百个 path 节点需要深度合并的极端场景我才会单独对某个文件跑一次 SVGO而且只启用我需要的插件比如mergePaths和convertPathData。简单说SVGO 是承包人你告诉它目标它替你把活干完但中间过程你不完全可控。tiny-svg 是脚手架它给你一个干净的工作台面具体怎么裁、怎么剪是你自己的事。2. tiny-svg 核心 API 与优化规则拆解2.1 extract一次调用拿到尺寸、viewBox 和基础信息extract是 tiny-svg 里最常用的接口。它接收一个 SVG 字符串返回一个结构化对象。我的工具里第一步做的就是这个import { extract } from tiny-svg; const source fs.readFileSync(icon.svg, utf-8); const info extract(source); console.log(info); // { width: 24, height: 24, viewBox: [0, 0, 24, 24] }注意返回的viewBox是一个数组不是字符串。这个细节在实际处理中非常有用因为生成 React 组件时需要把 viewBox 拼到 JSX 的viewBox0 0 24 24里数组直接join( )就行了不用再做字符串解析。还有一个容易被忽略的点如果 SVG 里没有width、height属性extract会从viewBox推导尺寸反之亦然。这种互相兜底的逻辑帮我处理了不少 Figma 导出时的怪癖。有次同事给我一个文件viewBox写的0 0 512 512宽度高度全是缺失的extract依然返回了正确的 512 和 512。2.2 parse 与 stringify以 DOM 为中心的优化思路extract负责读信息parse和stringify负责真正的内容处理。parse把 SVG 字符串转成 XML DOMstringify再转回去。这个组合给了我在结构化层面操作 SVG 的能力而不是停留在正则替换的粗糙阶段。import { parse, stringify } from tiny-svg; const doc parse(source); // doc 是一个标准的 XMLDocument // 我可以自由地 querySelector、遍历子节点、删除节点、修改属性 // 处理完之后转回字符串 const optimized stringify(doc);为什么要以 DOM 为中心因为很多优化动作本质是结构性的删除空的分组、把fill属性从父节点下沉到子节点、去掉没有任何作用defs。这些操作对正则来说极其痛苦但在 DOM 上就是一次遍历加几个条件判断的事。我后来还把parse出来的 DOM 直接交给document.importNode用做浏览器里的实时预览一套解析逻辑前后端通用这也是 tiny-svg 零依赖特性带来的好处。2.3 我自己整理的六条优化规则tiny-svg 本身不提供规则集规则是自己写的。我在项目里沉淀了六条最常用、效果最明显的规则删除空节点遍历所有g、path、rect如果没有任何子节点且没有必要的属性比如fill、stroke直接删掉。去除冗余命名空间把xmlns:serif、xmlns:xlink这类设计工具自动加的命名空间声明清掉能省不少字符。规范化颜色值把#FFFFFF统一成#fff把rgb(0,0,0)转成#000实测能把属性长度压掉近一半。移除不可见元素opacity0的节点、displaynone的节点直接移除留着只会拖慢渲染。清理无用的>// optimize-svg.mjs import fs from node:fs; import path from node:path; import { parse, stringify, extract } from tiny-svg; const inputDir process.argv[2] || svg-source; const outputDir process.argv[3] || dist-svg; function optimizeContent(svgString) { const doc parse(svgString); const info extract(svgString); // 规则1删除空节点 doc.querySelectorAll(g, path, rect, circle, ellipse, polygon).forEach(node { const hasChildren node.children.length 0; const hasAttrs node.attributes.length 0; if (!hasChildren !hasAttrs) { node.remove(); } }); // 规则2规范化颜色简化版 doc.querySelectorAll([fill], [stroke]).forEach(node { [fill, stroke].forEach(attr { const value node.getAttribute(attr); if (value #FFFFFF) node.setAttribute(attr, #fff); if (/^#([0-9a-fA-F]{6})$/.test(value)) { const hex value.slice(1); // 能缩写成3位的就缩写 if (hex[0] hex[1] hex[2] hex[3] hex[4] hex[5]) { node.setAttribute(attr, # hex[0] hex[2] hex[4]); } } }); }); return stringify(doc); } function walkDir(dir) { const results []; fs.readdirSync(dir).forEach(file { const full path.join(dir, file); const stat fs.statSync(full); if (stat.isDirectory()) { results.push(...walkDir(full)); } else if (file.endsWith(.svg)) { results.push(full); } }); return results; } const files walkDir(inputDir); let totalSaved 0; files.forEach(file { const raw fs.readFileSync(file, utf-8); const optimized optimizeContent(raw); const relative path.relative(inputDir, file); const target path.join(outputDir, relative); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, optimized, utf-8); const saved Buffer.byteLength(raw) - Buffer.byteLength(optimized); totalSaved saved; console.log(${relative}: ${saved 0 ? -${saved} bytes : no change}); }); console.log(Total saved: ${totalSaved} bytes);这版脚本我故意写得朴素没有封装插件机制也没有抽象规则类。原因很简单工具刚起步时越朴素越容易让人看懂和修改。等规则数量稳定了再考虑抽象成配置项也不迟。3.3 把优化后的 SVG 转成 React/Vue 组件代码生成是工具的另一半价值。优化的 SVG 最终要能用在前端项目里我直接在优化管线后面挂了一个模板渲染器。React 组件长这样// 由工具自动生成请勿手动修改 import React from react; function LineChartIcon(props) { return ( svg width{props.size || 24} height{props.size || 24} viewBox0 0 512 512 fillnone xmlnshttp://www.w3.org/2000/svg {...props} path d... stroke{props.color || #111827} strokeWidth{2} / /svg ); } export default LineChartIcon;生成逻辑很简单把优化后的 SVG 字符串解析成 DOM然后遍历顶层节点的属性把width、height替换成来自 props 的动态值其他属性原样透传到 JSX 里。这里最常被人忽略的一个点width和height不要写死在组件里一定要让使用者可以通过sizeprop 控制否则一个 512x512 的图标在 16px 的按钮里会非常尴尬。Vue 组件的生成逻辑完全一样只是模板语法不同。我做了配置文件让用户切换{ template: react, svgSource: svg-source, svgOutput: dist-svg, componentOutput: src/components/icons }3.4 输出报告每次优化省了多少字节脚本跑完会输出一个统计报告。别小看这个环节它直接影响团队是否愿意使用这个工具。没有数据支撑同事很难感知优化到底有没有用。我的报告会列出每个文件的原始大小、优化后大小、节省比例最后汇总总数。实际跑一次之后效果非常直观。一套 80 个图标原始总量 486KB优化后 351KB平均减少 28%。多数体积来自 Figma 导出的冗余属性和无效节点颜色规范化也贡献了不少。数据一出来团队里反对接入的声音基本就没了。4. 批量优化时翻车的五个场景4.1 动画 SVG 被优化掉了我第一次用这套管线处理设计师给的动态图标时翻了个大跟头。规则里有一条删除空节点但我没考虑到 SMIL 动画里animate和animateTransform这些元素本身没有实际渲染内容它们只是用来驱动其他节点运动的。结果一条规则下去动态图标全变成了静态的。animate被当成空元素删了个干净begin、dur、repeatCount这些关键属性也连带没了。修法很直接删空节点的判断条件必须排除 SMIL 动画相关元素。const ANIMATION_TAGS new Set([animate, animateTransform, animateMotion, set]); function isEmptyNode(node) { const tagName node.tagName.toLowerCase(); if (ANIMATION_TAGS.has(tagName)) return false; return !node.children.length !node.attributes.length; }这个教训后来被我写进了工具注释里任何规则在动手前先确认自己的业务场景里有没有动画 SVG别一视同仁地清理。4.2 id 冲突导致渐变和滤镜互相污染做雪碧图是另一个翻车高发地。多个 SVG 合并成一张 sprite 的时候每个文件里的linearGradient id...、clipPath id...、filter id...会全部混在同一个全局作用域里。如果两个文件当时都用idgradient那渲染结果就是相互污染A 图标明明用的是红色渐变实际画出来却是绿渐变。这个问题不是 tiny-svg 独有的SVGO 的cleanupIds插件也处理过类似的事情。我的方案是在生成雪碧图之前给每个文件的内部 id 加前缀function prefixIds(doc, prefix) { const allNodes doc.querySelectorAll([id]); allNodes.forEach(node { const oldId node.getAttribute(id); node.setAttribute(id, ${prefix}-${oldId}); }); // 还要同步处理引用这些 id 的 url(#...) 和 href doc.querySelectorAll([fill*url(#], [stroke*url(#], [clip-path*url(#], [filter*url(#]).forEach(node { [fill, stroke, clip-path, filter].forEach(attr { const value node.getAttribute(attr); if (!value) return; node.setAttribute(attr, value.replaceAll(/url\(#([^)])\)/g, (match, id) url(#${prefix}-${id}))); }); }); }这个 bug 的隐蔽性在于单独打开任何一个 SVG 都是正常的只有合成之后才出问题。排查的时候一度以为是雪碧图工具的问题后来才定位到 id 冲突。4.3 viewBox 与宽高信息丢失有一次我拿到一批缺胳膊少腿的 SVG只有viewBox没有width和height。我的工具在生成 React 组件时把width和height从顶层节点上取出来作为默认值取不到时直接写了undefined渲染出来的图标在页面上完全消失。后来发现extract其实已经处理过这种场景了它会从 viewBox 推导出宽高。我在生成组件时应该优先使用extract的返回值而不是直接读属性。const info extract(svgString); const width info.width || 24; const height info.height || 24;类似的问题还有viewBox缺失但宽高存在的情况这种文件缩放必然变形。工具里现在会直接给出告警提醒人工介入。4.4 class 与 style 被粗暴清理我早期版本的规则里有一条删除所有class属性。理由是 SVG 内的 class 大多是设计工具的图层名对渲染没有意义。直到有同事接了一个用 CSS 控制图标状态的项目我需要让图标支持class传入外部样式。实际上class在现代前端项目里是一个重要的接口Tailwind CSS 可以用class给 SVG 的局部元素上色CSS 动画也需要选择器命中内部节点。现在默认行为是保留 class只清理 design Tool 常见的前缀而不是一刀切删除。4.5 反复优化导致第二次几乎不生效有次我为了验证效果把优化后的 SVG 又跑了一遍工具发现体积只少了 0.3%。这算是个好消息说明规则具备幂等性但依然让我警惕。幂等性在批量任务里非常重要因为团队里可能出现多个同事先后跑工具的场景。如果第二次跑出来的结果和第一次差异巨大说明规则里存在相互打架的逻辑。我现在的做法是在 CI 里固定跑一遍工具如果 git diff 显示还有未优化的内容就让流水线失败确保进入主分支的 SVG 都是已经处理过的成品。5. 接入前端工程化的完整姿势5.1 用 npm script 串联优化和代码生成工具脚本最终要暴露给团队使用最省心的方法是挂到 npm script 上。我的 package.json 里是这么配的{ scripts: { icons:optimize: node tools/optimize-svg.mjs svg-source dist-svg, icons:generate: node tools/generate-components.mjs dist-svg src/components/icons, icons:build: npm run icons:optimize npm run icons:generate, icons:watch: node tools/watch-svg.mjs svg-source } }icons:watch是我后来加的一个小脚本原理很简单用fs.watch监听 SVG 源目录文件变化时自动执行优化和生成。这个命令做设计对接时非常好用设计师改完图保存前端这边组件代码瞬间就更新了。5.2 在 Vite 里写一个本地插件npm script 的方式适合手动触发但如果团队希望源码里的 SVG 自动被处理那最好还是接进构建工具。Vite 的插件机制很轻量写一个本地插件只需要几行代码// vite-svg-plugin.mjs import { readFile } from node:fs/promises; import { optimizeContent } from ./optimize-core.mjs; export default function viteSvgOptimize(options {}) { const include options.include || /\.svg$/; return { name: vite-svg-optimize, async transform(src, id) { if (!include.test(id)) return null; const raw await readFile(id, utf-8); const optimized optimizeContent(raw); return { code: export default ${JSON.stringify(optimized)}, map: null }; } }; }这里最关键的设计是让 Vite 插件复用优化核心。我把optimizeContent抽成了独立的optimize-core.mjsCLI 脚本和 Vite 插件都从那里引入同一个函数。这样保证命令行处理的产物和构建时处理的产物表现完全一致不会出现本地跑出来是优化过的线上构建出来却是原样这种诡异问题。5.3 pre-commit 钩子与 CI 校验工程化的最后一环是自动化校验。我用了 husky 加 lint-staged在提交前对 SVG 文件做自动优化{ lint-staged: { *.svg: [ node tools/optimize-svg.mjs --inline ] } }--inline模式表示原地覆盖文件把优化结果直接写回源文件。这样做的好处是团队成员在 commit 的时候只要动了 SVG提交进仓库的内容就自动是优化过的版本根本不存在忘了跑工具的情况。CI 里则反过来只检查不修改node tools/optimize-svg.mjs --check svg-source如果发现还有可优化的内容体积差超过阈值命令以非零退出码结束流水线直接报红。这种本地自动修、CI 强制查的组合在团队协作里很实用既不给每个人增加操作负担又保证了仓库里的 SVG 始终是干净状态。在我自己维护的图标库里这套工具已经稳定跑了快半年。最开始设计的时候我也纠结过是不是直接用 SVGO 就够了现在回头看SVGO 解决的是把体积压到最小的问题而 tiny-svg 配合自定义规则解决的是按我们团队的标准把 SVG 处理成可维护的组件源码的问题。两种场景都有价值但作为前端工程的一部分后者跟业务代码的贴合度显然更高。最后再分享一个小技巧不要动原始的设计稿 SVG。我所有的工具都在源文件目录和产物目录之间做隔离源文件永远保留设计师交出时的原始状态优化和生成只发生在产物目录里。这样设计师改起稿来不费劲前端也随时能用最新产物重新生成代码两边不会因为文件被自动化脚本改过了而产生任何摩擦。工具的价值从来不在于它有多复杂而在于它让团队的协作链路顺滑了多少。