撸一个自动压缩工具(nodejs)

1,417 阅读3分钟

前端开发有这么一个场景:前端同学要去指定的地址去下载切图。还需要压缩,完后才能使用

针对这个场景,作为一名合格的前端工程师,应该可以有一些自己的想法,提高工作效率;使用 NODEJS 撸一个自动压缩工具,减少这些冗余的无意义的工作。

使用到哪些内容

  • tinify
  • cluster
  • path
  • fs
  • events
  • chalk
  • process

如果有同学对这些模块不知道的话,可以去官网学习一下

封装一个基类

这个基类的作用就是底层处理,读取目标图片,然后交给 tinify 处理。tinify 提供了 fromBuffertoBuffer API 进行压缩和输出 buffer。最后将输出的 buffer 输出到指定的目录下生成文件。

核心代码其实就这些

  const rs = fs.createReadStream(oldPath);
  const ws = fs.createWriteStream(newPath);
  rs.on('data', (chunkData) => {
    tinify.fromBuffer(chunkData).toBuffer((error, chunk) => {
      if (error) return reject(error.message);
      // 写入目标文件
      ws.end(chunk);
      // 写入成功
      ws.on('finish', () => resolve());
      // 写入失败
      ws.on('err', () => reject(err.message));
    });
  });

验证 tinify 是否可以使用

我们需要去 tinify 官网申请一个开发者 key 然后才可以使用。tinify 同样提供了 validate API 进行验证。

  // key 就是我们申请得到的id
  tinify.key = this.key;
  // 开始验证,callback 是验证失败或成功都会调用的回调函数
  tinify.validate(callback);

完整代码如下:

  const EventEmitter = require('events');
  const tinify = require('tinify'); 
  const chalk = require('chalk');
  const fs = require('fs');

  class Tinify extends EventEmitter {
    constructor(key) {
      super();
      this.key = key;
      this.initTinify();
    }
    // 初始化验证阶段
    initTinify () {
      tinify.key = this.key;
      tinify.validate((error) => {
        if (error) {
          // tinify 初始化验证失败
          this.emit('error', error);
          return process.exit();
        }
        if (this.remainingCompressions() <= 0) {
          console.log(chalk.red('压缩数量已经用完'));
          this.emit('close');
          return process.exit();
        }

        // 初始化验证成功
        this.emit('initial');
      });
    }

    // 计算剩余压缩张数
    remainingCompressions () {
      return 500 - tinify.compressionCount;
    }
    // 压缩图片,并输出到指定目录
    miniIMG (oldPath, newPath) {
      if (this.remainingCompressions() <= 0) {
        this.emit('error', Error({message: 'tinify has no remaining compressed sheets', name: 'TypeError'}));
        return process.exit();
      }

      return new Promise((resolve, reject) => {
        const rs = fs.createReadStream(oldPath);
        const ws = fs.createWriteStream(newPath);
        rs.on('data', (chunkData) => {
          tinify.fromBuffer(chunkData).toBuffer((error, chunk) => {
            if (error) return reject(error.message);
            ws.end(chunk);
            ws.on('finish', () => resolve());
            ws.on('err', () => reject(err.message));
          });
        });
      });
    }
  }

封装子类

基于基类,实现一些复杂的场景需求。

需求分析

  • 自动压缩 -- 通过 watch 对目录及其子目录进行监听
  • 类型识别-- 分析该文件标示是否是一个目录还是文件、文件的格式是否是图片的类型(jpg\png\jpeg\gif)
  • 重复压缩 -- 压缩的过程是异步的,所以需要避免重复压缩
  • 完成删除 -- 完成压缩后、将文件清除
  const path = require('path');
  const fs = require('fs');
  const Tinify = require('./tinify');

  class Application extends Tinify {
    constructor({key, entry, output}) {
      super(key);
      this.config = {
        key,
        entry,
        output,
        mime: /\.(png|jpg|gif|jpeg)$/,
      };
      // 创建一个堆栈,防止图片被重复执行压缩
      this.stack = new Set();
      // 做一个防抖处理
      this.fileChange = this.debounce(this.watchFileChange, 2000);

      // 当输入和输出的目录都创建完成以后,触发 runing,表示可以开始压缩了
      Promise.all([this.mkDir(this.config.entry), this.mkDir(this.config.output)])
        .then(() => {
          this.emit('running');
        })
        .catch((error) => {
          this.emit('error', error);
          process.exit();
        });
    }

    async mkDir (dir) {
      const exists = fs.existsSync(dir);
      if (exists) return;
      fs.mkdirSync(dir);
      return;
    }

    debounce (fn, delay) {
      let timer;
      return function() {
        const self = this;
        const args = arguments;
        clearTimeout(timer);
        timer = setTimeout(function () {
          fn.apply(self, args);
        }, delay);
      }
    }

    // 监听
    watch (dir) {
      // recursive 标示是否监听子目录。目前只支持 macOS、windows
      fs.watch(dir, {recursive: true}, (eventName) => {
        if (eventName === 'change') return;
        this.fileChange(dir);
      });
    }

    watchFileChange (dir) {
      // 读取该目录下的所有文件
      fs.readdir(dir, (error, files) => {
        if (error) {
          this.emit('error', error);
          return process.exit();
        }
        // 如果改目录是一个空目录,直接删除
        if (files.length === 0 && dir !== this.config.entry) {
          try {
            fs.rmdirSync(dir);
            return;
          } catch (err) {
            console.log(err);
          }
        }

        for (let i = 0; i < files.length; i++) {
          this.handleFile(dir, files[i]);
        }
      });
    }

    // 文件处理
    handleFile (dir, base) {
      const entryPath = path.join(dir, base);
      const outputPath = path.join(this.config.output, base);
      // 如果该文件还在 stack 中不会进行压缩
      if (this.stack.has(entryPath)) return;

      // 查看是否还有可压缩的次数
      if (this.remainingCompressions() <= 0) {
        this.emit('error', Error({message: 'tinify has no remaining compressed sheets', name: 'TypeError'}));
        return process.exit();
      }

      fs.stat(entryPath, (error, stat) => {
        if (error) {
          this.emit('error', error);
          return process.exit();
        }

        // 如果是一个文件目录,递归遍历该目录中的所有文件
        if (stat.isDirectory()) {
          this.watchFileChange(entryPath);
          return;
        };

        // 文件格式不符合;或者是一个空的文件;执行删除
        if (!this.config.mime.test(base) || stat.size <= 0) {
          try {
            fs.unlinkSync(entryPath);
            return;
          } catch (err) {
            console.log(err);
          }
        }

        // 将文件的路径作为该文件的唯一标示,存放在 stack 中;避免重复压缩;
        this.stack.add(entryPath);
        // 压缩图片
        this.miniIMG(entryPath, outputPath)
          .then(() => {
            // 压缩完成
            this.emit('complete', null, base);
            fs.unlink(entryPath, () => this.stack.delete(entryPath));
          })
          .catch(() => {
            // 压缩失败
            this.emit('complete', true, base);
            this.stack.delete(entryPath);
          });
      });
    }

    run () {
      // 添加到队列的头部,当 running 触发的时候,开始监听
      this.prependOnceListener('running', () => {
        // 如果目录中已经有文件存在,执行压缩
        this.watchFileChange(this.config.entry);
        this.watch(this.config.entry);
      });
    }
  }

