Node.js 構建命令列工具:實現 ls 命令的 -a 和 -l 選項

一颗冰淇淋發表於2024-11-10

在日常的前端開發中,我們常常藉助各種基於 Node.js 的腳手架工具來加速專案搭建和維護,比如 create-react-app 可以一鍵初始化一個 React 專案,eslint 則幫助我們保持程式碼的整潔和一致。而在公司內部,為了更好地滿足特定業務的需求,我們往往會構建自己的腳手架工具,如自定義的 React 或 Vue 框架、內部使用的程式碼檢查工具等。本篇文章來和大家分享一下如何用 Node.js 實現一個簡單的命令列工具,模仿常用的 ls 命令,包括其 -a-l 引數的功能。

ls 命令概覽

首先,讓我們快速回顧一下 ls 命令的一些基本用法。

  • ls:列出當前目錄下所有的非隱藏檔案。
  • ls -a:列出所有檔案,包括以點(.)開頭的隱藏檔案,同時還會顯示當前目錄(.)和上級目錄(..)。
  • ls -l:以長格式列出檔案詳情,包括檔案型別、許可權、連結數等。
  • ls -alls -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";
  }
});

輸出內容如下圖所示:

處理檔案連結數、總數、檔案大小

LinuxUnix 系統中,透過命令列檢視檔案或目錄的詳細資訊時,許可權字串後面的數字並不直接表示檔案數量。例如,bin 資料夾下只有四個檔案,但該數字顯示為6。實際上,這個數字代表的是檔案連結數,即有多少個硬連結指向該目錄內的條目。

此外,ls -l 命令的第一行輸出中的 total 值,並非指代檔案總數,而是檔案系統塊的總用量。它反映了當前目錄下所有檔案及其子目錄所佔用的磁碟塊數的總和。

為了方便理解和處理這些資料,我們可以使用 Node.jsfs.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);
}

相關文章