NodeJS 樹結構遍歷 —— 深度優先和廣度優先

在這裏插入圖片描述

原文出自:https://www.pandashen.com


樹的基本概念

樹(Tree)是 n 個結點的有限集,n0 時,稱爲空樹,在任意一棵非空樹中有且僅有一個特定的被稱爲根(Root)的結點,當 n 大於 1 時,其餘結點可分爲 m 個互不相交的有限集 T1T2......Tm,其中每一個集合本身又是一棵樹,並且稱爲 SubTree,即根的子樹。

需要強調的是,n>0 時根結點是唯一的,不可能存在多個根結點,m>0 時,子樹的個數沒有限制,但它們一定是互不相交的。

從根開始定義起,根爲第一層,根的孩子爲第二層,若某結點在第 l 層,則其子樹就在第 l+1 層,其雙親在同一層的結點互爲 “堂兄弟”,樹中結點的最大層級數稱爲樹的深度(Depth)或高度。

在這裏插入圖片描述

在對樹結構進行遍歷時,按順序可分爲先序、中序和後續,按遍歷的方式可分爲深度優先和廣度優先,我們這篇文章就通過使用先序深度優先和先序廣度優先來實現 NodeJS 中遞歸刪除目錄結構,體會對樹結構的遍歷,文章中會大量用到 NodeJS 核心模塊 fs 的方法,可以通過 NodeJS 文件操作 —— fs 基本使用 來了解文中用到的 fs 模塊的方法及用法。

先序深度優先實現遞歸刪除文件目錄

深度優先的意思就是在遍歷當前文件目錄的時候,如果子文件夾內還有內容,就繼續遍歷子文件夾,直到遍歷到最深層不再有文件夾,則刪除其中的文件,再刪除這個文件夾,然後繼續遍歷它的 “兄弟”,直到內層文件目錄都被刪除,再刪除上一級,最後根文件夾爲空,刪除根文件夾。

在這裏插入圖片描述

1、同步的實現

我們要實現的函數參數爲要刪除的根文件夾的路徑,執行函數後會刪除這個根文件夾。

// 深度優先 —— 同步
// 引入依賴模塊
const fs = require("fs");
const path = require("path");

// 先序深度優先同步刪除文件夾
function rmDirDepSync(p) {
    // 獲取根文件夾的 Stats 對象
    let statObj = fs.statSync(p);

    // 檢查該文件夾的是否是文件夾
    if (statObj.isDirectory()) {
        // 查看文件夾內部
        let dirs = fs.readdirSync(p);

        // 將內部的文件和文件夾拼接成正確的路徑
        dirs = dirs.map(dir => path.jion(p, dir));

        // 循環遞歸處理 dirs 內的每一個文件或文件夾
        for (let i = 0; i < dirs.length; i++) {
            rmDirDepSync(dirs[i]);
        }

        // 等待都處理完後刪除該文件夾
        fs.rmdirSync(p);
    } else {
        // 若是文件則直接刪除
        fs.unlinkSync(p);
    }
}

// 調用
rmDirDepSync("a");

上面代碼在調用 rmDirDepSync 時傳入 a,先判斷 a 是否是文件夾,不是則直接刪除文件,是則查看文件目錄,使用 map 將根文件路徑拼接到每一個成員的名稱前,並返回合法的路徑集合,循環這個集合並對每一項進行遞歸,重複執行操作,最終實現刪除根文件夾內所有的文件和文件夾,並刪除根文件夾。

2、異步回調的實現

同步的實現會阻塞代碼的執行,每次執行一個文件操作,必須在執行完畢之後才能執行下一行代碼,相對於同步,異步的方式性能會更好一些,我們下面使用異步回調的方式來實現遞歸刪除文件目錄的函數。

函數有兩個參數,第一個參數同樣爲根文件夾的路徑,第二個參數爲一個回調函數,在文件目錄被全部刪除後執行。

// 深度優先 —— 異步回調
// 引入依賴模塊
const fs = require("fs");
const path = require("path");

