深析filemap.js——關於JS的演算法及優化的實踐

Jrain發表於2018-12-19

寫於 2016.07.17

專案地址:連結描述 專案簡介:juejin.im/post/5c125a…

關於專案的用法和介紹可以檢視上面的兩個連結,這篇文章主要內容是對filemap.js的程式碼進行一步一步的分析,詳細介紹其執行原理和優化策略。

知識點準備:

  1. NodeJS的基本使用方法(主要是fs檔案系統);
  2. ES6特性及語法(let, const, for...of, arrow function...)
  3. n叉樹先序遍歷演算法

知識點1和2請自行查閱資料,現在對知識點3進行分析。

N叉樹先序遍歷演算法

首先明白什麼是樹。引用資料結構與演算法JavaScript描述

一個樹結構包含一系列存在父子關係的節點。每個節點都有一個父節點(除了頂部的第一個節點)以及零個或多個子節點:

深析filemap.js——關於JS的演算法及優化的實踐
位於樹頂部的節點叫作根節點(11)。它沒有父節點。樹中的每個元素都叫作節點,節點分為內部節點和外部節點。至少有一個子節點的節點稱為內部節點(7、5、9、15、13和20是內部節點)。沒有子元素的節點稱為外部節點或葉節點(3、6、8、10、12、14、18和25是葉節點)。 一個節點可以有祖先和後代。一個節點(除了根節點)的祖先包括父節點、祖父節點、曾祖父節點等。一個節點的後代包括子節點、孫子節點、曾孫節點等。例如,節點5的祖先有節點7和節點11,後代有節點3和節點6。 有關樹的另一個術語是子樹。子樹由節點和它的後代構成。例如,節點13、12和14構成了上圖中樹的一棵子樹。 節點的一個屬性是深度,節點的深度取決於它的祖先節點的數量。比如,節點3有3個祖先節點(5、7和11),它的深度為3。 樹的高度取決於所有節點深度的最大值。一棵樹也可以被分解成層級。根節點在第0層,它的子節點在第1層,以此類推。上圖中的樹的高度為3(最大高度已在圖中表示——第3層)。

對於一棵樹的遍歷,有先序中序後序三種遍歷方式,在本例中使用的是先序遍歷的方式。至於三種遍歷方式的異同,請閱讀資料結構與演算法JavaScript描述,裡面有詳細的介紹。

首先我們建立一棵樹:

let treeObj = {
    '1': [
        { '2': [{ '5': [{ '11': '11' }, { '12': '12' }, { '13': '13' }, { '14': '14' }] }] },
        { '3': [{ '6': '6' }, { '7': '7' }] },
        { '4': [{ '8': '8' }, { '9': '9' }, { '10': '10' }] }
    ]
}
複製程式碼

為了簡單方便,我把它的key和value都設定成了相同的值。在例子中我們使用的都是key值。 然後分析先序遍歷的原理:

深析filemap.js——關於JS的演算法及優化的實踐
虛線為遍歷順序,可以看出先序遍歷可以得到整棵樹的結構,這正是我們所需要的。接下來看程式碼如何實現。先看完整程式碼:

let traverseNode = (node, deep) => {
    if (typeof node !== 'string') {
        let key = Object.keys(node)
        console.log(key, deep)
        for (let i = 0; i < node[key].length; i++) {
            traverseNode(node[key][i], deep + 1)
        }
    }
}

traverseNode(treeObj, 1)
複製程式碼

我們建立了一個traverseNode()函式,它接收兩個物件作為引數。node引數為傳入的節點,deep引數為節點的起始深度。 首先使用Object.keys(obj)方法取得節點的key值,同時輸出深度值:

let key = Object.keys(node)
console.log(key, deep)
複製程式碼

執行,在控制檯將會輸出[ '1' ] 1。接下來我們使用遞迴來重複這個過程,進行完整的遍歷運算:

for (let i = 0; i < node[key].length; i++) {
    traverseNode(node[key][i], deep + 1)
}
複製程式碼

