10分钟教你写一个图片压缩命令行工具

1. 前言

在小程序场景,由于代码包上传阶段限制了主包2M和总包20M,超过就会面临无法发版的风险,代码包体积的优化就变得特别重要。

ui 设计师输出的图片有 1x、2x、3x 倍,体积相对还是比较大的,我们可以使用 tinypng.com/ 压缩图片,保证图片高清的同时,尽量的减小图片的体积。

遇到多图片时,手动拖拽压缩、查看压缩质量,费时费力。能否通过自动压缩图片来解决呢?答案是可以,本文会通过写一个脚手架工具实现图片自动压缩。

2. 自动压缩图片脚本

2.1 思路分析

最开始,我思考的方向是考虑可以在项目打包的时候,自动压缩项目所有的图片,实测时会大大增加项目构建时间。并且依赖于 imageminimagemin-jpegtranimagemin-pngquant 几个包,在安装 imagemin-pngquant 时会安装不了,找到了一个原因是说这个库是基于一些底层语言实现,所以还不能直接安装,需要在电脑上先安装另一个依赖 libpng,非常麻烦。

因此换了个思路,考虑到一般是开发新功能时添加新图片,需要进行压缩。那么可以提供一个压缩脚本,压缩开发者选择的图片。

自动压缩脚本可以采用本地压缩或在线压缩,本文主要讲在线压缩,自动发起请求到 tinypng.com/ 压缩图片。

  • 核心:要能按需选择图片压缩,压缩后返回压缩大小和数据等指标供使用者检测图片质量。
  • 次要:可以提供 ui 在线预览图片。

2.2 代码实现

1.全局配置

const tinyConfig = {
    files: [],
    entry: '',
    deepLoop: false, // 是否递归处理
    replace: false, // 是否覆盖源文件
    Exts: ['.jpg', '.png', 'jpeg'],
    Max: 5120000 // 5MB
}

2.读取本地图片,会对一些非图片后缀的文件进行过滤。

/**
 * 过滤待处理文件夹,得到待处理文件列表
 */
function fileFilter(sourcePath) {
	const fileStat = fs.statSync(sourcePath)
	if (fileStat.isDirectory()) {
    	fs.readdirSync(sourcePath).forEach((file) => {
        	const fullFilePath = path.join(sourcePath, file)
            // 读取文件信息
            const fileStat = fs.statSync(fullFilePath)
            // 过滤大小、后缀名
            if (
                fileStat.size <= tinyConfig.max &&
                fileStat.isFile() &&
                tinyConfig.exts.includes(path.extname(file))
            ) {
                tinyConfig.files.push(fullFilePath)
            } else if (tinyConfig.deepLoop && fileStat.isDirectory()) {
                // 是否要深度递归
                fileFilter(fullFilePath)
            }
    	})
  } else {
      if (
          fileStat.size <= tinyConfig.max &&
          fileStat.isFile() &&
          tinyConfig.exts.includes(path.extname(sourcePath))
      ) {
          tinyConfig.files.push(sourcePath)
      }
    }
}

3.上传图片,先手动上传一张图片到 tinyPng 网站,然后查看 NetWork 的请求响应报文,我们可以构造这样的请求报文来实现自动化脚本处理。

function getAjaxOptions() {
    return {
        method: 'POST',
        hostname: 'tinypng.com',
        path: '/web/shrink',
        headers: {
            rejectUnauthorized: false,
            'X-Forwarded-For': Array(4).fill(1).map(() => parseInt(Math.random() * 254) + 1).join('.'), // 伪造随机 ip,避免限制
            'Postman-Token': Date.now(),
            'Cache-control': 'no-cache',
            'Content-type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Window NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'
        }
    }
}

/**
 * success {
 *   "input": { "size": 887, "type": "image/png"},
 *   "output": {
 *     "size": 785, "type": "image/png", "width": 81, "height": 81, "ratio": 0.885,
 *     "url": "https://tinypng.com/web/output/xxx"
 *   }
 * }
 */
