使用遞迴遍歷並轉換樹形資料(以 TypeScript 為例)

abcjob發表於2021-09-09

一個朋友問我應該怎麼從一個樹的 JSON 陣列生成 HTML,使用 <ul><li> 來構建頁面元素。於是我簡單的畫了個樹型結構圖


然後寫了對應的模擬資料(JavaScript 物件)

const data = {    name: "A",    nodes: [        { name: "B", nodes: [{ name: "F" }] },        { name: "C" },        {            name: "D",            nodes: [                { name: "G" },                { name: "H" },                { name: "I", nodes: [{ name: "J" }, { name: "K" }] }            ]        },        { name: "E" }    ]};

最後寫了一個遞迴,生成了 HTML 的樹型結構。原本是用 JavaScript ES6 寫的,為了表明資料結構,這裡改用 TypeScript 來寫:

interface INode {    name: string;    nodes?: INode[];}function makeTree(roots: INode[]): JQuery<HTMLElement> {    function makeNode(node: INode): JQuery<HTMLElement> {        const $div = $("<div>").text(node.name || "");        const $li = $("<li>").append($div);        if (node.nodes && node.nodes.length) {            $li.append(makeNodeList(node.nodes));        }        return $li;    }    function makeNodeList(nodes: INode[]): JQuery<HTMLElement> {        return nodes            .map(child => makeNode(child))            .reduce(($ul, $li) => {                return $ul.append($li);            }, $("<ul>"));    }    return makeNodeList(roots);}

效果還是蠻不錯的


看看原始碼(轉譯成 JS 之後的):

然後朋友說沒看明白,好吧,那我從頭講起

遍歷方法

樹形資料的遍歷有兩種方法,大家都知道:廣度遍歷和深度遍歷。一般情況下,廣度遍歷是採用佇列來實現,而深度遍歷剛更適合使用遞迴來實現。


廣度遍歷

從圖上大致可以理解廣度遍歷的過程:

  1. 準備一個空佇列;

  2. 將根(單根或多根均可)節點放到佇列中;

  3. 從佇列中取出一個節點

  4. 處理(比如列印)這個節點

  5. 檢查節點的子節點,如果有,全部依次新增到佇列中

  6. 回到第 3 步開始處理,直到佇列為空(處理完成)