// 先序深度優先異步(回調函數)刪除文件夾
function rmDirDepCb(p, callback) {
    // 獲取傳入路徑的 Stats 對象
    fs.stat(p, (err, statObj) => {
        // 判斷路徑下是否爲文件夾
        if (statObj.isDirectory()) {
            // 是文件夾則查看內部成員
            fs.readdir(p, (err, dirs) => {
                // 將文件夾成員拼接成合法路徑的集合
                dirs = dirs.map(dir => path.join(p, dir));

                // next 方法用來檢查集合內每一個路徑
                function next(index) {
                    // 如果所有成員檢查並刪除完成則刪除上一級目錄
                    if (index === dirs.length) return fs.rmdir(p, callback);

                    // 對路徑下每一個文件或文件夾執行遞歸,回調爲遞歸 next 檢查路徑集合中的下一項
                    rmDirDepCb(dirs[index], () => next(index + 1));
                }
                next(0);
            });
        } else {
            // 是文件則直接刪除
            fs.unlink(p, callback);
        }
    });
}

// 調用
rmDirDepCb("a", () => {
    console.log("刪除完成");
});

// 刪除完成

上面方法也遵循深度優先,與同步相比較主要思路是相同的,異步回調的實現更爲抽象,並不是通過循環去處理的文件夾下的每個成員的路徑,而是通過調用 next 函數和在成功刪除文件時遞歸執行 next 函數並維護 index 變量實現的。

3、異步 Promise 的實現

在異步回調函數的實現方式中,回調嵌套層級非常多,這在對代碼的可讀性和維護性上都造成困擾,在 ES6 規範中,Promise 的出現就是用來解決 “回調地獄” 的問題,所以我們也使用 Promise 來實現。

函數的參數爲要刪除的根文件夾的路徑,這次之所以不需要傳 callback 參數是因爲 callback 中的邏輯可以在調用函數之後鏈式調用 then 方法來執行。

// 深度優先 —— 異步 Promise
// 引入依賴模塊
const fs = require("fs");
const path = require("path");

// 先序深度優先異步(Promise)刪除文件夾
function rmDirDepPromise(p) {
    return new Promise((resolve, reject) => {
        // 獲取傳入路徑的 Stats 對象
        fs.stat(p, (err, statObj) => {
            // 判斷路徑下是否爲文件夾
            if (statObj.isDirectory()) {
                // 是文件夾則查看內部成員
                fs.readdir(p, (err, dirs) => {
                    // 將文件夾成員拼接成合法路徑的集合
                    dirs = dirs.map(dir => path.join(p, dir));

                    // 將所有的路徑都轉換成 Promise
                    dirs = dirs.map(dir => rmDirDepPromise(dir));

                    // 數組中路徑下所有的 Promise 都執行了 resolve 時,刪除上級目錄
                    Promise.all(dirs).then(() => fs.rmdir(p, resolve));
                });
            } else {
                // 是文件則直接刪除
                fs.unlink(p, resolve);
            }
        });
    });
}

// 調用
rmDirDepPromise("a").then(() => {
    console.log("刪除完成");
});

// 刪除完成

與異步回調函數的方式不同的是在調用 rmDirDepPromise 時直接返回了一個 Promise 實例,而在刪除文件成功或在刪除文件夾成功時直接調用了 resolve,在一個子文件夾下直接將這些成員通過遞歸 rmDirDepPromise 都轉換爲 Promise 實例,則可以用 Primise.all 來監聽這些成員刪除的狀態,如果都成功再調用 Primise.allthen 直接刪除上一級目錄。

4、異步 async/await 的實現

Promise 版本相對於異步回調版本從代碼的可讀性上有所提升,但是實現邏輯還是比較抽象,沒有同步代碼的可讀性好,如果想要 “魚” 和 “熊掌” 兼得,既要性能又要可讀性,可以使用 ES7 標準中的 async/await 來實現。

由於 async 函數的返回值爲一個 Promise 實例,所以參數只需要傳被刪除的根文件夾的路徑即可。

// 深度優先 —— 異步 async/await
// 引入依賴模塊
const fs = require("fs");
const path = require("path");
const { promisify } = require("util");

// 將用到 fs 模塊的異步方法轉換成 Primise
const stat = promisify(fs.stat);
const readdir = promisify(fs.readdir);
const rmdir = promisify(fs.rmdir);
const unlink = promisify(fs.unlink);

// 先序深度優先異步(async/await)刪除文件夾
async function rmDirDepAsync(p) {
    // 獲取傳入路徑的 Stats 對象
    let statObj = await stat(p);

    // 判斷路徑下是否爲文件夾
    if (statObj.isDirectory()) {
        // 是文件夾則查看內部成員
        let dirs = await readdir(p);

        // 將文件夾成員拼接成合法路徑的集合
        dirs = dirs.map(dir => path.join(p, dir));

        // 循環集合遞歸 rmDirDepAsync 處理所有的成員
        dirs = dirs.map(dir => rmDirDepAsync(dir));

        // 當所有的成員都成功
        await Promise.all(dirs);

        // 刪除該文件夾
        await rmdir(p);
    } else {
        // 是文件則直接刪除
        await unlink(p);
    }
}

