手把手教你用node撸一个图片压缩工具

6,679 阅读6分钟

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2018/10/27/166b172f7ccdcdab~tplv-t2oaga2asx-image.image

上篇文章中我们提到了用node撸一个简易的爬虫,本次基于上一篇文章中的项目get_picture给大家分享下我是如何用node撸一个图片压缩工具的。原文链接leeing.site/2018/10/27/…

历史《手把手教你用node撸一个简易的headless爬虫cli工具》

tinypng

依然是先介绍一下工具,本次我们主要用到了 tinypng 这个工具。tinypng是一个主流的图片压缩工具,他可以实现高保真的压缩我们的图片,一般我们可以进入他的官网tinypng.com/压缩图片,手动点击上传,但是每次只能压缩20张,这对于追求方便的我们来说肯定是不能满足的。我们需要一次性将所有图片都压缩!

这怎么办呢?tinypng官网十分的人性化,提供了各种服务端直接调用的接口,我们点开他的文档看一看,找到node.js,通过npm i --save tinify安装在我们的项目中,其次可以看到他提供了各种各样的功能,包括压缩图片resize图片上传cdn等。我们主要用到了他的压缩图片验证key查看已用数

目录结构

|-- Documents
    |-- .gitignore
    |-- README.md
    |-- package.json
    |-- bin
    |   |-- gp
    |-- output
    |   |-- .gitkeeper
    |-- src
        |-- app.js
        |-- clean.js
        |-- imgMin.js
        |-- index.js
        |-- config
        |   |-- default.js
        |-- helper
            |-- questions.js
            |-- regMap.js
            |-- srcToImg.js
            |-- tinify.js

基于上一个项目,我们新增了两个文件

  • /src/imgMin.js。即我们的主文件。
  • /src/helper/tinify.js。主要用于操作tinypng的相关API

主文件

在主文件中,我们主要用到了nodefs模块。 首先我们会判断输入的key是否有效,其次我们会判断该key剩余可用数是不是小于0,如果没问题的话,我们就开始查找检索路径下的所有文件。

检索路径 首先我们会通过fs.stat判断该路径是否是文件夹,如果是,则通过fs.readdir获取当前文件列表,遍历后然后将其传给获取图片方法。注意这边有个坑点,因为我们的操作几乎都是异步操作,所以我一开始也很理所当然的用了forEach来遍历,伪代码如下

files.forEach(async (file) => {
  await getImg(file);
});

后来发现,这种写法会导致await并不能如我们预期的阻断来执行,而是变成了一个同步的过程(一开始的预期是一张图片压缩输出完才执行第二张,虽然这样会导致很慢。所以后面还是换成了同步压缩),这是因为forEach可以理解为传入一个function,然后在内部执行循环,在循环中执行function并传回index和item,如果传入的是async函数的话,则其实是并行执行了多个匿名async函数自调,因此await无法按照我们预期的来执行。所以该处我们采用for-of循环,伪代码如下

for(let file of files){
  await getImg(file);
}

获取图片 在获取图片中,我们依然会通过fs.stat来判断,如果当前文件依然是个文件夹,我们则递归调用findImg检索其下的文件,如果是图片,先判断当前累计图片总数有没有超过剩余数的最大值(如果使用异步压缩,则不需要进行这一步,因为每一次图片处理都是等待上一张图片处理完成后再进行处理;如果是同步压缩,则必须要这一步,否则如果压缩过程中超数量了,会导致整批压缩失败),如果没有超过,则通过调用tinify.js中的imgMin方法开始进行压缩。

压缩图片 在这一步中,我们先通过fs.readFile读取文件内容sourceData,再通过tinypng的APItinify.fromBuffer(sourceData).toBuffer((err, resultData) => {})方法获取图片压缩后的数据resuleData,最后通过fs.writeFile对原图片进行覆盖。需要注意一点,async/await中,只有遇到await才会等待执行,并且await后面需要跟一个promise对象,因此,我们把readFiletinify.fromBuffer(sourceData).toBuffer((err, resultData) => {})fs.writeFile用promise进行封装。 至此,我们的主程序就大功告成了!怎么样,是不是依然非常简单。 最后只要在commander中加入我们的新命令就好了。

/src/imgMin.js代码如下:

const path = require('path');
const fs = require('fs');
const chalk = require('chalk');
const defaultConf = require('./config/default');
const { promisify } = require('util');
const readdir = promisify(fs.readdir);
const stat = promisify(fs.stat);
const regMap = require('./helper/regMap');
const { validate, leftCount, imgMin } = require('./helper/tinify');

class ImgMin {
    constructor(conf) {
        this.conf = Object.assign({}, defaultConf, conf);
        this.imgs = 0;
    }

