1. 前言
在小程序场景,由于代码包上传阶段限制了主包2M和总包20M,超过就会面临无法发版的风险,代码包体积的优化就变得特别重要。
ui 设计师输出的图片有 1x、2x、3x 倍,体积相对还是比较大的,我们可以使用 tinypng.com/ 压缩图片,保证图片高清的同时,尽量的减小图片的体积。
遇到多图片时,手动拖拽压缩、查看压缩质量,费时费力。能否通过自动压缩图片来解决呢?答案是可以,本文会通过写一个脚手架工具实现图片自动压缩。
2. 自动压缩图片脚本
2.1 思路分析
最开始,我思考的方向是考虑可以在项目打包的时候,自动压缩项目所有的图片,实测时会大大增加项目构建时间。并且依赖于 imagemin
、imagemin-jpegtran
、imagemin-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 终端应用程序。
用户可以终端上输入命令,告诉电脑要做什么。执行步骤为:
- 用户输入命令,提交给终端
- 判断是否为内置命令
- 在系统中查找该命令的文件并调入内存执行
- 内核中的系统功能调用。
在 Linux 中,可执行的文件也进行了分类:
- 内置命令:出于效率的考虑,将一些常用命令的解释程序构造在 Shell 内部。
- 外置命令:存放在 /bin、/sbin 目录下的命令。
- 实用程序:存放在 /usr/bin、/usr/sbin、/usr/share、/usr/local/bin 等目录下的实用程序。
- 用户程序:用户程序经过编译生成可执行文件后,可作为 Shell 命令运行
- Shell 脚本:由 Shell 语言编写的批处理文件,可作为 Shell 命令运行。
以上都可以称为命令行工具,比如 clear
、ls
、pwd
这些能够「在终端执行的系统命令」,被称为系统内置命令。可以使用 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」+ 「符号链接」
- npm 全局下载某个 package 到路径
/usr/local/lib/node_modules
下 (yarn 同理,对应路径~/.config/yarn/global/node_modules
) - 根据该库的 package.json 中
bin
字段的指示,把对应的命令行路径通过符号索引挂载到PATH
路径 - 对应的二进制脚本添加
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 link
把 package.json
的 bin
字段所指向的脚本进行全局软链接到系统 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 兼容的基本规律: 格式、可选、必选、简写、说明、帮助等等。
为快速实现开发,这里使用第三方库 commander
,yarn 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
的包不会被安装,平时写项目代码时,devDependencies
和 dependencies
没有差别,但是如果发布库和包就要严格区分开来。
5. 小结
本文主要从自动压缩图片需求入手,然后一步步构建一个命令行工具,我们主要学习到了:
- 全局可执行的命令行工具原理
- 使用 Node 如何开发一个命令工具
- 以及如何发布 npm 包以及使用
当前,这只是一个开始,很多脚本我们都可以封装成命令来使用。