function fileUpload(imgPath) {
    const req = https.request(getAjaxOptions(), (res) => {
        res.on('data', (buf) => {
            const obj = JSON.parse(buf.toString())
            if (obj.error) {
                console.log(`压缩失败! \n 当前文件:${imgPath} \n ${obj.message}`)
            } else {
                fileUpdate(imgPath, obj) // 更新文件到本地
            }
        })
    })
    req.write(fs.readFileSync(imgPath), 'binary')
    req.on('error', (err) => {
        console.error(`请求错误 \n 当前文件:${imgPath} \n ${err}`);
    })
    req.end();
}

4.请求压缩好的图片,更新到本地路径

function fileUpdate(entryImgPath, obj) {
    const url = new URL(obj.output.url)
    const req = https.request(url, (res) => {
        let body = ''
        res.setEncoding('binary')
        res.on('data', (data) => {
            body += data
        })
        res.on('end', () => {
            const [filename, extendsion] = entryImgPath.split('.')
            if (!tinyConfig.replace) {
                // 是否覆盖源文件
                entryImgPath = filename + '_tiny' + '.' + extendsion
            }
            fs.writeFile(entryImgPath, body, 'binary', (err) => {
                if (err) return console.log(err)
                let log = '压缩成功:';
                log += `优化比例:${((1 - obj.output.ratio) * 100).toFixed(2)}%,`;
                log += `原始大小:${(obj.input.size / 1024).toFixed(2)}KB,`;
                log += `压缩大小:${(obj.output.size / 1024).toFixed(2)}KB,`;
                log += `文件:${entryImgPath}`;
                console.log(log)
            })
      })
    })
    req.on('error', (e) => console.log(e))
	req.end()
}

自动压缩图片脚本写好了,可以先写死 tinyConfig 的读取图片文件夹路径,然后通过 node index.js 进行测试。

tinyConfig.entry = './tests/'
fileFilter(tinyConfig.entry)
tinyConfig.files.forEach((img) => fileUpload(img))

脚本测试没问题后,接下来,我们通过命令行工具读取图片路径进行压缩。

3. 命令行工具底盘搭建

3.1 认识命令行工具

命令行工具运行在终端上,终端提供了用户与操作系统内核进行交互操作的一种接口(命令解释器),比如 mac 的 shell 终端应用程序。

用户可以终端上输入命令,告诉电脑要做什么。执行步骤为:

  1. 用户输入命令,提交给终端
  2. 判断是否为内置命令
  3. 在系统中查找该命令的文件并调入内存执行
  4. 内核中的系统功能调用。

在 Linux 中,可执行的文件也进行了分类:

  • 内置命令:出于效率的考虑,将一些常用命令的解释程序构造在 Shell 内部
  • 外置命令:存放在 /bin、/sbin 目录下的命令。
  • 实用程序:存放在 /usr/bin、/usr/sbin、/usr/share、/usr/local/bin 等目录下的实用程序。
  • 用户程序:用户程序经过编译生成可执行文件后,可作为 Shell 命令运行
  • Shell 脚本:由 Shell 语言编写的批处理文件,可作为 Shell 命令运行。

以上都可以称为命令行工具,比如 clearlspwd 这些能够「在终端执行的系统命令」,被称为系统内置命令。可以使用 which 查看它们的来源:

$ which clear
/usr/bin/clear
$ which vue # vue cli 脚手架
/usr/local/bin/vue

使用 ls -lah $(which vue) 进一步解析命令:

$  ls -lah $(which vue)
lrwxr-xr-x  1 naluduo233  admin    65B  9 27 15:13 /usr/local/bin/vue -> ../../../Users/naluduo233/.config/yarn/global/node_modules/.bin/vue

可以看到 /usr/local/bin/vue 的真正执行命令是指向 yarn 下安装的目录 .bin/vue

而这些 /usr/local/bin 目录在环境变量 PATH 中,「在环境变量的PATH 中路径的命令可在 shell 终端 任意地方执行」

由此得出 Node 全局命令行的原理: 「环境变 Path」+ 「符号链接」

  1. npm 全局下载某个 package 到路径 /usr/local/lib/node_modules 下 (yarn 同理,对应路径 ~/.config/yarn/global/node_modules)
  2. 根据该库的 package.json 中 bin 字段的指示,把对应的命令行路径通过符号索引挂载到 PATH 路径
  3. 对应的二进制脚本添加 x 权限 (可执行文件权限)

3.2 构建 Node 命令行工具底盘

