基于TinyPng,通过node实现一个批量压缩图片的cli工具

3,327 阅读8分钟

前言

最近在做小程序开发的过程中,遇到了一个很头疼的问题,项目中使用了很多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来进行批量压缩操作

官方提供了多种类型的语言实现,包括RubyPHPNode.jsPythonJava.NET等。官方文档地址tynying API

同时,官方也提供了基于node的核心库tinify npm,tinify的使用挺简单的,但是基于它实现压缩时,需要申请API KEY,每个API KEY还是有一个月500张的限制

申请方式:

1.jpg

实现思路:通过申请多个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,避免重复压缩
  • 没有压缩数量限制
  • 会生成文件压缩比文件

输出的文件压缩情况:

2.jpg

代码实现:

#!/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