实现了哪些监听事件

  • initial - tinify 初始化验证完成
  • running - 开始监听
  • close - 推出程序
  • error - 程序报错
  • complete - 压缩成功/失败的

一切都是基于 events 模块,所以实现很容易

  const Application = require('./application.js');
  const chalk = require('chalk');
  const params = require('./package.json');

  const {key, entry, output} = {...params}
  const app = new Application({key, entry, output});
  // 开始运行
  app.run();

  app.on('initial', () => {
    console.log('初始化验证成功了');
  });

  app.on('running', () => {
    console.log('应用已经开始了');
  });

  app.on('close', () => {
    console.log('应用关闭了');
  });

  app.on('error', (error) => {
    console.log('应用出错了', error);
  });

  app.on('complete', (err, filename) => {
    if (err) {
      console.log(chalk.red(`文件压缩失败【${filename}】`));
      return;
    }
    console.log(chalk.green(`文件压缩成功【${filename}】`));
  });

  // 处理未捕获的未知错误
  process.on('uncaughtExpection', function(error) {
    // 打印日志
    console.log(error);
    // 推出程序
    process.exit();
  });

程序在运行中,可能会出现未知错误,当然这可能是因为我写的程序不够健壮导致的。所以我们需要在程序中做一个 uncaughtExpection 事件的监听。

nodejs 开启一个新的工作进程

一个自动化的工具,我们希望它开始以后,就可以永久的执行下去。所以我们单独的依赖一个进程肯定不行。所以需要有一个主进程对这个工作进程进行监听,当监听到工作进程报错或是退出程序时。主进程就可以立即重新开启一个新的工作进程。确保工作不被耽误。

cluster/child_process

nodejs 刚好提供了我们想要的东西。clusterchild_process 都可以开启一个新进程(cluster 其实就是 child_process 的一个封装)。

  const cluster = require('cluster');
  const child_process = require('child_process');

  cluster.setupMaster({exec: './index.js'});
  // 衍生一个新的进程
  const worker = cluster.fork();

  // 衍生一个进程
  const sunProcess = child_process.fork('./index.js');

处理异常的程序报错,避免进程无限重启

  const limit = 10;
  const stack = [];
  const stemp = 60000;
  // 限制频繁启动;这种场景一般出现在错误的程序中
  const lastStack = stack.slice(limit * -1);
  if (lastStack[lastStack.length - 1] - lastStack[0] < stemp) return;
  createApplication();

上面代码的作用:就是限制程序频繁的重新启动,这种情况一般是我们人为造成的,所以加入这段代码进行过滤。

完整代码如下:

  const chalk = require('chalk');
  const cluster = require('cluster');

  const args = process.argv.slice(2);
  const execArgv = process.execArgv;
  // 这里使用的是 cluster。
  cluster.setupMaster({'exec': './index.js', args, execArgv});


  let worker = null;
  const limit = 10;
  const stack = [];
  const stemp = 60000;

  const createApplication = () => {
    stack.push(Date.now());
    // 衍生一个新的进程;类似 child_process.fork()
    worker = cluster.fork();
    // 监听到子进程推出
    worker.on('exit', (code, signal) => {
      console.log(code, signal);
      console.log(chalk.red(`工作进程【${worker.process.pid}】已经推出`));
      if (signal === 'SIGTERM' || signal === 'SIGHUB' || signal === 'SIGINT') return;

      // 限制频繁启动;这种场景一般出现在错误的程序中
      const lastStack = stack.slice(limit * -1);
      if (lastStack[lastStack.length - 1] - lastStack[0] < stemp) return;
      createApplication();
    })
  }

  createApplication();

  // 当主进程推出时,杀死子进程
  process.on('exit', () => {
    process.kill(worker.process.pid, 'SIGTERM');
  })

最后

不要忘记在 package.json 中配置开发者 key、文件输入和输出的目录 entryoutput

运行:$ ndoe ./listener.js

打完收工。