使用Node.js实现一个简单的命令行工具
作者:一颗冰淇淋
在日常的前端开发中,我们常常借助各种基于 Node.js 的脚手架工具来加速项目搭建和维护,比如 create-react-app
可以一键初始化一个 React
项目,eslint
则帮助我们保持代码的整洁和一致。而在公司内部,为了更好地满足特定业务的需求,我们往往会构建自己的脚手架工具,如自定义的 React 或 Vue 框架、内部使用的代码检查工具等。本篇文章来和大家分享一下如何用 Node.js
实现一个简单的命令行工具,模仿常用的 ls
命令,包括其 -a
和 -l
参数的功能。
ls 命令概览
首先,让我们快速回顾一下 ls
命令的一些基本用法。
ls
:列出当前目录下所有的非隐藏文件。ls -a
:列出所有文件,包括以点(.)开头的隐藏文件,同时还会显示当前目录(.)和上级目录(..)。ls -l
:以长格式列出文件详情,包括文件类型、权限、链接数等。ls -al
或ls -a -l
:结合 -a 和 -l 的功能,展示所有文件的详细信息。
简单来说,-a
参数用于显示隐藏文件和当前及上级目录,而 -l
参数则提供了更详细的文件信息。
如下图所示,当在初始化的新 React 项目目录中运行 ls
命令时,会看到如下情况:
ls -l 文件信息详解
当我们加上 -l 参数时,ls 命令会输出更多关于文件的信息:
1、文件类型:取第一个字符,d 代表目录,- 代表文件,l 代表链接。
2、用户操作权限:接下来的9个字符分为三组,分别表示文件所有者、所属组及其他用户的读、写、执行权限。
3、文件链接数:文件或目录的硬链接数。对于普通文件,这个数字通常是1。对于目录,这个数字至少为2,因为每个目录都包含两个特殊的目录 . 和 ..。
4、文件所有者:文件的所有者用户名,
5、文件所属组:文件所属的用户组名。
6、文件大小:文件的大小,以字节为单位。
7、最后修改时间:表示文件最后一次被修改的时间,格式为 月 日 时:分。
8、文件名:文件或目录的名称。
初始化项目
接下来,我们来实际动手实现一个类似的工具。首先,创建一个新的项目文件夹 ice-ls
,并运行 npm init -y
来生成 package.json
文件。
然后,在项目根目录下创建一个 bin
文件夹,并在其中添加一个名为 index.js
的文件。这个文件是我们的命令行工具的入口点,文件头部添加 #!/usr/bin/env node
以便可以直接执行。
#!/usr/bin/env node console.log('hello nodejs')
可以通过 ./bin/index.js
命令来测试这段代码是否正常工作,会看到 "hello nodejs" 的输出。
为了让我们的工具更加易于使用,在 package.json
中配置 bin
字段,这样通过一个简短的名字就可以调用。
bin: { "ice-ls": "./bin/index.js" }
为了在本地可以调试,使用 npm link
命令将项目链接到全局 node_modules
目录中,这样就能像使用其他全局命令一样使用 ice-ls
。
解析参数
命令行工具的一大特点是支持多种参数来改变行为。在我们的例子中,我们需要处理 -a
和 -l
参数。为此,可以在项目中创建一个 parseArgv.js
文件,用于解析命令行参数。
function parseArgv() { const argvList = process.argv.slice(2); // 忽略前两个默认参数 let isAll = false; let isList = false; argvList.forEach((item) => { if (item.includes("a")) { isAll = true; } if (item.includes("l")) { isList = true; } }); return { isAll, isList, }; } module.exports = { parseArgv, };
接着,我们需要在 bin/index.js
文件中引入 parseArgv
函数,并根据解析结果来调整文件的输出方式。
#!/usr/bin/env node const fs = require("fs"); const { parseArgv } = require("./parseArgv"); const dir = process.cwd(); // 获取当前工作目录 let files = fs.readdirSync(dir); // 读取目录内容 let output = ""; const { isAll, isList } = parseArgv(); if (isAll) { files = [".", ".."].concat(files); // 添加 . 和 .. } else { files = files.filter((item) => item.indexOf(".") !== 0); // 过滤掉隐藏文件 } let total = 0; // 初始化文件系统块的总用量 if (!isList) { files.forEach((file) => { output += `${file} `; }); } else { files.forEach((file, index) => { output += file; if (index !== files.length - 1) { output += "\n"; // 如果不是最后一个元素,则换行 } }); } if (!isList) { console.log(output); } else { console.log(`total ${total}`); console.log(output); }
输出内容如下图所示:
处理文件类型及权限
在 index.js 文件同层级创建 getType.js
文件,用于判断文件类型是目录、文件还是链接。我们可以通过 fs
模块获取文件状态信息,其中 mode
属性包含了文件类型和权限的信息。通过与 fs
常量模块按位与来判断文件类型。
Node.js
文件系统模块 fs
中存在一些常量,其中和文件类型有关且常用的是以下三类:
S_IFDIR
:用于检查一个文件是否是目录,数值为 0o040000(八进制)S_IFREG
:用于检查一个文件是否是普通文件,数值为 0o100000(八进制)S_IFLNK
:用于检查一个文件是否是符号链接,数值:0o120000(八进制)
const fs = require("fs"); function getFileType(mode) { const S_IFDIR = fs.constants.S_IFDIR; const S_IFREG = fs.constants.S_IFREG; const S_IFLINK = fs.constants.S_IFLINK; if (mode & S_IFDIR) return "d"; if (mode & S_IFREG) return "-"; if (mode & S_IFLINK) return "l"; return '?'; // 若无法识别,则返回问号 } module.exports = { getFileType, };
在 Unix 系统中,文件权限分为三类:
- 所有者(User):文件的拥有者。
- 组(Group):文件所属的用户组。
- 其他(Others):除所有者和组以外的其他用户。
每类权限又分为三种:
- 读权限(Read, r):允许读取文件内容或列出目录内容。
- 写权限(Write, w):允许修改文件内容或删除、重命名目录中的文件。
- 执行权限(Execute, x):允许执行文件或进入目录。
其中和以上权限相关的 nodejs 变量为:
S_IRUSR
:表示文件所有者的读权限(数值:0o400,十进制: 256)S_IWUSR
:文件所有者的写权限(数值:0o200,十进制:128)S_IXUSR
:文件所有者的执行权限(数值:0o100,十进制:64)S_IRGRP
:文件所属组的读权限(数值:0o040,十进制:32)S_IWGRP
:文件所属组的写权限(数值:0o020,十进制:16)S_IXGRP
:文件所属组的执行权限(数值:0o010,十进制:8)S_IROTH
:其他用户的读权限(数值:0o004,十进制:4)S_IWOTH
:其他用户的写权限(数值:0o002,十进制:2)S_IXOTH
:其他用户的执行权限(数值:0o001,十进制:1)
在 index.js 同层级创建 getAuth.js 文件来处理文件权限信息:
const fs = require("fs"); function getAuth(mode) { const S_IRUSR = mode & fs.constants.S_IRUSR ? "r" : "-"; const S_IWUSR = mode & fs.constants.S_IWUSR ? "w" : "-"; const S_IXUSR = mode & fs.constants.S_IXUSR ? "x" : "-"; const S_IRGRP = mode & fs.constants.S_IRGRP ? "r" : "-"; const S_IWGRP = mode & fs.constants.S_IWGRP ? "w" : "-"; const S_IXGRP = mode & fs.constants.S_IXGRP ? "x" : "-"; const S_IROTH = mode & fs.constants.S_IROTH ? "r" : "-"; const S_IWOTH = mode & fs.constants.S_IWOTH ? "w" : "-"; const S_IXOTH = mode & fs.constants.S_IXOTH ? "x" : "-"; return ( S_IRUSR + S_IWUSR + S_IXUSR + S_IRGRP + S_IWGRP + S_IXGRP + S_IROTH + S_IWOTH + S_IXOTH ); } module.exports = { getAuth, };
在 bin/index.js 文件中引入这两个模块,并使用它们来丰富文件信息的输出。
const path = require("path"); const { getAuth } = require("./getAuth"); const { getFileType } = require("./getFileType"); files.forEach((file, index) => { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); const { mode } = stat; // 获取权限 const type = getFileType(mode); const auth = getAuth(mode); // 获取文件名,增加空格 const fileName = ` ${file}`; output += `${type}${auth}${fileName}`; // 除了最后一个元素,都需要换行 if (index !== files.length - 1) { output += "\n"; } });
输出内容如下图所示:
处理文件链接数、总数、文件大小
在 Linux
或 Unix
系统中,通过命令行查看文件或目录的详细信息时,权限字符串后面的数字并不直接表示文件数量。例如,bin
文件夹下只有四个文件,但该数字显示为6。实际上,这个数字代表的是文件链接数,即有多少个硬链接指向该目录内的条目。
此外,ls -l
命令的第一行输出中的 total
值,并非指代文件总数,而是文件系统块的总用量。它反映了当前目录下所有文件及其子目录所占用的磁盘块数的总和。
为了方便理解和处理这些数据,我们可以使用 Node.js
的 fs.stat()
方法来获取文件的状态信息。
const { mode, size } = stat; // 获取文件链接数 const count = stat.nlink.toString().padStart(3, " "); // 获取文件大小 const fileSize = size.toString().padStart(5, " "); // 获取文件系统块的总用量 total += stat.blocks; output += `${type}${auth}${count}${fileName}`;
输出内容如下图所示:
获取用户信息
创建 getFileUser.js
文件,处理用户名称和组名称。虽然直接从文件状态(stat
)对象中可以获取到用户ID(uid
)和组ID(gid
),但是要将这些ID转换成对应的名称需要一些转换工作。
获取用户名称相对简单,可以通过执行命令 id -un <uid>
来实现。而对于组名称的获取,则稍微复杂一些,我们需要先通过 id -G <uid>
命令获取与用户关联的所有组ID列表,然后再使用 id -Gn <uid>
获取这些组的名称列表。最后,通过查找 gid 在所有组ID列表中的位置,来确定组名称。
如下图所示,在我的系统中,uid 是 502,gid 是 20,用户名称是 xingchen,组名称是 staff。
代码实现:
const { execSync } = require("child_process"); function getFileUser(stat) { const { uid, gid } = stat; // 获取用户名 const username = execSync("id -un " + uid) .toString() .trim(); // 获取组名列表及对应关系 const groupIds = execSync("id -G " + uid) .toString() .trim() .split(" "); const groupIdsName = execSync("id -Gn " + uid) .toString() .trim() .split(" "); const index = groupIds.findIndex((id) => +id === +gid); const groupName = groupIdsName[index]; return { username, groupName, }; } module.exports = { getFileUser, };
在项目的主入口文件 index.js
中引入刚刚创建的 getFileUser
模块,并调用它来获取文件的用户信息。
const { getFileUser } = require("./getFileUser");
再调整一下输出的内容
// 获取用户名 const { username, groupName } = getFileUser(stat); const u = username.padStart(9, " "); const g = groupName.padStart(7, " "); output += `${type}${auth}${count}${u}${g}${fileSize}${fileName}`;
最终输出效果如图所示:
获取修改时间
为了更好地展示文件信息中的时间部分,我们需要将原本的数字形式的时间转换为更易读的格式。这涉及到将月份从数字转换为缩写形式(如将1转换为"Jan"),同时确保日期、小时和分钟等字段在不足两位数时前面补零。
首先,我们在 config.js
文件中定义了一个对象来映射月份的数字与它们对应的英文缩写:
// 定义月份对应关系 const monthObj = { 1: "Jan", 2: "Feb", 3: "Mar", 4: "Apr", 5: "May", 6: "Jun", 7: "Jul", 8: "Aug", 9: "Sep", 10: "Oct", 11: "Nov", 12: "Dec", }; module.exports = { monthObj, };
接下来创建 getFileTime.js
文件,用于从文件状态对象(stat
)中提取并格式化修改时间:
function getFileTime(stat) { const { mtimeMs } = stat; const mTime = new Date(mtimeMs); const month = mTime.getMonth() + 1; // 获取月份,注意JavaScript中月份从0开始计数 const date = mTime.getDate(); // 不足2位在前一位补齐0 const hour = mTime.getHours().toString().padStart(2, 0); const minute = mTime.getMinutes().toString().padStart(2, 0); return { month, date, hour, minute, }; } module.exports = { getFileTime, };
在主文件 index.js
中,我们引入了上述两个模块,并使用它们来处理和格式化时间数据:
const { getFileTime } = require("./getFileTime"); const { monthObj } = require("./config"); // ...其他代码... // 获取创建时间 const { month, date, hour, minute } = getFileTime(stat); const m = monthObj[month].toString().padStart(4, " "); const d = date.toString().padStart(3, " "); const t = ` ${hour}:${minute}`; output += `${type}${auth}${count}${u}${g}${fileSize}${m}${d}${t}${fileName}`;
通过上述步骤,我们成功地实现了对 -l
选项下显示的所有文件信息的功能,实现效果如图所示:
发布
在完成所有功能开发后,我们可以准备将项目发布到 npm
仓库,以便其他人也能使用这个工具。首先,需要移除本地的 npm
链接,这样可以确保发布的版本是最新的,不会受到本地开发环境的影响。执行以下命令即可移除本地链接:
npm unlink
执行该命令后,再次尝试运行 ice-ls
命令,系统将会提示找不到该命令,这是因为本地链接已被移除。接着,登录 npm
账户,使用以下命令进行登录:
npm login
登录后,就可以通过以下命令将包发布到 npm
仓库:
npm publish
实现效果如下图所示:
至此,我们已经成功实现了一个类似于Linux
系统的 ls
命令行工具,它支持 -a
和-l
选项,能够列出当前目录下的所有文件(包括隐藏文件)以及详细的文件信息。
如果你对前端工程化有兴趣,或者想了解更多相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享。
完整代码
以下是 index.js 的完整代码,其他文件的完整代码均已在上面分析过程中贴出。
#!/usr/bin/env node const fs = require("fs"); const path = require("path"); const { parseArgv } = require("./parseArgv"); const { getAuth } = require("./getAuth"); const { getFileType } = require("./getFileType"); const { getFileUser } = require("./getFileUser"); const { getFileTime } = require("./getFileTime"); const { monthObj } = require("./config"); const dir = process.cwd(); let files = fs.readdirSync(dir); let output = ""; const { isAll, isList } = parseArgv(); if (isAll) { files = [".", ".."].concat(files); } else { files = files.filter((item) => item.indexOf(".") !== 0); } let total = 0; if (!isList) { files.forEach((file) => { output += `${file} `; }); } else { files.forEach((file, index) => { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); const { mode, size } = stat; // 获取权限 const type = getFileType(mode); const auth = getAuth(mode); // 获取文件链接数 const count = stat.nlink.toString().padStart(3, " "); // 获取用户名 const { username, groupName } = getFileUser(stat); const u = username.padStart(9, " "); const g = groupName.padStart(7, " "); // 获取文件大小 const fileSize = size.toString().padStart(5, " "); // 获取创建时间 const { month, date, hour, minute } = getFileTime(stat); const m = monthObj[month].toString().padStart(4, " "); const d = date.toString().padStart(3, " "); const t = ` ${hour}:${minute}`; // 获取文件名 const fileName = ` ${file}`; total += stat.blocks; output += `${type}${auth}${count}${u}${g}${fileSize}${m}${d}${t}${fileName}`; // 除了最后一个元素,都需要换行 if (index !== files.length - 1) { output += "\n"; } }); } if (!isList) { console.log(output); } else { console.log(`total ${total}`); console.log(output); }
到此这篇关于使用Node.js实现一个简单的命令行工具的文章就介绍到这了,更多相关Node.js命令行工具内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!