基于nodejs开发右键菜单工具源码解析

885 阅读6分钟

前端基于nodejs的提效工具,最常见的是通过命令行方式,比如我们最常见的各种脚手架CLI工具。 这里我们进一步,介绍一个【命令行 + 右键菜单】相结合的(图片压缩)工具,看看如何实现通过鼠标右键菜单执行nodejs。从而省去让使用者手动打开终端,按照文档要求手写命令的步骤。

1. 搭建nodejs命令行工具基础架构

所有的nodejs命令行工具都有类似的最基础架构,总结如图:

CLI.png

2. 基于自己的工具需求,扩展架构

核心需求:

  • 命令行压缩图片

  • 鼠标右键压缩图片

围绕这两点核心需求,我们分析我们可能要处理的事情有:

  • 处理用户输入的各种命令 ———— command.js

  • 压缩图片能力 ———— miniOperation.js

  • 修改注册表实现右键菜单 ———— regeditOperation.js

  • 修改压缩工具配置 ———— tinifyOperation.js

扩展目录.png

command.png

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查看到我们配置的命令:

image.png

(4)regeditOperation注册表模块

nodejs实现注册表依赖 regedit,但关键的问题并不是它的API如何调用,而是给目录和图片文件增加注册表的配置应该是怎么写,人肉排雷一波得到一些总结:

注册表路径

增加注册表配置,你首先需要知道注册路径在哪里,注册表中与需求所关注的右键有关几个项:

  1. HKEY_CLASSES_ROOT*\shell\ 对所有文件有效
  2. HKEY_CLASSES_ROOT\Directory\shell\ 对一般文件夹有效
  3. HKEY_CLASSES_ROOT\Directory\Background\shell\ 在文件夹空白处右键弹出的菜单
  4. HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\CommandStore\shell 制作层叠菜单时用于存放子项名称及命令
  5. HKEY_CLASSES_ROOT\SystemFileAssociations\image\shell\ 图片右键(测试不能包含webp)
  6. HKEY_CLASSES_ROOT\SystemFileAssociations.webp\shell\ webp格式右键

结合我的需求,我需要:文件夹空白处右键 + 图片选中状态右键,选择3,5,6

注册表配置的项和键值

路径确定之后,接下来需要明确该配置什么,也就是配置怎么的项和键值,以目录下压缩所有图片为例,为了测试更简单,我们在前期通过手动配置,既通过CMD输入regedit打开注册表编辑器,手动添加注册表项测试。

image.png

  • 复制地址进入 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,执行配置(具体代码可以下载源码)

配置成功后,打开注册表编辑器检查结果:

image.png

4. 工具调试

命令行工具可以通过npm link实现本地调试

00f05b5408ef1619d5ff771aa83ee99.png

npm link实际上做了两件事情

  • 在{prefix}/node_modules中添加包源码的软链接
  • 在Unix上为{prefix}/bin,在Windows上为{prefix}目录下,生成一个package.json中配置的命令,并且命令最终的执行文件地址为刚刚添加的软连接的bin文件

5. 源码

6. 遗留问题

注册表如何配置二级菜单?