用Vue編寫一個簡單的仿Explorer文件管理器

大家一定很熟悉你桌面左上角那個小電腦吧,學名Windows資源管理器,幾乎所有的工作都從這裡開始,文件雲端化是一種趨勢。怎樣用瀏覽器實現一個Web版本的Windows資源管理器呢?今天來用Vue好好盤一盤它。

一、導航原理

首先操作和仔細觀察導航欄,我們有幾個操作途徑:

  • 點擊「向上」按鈕回到上一個目錄,點擊地址欄的文件夾名稱返回任意一個目錄
  • 雙擊文件夾進入新目錄
  • 點擊「前進」,「後退」按鈕操作導航

其中前進,後退操作,可以點擊小三角查看一個列表,點擊進入文件夾,列表會記錄導航歷史,哪怕反覆進入同一個文件夾,列表仍然會記錄下來,如下圖:

 

那麼我們就能分析並抽象出兩個變數:

  1. 一個用於存儲實際導航的變數(navigationStack)
  2. 另一個用於存儲導航歷史的變數(navigationHistoryStack)

導航堆棧用於存儲每一個瀏覽文件夾的資訊,拼接起這些文件夾就形成了當前路徑, 一組簡單的<li>元素通過綁定導航堆棧,就能形成地址欄(web世界裡也叫麵包屑導航)了。

navigationStack實際上是一個堆棧,用的是先進後出(FILO)原則

導航歷史則是單純記錄了用戶的操作軌跡,不會收到導航目標的影響,如剛才所述,哪怕反覆進入同一個文件夾,列表仍然會記錄下來

navigationHistoryStack實際上是一個隊列,用的是先進先出(FIFO)原則

接下來我們開始碼程式碼

我們先新建一個Vue項目(Typescript),打開App.vue文件

script標籤里編寫程式碼如下:

<script lang='ts'>
export default {
  name: "App",
  data: () => {
    return {
      navigationStack: new Array<FileDto>(),
      navigationHistoryStack: new Array<FileDto>(),
    };
  }
}
</script>

 

二、文件夾跳轉原理

我們先來看如下數據結構

export class FileDto {
  id: number;        //唯一id
  parentId: number;    //父id
  fileName: string;    //文件名稱
  fileType: number;    //文件類型:1-文件夾,2-常規文件
  byteSize: number;    //文件大小
}

FileDto是定義的文件描述類,這是描述一整個樹形結構的基本單元,通過唯一id和指定它的上級parentId,通過遞歸就可以描述你的某一文件,某一文件夾具體在哪一層級的哪一個分支中。現在假設我們有一堆的文件樹長這樣:

定義查詢函數checkMessage和當前目錄層級的文件集合listMessage:

      listMessage: new Array<FileDto>(),
      checkMessage: {},

再定義一個目錄訪問器gotoList函數,通過傳入查詢條件,更新當前目錄層級的文件列表:

gotoList() {
      this.listMessage = Enumerable.from(FileList)
        .where((c) => c.parentId == (this.checkMessage as any).parentId)
        .toArray();
    },

 編寫UI部分,簡單定義一個table,並綁定文件集合listMessage來顯示所有文件:

      <table border="1">
        <tr>
          <th>id</th>
          <th>文件名</th>
          <th>類型</th>
          <th>大小</th>
        </tr>
        <tr v-for="item in listMessage" :key="item.id">
          <td>{{ item.id }}</td>
          <td>
            <a href="javascript:void(0)" @click="open(item)">{{
              item.fileName
            }}</a>
          </td>
          <td>{{ item.fileType == 1 ? "目錄" : "文件" }}</td>
          <td>{{ item.fileType == 1 ? "/" : `${item.byteSize}M` }}</td>
        </tr>
      </table>

當調用gotoList函數的時候,相當與「刷新」功能,獲取了當前查詢條件下的所有文件

三、編寫導航邏輯

導航堆棧處理函數

剛剛我們分析了導航原理,導航堆棧的作用是形成地址,我們定義一個導航堆棧處理邏輯:

  1. 判斷當前頁面是否在導航堆棧中
  2. 若是,則彈出至目標在導航堆棧中所在的位置
  3. 若否,則壓入導航堆棧

 其中toFolder函數用於實際導航並刷新頁面的,稍後介紹

navigationTo(folder: FileBriefWithThumbnailDto) {
    var toIndex = Enumerable.from(this.NavigationStack).indexOf(folder);
    if (toIndex >= 0) {
      this.NavigationStack.splice(
        toIndex + 1,
        this.NavigationStack.length - toIndex - 1
      );
    } else {
      this.NavigationStack.push(folder);
    }
    if (this.toFolder(folder)) {
      this.navigationHistoryStack.unshift(folder);
    }
  }