1.首先建立入口文件,并在 package.json 中声明

{
	"bin": {
    	"naluduo": "./bin/naluduo.js" // 在 「package.json 中的 bin 字段」,用以指定最终的命令行工具的名字
  	}
}

2.在 naluduo.js 指明执行环境,使用 node 解释器来执行这个脚本,而通过 env node 能够正确定位到 node 解释器的位置。

#!/usr/bin/env node  

3.前面说到需要全局安装包到本地才能在终端执行,由于我们这个包还没发布,可以进入项目根目录下, 通过yarn linkpackage.jsonbin 字段所指向的脚本进行全局软链接到系统 PATH

yarn link

4.可以看到naluduo 已经放置在 /usr/local/bin 下。

$ which naluduo
/usr/local/bin/naluduo
$ ls -lah $(which naluduo)
lrwxr-xr-x  1 naluduo233  admin    65B 10 31 23:36 /usr/local/bin/naluduo -> ../../../Users/naluduo233/.config/yarn/link/nalu-cli/bin/naluduo.js

yarn link把脚本复制到 yarn 的 配置下,并且做好相关的软链接,/usr/local/bin 下目录下的脚本可以在 shell 终端任意地方被解析执行。

naluduo 

3.3 解析用户入口命令,添加自动压缩图片命令

现在让我们添加自动压缩图片命令,希望这样执行:

naluduo tinyimg -r -d  # -r,replace 是否替换源文件、-d,deep 是否递归处理文件夹

然后进入选择要压缩的图片文件夹或图片路径,选中后即可进入自动压缩图片环节。

现在要解决的问题,是解析用户输入命令,通过 process.argv 可获取用户输入:

$ node cmd.js 1 2 3

// Output: [
//   '/usr/local/bin/node',
//   '/Users/shanyue/cmd.js',
//   '1',
//   '2',
//   '3',
// ]

根据解析 process.argv 可以定制格式来获取各式各样的参数作为命令行的输入。解析参数参照 POSIX 兼容的基本规律: 格式、可选、必选、简写、说明、帮助等等。

为快速实现开发,这里使用第三方库 commanderyarn add commander --dev,然后编写入口文件

const { Command } = require('commander')
const program = new Command()
+ const { tinyimg } = require('./commands/tinyimg')

program
  .command('tinyimg')
  .description('压缩图片')
  .option('-d, --deep', '是否递归处理图片文件夹', false)
  .option('-r, --replace', '是否覆盖源文件', false)
  .action((commandAndOptions) => { // 参数为 option 对象
    tinyimg(commandAndOptions)
  })

program.version('0.1.0') // 设置了版本后,命令行会输出当前的版本号
program.parse(process.argv) // 解析命令行输入的参数

现在可以输入 naluduo 进行测试:

$ naluduo
Usage: naluduo [options] [command]

Options:
  -V, --version      output the version number
  -h, --help         display help for command

Commands:
  tinyimg [options]  压缩图片
  help [command]     display help for command

执行 naluduo tinyimg -d -r,可以获得的commandAndOptions 对象为:

{
  deepLoop: true,
  replace: true
}

完成图片命令的引入后,接下来要做的事情是,让用户选择要压缩的图片路径,然后传入 tinyimg 脚本。

3.4 添加可交互性,让用户选择要压缩的图片路径

这里使用 Inquirer.js 库和它的插件 inquirer-file-tree-selection-prompt 实现用户自定义选择图片路径。

yarn add inquirer inquirer-file-tree-selection-prompt

现在改造自动压缩图片脚本,接收从命令行输入的参数即可。

const inquirer = require('inquirer')
const inquirerFileTreeSelection = require('inquirer-file-tree-selection-prompt')
inquirer.registerPrompt('file-tree-selection', inquirerFileTreeSelection)
exports.tinyimg = async (commandAndOptions) => {
  const answer = await inquirer.prompt([
    {
      name: 'path', // 键
      type: 'file-tree-selection',
      message: '(必选) 压缩的图片文件夹路径/文件'
    }
  ])
  const { path } = answer
  tinyConfig.entry = path
  tinyConfig.replace = commandAndOptions && commandAndOptions.replace
  tinyConfig.deepLoop = commandAndOptions && commandAndOptions.deepLoop
  fileFilter(tinyConfig.entry)
  console.log('本次执行脚本的配置:', tinyConfig)
  console.log('等待处理文件的数量:', tinyConfig.files.length)
  tinyConfig.files.forEach((img) => fileUpload(img))
}

