本文于2021年9月22日在掘金发布
先放 npm 包链接:www.npmjs.com/package/com… www.npmjs.com/package/com…
初始化
首先,创建一个 git 仓库用于代码版本管理
git init -y
初始化 npm 项目
npm init -y
在生成的 package.json 文件中,配置包名 package.name ,叫做 command-encoding,然后设置 license、author、repository 等字段的信息
对 npm 来说,要实现命令行工具很简单,我们只需要在 package.json 文件中增加一个 bin 字段,key 为你的命令,value 为对应的入口文件
比如:
{
"bin": {
"ce": "./bin/command-encoding.js",
"command-encoding": "./bin/command-encoding.js"
}
}
之后我们在项目根目录中新建 bin 文件夹,并在其中新建 command-encoding.js 文件
此文件第一行需要写一行注释 #! /usr/bin/env node
为了方便调试,我们需要执行 npm link 命令,让 npm 为我们创建一个软链接,将当前目录软链到 npm 全局文件夹中,同时在 npm 的全局文件夹中自动生成 ce.exe (windows 中) command-encoding.exe 两个可执行文件,当我们在终端中执行 ce 命令时,就是在用 node 执行 ./bin/command-encoding.js 文件
命令参数解析
作为一个命令行工具,自然少不了命令的参数解析
在 node 中,用 node 执行脚本文件时,参数等信息都可以从 process 对象上获取,如 process.argv,但是这太麻烦了,当然是用别人写好的包来解析命令和参数比较好
我们安装 commander 模块
npm install commander -S
怎么使用呢?
首先引入,然后拿到它的实例 program
const { Command } = require("commander");
const program = new Command();
之后注册命令和参数
// 注册命令
const command = commandprogram.command("encode");
command.option(
// 注册参数
"-t, --type <encoding type>", // 参数简略写法和全称可由逗号等分割符隔开
"填一个编码类型", // 这是参数的描述
"base64" // 这是参数的默认值
);
command.action((options) => {
// 当触发这个命令时要执行的逻辑
console.log(options);
});
最后是解析参数,调用
program.parse();
我们在命令行试一试
ce --help # 显示帮助
ce encode --help # 显示 encode 的命令的用法
ce encode -t base64 # 控制台打印 { type: 'base64' }
既然我们要手写一个 base64 编码器的话,那还得需要一个输入参数
command.option(
// 在上面再注册参数
"-i, --input <encoding input>",
"需要转码的字符串"
);
验证一下 ce encode -t base64 -i 这是测试base64编码,打印 { type: 'base64', input: '这是测试base64编码' }
base64 编码解析
让我们来复习一下 base64 编码
我们知道每个字符都可以用二进制表示,一个字节就是 8 位二进制数
如:这是测试base64编码,就可以解析成
这 11101000 10111111 10011001
是 11100110 10011000 10101111
测 11100110 10110101 10001011
试 11101000 10101111 10010101
b 01100010
a 01100001
s 01110011
e 01100101
6 00110110
4 00110100
编 11100111 10111100 10010110
码 11100111 10100000 10000001
base64 编码其实就是对二进制数据进行操作,将每三个字节分为一组,一共 24 位二进制数,每组再分成四份,一份 6 位二进制数
为什么一份是 6 位呢,因为 6 位二进制数正好可以表示不大于 64 的十进制数,在命令行 node 中计算一下
base64 的标准编码表是由 26 个大写字母,26 个小写字母,0 - 9,以及 + / 共 64 个字符组成,分别代表十进制数 0 - 十进制数 63
因此某个字符如果转换后的二进制数得到的十进制数为 0 20 30 61 25 10(我编的,你可以用 npx command-encoding decode -i ATd9YJ 看看解码结果是什么),那么对应的编码应该为 ATd9YJ
我们来看看在 这是测试base64编码 这个字符中的 这,是如何转换为 base64 编码的,在 utf8 编码下,它由三个字节组成,共 8 位二进制数,我们需要把它等分为 4 份
11101000 10111111 10011001 -> 111010 001011 111110 011001,如何把 2 进制转 10 进制呢
你可以这样计算 1 * 2^5 + 1 * 2^4 + 1 * 2^3 + 0 * 2^2+ 1 * 2^1 + 0 * 2^0 得 58,依次计算得二进制转换后的十进制数为 58、11、62、25,得到的 base64 编码为 6L+Z
标准的 base64 编码在最后是可能带有 = 号的,如果最后一组不满 4 份,则还差几份,就会在最后补充几个等号,因此 base64 编码末尾会有 0 - 2 个等号
实现 base64 编码
在上述代码中注册的 action 中的回调函数中,我们可以进行 base64 编码
先将输入转为 16 进制的 buffer
command.action((options) => {
const buffer = Buffer.from(options.input);
});
然后将 buffer 转为 2 进制的长字符串
/* 补充字符串的函数,由给定的最小长度与给定字符串的长度计算还需要多少字符来填充,并返回需要填充的字符串 */
const fillStr = (min, str, filledStr = "0") =>
new Array(min - str.length).fill(filledStr).join("");
command.action((options) => {
const buffer = Buffer.from(options.input);
// 十进制的数组
const _10Arr = Array.from(buffer);
// _2Str 会得到一个这样的字符串 "111010001011111110011001"
let _2Str = _10Arr.reduce((prevTotal, curr) => {
// 10 进制转为 2 进制
let _cur2Str = curr.toString(2);
// 不足 8 位则往前面补 0
if (_cur2Str.length < 8) {
_cur2Str = fillStr(8, _cur2Str) + _cur2Str;
}
// 然后依次拼接到一起
return prevTotal + _cur2Str;
}, "");
});
接下来,处理这个 _2Str 字符串,每次从前面裁剪 6 位出来,直到裁剪完毕后停止,这样我们可以用一个 while 循环来处理
while (_2Str) {
// 将 _2Str 的前面 6 位提取出来,每次循环只处理最前面 6 位
let current2Str = _2Str.slice(0, 6);
// 然后去掉 _2Str 中本次操作的前 6 位
// _2Str 赋值为 _2Str 的第6位之后的字符串
_2Str = _2Str.substr(6, _2Str.length);
// current2Str 有可能小于 6 位,此时需要往后面补 0
// 如果最后一次不足 6 位,则往后面补 0
if (current2Str.length < 6) {
current2Str += fillStr(6, current2Str);
}
}
有了转换后的二进制数据,我们就可以将其转换成十进制,然后基于编码表,转换为对应的编码字符
// 声明一个变量来存储编码后的 base64 结果字符串
let result = "";
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
while (_2Str) {
let current2Str = _2Str.slice(0, 6);
_2Str = _2Str.substr(6, _2Str.length);
if (current2Str.length < 6) {
current2Str += fillStr(6, current2Str);
}
// 转为 10 进制并依次添加到 result 中
result += chars[parseInt(current2Str, 2)];
}
// 循环完毕后,这个 result 就是编码后的 base64 字符串
但是这还没有结束,我们还需要判断是否需要补充等号,最后有不满 4 份的,还剩几份就补充几个等号
我们可以先求出还需要多少二进制数据,每份是 6,那么 还需要补充多少二进制数据 / 6,就能得到最后需要补充等号的个数
如何求出还需要多少字节才能组成一组 4 份呢?
我们想到了取余,用 当前二进制数据的总长度 % (4 * 6) 得到最后一组一共有多少个二进制数据,然后用 4 * 6 - 最后一组的二进制数据 得到还需要补充的二进制数据
let _2StrLength = 0;
while (_2Str) {
// xxxx
_2StrLength += current2Str.length;
// xxxx
}
// 一组里有多少个二进制数据
const groupLength = 4 * 6;
// 最后一组的二进制数据
const _2DataInLastGroup = _2StrLength % groupLength;
// 还需要补充的二进制数的个数
const _2LengthNeed = groupLength - (_2Left || groupLength);
// 得到等号的个数
const equalCount = _2LengthNeed / 6;
// 补充进最后的结果
result += fillStr(result.length + equalCount, result, "=");
这样一个 base64 编码器就完成了,最后 console.log(result) 就可以在控制台输出编码结果啦!
其他 basex 编码
base64 编码器能否更通用一些呢?
比如用它实现 base32 编码,base16 编码?
知道了 base32,base16 编码的原理,你就会发现这很简单
base64 编码中,编码表有 64 个字符,能表示的十进制数是从 0 - 63,所以其对应的二进制数不超过 6 位
base32 编码中,编码表有 32 个字符,能表示的十进制数是从 0 - 31,其对应的二进制数不能超过 5 位
同样的,base16 编码中,编码表有 16 个字符,能表示的十进制数是从 0 - 15,其对应的二进制数不能超过 4 位
我们得到这样一个关系
64 -> 6、32 -> 5、16 -> 4
这个关系能否用代码计算出来呢?我们可以用一个递归来进行计算
const calcBaseNum = (int, flag = 0, total = 0) => {
const current = 1 * Math.pow(2, flag++) + total;
if (int > current) return calcBaseNum(int, flag, current);
else return flag - 1;
};
const baseNum = calcBaseNum(64); // 6
编码表可以通过配置的形式
const chars = {
64: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
32: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
16: "0123456789ABCDEF",
};
那么,还剩余补等号规则的计算方法,三种编码中,唯一不同的可能就是上述 groupLength 这个值
在 base64 中,groupLength === 4 * 6 是怎么来的?
其实,这个数就是 6 和 8 的最小公倍数,是 24,同理,base32 编码中,5 和 8 的最小公倍数是 40,base16 编码中,4 和 8 的最小公倍数是 8,因此它不存在补充等号的情况
如何求出两个数的最大公倍数呢?
我们可以先利用辗转相除法求出它们的最小公约数
/**
* 求最大公约数
* 辗转相除法 就是用一个数除以另一个数(不需要知道大小),取余数,再用被除数除以余数再取余,再用新的被除数除以新的余数再取余,直到余数为0,最后的被除数就是最大公约数
* @param {number} number1
* @param {number} number2
*/
function getGreatestCommonDivisor(number1, number2) {
if (!Array.from(arguments).every(isNumber))
throw new TypeError(
"Only type of number can be got greatest common divisor!"
);
if (number2 == 0) return number1;
return getGreatestCommonDivisor(number2, number1 % number2);
}
然后,最大公倍数就是两数相乘再除以最大公约数
/**
* 求最小公倍数
* 最小公倍数=两数相乘再除以最大公约数
* @param {number} number1
* @param {number} number2
*/
function getLowestCommonMultiple(number1, number2) {
if (!Array.from(arguments).every(isNumber))
throw new TypeError(
"Only type of number can be got lowest common multiple!"
);
const gcd = getGreatestCommonDivisor(number1, number2);
if (gcd !== 0) {
return (number1 * number2) / gcd;
}
return 0;
}
所以 groupLength = getLowestCommonMultiple(calcBaseNum(64 /* 此处或32、16 */), 8)
基于上面 base64 编码的代码,我们就可以得出这三种编码的公有逻辑的完整代码
const chars = {
64: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
32: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
16: "0123456789ABCDEF",
};
function createEncodingBaseInt(int, chars) {
const baseNum = calcBaseNum(int);
// 最大公倍数,lcm / baseNum 得每组多少个 8bit, base64 -> 4 / base32 -> 8 / base16 -> 2
const lcm = getLowestCommonMultiple(baseNum, 8);
/**
* 编码 base x
* @param {Buffer | string} input buffer 数据或字符串
*/
return function encode(input) {
let buffer = input;
// 如果 buffer 不是 buffer 对象,则转为 buffer 对象
if (!Buffer.isBuffer(buffer)) {
buffer = Buffer.from(buffer);
}
// 将 buffer 对象转为数组
const _10Arr = Array.from(buffer);
// 将 bufferOfStr 生成一整个二进制字符串
let _2Str = _10Arr.reduce((prevTotal, curr) => {
// 10 进制转为 2 进制
let _cur2Str = curr.toString(2);
// 不足 8 位则往前面补 0
if (_cur2Str.length < 8) {
_cur2Str = fillStr(8, _cur2Str) + _cur2Str;
}
return prevTotal + _cur2Str;
}, "");
// 声明一个结果字符串,encodeBase64 函数最后返回的就是 result
let result = "";
let _2CurrentLength = 0;
// 当 _2Str 为空时,停止循环,循环体内操作 _2Str
while (_2Str) {
// 将 _2Str 的前面 6 位提取出来
let current2Str = _2Str.slice(0, baseNum);
// 如果最后一次不足 6 位,则往后面补 0
if (current2Str.length < baseNum) {
current2Str += fillStr(baseNum, current2Str);
}
_2CurrentLength += current2Str.length;
// _2Str 赋值为 _2Str 的第6位之后的字符串
_2Str = _2Str.substr(baseNum, _2Str.length);
// 将 2 进制的 current2Str 转为 10进制,作为编码字符串的索引,在其中查找字符,然后累加到 result 上
result += chars[parseInt(current2Str, 2)];
}
// 补等号处理
// 用最大公倍数 求还需要多少字节才能把组分完,然后除以 baseNum 求出需要多少组,就是需要补的等号的数量
const equalCount = (lcm - (_2CurrentLength % lcm || lcm)) / baseNum;
result += fillStr(result.length + equalCount, result, "=");
// 循环结束,返回生成的 base64 编码
return result;
};
}
const encode = createEncodingBaseInt(64, chars[64]);
console.log(encode("这是测试base64编码")); // 6L+Z5piv5rWL6K+VYmFzZTY057yW56CB
base64、base32、base16 的解码
知道了三种编码方式的原理,想要推导出它们的解码原理也并不困难,就是利用编码表,先将编码后的字符转为 10 进制数
然后,把 10 进制数转为 2 进制数,每 8 位合并在一起,再转换为原来的值,等号可以忽略
那么就废话不多说直接贴代码
/**
* 创建字符与10进制数的映射表
* @param {string} str
* @returns {Map}
*/
function createCharIndexMap(str) {
const map = new Map()
if (!isString(str)) return map
for (let i = 0; i < str.length; i ++) {
map.set(str[i], i)
}
return map
}
function createDecodingBaseInt(int, charsMap) {
const baseNum = calcBaseNum(int);
/**
* 解码 base x
*/
return function decode(input) {
if (!isString(input)) input = Buffer.from(input).toString();
let _2Str = "";
for (let i = 0; i < input.length; i++) {
const char = input[i];
if (char === "=") continue;
const charIndex = charsMap.get(char);
if (charIndex === undefined)
throw new Error("error: unsupport input: " + char);
let _current2Str = charIndex.toString(2);
_current2Str.length < baseNum &&
(_current2Str = fillStr(baseNum, _current2Str) + _current2Str);
_2Str += _current2Str;
}
const _10Arr = [];
while (_2Str) {
let current2Str = _2Str.slice(0, 8);
_10Arr.push(parseInt(current2Str, 2));
_2Str = _2Str.substr(8, _2Str.length);
}
return Buffer.from(_10Arr);
};
}
const decode = createDecodingBaseInt(64, createCharIndexMap(chars[64]))
console.log(decode("6L+Z5piv5rWL6K+VYmFzZTY057yW56CB")) // 是测试base64编码
data url 中的 base64 编码
在开发中我们能经常看见 data url 的身影,比如 img 标签的 src 属性中可以直接放置 data url,css 中,background 属性的 url() 中也可以直接放置 data url
浏览器中直接输入也可以直接进行访问
对比普通的 base64 编码,它就只是加入了前缀标注文件类型(和编码)
我们可以引入 mine 模块
npm install mime -S
先用 path 模块获取路径的后缀名 path.extname(xxxx),如果有,就可以通过 mine 模块获得 minetype mime.getType(path.extname(xxxx))
然后把得到的 mimetype 拼在 base64 编码前面
let base64Str = "6L+Z5piv5rWL6K+VYmFzZTY057yW56CB"
base64Str = `data:${mimetype};base64,${base64Str}`
这样就大功告成了!
如何把项目发布到 npm 上?
首先你需要一个 npm 账号
注册账号 www.npmjs.com/signup
之后在项目中登录 npm
npm login
注意把地址指向 npm 源
如:nrm use npm 或 npm login --registry=https://registry.npmjs.org
你可以使用
npm version patch
创建一个补丁版本,注意这会在本地 git 中创建一个 tag
然后直接发布
npm publish --registry=https://registry.npmjs.org
你可以在 package.json 中配置发布前要执行的命令,或者发布后要执行的命令
如:
{
"scripts": {
"postpublish": "git push origin master"
}
}
然后进入 www.npmjs.com/ 搜索你自己的 package,或者下载 npm install [your package name]
其他
调试结束后记得使用 npm unlink 解除软链接哦