「向上」導航函數:

向上的作用屬於一個特定的導航堆棧處理:

  1. 直接彈出最上的條目,
  2. 拿到最上層條目並導航
  navigationBack() {
    this.NavigationStack.pop();
    var lastItem = Enumerable.from(this.NavigationStack).lastOrDefault();
    if (this.getIsNull(lastItem)) {
      return;
    }
    if (this.toFolder(lastItem)) {
      this.NavigationHistoryStack.push(lastItem);
    }
  }

定義跳轉函數toFolder,之後許多函數引用此函數,這個函數單純執行跳轉,傳入文件描述對象,執行導航,刷新頁面,返回bool值代表成功與否:

toFolder(folder: FileDto) {
      if ((this.checkMessage as any).parentId == folder.id) {
        return false;
      }

      (this.checkMessage as any).parentId = folder.id;

      this.gotoList();
      return true;
    },

簡單的寫一下導航操作區域和地址欄的Ui介面: 

    <div class="crumbs">
      <ul>
        <li v-for="(item, index) in navigationStack" :key="item.id">
          {{ index > 0 ? " /" : "" }}
          <a href="javascript:void(0)" @click="navigationTo(item)">{{
            item.fileName
          }}</a>
        </li>
      </ul>
    </div>

四、編寫歷史導航處理邏輯

「後退」函數

  1. 首先確定當前頁面在歷史導航的哪個位置
  2. 拿到角標後+1(因為是隊列,所以越早的角標越大),拿到歷史導航隊列中後一個頁面條目,並執行導航函數
navigationHistoryBack() {
    var currentIndex = Enumerable.from(this.NavigationHistoryStack).indexOf(
      (c) => c.id == (this.checkMessage as any).parentId
    );
    if (currentIndex < this.NavigationHistoryStack.length - 1) {
      var forwardIndex = currentIndex + 1;
      var folder= this.NavigationHistoryStack[forwardIndex]     
      this.toFolder(folder);
    }
  }

「前進」函數

  1. 首先確定當前頁面在歷史導航的哪個位置
  2. 拿到角標後-1(因為是隊列,所以越晚的角標越小),拿到歷史導航隊列中前一個頁面條目,並執行導航函數


  navigationHistoryForward() {
    var currentIndex = Enumerable.from(this.NavigationHistoryStack).indexOf(
      (c) => c.id == (this.checkMessage as any).parentId
    );
    if (currentIndex > 0) {
      var forwardIndex = currentIndex - 1;
      var folder= this.NavigationHistoryStack[forwardIndex]
      this.toFolder(folder);
    }
  }

然後我們需要一個函數,用於顯示歷史隊列中(當前)標籤:

getIsCurrentHistoryNavigationItem(item) {
    var itemIndex = Enumerable.from(this.NavigationHistoryStack).indexOf(
      (c) => c.id == item.id
    );
    var result = (this.checkMessage as any).parentId == itemIndex;
    return result;
  }

簡單的寫一下導航操作區域:

導航按鈕以及歷史列表:

程式碼如下: 

<div class="buttons">
      <div>
        <button @click="navigationHistoryBack">
          <img
            style="transform: rotate(180deg)"
            :src="require('@/assets/arr.png')"
          />
        </button>
      </div>
      <div>
        <button @click="navigationHistoryForward">
          <img :src="require('@/assets/arr.png')" />
        </button>
      </div>
      <div>
        <a @click="show">
          <img
            :src="require('@/assets/arr2.png')"
            :style="{
              transform: showHistory ? 'rotate(0deg)' : 'rotate(-180deg)',
            }"
          />
        </a>
      </div>
      <ul class="history" v-show="showHistory">
        <li v-for="(item, index) in navigationHistoryStack" :key="index">
          <span>{{ item.fileName }}</span>

          <span v-if="getIsCurrentHistoryNavigationItem(item)"> (當前)</span>
        </li>
      </ul>

      <div>
        <button @click="navigationBack">
          <img
            style="transform: rotate(-90deg)"
            :src="require('@/assets/arr.png')"
          />
        </button>
      </div>
    </div>

五、問題修復與優化

問題1:歷史條目判斷錯誤

測試的時候會發現一個問題,用id判斷當前頁面所在的堆棧位置,會始終定位到最近一次,相當於FirstOrDefault,因為歷史隊列可以重複添加,所以需要引入一個isCurrent的bool值屬性,來作為判斷依據。