最终,实现的效果如下:

$ naluduo tinyimg -r -d
? (必选) 压缩的图片文件夹路径/文件 
  ↓ .(root directory)/
    → .git/
      .gitignore
      README.md
    → bin/
    → node_modules/
      package-lock.json
      package.json
    → src/
    → tests/
(Move up and down to reveal more choices)

选中 tests 文件夹:

$ naluduo tinyimg
? (必选) 压缩的图片文件夹路径/文件 ~/Documents/develop/nalu-cli/tests
本次执行脚本的配置: {
  files: [],
  entry: '~/Documents/develop/nalu-cli/tests',
  deepLoop: undefined,
  replace: false,
  exts: [ '.jpg', '.png', '.jpeg' ],
  max: 5120000
}
等待处理文件的数量: 3

自动压缩图片压缩率高,如果遇到压缩的质量不好,这时候可考虑用 PhotoShop 手动检查图片质量。

4. 发布 npm 包与安装使用

为了让其他人也可以使用你的命令行工具,可考虑发布到 npm 仓库上,给 package.json 补充发布的配置:

{
  "name": "nalu-cli", // 指定包名,发布之前都要去NPM 官网上搜索一遍,确认想要使用的包名,是否已经被占用。
  "version": "0.1.0",
  "main": "src/index.js", // 指定包的入口文件
  "private": false,
  "bin": {
    "naluduo": "./bin/naluduo.js"
  }
}

然后进行登录和发布,注意的是,如果你是新用户,一定要点击激活 npm 发送给你的邮件,否则发包会出现 403 错误。激活后,要重新登录,重新发布。

$ npm login # 进行登录
Username: xxx
Password: 
Email: (this IS public) xxx
Logged in as naluduo233 on xxx

$ npm publish 
npm notice 
npm notice 📦  nalu-cli@0.1.0
npm notice === Tarball Contents === 
npm notice 435B  src/index.js           
npm notice 47B   bin/naluduo.js         
npm notice 4.6kB src/commands/tinyimg.js
npm notice 455B  package.json           
npm notice 75B   README.md              
npm notice === Tarball Details === 
npm notice name:          nalu-cli                                
npm notice version:       0.1.0                                   
npm notice package size:  2.7 kB                                  
npm notice unpacked size: 5.6 kB                                  
npm notice shasum:        4f71dd896bac28c4c1b284975d4df1c737e43292
npm notice integrity:     sha512-+NkZL5w0VFAGB[...]ODzboPWQ4Q7FA==
npm notice total files:   5                                       
npm notice 
+ nalu-cli@0.1.0

发布成功后,再在本地下载命令行工具,即可使用。也可以通过 npx naluduo tiny 的方式进行调用。

$ npm i -g naluduo

这里踩了个坑,执行 naluduo 命令时发现报错,找不到 commander

$ naluduo 
Error: Cannot find module 'commander'
Require stack:
- /Users/kayliang/.nvm/versions/node/v14.17.5/lib/node_modules/nalu-cli/src/index.js
- /Users/kayliang/.nvm/versions/node/v14.17.5/lib/node_modules/nalu-cli/bin/naluduo.js

查看对应的文件 nalu-cli 后,发现没有 node_modules 包,查看 package.json 发现:

 "devDependencies": {
    "commander": "^8.3.0",
    "inquirer": "^8.2.0",
    "inquirer-file-tree-selection-prompt": "^1.0.13"
 }

声明在 devDependencies 的包不会被安装,平时写项目代码时,devDependenciesdependencies 没有差别,但是如果发布库和包就要严格区分开来。

5. 小结

本文主要从自动压缩图片需求入手,然后一步步构建一个命令行工具,我们主要学习到了:

  • 全局可执行的命令行工具原理
  • 使用 Node 如何开发一个命令工具
  • 以及如何发布 npm 包以及使用

当前,这只是一个开始,很多脚本我们都可以封装成命令来使用。

代码地址 github.com/jecyu/nalu-…

参考资料