查看原文
其他

在 NPM 发布自己造的轮子

前端大全 2020-02-18

(给前端大全加星标,提升前端技能


作者:Croc_wend

https://segmentfault.com/a/1190000018439337


1、前言

自从Node.js出现,它的好基友npm(node package manager)也是我们日常开发中必不可少的东西。npm让js实现了模块化,使得复用其他人写好的模块(搬砖)变得更加方便,也让我们可以分享一些自己的作品给大家使用(造轮子),今天这里我就给大家分享一个用命令行压缩图片的工具,它的用法大致是这样的:

  1. // 全局安装后,在图片目录下,运行这行

  2. $ tinyhere

这样就把文件夹内的图片进行压缩。这里压缩采用的是tinypng(https://tinypng.com/)提供的接口,压缩率大致上是50%,基本可以压一半的大小。以前在写项目的时候,测试验收完成后总是要自己手动去压一次图片,后来想把这个枯燥重复的事自动化去完成(懒),但是公司脚手架又没有集成这个东西,就想自己写一个轮子做出来用用就好了。它的名字叫做 tinyhere,大家可以去安装使用试一下:

  1. $ npm i tinyhere -g

2、npm简介

如果要写一个模块发布到npm,那么首先要了解一下npm的用法。

给这个模块建一个文件夹,然后在目录内运行 npm init来初始化它的package.json,就是这个包的描述:

  1. // 个人比较喜欢后面带--yes,它会生成一个带默认参数的package.json

  2. $ npm init (--yes)

package.json详情:

  1. {

  2. "name": "pkgname", // 包名,默认文件夹的名字

  3. "version": "1.0.0",

  4. "description": "my package",

  5. "main": "index.js", // 如果只是用来全局安装的话,可以不写

  6. "bin": "cli", // 如果是命令行使用的话,必须要这个,名字就是命令名

  7. "scripts": {

  8. "test": "echo \"Error: no test specified\" && exit 1" // npm run test对应的test

  9. },

  10. "keywords": ['cli', 'images', 'compress'],

  11. "author": "croc-wend",

  12. "license": "MIT",

  13. ...

  14. }

更多配置信息可以参考一下vue的package.json(https://github.com/vuejs/vue/blob/dev/package.json)。

初始化完成之后,你就可以着手写这个包了,当你觉得你写好了之后,就可以发布到npm上面:

  1. npm login

  2. npm publish

  3. + pkgname@1.0.0 // 成功

这时,你在npm上面搜你的包名,你写在package.json的信息都会被解析,然后你的包的页面介绍内容就是你的README.md。

3、写这个包

包初始化好了之后,我们就可以开始写这个包了。

对于这个压缩工具来说,要用到的素材只有两个,tinypng接口要用到的api-key,需要压缩的图片,所以我对这两个素材需要用到的一些操作进行了以下分析:


我的初衷是想把这个命令写的尽量简单,让我可以联想到压缩图片=简单,所以我待定了整个包只有一个单词就能跑,是这样:

  1. $ tinyhere

其他的操作都放在子命令和可选项上。

然后开始划分项目结构:

大致上是这样,把全局命令执行的 tinyhere 放在bin目录下,然后subCommand负责提供操作函数,然后把可复用的函数(比如读写操作)抽离出来放在util上,比较复杂的功能单独抽离成一个文件,比如compress,然后导出一个函数给subCommand。至于存放用户的api-key,就存放在data下面的key里。

tinyhere的执行文件就负责解析用户的输入,然后执行subCommand给出的对应函数。

4、过程解析

压缩图片的这个包的过程是这样的:

1、解析当前目录内的所有图片文件,这里应该根据二进制流及文件头获取文件类型mime-type,然后读取文件二进制的头信息,获取其真实的文件类型,来判断它是否真的是图片文件,而不是那些仅仅是后缀名改成.png的假货。

2、 如果用户有要求把压缩的图片存放到指定目录,那就需要生成一个文件夹来存放它们。那么,首先要判断这个路径是否合法,然后再去生成这个目录。

3、判断用户的api-key的剩余次数是否足够这次的图片压缩,如果这个key不够,就换到下一个key,知道遍历文件内所有的key找到有可用的key为止。

4、图片和key都有了,这时可以进行压缩了。用一个数组把压缩失败的存起来,然后每次压缩完成都输出提示,在所有图片都处理完成后,如果存在压缩失败的,就询问是否把压缩失败的图继续压缩。

5、这样,一次压缩就处理完成了。压缩过的图片会覆盖原有的图片,或者是存放到指定的路径里。

ps: $ tinyhere deep >>> 把目录内的所有图片都进行压缩(含子目录)。这个命令和上述的主命令的流程有点不同,目前有点头绪,还没有开发完成,考虑到文件系统是树形结构,我目前的想法是通过深度遍历,把存在图片的文件夹当作一个单位,然后递归执行压缩。

其他:

这里吐槽一下tinypng 的接口写的真的烂。。在查询key的合法性的 validate 函数只接受报错的回调,但是成功却没有任何动作。我真是服了,之前是做延时来判断用户的key的合法性,最后实在是受不了这个bug一样的写法了,决定用 Object.defineProperty 来监听它的使用次数的变化。如果它的setter被调用则说明它是一个合法的key了。

5、小结

在这里,我想跟大家说,如果你做了一个你觉得很酷的东西,也想给更多的人去使用,来让它变得更好,选择发布在NPM上面就是一个非常好的途径,看了上面的内容你会发现分享其实真的不难,你也有机会让世界看到属于你的风采!

如果大家觉得我有哪里写错了,写得不好,有其它什么建议(夸奖),非常欢迎大家补充。希望能让大家交流意见,相互学习,一起进步! 我是一名 19 的应届新人,以上就是今天的分享,新手上路中,后续不定期周更(或者是月更哈哈),我会努力让自己变得更优秀、写出更好的文章,文章中有不对之处,烦请各位大神斧正。如果你觉得这篇文章对你有所帮助,请记得点赞或者品论留言哦~。

6、写在最后

欢迎大家提issue或者建议!地址在这:

  • https://github.com/Croc-ye/tinyhere

  • https://www.npmjs.com/package/tinyhere

最后贴上部分代码,内容过长,可以跳过哦

bin/tinyhere

  1. #!/usr/bin/env node


  2. const commander = require('commander');

  3. const {init, addKey, deleteKey, emptyKey, list, compress} = require('../libs/subCommand.js');

  4. const {getKeys} = require('../libs/util.js');


  5. // 主命令

  6. commander

  7. .version(require('../package').version, '-v, --version')

  8. .usage('[options]')

  9. .option('-p, --path <newPath>', '压缩后的图片存放到指定路径(使用相对路径)')

  10. .option('-a, --add <key>', '添加api-key')

  11. .option('--delete <key>', '删除指定api-key')

  12. .option('-l, --list', '显示已储存的api-key')

  13. .option('--empty', '清空已储存的api-key')


  14. // 子命令

  15. commander

  16. .command('deep')

  17. .description('把该目录内的所有图片(含子目录)的图片都进行压缩')

  18. .action(()=> {

  19. // deepCompress();

  20. console.log('尚未完成,敬请期待');

  21. })


  22. commander.parse(process.argv);



  23. // 选择入口

  24. if (commander.path) {

  25. // 把图片存放到其他路径

  26. compress(commander.path);

  27. } else if (commander.add) {

  28. // 添加api-key

  29. addKey(commander.add);

  30. } else if (commander.delete) {

  31. // 删除api-key

  32. deleteKey(commander.delete);

  33. } else if (commander.list) {

  34. // 显示api-key

  35. list();

  36. } else if (commander.empty) {

  37. // 清空api-key

  38. emptyKey();

  39. } else {

  40. // 主命令

  41. if (typeof commander.args[0] === 'object') {

  42. // 子命令

  43. return;

  44. }

  45. if (commander.args.length !== 0) {

  46. console.log('未知命令');

  47. return;

  48. }

  49. if (getKeys().length === 0) {

  50. console.log('请初始化你的api-key')

  51. init();

  52. } else {

  53. compress();

  54. }

  55. };

libs/compress.js

  1. const tinify = require('tinify');

  2. const fs = require("fs");

  3. const path = require('path');

  4. const imageinfo = require('imageinfo');

  5. const inquirer = require('inquirer');

  6. const {checkApiKey, getKeys} = require('./util');


  7. // 对当前目录内的图片进行压缩

  8. const compress = (newPath = '')=> {

  9. const imageList = readDir();

  10. if (imageList.length === 0) {

  11. console.log('当前目录内无可用于压缩的图片');

  12. return;

  13. }

  14. newPath = path.join(process.cwd(), newPath);

  15. mkDir(newPath);


  16. findValidateKey(imageList.length);

  17. console.log('===========开始压缩=========');

  18. if (newPath !== process.cwd()) {

  19. console.log('压缩到: ' + newPath.replace(/\./g, ''));

  20. }

  21. compressArray(imageList, newPath);

  22. };


  23. // 生成目录路径

  24. const mkDir = (filePath)=> {

  25. if (filePath && dirExists(filePath) === false) {

  26. fs.mkdirSync(filePath);

  27. }

  28. }


  29. // 判断目录是否存在

  30. const dirExists = (filePath)=> {

  31. let res = false;

  32. try {

  33. res = fs.existsSync(filePath);

  34. } catch (error) {

  35. console.log('非法路径');

  36. process.exit();

  37. }

  38. return res;

  39. };



  40. /**

  41. * 检查api-key剩余次数是否大于500

  42. * @param {*} count 本次需要压缩的图片数目

  43. */

  44. const checkCompressionCount = (count = 0)=> {

  45. return (500 - tinify.compressionCount - count) >> 0;

  46. }


  47. /**

  48. * 找到可用的api-key

  49. * @param {*} imageLength 本次需要压缩的图片数目

  50. */

  51. const findValidateKey = async imageLength=> { // bug高发处

  52. const keys = getKeys();

  53. for (let i = 0; i < keys.length; i++) {

  54. await checkApiKey(keys[i]);

  55. res = checkCompressionCount(imageLength);

  56. if (res) return;

  57. }

  58. console.log('已存储的所有api-key都超出了本月500张限制,如果要继续使用请添加新的api-key');

  59. process.exit();

  60. }


  61. // 获取当前目录的所有png/jpg文件

  62. const readDir = ()=> {

  63. const filePath = process.cwd()

  64. const arr = fs.readdirSync(filePath).filter(item=> {

  65. // 这里应该根据二进制流及文件头获取文件类型mime-type,然后读取文件二进制的头信息,获取其真实的文件类型,对与通过后缀名获得的文件类型进行比较。

  66. if (/(\.png|\.jpg|\.jpeg)$/.test(item)) { // 求不要出现奇奇怪怪的文件名。。

  67. const fileInfo = fs.readFileSync(item);

  68. const info = imageinfo(fileInfo);

  69. return /png|jpg|jpeg/.test(info.mimeType);

  70. }

  71. return false;

  72. });

  73. return arr;

  74. };


  75. /**

  76. * 对数组内的图片名进行压缩

  77. * @param {*} imageList 存放图片名的数组

  78. * @param {*} newPath 压缩后的图片的存放地址

  79. */

  80. const compressArray = (imageList, newPath)=> {

  81. const failList = [];

  82. imageList.forEach(item=> {

  83. compressImg(item, imageList.length, failList, newPath);

  84. });

  85. }


  86. /**

  87. * 压缩给定名称的图片

  88. * @param {*} name 文件名

  89. * @param {*} fullLen 全部文件数量

  90. * @param {*} failsList 压缩失败的数组

  91. * @param {*} filePath 用来存放的新地址

  92. */

  93. const compressImg = (name, fullLen, failsList, filePath)=> {

  94. fs.readFile(name, function(err, sourceData) {

  95. if (err) throw err;

  96. tinify.fromBuffer(sourceData).toBuffer(function(err, resultData) {

  97. if (err) throw err;

  98. filePath = path.join(filePath, name);

  99. const writerStream = fs.createWriteStream(filePath);

  100. // 标记文件末尾

  101. writerStream.write(resultData,'binary');

  102. writerStream.end();


  103. // 处理流事件 --> data, end, and error

  104. writerStream.on('finish', function() {

  105. failsList.push(null);

  106. record(name, true, failsList.length, fullLen);

  107. if (failsList.length === fullLen) {

  108. finishcb(failsList, filePath);

  109. }

  110. });


  111. writerStream.on('error', function(err){

  112. failsList.push(name);

  113. record(name, false, failsList.length, fullLen);

  114. if (failsList.length === fullLen) {

  115. finishcb(failsList, filePath);

  116. }

  117. });

  118. });

  119. });

  120. }


  121. // 生成日志

  122. const record = (name, success = true, currNum, fullLen)=> {

  123. const status = success ? '完成' : '失败';

  124. console.log(`${name} 压缩${status}。 ${currNum}/${fullLen}`);

  125. }


  126. /**

  127. * 完成调用的回调

  128. * @param {*} failList 存储压缩失败图片名的数组

  129. * @param {*} filePath 用来存放的新地址

  130. */

  131. const finishcb = (failList, filePath)=> {

  132. const rest = 500 - tinify.compressionCount;

  133. console.log('本月剩余次数:' + rest);

  134. const fails = failList.filter(item=> item !== null);

  135. if (fails.length > 0) {

  136. // 存在压缩失败的项目(展示失败的项目名),询问是否把压缩失败的继续压缩 y/n

  137. // 选择否之后,询问是否生成错误日志

  138. inquirer.prompt({

  139. type: 'confirm',

  140. name: 'compressAgain',

  141. message: '存在压缩失败的图片,是否将失败的图片继续压缩?',

  142. default: true

  143. }).then(res=> {

  144. if (res) {

  145. compressArray(failList, filePath);

  146. } else {

  147. // 询问是否生成错误日志

  148. }

  149. })

  150. } else {

  151. // 压缩完成

  152. console.log('======图片已全部压缩完成======');

  153. }

  154. }


  155. module.exports = {

  156. compress

  157. }

libs/subCommand.js

  1. const inquirer = require('inquirer');

  2. const {compress} = require('./compress.js');

  3. const {checkApiKey, getKeys, addKeyToFile, list} = require('./util.js');


  4. module.exports.compress = compress;

  5. module.exports.init = ()=> {

  6. inquirer.prompt({

  7. type: 'input',

  8. name: 'apiKey',

  9. message: '请输入api-key:',

  10. validate: (apiKey)=> {

  11. // console.log('\n正在检测,请稍候...');

  12. process.stdout.write('\n正在检测,请稍候...');

  13. return new Promise(async (resolve)=> {

  14. const res = await checkApiKey(apiKey);

  15. resolve(res);

  16. });

  17. }

  18. }).then(async res=> {

  19. await addKeyToFile(res.apiKey);

  20. console.log('apikey 已完成初始化,压缩工具可以使用了');

  21. })

  22. }


  23. module.exports.addKey = async key=> {

  24. await checkApiKey(key);

  25. const keys = await getKeys();

  26. if (keys.includes(key)) {

  27. console.log('该api-key已存在文件内');

  28. return;

  29. }

  30. const content = keys.length === 0 ? '' : keys.join(' ') + ' ';

  31. await addKeyToFile(key, content);

  32. list();

  33. }


  34. module.exports.deleteKey = async key=> {

  35. const keys = await getKeys();

  36. const index = keys.indexOf(key);

  37. if (index < 0) {

  38. console.log('该api-key不存在');

  39. return;

  40. }

  41. keys.splice(index, 1);

  42. console.log(keys);

  43. const content = keys.length === 0 ? '' : keys.join(' ');

  44. await addKeyToFile('', content);

  45. list();

  46. }


  47. module.exports.emptyKey = async key=> {

  48. inquirer.prompt({

  49. type: 'confirm',

  50. name: 'emptyConfirm',

  51. message: '确认清空所有已存储的api-key?',

  52. default: true

  53. }).then(res=> {

  54. if (res.emptyConfirm) {

  55. addKeyToFile('');

  56. } else {

  57. console.log('已取消');

  58. }

  59. })

  60. }


  61. module.exports.list = list;

libs/util.js

  1. const fs = require('fs');

  2. const path = require('path');

  3. const tinify = require('tinify');

  4. const KEY_FILE_PATH = path.join(__dirname, './data/key');


  5. // 睡眠

  6. const sleep = (ms)=> {

  7. return new Promise(function(resolve) {

  8. setTimeout(()=> {

  9. resolve(true);

  10. }, ms);

  11. });

  12. }

  13. // 判定apikey是否有效

  14. const checkApiKey = async apiKey=> {

  15. return new Promise(async resolve=> {

  16. let res = true;

  17. res = /^\w{32}$/.test(apiKey);

  18. if (res === false) {

  19. console.log('api-key格式不对');

  20. resolve(res);

  21. return;

  22. }

  23. res = await checkKeyValidate(apiKey);

  24. resolve(res);

  25. })

  26. }

  27. // 检查api-key是否存在

  28. const checkKeyValidate = apiKey=> {

  29. return new Promise(async (resolve)=> {

  30. tinify.key = apiKey;

  31. tinify.validate(function(err) {

  32. if (err) {

  33. console.log('该api-key不是有效值');

  34. resolve(false);

  35. }

  36. });

  37. let count = 500;

  38. Object.defineProperty(tinify, 'compressionCount', {

  39. get: ()=> {

  40. return count;

  41. },

  42. set: newValue => {

  43. count = newValue;

  44. resolve(true);

  45. },

  46. enumerable : true,

  47. configurable : true

  48. });

  49. });

  50. };


  51. // 获取文件内的key,以数组的形式返回

  52. const getKeys = ()=> {

  53. const keys = fs.readFileSync(KEY_FILE_PATH, 'utf-8').split(' ');

  54. return keys[0] === '' ? [] : keys;

  55. }


  56. // 把api-key写入到文件里

  57. const addKeyToFile = (apiKey, content = '')=> {

  58. return new Promise(async resolve=> {

  59. const writerStream = fs.createWriteStream(KEY_FILE_PATH);

  60. // 使用 utf8 编码写入数据

  61. writerStream.write(content + apiKey,'UTF8');


  62. // 标记文件末尾

  63. writerStream.end();


  64. // 处理流事件 --> data, end, and error

  65. writerStream.on('finish', function() {

  66. console.log('=====已更新=====');

  67. resolve(true);

  68. });


  69. writerStream.on('error', function(err){

  70. console.log(err.stack);

  71. console.log('写入失败。');

  72. resolve(false);

  73. });

  74. })

  75. }


  76. // 显示文件内的api-key

  77. const list = ()=> {

  78. const keys = getKeys();

  79. if (keys.length === 0) {

  80. console.log('没有存储api-key');

  81. } else {

  82. keys.forEach((key)=> {

  83. console.log(key);

  84. });

  85. }

  86. };

  87. module.exports = {

  88. sleep,

  89. checkApiKey,

  90. getKeys,

  91. addKeyToFile,

  92. list

  93. }



推荐阅读

(点击标题可跳转阅读)

635000 个 npm 包中我应该用哪个?

趣图:如果 NPM 是快递公司…

从0开始发布一个无依赖、高质量的 npm 包



觉得本文对你有帮助?请分享给更多人

关注「前端大全」加星标,提升前端技能

喜欢就点一下「好看」呗~

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存