// 調用
rmDirDepAsync("a").then(() => {
    console.log("刪除完成");
});

// 刪除完成

在遞歸 rmDirDepAsync 時,所有子文件夾內部的成員必須都刪除成功,才刪除這個子文件夾,在使用 unlink 刪除文件時,必須等待文件刪除結束才能讓 Promise 執行完成,所以也需要 await,所有遞歸之前的異步 Promise 都需要在遞歸內部的異步 Promise 執行完成後才能執行完成,所以涉及到異步的操作都使用了 await 進行等待。

先序廣度優先實現遞歸刪除文件目錄

廣度優先的意思是遍歷文件夾目錄的時候,先遍歷根文件夾,將內部的成員路徑一個一個的存入數組中,再繼續遍歷下一層,再將下一層的路徑都存入數組中,直到遍歷到最後一層,此時數組中的路徑順序爲第一層的路徑,第二層的路徑,直到最後一層的路徑,由於要刪除的文件夾必須爲空,所以刪除時,倒序遍歷這個數組取出路徑進行文件目錄的刪除。

在這裏插入圖片描述

在廣度優先的實現方式中同樣按照同步、異步回調、和 異步 async/await 這幾種方式分別來實現,因爲在拼接存儲路徑數組的時候沒有異步操作,所以單純使用 Promise 沒有太大的意義。

1、同步的實現

參數爲根文件夾的路徑,內部的 fs 方法同樣都使用同步方法。

// 廣度優先 —— 同步
// 引入依賴模塊
const fs = require("fs");
const path = require("path");

// 先序廣度優先同步刪除文件夾
function rmDirBreSync(p) {
    let pathArr = [p]; // 創建存儲路徑的數組,默認存入根路徑
    let index = 0; // 用於存儲取出數組成員的索引
    let current; // 用於存儲取出的成員,即路徑

    // 如果數組中能找到當前指定索引的項,則執行循環體,並將該項存入 current
    while ((current = arr[index++])) {
        // 獲取當前從數組中取出的路徑的 Stats 對象
        let statObj = fs.statSync(current);

        // 如果是文件夾,則讀取內容
        if (statObj.isDirectory()) {
            let dirs = fs.readdir(current);

            // 將獲取到的成員路徑處理爲合法路徑
            dirs = dirs.map(dir => path.join(current, dir));

            // 將原數組的成員路徑和處理後的成員路徑重新解構在 pathArr 中
            pathArr = [...pathArr, ...dirs];
        }
    }

    // 逆序循環 pathArr
    for (let i = pathArr.length - 1; i >= 0; i--) {
        let pathItem = pathArr[i]; // 當前循環項
        let statObj = fs.statSync(pathItem); // 獲取 Stats 對象

        // 如果是文件夾則刪除文件夾,是文件則刪除文件
        if (statObj.isDirectory()) {
            fs.rmdirSync(pathItem);
        } else {
            fs.unlinkSync(pathItem);
        }
    }
}

// 調用
rmDirBreSync("a");

通過 while 循環廣度遍歷,將所有的路徑按層級順序存入 pathArr 數組中,在通過 for 反向遍歷數組,對遍歷到的路徑進行判斷並調用對應的刪除方法,pathArr 後面的項存儲的都是最後一層的路徑,從後向前路徑的層級逐漸減小,所以反向遍歷不會導致刪除非空文件夾的操作。

2、異步回調的實現

函數有兩個參數,第一個參數爲根文件夾的路徑,第二個爲 callback,在刪除結束後執行。

// 廣度優先 —— 異步回調
// 引入依賴模塊
const fs = require("fs");
const path = require("path");

