做设计开发的时候,最烦的不是写功能,而是收集资源。最近在做一个设备管理类的后台项目,需要一整套电池图标:不同电量档位、充电状态、省电模式、有线无线混合,粗算下来少说五六十个图标。一开始想找设计师要,结果人家排期一周起;想从网上下载,一个页面一个页面地右击另存为,手指都快抽筋了,而且下载下来的文件名乱七八糟,还得一个个改。于是我决定自己写一个基于 JavaScript 的图标批量下载工具,把废旧时间换成喝咖啡的时间。
这篇文章就分享一下这个“电池图标批量下载器”的开发思路和完整落地过程。用一句话概括:在 Node.js 环境里写一个脚本,从公开图库拉取批量电池图标,自动分类、命名、去重,并转换成多倍尺寸的 PNG 供项目直接使用。适合所有被重复性下载工作折磨的前端、客户端开发和 UI 工程师参考。我自己实测下来,五六十个图标从手工下载的“半小时起步”压缩到了几十秒,文件名还干净得像处女座亲手整理过一样。
1. 电池图标为什么需要批量下载
1.1 一个典型的开发需求场景
先还原一下需求是怎么来的。那套设备管理后台的界面里,首页要展示一批“在线设备”,每个设备旁边要有一个电池电量指示条,查下来需要这些状态的图标:
- 电量:0%、25%、50%、75%、100%,至少要五档,考虑到低电量告警可能还要单加一档“低于 10%”;
- 状态:充电中、放电中(正常)、省电模式、电池异常、未检测到电池;
- 风格:页面上有亮色背景区域,也有暗色图表卡片,所以同一套图标最好有填充版和线框版两种 visual weight;
- 尺寸:给前端用的图标基本都是 1x、2x、3x 三套 PNG,按 16、24、32 的基准尺寸向外扩。
把这些条件做个排列组合,需求直接奔着四五十个文件去。如果算上后续迭代可能用到的“无线充电”“已充满”这类特殊状态,轻松突破七十个。这时候如果不批量处理,在图标网站上手动搜索、下载、改名、导尺寸,没有一整个下午根本下不来。
1.2 手抄式下载的三个痛点
手动下载看着简单,但真做起来有硬伤。
第一是命名混乱。从图库网站另存为得到的文件名往往是随机字符串,比如5f3a2c9e1b8d.svg或者download-132.svg。直接扔进项目,要么依赖母命令强行加 alias,要么忘掉这是什么东西。几周后回来看,自己都会怀疑“这个 5f3a2c9e 到底是什么”。
第二是重复劳动。同一套图在网站上可能是 16、24、32、48、64 多个尺寸,但 PNG 下载回来就是一堆相似文件。你会在“到底保留哪个”上浪费大量时间,最后往往全留下——项目体积就这么膨胀起来的。
第三是精准匹配难。网站上预览图容易有版权提示、水印、背景网格,真正下载下来才发现 SVG 内部带有不需要的元素,还得手工清理。一条条核对非常考验耐心。
这类任务只干一次还好,要是以后做第二个、第三个项目还要用同款图标,那就纯粹是灾难。写个批量下载脚本,本质上是把这段重复劳动一次性封存。
2. 技术方案选型与整体设计
2.1 为什么是 JavaScript 而不是 Python 或 Shell
做批量下载很多人第一反应是 Python,requests 加 BeautifulSoup 确实生态丰富。但这次我有三个理由非 JavaScript 不可。
一是项目本身是前端后台,团队里大家都能读写 JS,交付后别人接替维护的成本低。二是新写的自动化脚本可以直接复用项目中已装的 Node 依赖(比如sharp做图像处理),不用引入 Python 运行环境。三是在调试阶段我可以把下载逻辑和浏览器里的实际渲染结果对照着调,保持技术栈统一,排查时省一份心智负担。
当然,如果只是批量拉文件不改格式,Shell 的curl+ 循环也可以。但这套流程还要做名称映射、倍图生成、内容去重,用 Shell 写条件判断和字符串处理会很痛苦。JavaScript 的数组、对象方法让这些操作非常顺手,天然适合做“数据结构 + 流程编排”的活。
2.2 落地环境与依赖准备
我用的是Node.js 18+版本,因为 18+ 内置了全局fetch,可以少引一个请求库,脚本更轻。本机没有 Node 的话,直接去官网下 LTS 版本即可,不需要额外编译。
实际用到的第三方依赖只有四个:
| 包名 | 作用 | 为什么选它 |
|---|---|---|
sharp | SVG 转 PNG、缩放多倍图 | 底层是 libvips,处理 SVG 极其快,且无需额外安装 GraphicsMagick |
p-limit | 控制并发下载数量 | 原生 Promise 无法限制并发,这个库极小且稳定 |
fs-extra | 文件操作增强 | ensureDir、copy这些方法好用,省去大量手写递归 |
iconv-lite | 处理历史遗留的 GBK 编码页面 | 有些图库站点响应头是 UTF-8,内容却混着 GBK 字符,防乱码要兜底 |
项目初始化非常简单:
mkdir battery-icon-downloader cd battery-icon-downloader npm init -y npm install sharp p-limit fs-extra iconv-lite2.3 下载源的选择策略
这一步很多人会纠结:到底从哪里批量拿 SVG?我踩过几次坑之后,推荐首选提供“直接复制路径”功能的公开 SVG 图库,而不是去爬整个网页。原因有两点:
一是稳定性。公开图库(比如以 SVG 文件命名的开源图标集 CDN)通常有稳定的 URL 规则,只要找到命名规律,批量拼地址就能下载。二是版权相对省心。开源图标集的 license 一般允许商用,但要仔细看条款,有些要求署名,有些要求修改后以相同方式共享,不能直接抄。
我这次用的图标源,是某个开源图标仓库的纯 SVG 子集,文件名也是battery-{档位}-{状态}.svg这种有规律的命名。下面给出 URL 模式的抽象表示,具体域名需要你自己替换成可用的图库地址:
https://your-icon-host.example/cdn/icons/battery-{level}-{status}.svg例如:
https://your-icon-host.example/cdn/icons/battery-100-charging.svg https://your-icon-host.example/cdn/icons/battery-50-saver.svg选定源之后,批量下载的整个基调就定了:核心是“拼地址、限并发、做校验、改名字、转格式”。这五个动作消耗了本文后续百分之八十的代码量。
3. 核心模块拆解与关键代码实现
3.1 数据清单设计:下载前先把要什么列清楚
很多人写下载脚本上来就写循环,这是个坏习惯。我习惯先声明一份“需要哪些图标”的清单数据。效果有两层:一是改需求时只维护清单,不用动核心逻辑;二是后续对接 CI 或 UI 稿配置时,清单可以直接导出成 JSON 给设计系统用。
我的清单设计大致长这样:
// icon-manifest.js const batteryLevels = [0, 25, 50, 75, 100, 10]; // 10 是额外低电量告警档 const batteryStates = [ { id: 'idle', label: 'normal' }, // 放电/普通 { id: 'charging', label: 'charging' }, // 充电 { id: 'saver', label: 'saver' }, // 省电模式 { id: 'error', label: 'error' }, // 电池异常 ]; module.exports = { batteryLevels, batteryStates };然后用这个清单生成真正的 URL 列表:
const { batteryLevels, batteryStates } = require('./icon-manifest'); const ICON_BASE_URL = 'https://your-icon-host.example/cdn/icons'; function buildDownloadList() { const tasks = []; for (const level of batteryLevels) { for (const state of batteryStates) { const fileName = `battery-${level}-${state.label}.svg`; tasks.push({ url: `${ICON_BASE_URL}/${fileName}`, fileName, level, state: state.label, }); } } return tasks; }为什么要单独抽象出buildDownloadList,而不是直接写双层循环去下载?因为下载完之后的重命名、去重、倍图处理全都要用到level和state这两个字段。把它们提前结构化,后面的过滤、排序、统计代码会干净很多。
3.2 并发下载器:不能三秒打爆图标服务器
下载看起来就是fetch套循环,其实最大的坑是并发控制。如果一次性把几十个请求全发出去,本地网卡没问题,但对方服务器很可能直接给你返回 403 或者把你 IP 封掉。真实项目里,尤其后端接口不是自家控制时,一定要克制。
p-limit的用法很简单,建立一个并发数为 5 的限流器,然后把每个下载任务包进限流器执行:
const pLimit = require('p-limit'); const limit = pLimit(5); // 同时最多 5 个下载请求 const files = []; async function downloadOne(task) { const response = await fetch(task.url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; IconBatch/1.0)' }, signal: AbortSignal.timeout(15000), // 15 秒超时 }); if (!response.ok) { throw new Error(`${task.url} -> HTTP ${response.status}`); } const buffer = Buffer.from(await response.arrayBuffer()); const filePath = path.join(DOWNLOAD_DIR, task.fileName); await fse.outputFile(filePath, buffer); return filePath; } async function downloadAll(taskList) { const results = await Promise.allSettled( taskList.map((task) => limit(() => downloadOne(task))) ); return results; }这里有几个细节值得说明。
AbortSignal.timeout(15)是 Node 18 里内置的请求超时方案,比setTimeout手动清理请求要简洁。15 秒看起来长,但在弱网环境下 SVG 文件本身只有几 KB,其实非常够用。如果超过 15 秒还在传输,绝大多数情况是连接被劫持或服务器卡死,继续等只会更糟。
Promise.allSettled而不是Promise.all的关键在于:下载任务单条失败不应该拖垮整批任务。比如某个图标在源仓库里恰好缺失(404),我们希望记录失败原因,继续下载其它图标,最后统一生成报告,而不是让脚本中途崩溃。
3.3 重试机制:一次失败不代表永久失败
网络请求总有偶发超时。遇到 5xx、网络抖动,直接放弃有点亏。我加了一层小重试,思路比较简单:每个任务最多尝试 3 次,重试间隔按 500ms、2000ms、5000ms 指数退避。这里所谓“指数退避”就是每次失败后等待时间翻倍,和 4 倍、8 倍的激进策略相比,这套更平稳,不让下一次请求恰好在服务器抖动期撞上去。
顺便说一句,不是所有错误都该重试。比如 HTTP 404 —— 源文件根本不存在,重试一百次也白搭。此时应该记录失败原因直接跳过。我在代码里对错误类型做了区分:
const RETRYABLE_CODES = new Set([408, 429, 500, 502, 503, 504]); async function downloadWithRetry(task, retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { try { return await downloadOne(task); } catch (err) { const isRetryable = RETRYABLE_CODES.has(err.statusCode); if (!isRetryable || attempt === retries) throw err; const delay = 500 * 2 ** (attempt - 1); await sleep(delay); } } }这个“可重试错误码”的筛选逻辑非常重要。很多时候我们把网络脚本写成“凡失败必重试”,结果一只不存在的文件导致脚本多花了几分钟,纯粹浪费时间。
3.4 SVG 转 PNG 与多倍图生成
下载下来的battery-100-charging.svg是矢量图,前端可以直接用 SVG,但很多桌面客户端和移动端图标组件只吃 PNG。所以需要批量转换。
我在转换环节用sharp,原因是它对 SVG 的渲染非常细致,可以指定背景透明、密度(density)和缩放倍数,一步到位:
const sharp = require('sharp'); async function convertToPng(svgPath, outputDir, size) { const outputName = path.basename(svgPath, '.svg') + '.png'; const outputPath = path.join(outputDir, outputName); await sharp(svgPath, { density: 300 }) .resize({ width: size, height: size, fit: 'contain' }) .png({ compressionLevel: 9 }) .toFile(outputPath); return outputPath; }这里的density: 300是我非常推荐加上的一个参数。SVG 本身是矢量,但 sharp 在光栅化时内部有默认 DPI,如果不把 density 调高,小尺寸图标边缘容易发虚。调高 density 再 resize 到目标尺寸,相当于先渲染出超采样图片再缩小,锯齿不明显。实测下来,同样一个 24px 的 PNG,用 density 300 的边缘明显比默认值更平滑。
多倍图生成就是一个尺寸映射循环:
const scaleFactor = { '1x': 24, '2x': 48, '3x': 72, }; for (const [label, size] of Object.entries(scaleFactor)) { const outDir = path.join(OUTPUT_DIR, `png/${label}`); await fse.ensureDir(outDir); await convertToPng(filePath, outDir, size); }3.5 画幅校验:不要让空文件流入项目
下载最隐蔽的坑是“下载成功但内容不对”。比如某些 CDN 对 404 会返回一个 HTML 错误页,但 HTTP 状态码是 200;又比如 SVG 文件本身内容是空的,或者只有一行<!-- placeholder -->。如果直接拿去转换,sharp 可能生成一张全透明空图,合入工程后眼睛却看不见,排查起来非常浪费时间。
所以下载完成之后我加了一道“校验关卡”。
第一层是文件大小。小于 200 字节的 SVG 大概率异常,直接删除并计入失败清单。第二层是SVG 标签检查。必须包含<svg标签且存在<path或<circle之类的图形节点,否则判定无效。第三层是sharp 实际渲染检查。尝试渲染成 PNG,如果结果尺寸是 0×0,说明 SVG 内部结构有问题。
function validateSvg(filePath) { const content = fs.readFileSync(filePath, 'utf8'); if (content.length < 200) return { ok: false, reason: 'file-too-small' }; if (!content.includes('<svg') || !/(<path|<circle|<rect|<polygon)/.test(content)) { return { ok: false, reason: 'invalid-svg-body' }; } return { ok: true }; }这道校验在批量场景里尤其重要——它把“质量检查”从人眼判断变成了程序判断,哪怕后面图片量增长到几百个,也不会漏掉任何一个异常文件。
4. 交付目录与命名规范
4.1 输出目录结构设计
一次完整的批量下载跑完后,我期望看到的目录是这样的:
battery-icon-downloader/ ├── downloads/ │ ├── battery-0-normal.svg │ ├── battery-25-normal.svg │ ├── battery-100-charging.svg │ └── ... ├── output/ │ ├── svg/ │ │ ├── battery-0-normal.svg │ │ └── ... │ ├── png/ │ │ ├── 1x/ │ │ │ ├── battery-0-normal.png │ │ │ └── ... │ │ ├── 2x/ │ │ │ └── ... │ │ └── 3x/ │ │ └── ... │ └── manifest.json └── failed.log这里我有意区分了downloads/和output/svg/。downloads/是原始素材,保留 URL 原名,万一后续需要回溯可以找到源头;output/是经过命名规范化、校验、转换的最终产物。这个区分在返工时特别好用——设计师改了命名规则,我只要重新跑一遍整理流程,不用重新下载。
4.2 命名规范:从源头消除歧义
文件名本身就是一份元数据。我定的规则是小写字母 + 连字符:
battery-{电量}-{状态}.svg比如:
battery-0-normal.svg battery-25-charging.svg battery-10-error.svg这样在 IDE 里按文件名排序,电量档位从低到高一目了然;前端代码里引用时,也能直接从业务状态映射到对应文件名,不需要额外的翻译表。
有人喜欢用下划线或者驼峰,比如battery_100_Charging,在跨平台项目里容易出问题。Windows、macOS、Linux 对大小写敏感程度不一样,团队协作时最容易翻车。全小写 + 连字符是最稳妥的通用规范,CDN 上一般也只推荐这种命名方式。
4.3 去重策略:同样内容的图标只留一份
批量下载过程中,很可能遇到两个不同 URL 但内容几乎完全相同的文件。不处理的话,产物体积白白膨胀。我用的去重方案比较简单但有效:按文件内容的 MD5 指纹去重。
下载完扫描downloads/目录中的所有 SVG,计算哈希:
const crypto = require('crypto'); function calcMd5(filePath) { const content = fs.readFileSync(filePath); return crypto.createHash('md5').update(content).digest('hex'); }同一份 SVG 如果内容和另一个文件名不同但 hash 相同,判定为重复文件,仅保留命名更规范的那个。不过要注意,MD5 去重只能处理字节级完全相同的重复。如果两个 SVG 内容等价但元素顺序不同,长度不同,则不会判重。更好一点的方案是把 SVG 的 path 数据用库做归一化后再哈希,比如清洗标签属性、统一小数位,这次改动成本较高,我认为对这次使用场景来说 md5 已经够用了。
输出manifest.json时,我同时把去重信息写进去:
{ "battery-100-charging.svg": { "source": "battery-100-charging.svg", "md5": "ab3f19c4...", "duplicates": ["battery-100-charging-copy-2.svg"] } }这个清单对后续接设计系统、审查交付情况都很方便。
5. 全流程整合与实战运行
5.1 把下载、校验、转换串成一个管道
前面各模块是零件,现在把它们组装成一个完整脚本。我推荐用一个pipeline.js统一调度,而不是把这些逻辑散在入口文件里:
// pipeline.js const path = require('path'); const fse = require('fs-extra'); const { buildDownloadList } = require('./icon-manifest'); const { downloadAll } = require('./downloader'); const { validateSvg } = require('./validator'); const { convertToPng } = require('./converter'); const { calcMd5, writeManifest } = require('./utils'); async function main() { const taskList = buildDownloadList(); const DOWNLOAD_DIR = path.join(__dirname, 'downloads'); const OUTPUT_DIR = path.join(__dirname, 'output'); const failedLog = []; await fse.ensureDir(DOWNLOAD_DIR); await fse.ensureDir(OUTPUT_DIR); // 第一步:批量下载 const results = await downloadAll(taskList); const successFiles = []; results.forEach((res, index) => { if (res.status === 'fulfilled') { successFiles.push(res.value); } else { failedLog.push({ url: taskList[index].url, reason: res.reason.message }); } }); // 第二步:校验去重 const validFiles = []; const hashSet = new Set(); for (const file of successFiles) { const check = validateSvg(file); if (!check.ok) { failedLog.push({ file, reason: check.reason }); continue; } const hash = calcMd5(file); if (hashSet.has(hash)) { failedLog.push({ file, reason: 'duplicate-content' }); continue; } hashSet.add(hash); validFiles.push(file); } // 第三步:整理并转换 const svgOutputDir = path.join(OUTPUT_DIR, 'svg'); const png1xDir = path.join(OUTPUT_DIR, 'png/1x'); const png2xDir = path.join(OUTPUT_DIR, 'png/2x'); const png3xDir = path.join(OUTPUT_DIR, 'png/3x'); await fse.ensureDir(svgOutputDir); for (const file of validFiles) { // 规范重命名 const normalized = normalizeFileName(file); // 具体逻辑见后文 await fse.copy(file, path.join(svgOutputDir, normalized)); // 生成多倍图 await convertToPng(file, png1xDir, 24); await convertToPng(file, png2xDir, 48); await convertToPng(file, png3xDir, 72); } // 第四步:输出报告 await writeManifest(validFiles, OUTPUT_DIR); fse.writeFileSync('failed.log', JSON.stringify(failedLog, null, 2)); console.log(`完成:成功 ${validFiles.length} 个,失败 ${failedLog.length} 个`); } main().catch(err => { console.error('管线执行中断', err); process.exit(1); });5.2 命名规整函数:用元数据反推文件名
normalizeFileName是个容易被人忽略但很关键的模块。因为源 URL 的文件名可能带后缀版本号,比如battery-100-charging-2a01.svg,或者特殊前缀ic_battery_100_charging。我们希望最终产物统一成battery-100-charging.svg。
这种情况下,我直接利用taskList里面已经存好的level和state字段来构造文件名,而不是解析原始文件名。任务列表是结构化数据,下载前就已经知道每个文件代表什么。规整函数写起来就很简单:
function normalizeFileName(file) { const task = taskLookup[path.basename(file)]; if (!task) return path.basename(file); return `battery-${task.level}-${task.state.label}.svg`; }这对“为什么提前要有一份清单”给出第二个理由:清单不仅仅是待办列表,还是文件命名的唯一数据源。有了它,任何下载回来的文件都能映射回自己的语义。
5.3 运行输出与实际效果
运行命令就一行:
node pipeline.js看到控制台输出“完成:成功 62 个,失败 2 个”的时候,我就可以去output/png/2x目录里直接拖出图片压缩进项目。整个流程从零开始到拿到可用的图标,大概 40 秒。如果只有网络快的地儿,20 秒就能完成。
这里的让脚本重跑能力也非常重要。第一次跑失败的两个文件,我在failed.log里看到原因是“URL 拼写不规范导致的 404”。修改了battery-level参数表之后,重新执行node pipeline.js,这次是完全幂等的——已经成功下载的文件会被重写,但最终产物是一致的。所谓“幂等”就是说不管跑多少遍,只要输入不变,输出保持一致,不会产生额外垃圾文件。
5.4 把这些能力暴露给非技术同事
考虑到团队里可能有不会敲命令的 UI 同学,我顺手包了一层简单的命令行交互。用readline让用户输入要下载的电量档位和状态集合,比如:
? 电量档位(逗号分隔): 0, 10, 25, 50, 75, 100 ? 状态列表(逗号分隔): normal, charging, saver, error收集完参数后自动调buildDownloadList()。这样设计师拿到工具,不用打开icon-manifest.js也能直接改配置下载自己那套图标。
6. 常见问题与排查实录
6.1 高频异常速查表
实际跑脚本过程中最容易遇到的几个问题,整理成一张速查表:
| 异常现象 | 可能原因 | 处理方式 |
|---|---|---|
| 所有请求都返回 403 | 目标服务器有防盗链检查 | 补充User-Agent与Referer头,或检查是否被限流 |
下载成功但sharp转出来全透明 | SVG 只有占位注释,图形为空 | 启用 SVG 内容校验,删除占位文件并人工核实 |
| 部分文件下载 404 | 图库版本更新导致路径变更 | 检查清单中的档位与状态是否符合新规则 |
| 控制台输出乱码 | 页面/文件名是 GBK 编码 | 用 iconv-lite 解码,或统一转成 UTF-8 |
| 脚本跑到一半内存暴涨 | 并发设置过高 | 降低pLimit的并发数,比如从 10 改为 3 |
| 文件名大小写与引用不一致 | 源文件本身就是大写命名 | 规整为全小写,并在清单中做映射 |
6.2 一个真实排查案例
我遇到过一次非常有意思的问题。所有图标下载都很顺利,但生成的 PNG 放到后台界面上,有接近三分之一是“带灰色底纹”的。肉眼看到的是图标边缘有一层淡淡的阴影,放到白色背景下很突兀,放到暗色背景下又看不见了。
排查过程大概是这样的:先怀疑是 sharp 转换参数不对,反复调整背景透明度没有效果;然后怀疑是源 SVG 本身带样式,用文本编辑器打开downloads/battery-0-normal.svg一看,发现源 SVG 里自带一个fill-opacity不为 0 的背景矩形层和一条描边 shadow,在网页预览时由于是白色页面,看不出来,但一旦渲染到透明背景就露馅。
解决方案也很粗暴:在 validator 里增加一个 DOM 级清洗流程,如果检测到 SVG 根节点下第一层存在rect或path但是颜色接近白色,就自动从 SVG 内容中剥离该节点,再输出到干净产物目录。这算是非常“脏活累活”的经验,但恰恰是这类细节决定了交付图能不能直接进设计系统。
6.3 后续扩展方向
如果这套工具不只是给自己的项目用,而是想沉淀成团队的通用脚本,可以再扩展几块能力:
一是把来源从“固定图标库”扩展成“任意符合规则的 URL”。维护一份 adapter 清单,比如“该图库的 URL 规则是什么、需要哪些请求头、命名映射怎么拿”。这样切换资源供应商时只加 adapter,核心管线完全不用动。
二是支持自动生成 SVG Sprite。前端切换图标时,合并成雪碧图可以减少 HTTP 请求。用svg-sprite库可以把output/svg下的所有图标合并成一个battery-icons.svg,并输出对应的符号引用symbol信息。
三是把失败重试策略做成可配置。某些内网环境下,重试次数和超时时间需要单独调,做成config.js里的字段比写死在函数里灵活得多。
写在最后的体会
这套批量下载工具做完之后,我的体会是:一切“找一个文件、改一个名、导一张图”的手工重复动作,都值得用脚本封装起来。表面上看,写脚本花了一两个小时,好像不如直接复制粘贴来得快。但批量任务的真正成本从来不在单次操作,而在重复次数和出错后的返工。我把同样思路扩展到团队里其他几个资源下载场景,比如天气图标、设备状态图标,都被证明非常划算。
另外也想提醒一句:批量下载一定要克制。控制并发、做好重试、校验内容,是对目标服务器负责,也是对自己的交付质量负责。脚本跑完几秒钟很简单,难的是下载下来的每一个文件都敢直接合进项目里。如果你正准备做类似的资源批量收集,别一上来就写复杂的爬虫解析,先把“清单”“下载”“校验”“命名”“转换”这五个环节的边界理清楚,照着这个框架走,很快就能跑出一条稳定的流水线。