前端基于nodejs的提效工具,最常见的是通过命令行方式,比如我们最常见的各种脚手架CLI工具。 这里我们进一步,介绍一个【命令行 + 右键菜单】相结合的(图片压缩)工具,看看如何实现通过鼠标右键菜单执行nodejs。从而省去让使用者手动打开终端,按照文档要求手写命令的步骤。
1. 搭建nodejs命令行工具基础架构
所有的nodejs命令行工具都有类似的最基础架构,总结如图:
2. 基于自己的工具需求,扩展架构
核心需求:
-
命令行压缩图片
-
鼠标右键压缩图片
围绕这两点核心需求,我们分析我们可能要处理的事情有:
-
处理用户输入的各种命令 ———— command.js
-
压缩图片能力 ———— miniOperation.js
-
修改注册表实现右键菜单 ———— regeditOperation.js
-
修改压缩工具配置 ———— tinifyOperation.js
3. 关键代码
(1)miniOpration压缩模块
- 核心依赖
const tinify = require("tinify")
... // 设置key
tinify.fromFile(url).toFile(url).then(res => {})
- 扩展结构
const tinify = require("tinify")
const tinifyOperation = require("./tinifyOperation")
let globUrl = `${process.cwd()}/*.+(png|jpg|jpeg|webp)`
function miniImage() {
glob(globUrl, null, function (er, files) {
const promise = []
files.forEach((url) => {
promise.push(
tinify.fromFile(url).toFile(url).then(res => {
}).catch(error => {
})
)
})
Promise.allSettled(promise).then(() => {
// 全部完成
})
})
}
function init() {
tinify.key = tinifyOperation.getKey()
miniImage()
}
init()
为什么这里要拆分两个函数,因为压缩的途径有命令行,也有右键菜单,可能压缩一张也可能压缩目录下所有图片,需要一个init入口对场景做区分,然后设置场景对应的 globUrl
- 完整代码
const fs = require("fs")
const path = require("path")
const glob = require("glob")
const tinify = require("tinify")
const chalk = require("chalk")
const Spinnies = require('spinnies')
const tinifyOperation = require("./tinifyOperation")
let globUrl = `${process.cwd()}/*.+(png|jpg|jpeg|webp)`
function miniImage() {
function getSize(url){
...
}
const imgs = {}
glob(globUrl, null, function (er, files) {
if(!files.length) {
...
return
}
console.log(chalk.white(`======将压缩以下图片:`));
console.log(files)
console.log()
const spinnies = new Spinnies();
spinnies.add(`loading`, { text: `正在压缩...` });
const promise = []
files.forEach((url) => {
imgs[url] = {
originSize: getSize(url),
minSize: 0
}
promise.push(
tinify.fromFile(url).toFile(url).then(res => {
imgs[url].minSize = getSize(url)
console.log(chalk.green(`✓ Success! 压缩 ${url} 成功 ${imgs[url].originSize} ==> ${imgs[url].minSize}`));
}).catch(error => {
console.log(chalk.red(`✖ Fail!压缩 ${url} 失败`));
console.error(error)
})
)
})
Promise.allSettled(promise).then(() => {
...
spinnies.succeed(`loading`, { text: `Success! 压缩工作已完成, 总体压缩结果: ${totalOriginSize.toFixed(2)} KB ==> ${totalMinSize.toFixed(2)} KB` });
})
})
}
function init() {
if(process.argv[2] === 'min') {
// 命令行压缩
if(process.argv.length > 3) {
// 判断是否为指定压缩图片
globUrl = `${process.cwd()}/+(${process.argv.splice(3).join('|')})`
}
}else{
// 菜单压缩
if(process.argv.length > 2) {
// 判断是否为压缩选择文件
globUrl = process.argv.splice(2).join(' ')
if(!['png', 'jpg', 'jpeg', 'webp'].includes(path.extname(globUrl).substr(1))){
console.log(chalk.red('压缩失败,只支持:png,jpg,webp!'))
return
}
}
}
tinify.key = tinifyOperation.getKey()
miniImage()
}
init()
(2)tinifyOperation配置模块
const path = require("path")
const fs = require("fs")
function getKey() {
return fs.readFileSync(path.join(__dirname, '../config/tinify.key.txt'), {encoding: 'utf-8'})
}
function setKey(key) {
return fs.writeFileSync(path.join(__dirname, '../config/tinify.key.txt'), key, {encoding: 'utf-8'})
}
module.exports = {
getKey,
setKey
}
(3)command分发模块
- 命令行基础结构
const { program } = require('commander')
function initCommander(){
const packageJson = require('../package.json');
program
.version(packageJson.version)
.description(packageJson.description)
.usage('<command> [options]')
program
.command('***')
.description('******')
.action((***) => {
...
})
program
.command('***')
.description('******')
.action((***) => {
...
})
...
program.parse(process.argv);
}
module.exports = {initCommander}
- 根据功能需求扩展
const { program } = require('commander')
const chalk = require("chalk")
const Spinnies = require('spinnies')
const tinifyConfig = require("./tinifyOperation")
const regeditOperation = require("./regeditOperation")
const spinnies = new Spinnies();
function initCommander(){
const packageJson = require('../package.json');
program
.version(packageJson.version)
.description(packageJson.description)
.usage('<command> [options]')
program
.command('init [key]')
.description('【请以管理员身份运行CMD】执行此命令,可选参数为tinyfy API Key')
.action((key) => {
// 配置注册表
regeditOperation.init()
// 设置tinyfy key
if(!key) {
console.log()
console.log(chalk.red('======警告======'))
console.log(chalk.white('检查到您未输入 tinify API KEY'))
}else {
tinifyConfig.setKey(key)
console.log(chalk.green('Success! 设置 tinify API KEY 成功!'))
}
})
program
.command('setkey <key>')
.description('设置工具所依赖tinify的API KEY值')
.action((key) => {
spinnies.add('addkey', { text: `设置 KEY...` });
tinifyConfig.setKey(key)
spinnies.succeed('addkey', { text: `Success! 设置 KEY 成功` });
})
program
.command('cleanRegedit')
.description('【请以管理员身份运行CMD】清除注册表信息')
.action(() => {
regeditOperation.cleanRegedit()
})
program
.command('min [name...]')
.description('命令行方式压缩图片')
.action((name) => {
require("./miniOpration")
})
program.parse(process.argv);
}
可以通过 -h查看到我们配置的命令:
(4)regeditOperation注册表模块
nodejs实现注册表依赖 regedit,但关键的问题并不是它的API如何调用,而是给目录和图片文件增加注册表的配置应该是怎么写,人肉排雷一波得到一些总结:
注册表路径
增加注册表配置,你首先需要知道注册路径在哪里,注册表中与需求所关注的右键有关几个项:
- HKEY_CLASSES_ROOT*\shell\ 对所有文件有效
- HKEY_CLASSES_ROOT\Directory\shell\ 对一般文件夹有效
- HKEY_CLASSES_ROOT\Directory\Background\shell\ 在文件夹空白处右键弹出的菜单
- HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\CommandStore\shell 制作层叠菜单时用于存放子项名称及命令
- HKEY_CLASSES_ROOT\SystemFileAssociations\image\shell\ 图片右键(测试不能包含webp)
- HKEY_CLASSES_ROOT\SystemFileAssociations.webp\shell\ webp格式右键
结合我的需求,我需要:文件夹空白处右键 + 图片选中状态右键,选择3,5,6
注册表配置的项和键值
路径确定之后,接下来需要明确该配置什么,也就是配置怎么的项和键值,以目录下压缩所有图片为例,为了测试更简单,我们在前期通过手动配置,既通过CMD输入regedit打开注册表编辑器,手动添加注册表项测试。
- 复制地址进入 HKEY_CLASSES_ROOT\Directory\shell\
- 在shell目录,新建,项,这里项名称为imgmin
- 右侧窗口修改【默认】键值对,值改为“压缩目录下所有图片”,配置右键菜单的文案
- 右侧窗口,新建,字符串值,名称为“Icon”,值改为我的node.exe地址,这是配置菜单图片,不配置不影响功能
- 选中imgmin,新建,项,项名称必须为command
- 选中command,值配置为你要执行的命令,比如cmd
打开任意磁盘文件夹,空白处右键,即可看见新增菜单“压缩目录下所有图片”,点击打开CMD窗口
通过regedit实现注册
- 核心API
const regedit = require('regedit')
regedit.createKey(["your path..."],(err) => {
regedit.putValue({
[key] : val,
[key] : val,
...
}
})
...
regedit.deleteKey(["your path..."],(err) => {...})
代码中用到的API分别是:新增项,给项设置键值,删除项。我们依赖这三个API就可以完成我们的需求。
- 注册表配置代码
const regeditConfig =
{
path:[
['HKCR\\Directory\\Background\\shell\\imgmin', 'HKCR\\Directory\\Background\\shell\\imgmin\\command'],
['HKCR\\SystemFileAssociations\\image\\shell\\imgmin', 'HKCR\\SystemFileAssociations\\image\\shell\\imgmin\\command'],
['HKCR\\SystemFileAssociations\\.webp\\shell\\imgmin', 'HKCR\\SystemFileAssociations\\.webp\\shell\\imgmin\\command'],
],
value:{
// 目录压缩
allType: [{
'REG_DEFAULT': {
value: '压缩目录下所有图片',
type: 'REG_DEFAULT'
},
'Icon': {
value: process.execPath,
type: 'REG_SZ'
}
},
{
'REG_DEFAULT': {
value: `cmd /k node ${path.join(__dirname,'./miniOpration.js').replace(/\\/g, '/')}`,
type: 'REG_DEFAULT'
}
}],
// 选择文件压缩
selectType: [{
'REG_DEFAULT': {
value: '压缩图片',
type: 'REG_DEFAULT'
},
'Icon': {
value: process.execPath,
type: 'REG_SZ'
}
},
{
'REG_DEFAULT': {
value: `cmd /c node ${path.join(__dirname,'./miniOpration.js').replace(/\\/g, '/')} %1`,
type: 'REG_DEFAULT'
}
}],
}
}
关键解读:
- 注册表项,默认键值对的KEY名称为“REG_DEFAULT”
- 这里Icon图片使用的值为 process.execPath,其实就是Node.js 的可执行文件的绝对路径名,也就是用了Node的图标
- 最关键的是菜单执行的命令值:
// 压缩目录下所有图片,执行的node命令地址其实指向的直接是miniOpration模块
cmd /k node ${path.join(__dirname,'./miniOpration.js').replace(/\\/g, '/')
// 压缩单个或选择多个图片压缩
cmd /c node ${path.join(__dirname,'./miniOpration.js').replace(/\\/g, '/')} %1
后者是选择单个或多个文件的时候,有两个区别点:
- /c: 执行完成之后自动关闭,否则有可能打开非常多的窗口。没有找到办法在不打开窗口的情况下执行node命令
- %1: 路径结尾加 %1的目的是,在执行node命令文件的时候,js可以从process.argv获取当前选中文件的文件名,这样我们才能知道到底要压缩当前目录下哪个文件
将以上配置文件,通过regedit核心API,执行配置(具体代码可以下载源码)
配置成功后,打开注册表编辑器检查结果:
4. 工具调试
命令行工具可以通过npm link实现本地调试
npm link实际上做了两件事情
- 在{prefix}/node_modules中添加包源码的软链接
- 在Unix上为{prefix}/bin,在Windows上为{prefix}目录下,生成一个package.json中配置的命令,并且命令最终的执行文件地址为刚刚添加的软连接的bin文件
5. 源码
-
npm包名:@lilingyun132/imgmin
6. 遗留问题
注册表如何配置二级菜单?