function travelWidely(roots: INode[]) {    const queue: INode[] = [...roots];    while (queue.length) {        const node = queue.shift()!;        // 列印節點名稱及其子節點數        console.log(`${node.name} ${node.nodes && node.nodes.length || ""}`);        if (node.nodes && node.nodes.length) {            queue.push(...node.nodes);        }    }}// 開始遍歷travelWidely([data]);

const node = queue.shift()!,這後面的 ! 字尾表示宣告其結果不為 undefinednull。這是一個 TypeScript 語法。由於 .shift() 在陣列中沒有元素時會返回 undefined,所以其返回型別被宣告為 INode | undefined,由於從邏輯可以保證 .shift() 一定會返回一個節點物件,所以這裡用 ! 字尾忽略型別中的 undefined 部分,使 node 的型別被推導為 INode

程式碼裡稍難理解一點的是要注意 queue 的內容和長度隨時在變化。如果想使用 for 代替 while 迴圈,節點序號會因 .shift() 而不斷變化,所以 i &lt; queue.length 這樣的判斷是錯誤的。

深度遍歷

深度遍歷是一個遞迴過程,遞迴一直是程式設計的難點

遞迴是一個迴圈往復的處理過程,它有兩個點需要注意:

  • 遞迴呼叫點,遞迴呼叫自己(或另一個可能會呼叫自己的函式)

  • 遞迴結束點,退出當前函式

以樹節點為例,我們期望處理過程是處理(列印)一個樹結點,即 printNode(node: INode)。那麼它的

  • 遞迴呼叫點:如果該節點有子節點,依次對子節點呼叫 printNode(children[i])

  • 遞迴結束點:處理完所有子節點(子節點數量是有限的,所以一定會結束)

用一段虛擬碼描述這一過程

function printNode(node: INode) {    // 處理該節點    console.log(node.name);    // 遞迴呼叫點:迴圈對子節點呼叫 printNode    node.nodes!.forEach(child => printNode(child));    // 遞迴結束點:迴圈完成,return}

上面兩句程式碼就完成了遞迴過程,但實際上情況還要複雜些,因為要處理入口和容錯。

// 注意引數支援傳入單根或多根,// 如果像 travelWidely 那樣只支援多根(單根是特例)也是可以的function travelDeeply(roots: INode | INode[]) {    function printNode(node: INode) {        console.log(`${node.name} ${node.nodes && node.nodes.length || ""}`);        if (node.nodes && node.nodes.length) {            // 依次對子節點遞迴呼叫 printNode            node.nodes.forEach(child => printNode(child));        }    }    // 這裡 printNode 和 node => printNode(node) 等價    (Array.isArray(roots) ? roots : [roots]).forEach(printNode);}// 開始遍歷travelDeeply(data);

關於遞迴,我正好在慕課網上講生成資料解決方案的時候講到了,有興趣可以看看。

遍歷還沒講完

上面兩種遍歷都講到了,但是還沒講完——因為兩種遍歷都是以列印為例,而我們的目的是要生成 DOM 樹。生成 DOM 樹與純列印資訊的不同之處在於,我們不僅要使用節點資訊,還要從節點資訊生成 DOM 返回出來。

深度遍歷生成節點

這次先講深度遍歷,因為遞迴更容易實現。遞迴本身具有層次資訊,每進入一個遞迴呼叫點,就會深入一層,每離開一個遞迴結束點,就會減少一層。所以這個演算法本身能夠保留結構資訊,相應程式碼也會更容易實現。而且在本文一開始,就已經實現出來了。

需要注意的一點是那段程式碼用了兩個函式來完成遞迴過程:

  • makeNode 處理單個節點,它呼叫 makeNodeList 處理子節點列表

  • makeNodeList 遍歷節點列表,分別對其呼叫 makeNode 來進行處理

makeNodemakeNodeList 的相互呼叫形成了遞迴,上述兩條都是遞迴呼叫點,而遞迴結束點同樣也有兩條:

  • makeNode 處理的節點沒有子節點時,不會呼叫 makeNodeList

  • makeNodeList 中的迴圈結束時,不會再呼叫 makeNode

廣度遍歷生成節點

廣度遍歷的過程是把所有節點扁平化到一個佇列中了,這個過程是不可逆 的,換句話說,我們在處理過程中丟掉了樹形結構資訊。然後我們要生成的 DOM 樹,是需要結構資訊的——因此,需要將結構資訊附加在每個節點上。這裡我們把生成的 DOM 和資料節點繫結起來,由 DOM 儲存結構資訊。為此,需要修改一下節點型別

interface INode {    name: string;    nodes?: INode[];    dom: JQuery;    // 附加生成的 DOM}
function makeTreeWidely(roots: INode[]): JQuery {    // 從一組節點生成 <ul>,為每個節點生成並附加 <li>,    // 同時將 <li> 到到 <ul> 中儲存結構資訊    function makeUl(nodes: INode[]) {        return nodes            .map(node => {                const $li = $("<li>")                    .append($("<div>").text(node.name || ""));                node.dom = $li;                return $li;            })            .reduce(($ul, $li) => $ul.append($li), $("<ul>"));    }    const $rootUl = makeUl(roots);    const queue: INode[] = [...roots];    while (queue.length) {        const node = queue.shift()!;        if (node.nodes && node.nodes.length) {            const $ul = makeUl(node.nodes);            node.dom.append($ul);            queue.push(...node.nodes);        }    }    return $rootUl;}

雖然這裡和上面講遞迴遍歷 printNode 的時候一樣定義了區域性函式表示式 makeUl,但這裡沒有遞迴,因為 makeUl 內部沒有呼叫自身,或者某個會呼叫 makeUl 的函式。

但問題還是再深入一點,因為上面的程式碼改變了原資料。而一般情況下,我們應該儘量避免這樣的副作用

沒有副作用的廣度遍歷生成節點

// 宣告一個新結構,它把 INode 和 DOM 組合在一起。// 這個結構將代替 INode 作為佇列的元素型別interface IDomNode {    node: INode;    dom: JQuery;}function makeTreeWidely(roots: INode[]): JQuery {    // convert 將節點陣列轉換為 IDomNode 陣列,    // 同時還幹了原來 makeUl 乾的事情,返回一個 $ul    function convert(nodes: INode[]) {        const domNodes = nodes            .map(node => {                const $li = $("<li>")                    .append($("<div>").text(node.name || ""));                return {                    node,                    dom: $li                };            });        const $ul = domNodes            .reduce(($ul, dn) => $ul.append(dn.dom), $("<ul>"));        // 將兩個陣列組成一個元組(物件)返回        return {            domNodes,            $ul        };    }    // 解析元組,宣告變數 queue 和 $rootUl,    // 並分別將 domNodes 和 $ul 的值賦值給 queue 和 $rootUl 兩個變數    const { domNodes: queue, $ul: $rootUl } = convert(roots);    while (queue.length) {        const { node, dom } = queue.shift()!;        if (node.nodes && node.nodes.length) {            const { domNodes, $ul } = convert(node.nodes);            dom.append($ul);            queue.push(...domNodes);        }    }    return $rootUl;}


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4798/viewspace-2817615/,如需轉載,請註明出處,否則將追究法律責任。

相關文章