// 先序廣度優先異步(回調函數)刪除文件夾
function rmDirBreCb(p, callback) {
    let pathArr = [p]; // 創建存儲路徑的數組,默認存入根路徑

    function next(index) {
        // 如果已經都處理完,則調用刪除的函數
        if (index === pathArr.length) return remove();

        // 取出數組中的文件路徑
        let current = arr[index];

        // 獲取取出路徑的 Stats 對象
        fs.stat(currrent, (err, statObj) => {
            // 判斷是否是文件夾
            if (statObj.isDirectory()) {
                // 是文件夾讀取內部成員
                fs.readdir(current, (err, dirs) => {
                    // 將數組中成員名稱修改爲合法路徑
                    dirs = dirs.map(dir => path.join(current, dir));

                    // 將原數組的成員路徑和處理後的成員路徑重新解構在 pathArr 中
                    pathArr = [...pathArr, ...dirs];

                    // 遞歸取出數組的下一項進行檢測
                    next(index + 1);
                });
            } else {
                // 如果是文件則直接遞歸獲取數組的下一項進行檢測
                next(index + 1);
            }
        });
    }
    next(0);

    // 刪除的函數
    function remove() {
        function next(index) {
            // 如果全部刪除完成,執行回調函數
            if (index < 0) return callback();

            // 獲取數組的最後一項
            let current = pathArr[index];

            // 獲取該路徑的 Stats 對象
            fs.stat(current, (err, statObj) => {
                // 不管是文件還是文件夾都直接刪除
                if (statObj.isDirectory()) {
                    fs.rmdir(current, () => next(index - 1));
                } else {
                    fs.unlink(current, () => next(index - 1));
                }
            });
        }
        next(arr.length - 1);
    }
}

// 調用
rmDirBreCb("a", () => {
    console.log("刪除完成");
});

// 刪除完成

在調用 rmDirBreCb 時主要執行兩個步驟,第一個步驟是構造存儲路徑的數組,第二個步驟是逆序刪除數組中對應的文件或文件夾,爲了保證性能,兩個過程都是通過遞歸 next 函數並維護存儲索引的變量來實現的,而非循環。

在構造數組的過程中如果構造數組完成後,調用的刪除函數 remove,在 remove 中在刪除完成後,調用的 callback,實現思路是相同的,都是在遞歸時設置判斷條件,如果構造數組或刪除結束以後不繼續遞歸,而是直接執行對應的函數並跳出。

3、異步 async/await 的實現

參數爲刪除根文件夾的路徑,因爲 async 最後返回的是 Promise 實例,所以不需要 callback,刪除後的邏輯可以通過調用返回 Promise 實例的 then 來實現。

// 廣度優先 —— 異步 async/await
// 引入依賴模塊
const fs = require("fs");
const path = require("path");
const { promisify } = require("util");

// 將用到 fs 模塊的異步方法轉換成 Primise
const stat = promisify(fs.stat);
const readdir = promisify(fs.readdir);
const rmdir = promisify(fs.rmdir);
const unlink = promisify(fs.unlink);

// 先序廣度優先異步(async/await)刪除文件夾
async function rmDirBreAsync(p) {
    let pathArr = [p]; // 創建存儲路徑的數組,默認存入根路徑
    let index = 0; // 去數組中取出路徑的索引

    // 如果存在該項則繼續循環
    while (index !== pathArr.length) {
        // 取出當前的路徑
        let current = pathArr[index];

        // 獲取 Stats 對象
        let statObj = await stat(current);

        // 判斷是否是文件夾
        if (statObj.isDirectory()) {
            // 查看文件夾成員
            let dirs = await readdir(current);

            // 將路徑集合更改爲合法路徑集合
            dirs = dirs.map(dir => path.join(current, dir));

            // 合併存儲路徑的數組
            pathArr = [...pathArr, ...dirs];
        }
        index++;
    }

    let current; // 刪除的路徑

    // 循環取出路徑
    while ((current = pathArr.pop())) {
        // 獲取 Stats 對象
        let statObj = await stat(current);

        // 不管是文件還是文件夾都直接刪除
        if (statObj.isDirectory()) {
            await rmdir(current);
        } else {
            await unlink(current);
        }
    }
}

// 調用
rmDirBreAsync("a").then(() => {
    console.log("刪除完成");
});

// 刪除完成

上面的寫法都是使用同步的寫法,但對文件的操作都是異步的,並使用 await 進行等待,在創建路徑集合的數組和倒序刪除的過程都是通過 while 循環實現的。

總結

深度優先和廣度優先的兩種遍歷方式應該是考慮具體場景選擇最適合的方式使用,上面這麼多實現遞歸刪除文件目錄的方法中,重點在於體會深度遍歷和廣度遍歷的不同,其實在類似於遞歸刪除文件目錄的這種功能使用深度優先更適合一些。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章