前言
最近在做小程序开发的过程中,遇到了一个很头疼的问题,项目中使用了很多icon类的图标,因为体积不大,就没有使用cdn来访问,而是通过打包到本地来使用的。但是小程序有包体积的限制,因此每次新需求开发时,新加入的图标就需要在tinypng中压缩一下,操作感觉也挺繁琐
因此实现了一个基于tinify的图片批量压缩cli工具,仓库地址img-squash-cli
该项目是用来学习使用的,主要为了减少工作中重复的图片压缩操作,不做商业化使用。因此,如果要商用,请购买正版服务tinypng
工具介绍
常用图片压缩工具tinypng介绍
tinypng是一款高性能的图片压缩工具,压缩后的图片质感肉眼看不出来差异。对于前端开发来说,在很多大量使用图片的场景中,为了优化访问性能,最简单的就是压缩相关图片资源来达到减小资源体积的目的
tinypng常见的优点:
- 图片压缩质感好
- 对外API
- 使用方便,一定数量下免费,可批量压缩
tinypng的缺点:
- 一个月只能免费压缩500张
- 免费压缩的情况下图片有大小限制(5M)
- 批量压缩的情况下,存在数量限制(20张)
- 可压缩类型较少(JPG与PNG)
上面说了那么多的缺点,但是,我还是要用它,因为它满足了大多数人大部分的使用场景,一般人一个月压缩的图片也不会超过500张的限制,而且一般情况压缩的图片都是icon之类的,体积也在可控范围内
毕竟免费白嫖的产品,而且体验还不错,你还有什么意思好要求更多呢?
使用tinypng中遇到的问题
但是,在有一些场景下,可能你会遇到下面的问题。当你需要对整个项目下使用的图片进行批量压缩,你会发现就会超过500张的限制,而且不能一次性对所有图片进行压缩(一次有20张的限制)
常见的解决方式有下面几种:
- 购买付费服务(对于经济实力允许的团队,还是购买服务比较好,无脑操作,只需要花钱就可以少很多的烦恼)
- 注册多个邮箱,避开单个账号500张的限制(注册10个邮箱,一个月就可以白嫖5000张的图片压缩了)
- 搭建一个批量压缩的服务,自动读取文件夹下资源进行压缩
实现方式
通过官方提供的API来进行批量压缩操作
官方提供了多种类型的语言实现,包括Ruby, PHP, Node.js, Python, Java和.NET等。官方文档地址tynying API
同时,官方也提供了基于node的核心库tinify npm,tinify的使用挺简单的,但是基于它实现压缩时,需要申请API KEY,每个API KEY还是有一个月500张的限制
申请方式:
实现思路:通过申请多个API KEY,在进行压缩时轮流使用不同的API KEY,来达到避开500张限制的问题
代码实现:
// index.js
const fs = require('fs');
const path = require('path');
const tinify = require('tinify');
// 需要处理的文件类型
const imgsInclude = ['.png', '.jpg'];
// 不进行处理的文件夹
const filesExclude = ['dist', 'build', 'node_modules', 'config'];
const keys = ['ZNsTlW44Lbv1v82GG7WwBWW8VVD09nh9', 'HR5gGtwfVvNYyQtwS4HL1VLww3dnndvH'];
const config = {
// 图片最大限制5M
max: 5200000,
// 每次最多处理20张,默认处理10张
maxLength: 10,
};
tinify.key = keys[1];
function readFile(filePath, filesList) {
const files = fs.readdirSync(filePath);
files.forEach((file) => {
const fPath = path.join(filePath, file);
const states = fs.statSync(fPath);
// 获取文件后缀
const extname = path.extname(file);
if (states.isFile()) {
const obj = {
size: states.size,
name: file,
path: fPath,
};
if (states.size > config.max) {
console.log(`文件${file}超出5M的压缩限制`);
}
if (states.size < config.max && imgsInclude.includes(extname)) {
filesList.push(obj);
}
} else {
if (!filesExclude.includes(file)) {
readFile(fPath, filesList);
}
}
});
}
function getFileList(filePath) {
const filesList = [];
readFile(filePath, filesList);
return filesList;
}
function complete() {
console.log('文件生成成功');
}
function writeFile(fileName, data) {
fs.writeFile(fileName, data, 'utf-8', complete);
}
function sort(m, n, key = 'size') {
if (m[key] > n[key]) {
return -1;
} else if (m[key] < n[key]) {
return 1;
} else {
return 0;
}
}
const filesList = getFileList('src');
filesList.sort(sort);
let str = `# 项目原始图片对比\n
## 图片压缩信息\n
图片总数量${filesList.length}\n
| 文件名 | 文件体积 | 压缩后体积 | 压缩比 | 文件路径 |\n| -- | -- | -- | -- | -- |\n`;
function output(list) {
for (let i = 0; i < list.length; i++) {
const { name, path: _path, size, miniSize } = list[i];
const fileSize = `${size > 1024 ? (size / 1024).toFixed(2) + 'KB' : size + 'B'}`;
const compressionSize = `${
miniSize > 1024 ? (miniSize / 1024).toFixed(2) + 'KB' : miniSize + 'B'
}`;
const compressionRatio = `${((size - miniSize) / size).toFixed(2) * 100 + '%'}`;
const desc = `| ${name} | ${fileSize} | ${compressionSize} | ${compressionRatio} | ${_path} |\n`;
str += desc;
}
}
const list = [];
function squash() {
Promise.all(
filesList.map(async (item) => {
const io = path.resolve(item.path);
const source = tinify.fromFile(item.path);
try {
return new Promise(async (resolve, reject) => {
await source.toFile(io);
const miniSize = fs.statSync(item.path).size;
list.push({ ...item, miniSize });
resolve();
});
} catch (error) {
if (error instanceof tinify.AccountError) {
console.log('The error message is: 已超出你每个月限额');
} else if (error instanceof tinify.ClientError) {
console.log('The error message is: 检查您的源图像和请求选项');
} else if (error instanceof tinify.ServerError) {
console.log('The error message is: Tinify API 的临时问题');
} else if (error instanceof tinify.ConnectionError) {
console.log('The error message is: 发生网络连接错误');
} else {
console.log('The error message is: 其他出错了,与 Tinify API 无关');
}
}
})
).then(() => {
output(list);
writeFile('test.md', str);
});
}
squash();
使用方式,修改getFileList('src')来表示压缩不通文件夹内容,在项目根目录下运行node index.js来启动压缩
存在的问题:
- API KEY获取麻烦,并且在批量压缩过程中,假如某次压缩501张,有可能会导致压缩失败,并且压缩次数也被使用,而且还需要实时维护数量状态
- 修改压缩目录时需要修改代码,难以维护
- 当图片压缩完成后,二次压缩时,还是会压缩之前已压缩文件,造成浪费
- 不同项目间想使用时,需要拷贝代码到指定项目,操作繁琐
那么怎么解决上面的几个问题呢?
获取API KEY麻烦,那么是否有某种方式进行压缩时,不需要API KEY就可以实现呢?
是有的,Tinify官方还提供了一种web 压缩端的图片压缩方式,在浏览器端拖入图片上传,压缩完成后下载即可
这种方式存在的问题:
- 图片大小限制
- 每次图片上传张数限制
- 每台电脑500张压缩的限制
- 需要手动替换压缩文件
针对其它问题,可以在运行压缩命令时,通过全局安装的cli命令来运行,就可以不必每个项目拷贝脚本等繁琐的操作。项目的压缩目录,也可以通过命令的方式传递进去,并且针对压缩的文件,生成md5,来避免重复压缩的问题出现
通过web端接口进行批量操作
web端的访问地址有两个,可以通过轮流调用不同的地址,来避免访问频率过高导致IP访问失败的问题出现。同时,也可以在请求头中添加动态生成的IP,来避免单IP频繁访问时访问拒绝的情况出现
const urls = [
"tinyjpg.com",
"tinypng.com"
];
const ip = new Array(4).fill(0).map(() => parseInt(Math.random() * 255)).join(".");
优点:
- 动态IP
- 文件压缩会生成md5,避免重复压缩
- 没有压缩数量限制
- 会生成文件压缩比文件
输出的文件压缩情况:
代码实现:
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const https = require('https')
const chalk = require('chalk');
const md5 = require('md5');
const args = require('minimist')(process.argv.slice(2))
/**
* args参数
* @param {*} md
* 默认不生成md文件
* 如果需要生成md文件,传入参数md
* node index.js --md=true
* @returns 是否生成md文件
*
* @param {*} folder
* 图片压缩文件范围,默认src文件夹
* node index.js --folder=src
* @returns
*/
// 需要处理的文件类型
const imgsInclude = ['.png', '.jpg'];
// 不进行处理的文件夹
const filesExclude = ['dist', 'build', 'node_modules', 'config'];
const urls = [
"tinyjpg.com",
"tinypng.com"
];
const config = {
// 图片最大限制5M
max: 5242880,
// 每次最多处理20张,默认处理10张
maxLength: 10,
};
const Log = console.log
const Success = chalk.green
const Error = chalk.bold.red;
const Warning = chalk.keyword('orange');
// 历史文件压缩后生成的md5
let keys = []
// 读取指定文件夹下所有文件
let filesList = []
// 压缩后文件列表
const squashList = []
// 请求头
function header() {
const ip = new Array(4).fill(0).map(() => parseInt(Math.random() * 255)).join(".");
const index = Math.round(Math.random(0, 1));
return {
headers: {
"Cache-Control": "no-cache",
"Content-Type": "application/x-www-form-urlencoded",
"Postman-Token": Date.now(),
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36",
"X-Forwarded-For": ip
},
hostname: urls[index],
method: "POST",
path: "/web/shrink",
rejectUnauthorized: false
};
}
// 判断文件是否存在
function access(dir) {
return new Promise((resolve, reject) => {
fs.access(dir, fs.constants.F_OK, async err => {
if (!err) {
resolve(true)
} else {
resolve(false)
}
})
})
}
// 读取文件
function read(dir) {
return new Promise((resolve, reject) => {
fs.readFile(dir, 'utf-8', (err, data) => {
if (!err) {
keys = JSON.parse(data.toString()).list
Log(Success('文件指纹读取成功'))
resolve(keys)
}
})
})
}
// 上传文件
function upload(file) {
const options = header()
return new Promise((resolve, reject) => {
const req = https.request(options, res => res.on('data', data => {
const obj = JSON.parse(data.toString());
obj.error ? reject(obj.message) : resolve(obj);
}));
req.on('error', error => {
Error('upload', file)
reject(error)
})
req.write(file, 'binary')
req.end()
})
}
// 下载文件
function download(url) {
const options = new URL(url);
return new Promise((resolve, reject) => {
const req = https.request(options, res => {
let file = '';
res.setEncoding('binary');
res.on('data', chunk => {
file += chunk;
});
res.on('end', () => resolve(file));
});
req.on('error', error => {
Error('download', url)
reject(error)
})
req.end();
});
}
// 遍历指定类型文件
function readFile(filePath, filesList) {
const files = fs.readdirSync(filePath);
files.forEach((file) => {
const fPath = path.join(filePath, file);
const states = fs.statSync(fPath);
// 获取文件后缀
const extname = path.extname(file);
if (states.isFile()) {
const obj = {
size: states.size,
name: file,
path: fPath,
};
const key = md5(fPath + states.size)
if (states.size > config.max) {
Warning(fPath)
Log(`文件${file}超出5M的压缩限制`);
}
if (states.size < config.max && imgsInclude.includes(extname) && !keys.includes(key)) {
filesList.push(obj);
}
} else {
if (!filesExclude.includes(file)) {
readFile(fPath, filesList);
}
}
});
}
function getFileList(filePath) {
const filesList = [];
readFile(filePath, filesList);
return filesList;
}
function writeFile(fileName, data) {
fs.writeFile(fileName, data, 'utf-8', () => {
Log(Success('文件生成成功'))
});
}
function transformSize(size) {
return size > 1024 ? (size / 1024).toFixed(2) + 'KB' : size + 'B'
}
let str = `# 项目原始图片对比\n
## 图片压缩信息\n
| 文件名 | 文件体积 | 压缩后体积 | 压缩比 | 文件路径 |\n| -- | -- | -- | -- | -- |\n`;
function output(list) {
for (let i = 0; i < list.length; i++) {
const { name, path: _path, size, miniSize } = list[i];
const fileSize = `${transformSize(size)}`;
const compressionSize = `${transformSize(miniSize)}`;
const compressionRatio = `${(100 * (size - miniSize) / size).toFixed(2) + '%'}`;
const desc = `| ${name} | ${fileSize} | ${compressionSize} | ${compressionRatio} | ${_path} |\n`;
str += desc;
}
let size = 0, miniSize = 0
list.forEach(item => {
size += item.size
miniSize += item.miniSize
})
const s = `
## 体积变化信息\n
| 原始体积 | 压缩后提交 | 压缩比 |\n| -- | -- | -- |\n| ${transformSize(size)} | ${transformSize(miniSize)} | ${(100 * (size - miniSize) / size).toFixed(2) + '%'} |
`
str = str + s
writeFile('图片压缩比.md', str);
}
// 生成文件指纹
function fingerprint() {
const list = []
squashList.forEach(item => {
const { miniSize, path } = item
const md5s = md5(path + miniSize)
list.push(md5s)
})
fs.writeFile('squash.json', JSON.stringify({ list: keys.concat(list) }, null, '\t'), err => {
if (err) throw err
Log(Success('文件指纹生成成功'))
})
}
function squash() {
try {
Promise.all(
filesList.map(async item => {
Log(Success(item.path))
const fpath = fs.readFileSync(item.path, 'binary')
const { output = {} } = await upload(fpath)
if (!output.url) return
const data = await download(output.url)
if (!data) return
fs.writeFileSync(item.path, data, 'binary')
return new Promise(async (resolve, reject) => {
const miniSize = fs.statSync(item.path).size;
squashList.push({ ...item, miniSize });
resolve();
});
})
).then(() => {
if (args['md']) {
output(squashList);
}
fingerprint()
console.timeEnd('squash time')
})
} catch (error) {
return Promise.reject(error)
}
}
async function start() {
try {
const files = args['folder'] || 'src'
const check = await access(files)
if (!check) {
Log(Error('当前文件或者文件夹不存在,请更换压缩目录'))
return
}
const res = await access('squash.json')
if (res) {
await read('squash.json')
}
new Promise((resolve, reject) => {
filesList = getFileList(files)
resolve()
}).then(() => {
squash()
})
} catch (error) {
Log(error)
}
}
console.time('squash time')
start()
img-squash-cli
上面的实现我已经发布为了npm包的形式,可以直接通过全局安装,在项目根目录直接使用即可,npm img-squsah-ci
使用方式:
安装依赖
npm install -g img-squash-cli
API
npx squash-cli
如果需要指定图片压缩的文件,请添加folder参数,默认为项目目录下的src文件夹
// 修改为处理config文件夹下内容
npx squash-cli --folder=config
如果要生成图片压缩比等信息,请添加md参数,默认不开启
npx suqash-cli --md=true