    async isDir(filePath) {
        try {
            const stats = await stat(filePath);
            if(stats.isDirectory()){
                return true;
            }
            return false;
        } catch (error) {
            return false;
        }
    }

    async findImg(filePath) {
        try {
            const isDirectory = await this.isDir(filePath);
            if(!isDirectory){
                return;
            }
            const files = await readdir(filePath);
            for(let file of files){
                // 这里不能用forEach,只能用for循环
                // 加上await,则是一张张异步压缩图片,如果中间出错,则部分成功
                // 不加await,则是同步发起压缩图片请求,异步写入,如果中间出错,则全部失败
                // 这里为了压缩更快,采用同步写法

                // await this.getImg(file);
                const fullPath = path.join(filePath, file);
                this.getImg(fullPath);
            }
        } catch (error) {
            console.log(error);
        }
    }

    async getImg(file) {
        const stats = await stat(file);
        // 如果是文件夹,则递归调用findImg
        if(stats.isDirectory()){
            this.findImg();
        }else if(stats.isFile()){
            if(regMap.isTinyPic.test(file)){
                this.imgs ++;
                const left = leftCount();
                // 剩余数判断,解决同步时剩余数不足导致的全部图片压缩失败问题
                if(this.imgs > left || left < 0){
                    console.log(chalk.red(`当前key的可用剩余数不足!${file} 压缩失败!`));
                    return;
                }
                await imgMin(file);
            }else{
                console.log(chalk.red(`不支持的文件格式 ${file}`));
            }
        }
    }

    async start() {
        try {
            const isValidated = await validate(this.conf.key);
            if(!isValidated){
                return;
            }
            const filePath = this.conf.imgMinPath;
            await this.findImg(filePath);
        } catch (error) {
            console.log(error);
        }
    }
}

module.exports = ImgMin;

/src/helper/tinify.js代码如下:

const fs = require('fs');
const tinify = require('tinify');
const chalk = require('chalk');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);

function setKey(key) {
    tinify.key = key;
}

async function validate(key) {
    console.log(chalk.green('正在认证tinyPng的key...'));
    setKey(key);
    return new Promise(resolve => {
        tinify.validate((err) => {
            if(err){
                console.log(err);
                return resolve(false);
            }
            console.log(chalk.green('认证成功!'));
            const left = leftCount();
            if(left <= 0){
                console.log(chalk.red('当前key的剩余可用数已用尽,请更换key重试!'));
                return resolve(false);
            }
            console.log(chalk.green(`当前key剩余可用数为 ${left}`));
            resolve(true);
        });
    });
};

function compressionCount() {
    return tinify.compressionCount;
};

function leftCount() {
    const total = 500;
    return total - Number(compressionCount());
};

function writeFilePromise(file, content, cb) {
    return new Promise((resolve, reject) => {
        fs.writeFile(file, content, (err) => {
            if(err){
                return reject(err);
            }
            cb && cb();
            resolve();
        });
    });
};

function toBufferPromise(sourceData) {
    return new Promise((resolve, reject) => {
        tinify.fromBuffer(sourceData).toBuffer((err, resultData) => {
            if (err) {
                return reject(err);
            }
            resolve(resultData);
        })
    });
};

async function imgMin(img) {
    try {
        console.log(chalk.blue(`开始压缩图片 ${img}`));
        const sourceData = await readFile(img);
        const resultData = await toBufferPromise(sourceData);
        await writeFilePromise(img, resultData, () => console.log(chalk.green(`图片压缩成功 ${img}`)));
    } catch (error) {
        console.log(error);
    }
};

module.exports = { validate, compressionCount, leftCount, imgMin };

命令行工具 在index.js中,我们加入以下代码

program
    .command('imgMin')
    .alias('p')
    .option('-k, --key [key]', `Tinypng's key, Required`)
    .option('-p, --path [path]', `Compress directory. By default, the /images in the current working directory are taken. 
    Please enter an absolute path such as /Users/admin/Documents/xx...`)
    .description('Compress your images by tinypng.')
    .action(options => {
        let conf = {};
        if(!options.key){
            console.log(chalk.red(`Please enter your tinypng's key by "gp p -k [key]"`));
            return;
        }
        options.key && (conf.key = options.key);
        options.path && (conf.imgMinPath = options.path);
        const imgMin = new ImgMin(conf);
        imgMin.start();
    });

commander具体的用法本章就不再重复了,相信有心的同学通过上章的学习已经掌握基本用法了~

这样,我们就完成了我们的需求,再将其更新到npm中,我们就可以通过gp p -k [key]来压缩我们的图片。

项目下载

npm i get_picture -g

参考链接