這相當於是增加了狀態變數,從「無狀態」變換成「有狀態」,意味著我們要維護這個狀態。好處是可以簡單的從isCurrent就能判斷狀態,壞處就是要另寫程式碼維護狀態,增加了程式碼的複雜性。

將navigationTo函數改寫成如下:


navigationTo(folder: FileBriefWithThumbnailDto) {
    var toIndex = Enumerable.from(this.NavigationStack).indexOf(folder);
    if (toIndex >= 0) {
      this.NavigationStack.splice(
        toIndex + 1,
        this.NavigationStack.length - toIndex - 1
      );
    } else {
      this.NavigationStack.push(folder);
    }
    if (this.toFolder(folder)) {
        this.navigationHistoryStack.forEach((element) => {
          element["isCurrent"] = false;
        });
        folder["isCurrent"] = true;
        this.navigationHistoryStack.unshift(folder);
      }
  }

判斷是否為當前的函數則簡化為如下:

    getIsCurrentHistoryNavigationItem(item) {
      var result = item["isCurrent"];
      return result;
    },

從導航歷史隊列跳轉的目錄,也需要處理導航堆棧,因此從navigationTo函數中將這一部分剝離出來單獨形成函數命名為dealWithNavigationStack:

dealWithNavigationStack(folder) {
      var toIndex = Enumerable.from(this.navigationStack).indexOf(
        (c) => c.id == folder.id
      );
      if (toIndex >= 0) {
        this.navigationStack.splice(
          toIndex + 1,
          this.navigationStack.length - toIndex - 1
        );
      } else {
        this.navigationStack.push(folder);
      }
    },

「前進」函數與「後退」函數分別改寫為: 

navigationHistoryForward() {
      var currentIndex = Enumerable.from(this.navigationHistoryStack).indexOf(
        (c) => c["isCurrent"]
      );
      if (currentIndex > 0) {
        var forwardIndex = currentIndex - 1;

        var folder = this.navigationHistoryStack[forwardIndex];
        this.dealWithNavigationStack(folder);

        if (this.toFolder(folder)) {
          this.navigationHistoryStack.forEach((element) => {
            element["isCurrent"] = false;
          });
          this.navigationHistoryStack[forwardIndex]["isCurrent"] = true;
        }
      }
    },

navigationHistoryBack() {
      var currentIndex = Enumerable.from(this.navigationHistoryStack).indexOf(
        (c) => c["isCurrent"]
      );
      if (currentIndex < this.navigationHistoryStack.length - 1) {
        var forwardIndex = currentIndex + 1;

        var folder = this.navigationHistoryStack[forwardIndex];
        this.dealWithNavigationStack(folder);

        if (this.toFolder(folder)) {
          this.navigationHistoryStack.forEach((element) => {
            element["isCurrent"] = false;
          });
          this.navigationHistoryStack[forwardIndex]["isCurrent"] = true;
        }
      }
    },

問題2:文件描述對象重疊

 

先看現象,重複進入「文件夾A」的時候,都標記為(當前),這顯然是錯誤的

請留意navigationTo中的這一段程式碼:

 if (this.toFolder(folder)) {
        this.navigationHistoryStack.forEach((element) => {
          element["isCurrent"] = false;
        });
        folder["isCurrent"] = true;
        this.navigationHistoryStack.unshift(folder);
      }

這裡隱藏了一個bug,邏輯是將所有的歷史隊列條目去除當前標記,然後將最新的目標標記為當前並壓入歷史隊列,這裡的 folder這一對象來自於listMessages,

JavaScript在5中基本數據類型(Undefined、Null、Boolean、Number和String)之外的類型,都是按地址訪問的,因此賦值的是對象的引用而不是對象本身,當重複進入文件夾時,folder與上一次進入添加到隊列中的folder,實際上是同一個對象!

因此所有的「文件夾A」都被標記為「(當前)」了

我們需要將 this.navigationHistoryStack.unshift(folder);改寫,提取出一個名稱為pushNavigationHistoryStack的入隊函數:

   pushNavigationHistoryStack(item) {
      var newItem = Object.assign({}, item);

      if (this.navigationHistoryStack.length > 10) {
        this.navigationHistoryStack.pop();
      }
      this.navigationHistoryStack.unshift(newItem);
    },

這裡加入了一個控制,歷史隊列最多容納10個條目,大於10個有新的條目入隊列時,將剔除最後一條(也就是最早的一條記錄,記錄越早角標越大)。

接下來運行yarn serve來看看最終效果:

 

 程式碼倉庫:

jevonsflash/vue-explorer-sample (github.com)

jevonsflash/vue-explorer-sample (gitee.com)