這個遞迴就是我們前文一直在說的先序遍歷。對於二叉樹

先序遍歷是以優先於後代節點的順序訪問每個節點的。先序遍歷的一種應用是列印一個結構化的文件。 先序遍歷會先訪問節點本身,然後再訪問它的左側子節點,最後是右側子節點。

深析filemap.js——關於JS的演算法及優化的實踐

在理解完上面這段話以後,不難把先序遍歷的思路擴充套件到n叉樹:先訪問節點本身,然後從左到右訪問它的n個子節點。 每一次完整的for迴圈都意味著“往下走一層”,所以只需要deep + 1即可知道每一個節點對應的深度。

在本例子的遍歷過程中,node都是一個個的物件而非字串。如果檢測到node為字串,證明其已經到了最後一層,需要停止,否則會無限迴圈導致溢位,所以我們需要新增一個判斷:

if (typeof node !== 'string')
複製程式碼

大功告成,現在我們嘗試執行一下:

[ '1' ] 1
[ '2' ] 2
[ '5' ] 3
[ '11' ] 4
[ '12' ] 4
[ '13' ] 4
[ '14' ] 4
[ '3' ] 2
[ '6' ] 3
[ '7' ] 3
[ '4' ] 2
[ '8' ] 3
[ '9' ] 3
[ '10' ] 3
複製程式碼

完美。

filemap.js原理

filemap.js通過遍歷一個資料夾內部的所有子檔案和子資料夾,輸出其目錄結構。我們使用fs檔案系統來進行。

const fs = require('fs')
複製程式碼

然後來構造核心部分程式碼:

// 判斷型別。若該路徑對應的是資料夾則返回true,否則返回false
let isDic = (url) => fs.statSync(url).isDirectory()

const traverseFiles = (path, deep) => {
  let files = fs.readdirSync(path)
  for (let i = 0, len = files.length; i < len; i++) {
    if (files[i] !== 'filemap.js') console.log(deep, files[i], '\n') // 忽略filemap.js本身
    let dirPath = path + '\\' + files[i]
    // 當且僅當是資料夾時才進行下一輪遍歷
    if (isDic(dirPath)) traverseFiles(dirPath, deep + 1)
  }
}
複製程式碼

檔案目錄結構其實就是一棵典型的n叉樹,通過前文的例子,不難明白這段程式碼的原理。首先通過fs.readdirSync(path)同步地獲取某路徑對應的所有檔案(夾),然後進行遞迴。可以把它理解為從第二層開始遍歷,所以在寫法上和前文例子稍有不同。

現在我們已經可以獲取檔案及其所在的深度了,接下來就是對這些資訊進行格式化,使其輸出更加直觀。為了輸出類似

|__folder
    |__file1
    |__file2
複製程式碼

這樣的樹狀結構,我們需要判斷不同的深度對應的縮排,所以我們來定義一個placeHolder()函式:

const placeHolder = (num) => {
  if (placeHolder.cache[num]) return placeHolder.cache[num] + '|__'
  placeHolder.cache[num] = ''
  for (let i = 0; i < num; i++) {
    placeHolder.cache[num] += '  '
  }
  return placeHolder.cache[num] + '|__'
}
placeHolder.cache = {}
複製程式碼

這裡涉及到一個快取函式執行結果的優化策略。由於該函式多次被使用,如果每一次都是從頭開始進行for迴圈,在效能上有著巨大的浪費。所以我們可以把它的執行結果快取起來,當以後遇到相同情況時只需要取出快取的結果即可,無需重新運算,大大提升了效能。

現在我們把核心程式碼改寫一下:

let isDic = (url) => fs.statSync(url).isDirectory()

const traverseFiles = (path, deep) => {
  let files = fs.readdirSync(path)
  for (let i = 0, len = files.length; i < len; i++) {
    if (files[i] !== 'filemap.js') console.log(placeHolder(deep), files[i], '\n') // 忽略filemap.js本身
    let dirPath = path + '\\' + files[i]
    if (isDic(dirPath)) traverseFiles(dirPath, deep + 1)
  }
}

traverseFiles('./', 1)
複製程式碼

在根目錄中執行node filemap.js,我們就能夠得到完美的檔案目錄樹狀結構圖了。

功能進一步擴充套件

現在是“無差別”地對所有資料夾進行展開。如果想要忽略某些資料夾,比如.git或者node_modules之類的資料夾,應該如何做呢?參考命令列輸入引數的方法,這個需求不難實現。 首先獲取需要忽略的資料夾名:

let ignoreCase = {}
if(process.argv[2] === '-i'){
    for (let i of process.argv.slice(3)) {
      ignoreCase[i] = true
    }
}
複製程式碼

ignoreCase儲存著需要忽略的資料夾名。這裡使用物件而不是陣列的原因是,當判斷一個item是否被已經被儲存的時候,item.indexOf(Array)的效率並沒有Object[item]來得高。使用for...of迴圈能夠直接取得物件。

接下來我們可以在核心程式碼中多加一個判斷:

let isDic = (url) => fs.statSync(url).isDirectory()

const traverseFiles = (path, deep) => {
  let files = fs.readdirSync(path)
  let con = false
  for (let i = 0, len = files.length; i < len; i++) {
    if (files[i] !== 'filemap.js') console.log(placeHolder(deep), files[i], '\n')
    con = ignoreCase[files[i]] === undefined? true: false
    let dirPath = path + '\\' + files[i]
    if (isDic(dirPath) && con) traverseFiles(dirPath, deep + 1)
  }
}
複製程式碼

被忽略的資料夾將不會進行遞迴運算。 最後別忘了在退出程式:

process.exit()
複製程式碼

至此,完整的filemap.js已經完成,其所有程式碼如下:

/**
 * @author Jrain Lau
 * @email jrainlau@163.com
 * @date 2016-07-14
 */
 
'use strict'
const fs = require('fs')

let ignoreCase = {}
if(process.argv[2] === '-i'){
    for (let i of process.argv.slice(3)) {
      ignoreCase[i] = true
    }
}

console.log('\n\nThe files tree is:\n=================\n\n')

const placeHolder = (num) => {
  if (placeHolder.cache[num]) return placeHolder.cache[num] + '|__'
  placeHolder.cache[num] = ''
  for (let i = 0; i < num; i++) {
    placeHolder.cache[num] += '  '
  }
  return placeHolder.cache[num] + '|__'
}
placeHolder.cache = {}

let isDic = (url) => fs.statSync(url).isDirectory()

const traverseFiles = (path, deep) => {
  let files = fs.readdirSync(path)
  let con = false
  for (let i = 0, len = files.length; i < len; i++) {
    if (files[i] !== 'filemap.js') console.log(placeHolder(deep), files[i], '\n')
    con = ignoreCase[files[i]] === undefined? true: false
    let dirPath = path + '\\' + files[i]
    if (isDic(dirPath) && con) traverseFiles(dirPath, deep + 1)
  }
}

traverseFiles('./', 1)

process.exit()
複製程式碼

使用時只需要帶上引數-i 資料夾1 資料夾2 ...即可控制資料夾的展開與否。

後記

在學習資料結構與演算法JavaScript描述的過程中,有時候真的覺得特別困,後來發揮自己喜歡折騰的個性,想辦法把枯燥的東西進行實踐,不知不覺就會變得有趣了。在filemap.js的早期版本中有著許多bug和效能問題,比如不合理使用三元表示式,沒有快取函式執行結果,判斷檔案型別考慮不周等等情況。文中所涉及到的優化策略,有很多是來自他人的指點和一次次的修改才最終得出來的,在此非常感謝給予我幫助的人。

最後感謝你的閱讀。我是Jrain,歡迎關注我的專欄,將不定期分享自己的學習體驗,開發心得,搬運牆外的乾貨。下次見啦!

相關文章