手把手教你實現 Tree 元件搜尋過濾功能,乾貨滿滿!

DevUI團隊發表於2022-07-18

daviForevel.png

本文源於 Vue DevUI 開源元件庫實踐。

1 Tree 元件搜尋過濾功能簡介

樹節點的搜尋功能主要是為了方便使用者能夠快速查詢到自己需要的節點。過濾功能不僅要滿足搜尋的特性,同時還需要隱藏掉與匹配節點同層級的其它未能匹配的節點。

搜尋功能主要包括以下功能:

  1. 與搜尋過濾欄位匹配的節點需要進行標識,和普通節點進行區分
  2. 子節點匹配時,其所有父節點需要展開,方便使用者檢視層級關係
  3. 對於大資料量,採用虛擬滾動時,搜尋過濾完成後滾動條需滾動至第一個匹配節點的位置

搜尋會將匹配到的節點高亮:

搜尋.png

過濾除了將匹配到的節點高亮之外,還會將不匹配的節點篩除掉:

過濾.png

2 元件互動邏輯分析

2.1 對於匹配節點的標識如何呈現?

通過將節點與搜尋欄位相匹配的 label 部分文字進行高亮加粗的方式進行標記。易於使用者一眼就能夠找到搜尋到的節點。

2.2 使用者如何呼叫 tree 元件的搜尋過濾功能?

通過新增searchTree方法,使用者通過ref的方式進行呼叫。並通過option引數配置區分搜尋、過濾。

2.3 對於匹配的節點其父節點及兄弟節點如何獲取及處理?

對於節點的獲取及處理是搜尋過濾功能的核心。尤其在大資料量的情況下,帶來的效能消耗如何優化,將在實現原理中詳情闡述。

3 實現原理和步驟

3.1 第一步:需要熟悉 tree 元件整個程式碼及邏輯組織方式

tree元件的檔案結構:

tree
├── index.ts
├── src
|  ├── components
|  |  ├── tree-node.tsx
|  |  ├── ...
|  ├── composables
|  |  ├── use-check.ts
|  |  ├── use-core.ts
|  |  ├── use-disable.ts
|  |  ├── use-merge-nodes.ts
|  |  ├── use-operate.ts
|  |  ├── use-select.ts
|  |  ├── use-toggle.ts
|  |  ├── ...
|  ├── tree.scss
|  ├── tree.tsx
└── __tests__
   └── tree.spec.ts

可以看出,vue3.0中 composition-api 帶來的便利。邏輯層之間的分離,方便程式碼組織及後續問題的定位。能夠讓開發者只專心於自己的特性,非常有利於後期維護。

新增檔案use-search-filter.ts, 檔案中定義searchTree方法。

import { Ref, ref } from 'vue';
import { trim } from 'lodash';
import { IInnerTreeNode, IUseCore, IUseSearchFilter, SearchFilterOption } from './use-tree-types';

export default function () {
  return function useSearchFilter(data: Ref<IInnerTreeNode[]>, core: IUseCore): IUseSearchFilter {
    const searchTree = (target: string, option: SearchFilterOption): void => {
      // 搜尋主邏輯
    };

    return {
      virtualListRef,
      searchTree,
    };
  }
}

SearchFilterOption的介面定義,matchKeypattern的配置增添了搜尋的匹配方式多樣性。

export interface SearchFilterOption {
  isFilter: boolean; // 是否是過濾節點
  matchKey?: string; // node節點中匹配搜尋過濾的欄位名
  pattern?: RegExp; // 搜尋過濾時匹配的正規表示式
}

tree.tsx主檔案中新增檔案use-search-fliter.ts的引用, 並將searchTree方法暴露給第三方呼叫者。

import useSearchFilter from './composables/use-search-filter';

  setup(props: TreeProps, context: SetupContext) {
    const userPlugins = [useSelect(), useOperate(), useMergeNodes(), useSearchFilter()];
    const treeFactory = useTree(data.value, userPlugins, context);
    expose({
      treeFactory,
    });
  }

3.2 第二步:需要熟悉 tree 元件整個nodes資料結構是怎樣的。nodes資料結構直接決定如何訪問及處理匹配節點的父節點及兄弟節點

use-core.ts檔案中可以看出, 整個資料結構採用的是扁平結構,並不是傳統的樹結構,所有的節點包含在一個一維的陣列中。

const treeData = ref<IInnerTreeNode[]>(generateInnerTree(tree));
// 內部資料結構使用扁平結構
export interface IInnerTreeNode extends ITreeNode {
  level: number;
  idType?: 'random';
  parentId?: string;
  isLeaf?: boolean;
  parentChildNodeCount?: number;
  currentIndex?: number;
  loading?: boolean; // 節點是否顯示載入中
  childNodeCount?: number; // 該節點的子節點的數量

  // 搜尋過濾
  isMatched?: boolean; // 搜尋過濾時是否匹配該節點
  childrenMatched?: boolean; // 搜尋過濾時是否有子節點存在匹配
  isHide?: boolean; // 過濾後是否不顯示該節點
  matchedText?: string; // 節點匹配的文字(需要高亮顯示)
}

3.3 第三步: 處理匹配節點及其父節點的展開屬性

節點中新增以下屬性,用於標識匹配關係

  isMatched?: boolean; // 搜尋過濾時是否匹配該節點
  childrenMatched?: boolean; // 搜尋過濾時是否有子節點存在匹配
  matchedText?: string; // 節點匹配的文字(需要高亮顯示)

通過 dealMatchedData 方法來處理所有節點關於搜尋屬性的設定。

它主要做了以下事情:

  1. 將使用者傳入的搜尋欄位進行大小寫轉換
  2. 迴圈所有節點,先處理自身節點是否與搜尋欄位匹配,匹配就設定 selfMatched = true。首先判斷使用者是否通過自定義欄位進行搜尋 ( matchKey 引數),如果有,設定匹配屬性為node中自定義屬性,否則為預設 label 屬性;然後判斷是否進行正則匹配 ( pattern 引數),如果有,就進行正則匹配,否則為預設的忽略大小寫的模糊匹配。
  3. 如果自身節點匹配時, 設定節點 matchedText 屬性值,用於高亮標識。
  4. 判斷自身節點有無 parentId,無此屬性值時,為根節點,無須處理父節點。有此屬性時,需要進行內層迴圈處理父節點的搜尋屬性。利用set儲存節點的 parentId , 依次向前查詢,找到parent節點,判讀是否該parent節點被處理過,如果沒有,設定父節點的 childrenMatchedexpanded 屬性為true,再將parent節點的 parentId 屬性加入set中,while迴圈重複這個操作,直到遇到第一個已經處理過的父節點或者直到根節點停止迴圈。
  5. 整個雙層迴圈將所有節點處理完畢。

dealMatchedData核心程式碼如下:

const dealMatchedData = (target: string, matchKey: string | undefined, pattern: RegExp | undefined) => {
    const trimmedTarget = trim(target).toLocaleLowerCase();
    for (let i = 0; i < data.value.length; i++) {
        const key = matchKey ? data.value[i][matchKey] : data.value[i].label;
        const selfMatched = pattern ? pattern.test(key) : key.toLocaleLowerCase().includes(trimmedTarget);
        data.value[i].isMatched = selfMatched;
        // 需要向前找父節點,處理父節點的childrenMatched、expand引數(子節點匹配到時,父節點需要展開)
        if (selfMatched) {
            data.value[i].matchedText = matchKey ? data.value[i].label : trimmedTarget;
            if (!data.value[i].parentId) {
                // 沒有parentId表示時根節點,不需要再向前遍歷
                continue;
            }
            let L = i - 1;
            const set = new Set();
            set.add(data.value[i].parentId);
            // 沒有parentId時,表示此節點的縱向parent已訪問完畢
            // 沒有父節點被處理過,表示時第一次向上處理當前縱向父節點
            while (L >= 0 && data.value[L].parentId && !hasDealParentNode(L, i, set)) {
                if (set.has(data.value[L].id)) {
                    data.value[L].childrenMatched = true;
                    data.value[L].expanded = true;
                    set.add(data.value[L].parentId);
                }
                L--;
            }
            // 迴圈結束時需要額外處理根節點一層
            if (L >= 0 && !data.value[L].parentId && set.has(data.value[L].id)) {
                data.value[L].childrenMatched = true;
                data.value[L].expanded = true;
            }
        }
    }
};

const hasDealParentNode = (pre: number, cur: number, parentIdSet: Set<unknown>) => {
    // 當訪問到同一層級前已經有匹配時前一個已經處理過父節點了,不需要繼續訪問
    // 當訪問到第一父節點的childrenMatched為true的時,不再需要向上尋找,防止重複訪問
    return (
    (data.value[pre].parentId === data.value[cur].parentId && data.value[pre].isMatched) ||
    (parentIdSet.has(data.value[pre].id) && data.value[pre].childrenMatched)
    );
};

3.4 第四步: 如果是過濾功能時,需要將未匹配到的節點進行隱藏

節點中新增以下屬性,用於標識節點是否隱藏。

  isHide?: boolean; // 過濾後是否不顯示該節點

同3.3中核心處理邏輯大同小異,通過雙層迴圈, 節點的 isMatchedchildrenMatched 以及父節點的 isMatched 設定自身節點是否顯示。

核心程式碼如下:

const dealNodeHideProperty = () => {
  data.value.forEach((item, index) => {
    if (item.isMatched || item.childrenMatched) {
      item.isHide = false;
    } else {
      // 需要判斷是否有父節點有匹配
      if (!item.parentId) {
        item.isHide = true;
        return;
      }
      let L = index - 1;
      const set = new Set();
      set.add(data.value[index].parentId);
      while (L >= 0 && data.value[L].parentId && !hasParentNodeMatched(L, index, set)) {
        if (set.has(data.value[L].id)) {
          set.add(data.value[L].parentId);
        }
        L--;
      }
      if (!data.value[L].parentId && !data.value[L].isMatched) {
        // 沒有parentId, 說明已經訪問到當前節點所在的根節點
        item.isHide = true;
      } else {
        item.isHide = false;
      }
    }
  });
};

const hasParentNodeMatched = (pre: number, cur: number, parentIdSet: Set<unknown>) => {
    return parentIdSet.has(data.value[pre].id) && data.value[pre].isMatched;
};

3.5 第五步:處理匹配節點的高亮顯示

如果該節點被匹配,將節點的label處理成[preMatchedText, matchedText, postMatchedText]格式的陣列。 matchedText新增 span標籤包裹,通過CSS樣式顯示高亮效果。

const matchedContents = computed(() => {
    const matchItem = data.value?.matchedText || '';
    const label = data.value?.label || '';
    const reg = (str: string) => str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
    const regExp = new RegExp('(' + reg(matchItem) + ')', 'gi');
    return label.split(regExp);
});
<span class={nodeTitleClass.value}>
    { !data.value?.matchedText && data.value?.label }
    {
      data.value?.matchedText
      && matchedContents.value.map((item: string, index: number) => (
        index % 2 === 0
        ? item
        : <span class={highlightCls}>{item}</span>
      ))
    }
</span>

3.6 第六步:tree元件採用虛擬列表時,需將滾動條滾動至第一個匹配的節點,方便使用者檢視

先得到目前整個樹顯示出來的節點,找到第一個匹配的節點下標。呼叫虛擬列表元件的 scrollTo 方法滾動至該匹配節點。

const getFirstMatchIndex = (): number => {
  let index = 0;
  const showTreeData = getExpendedTree().value;
  while (index <= showTreeData.length - 1 && !showTreeData[index].isMatched) {
      index++;
  }
  return index >= showTreeData.length ? 0 : index;
};

const scrollIndex = getFirstMatchIndex();
virtualListRef.value.scrollTo(scrollIndex);

通過 scrollTo 方法定位至第一個匹配項效果圖:

原始樹結構顯示圖:

scrollBefore.png

過濾功能:

scrollAfter.png

4 遇到的難點問題

4.1 搜尋的核心在於對匹配節點的所有父節點的訪問以及處理

整棵樹資料結構就是一個一維陣列,向上需要將匹配節點所有的父節點全部展開, 向下需要知道有沒有子節點存在匹配。傳統tree元件的資料結構是樹形結構,通過遞迴的方式完成節點的訪問及處理。對於扁平的資料結構應該如何處理?

  • 方案一:扁平資料結構 --> 樹形結構 --> 遞迴處理 --> 扁平資料結構 (NO)
  • 方案二: node新增parent屬性,儲存該節點父級節點內容 --> 遍歷節點處理自身節點及parent節點 (No)
  • 方案三: 同過雙層迴圈,第一層迴圈處理當前節點,第二層迴圈處理父節點 (Yes)

方案一:通過資料結構的轉換處理,不僅丟掉了扁平資料結構的優勢,還增加了資料格式轉換的成本,並帶來了更多的效能消耗。

方案二:parent屬性新增其實就是一種樹形結構的模仿,增加記憶體消耗,儲存很多無用重複資料。迴圈訪問節點時也存在節點的重複訪問。節點越靠後,重複訪問越嚴重,無用的效能消耗。

方案三: 利用扁平資料結構的優勢,節點是有順序的。即:樹節點的顯示順序就是節點在陣列中的順序,父節點一定是在子節點之前。父節點訪問處理只需要遍歷該節點之前的節點,通過 childrenMatched屬性標識該父節點有子節點存在匹配。 不用新增parent欄位存取所有的父節點資訊,不用通過資料轉換,再遞迴尋找處理節點。

4.2 處理父級節點時進行優化,防止內層遍歷重複處理已經訪問過的父級節點,帶來效能提升

外層迴圈,如果該節點沒有匹配搜尋欄位,將不進行內層迴圈,直接跳過。 詳見3.3中的程式碼

通過對內層迴圈終止條件的優化,防止重複訪問同一個父節點

let L = index - 1;
const set = new Set();
set.add(data.value[index].parentId);
while (L >= 0 && data.value[L].parentId && !hasParentNodeMatched(L, index, set)) {
    if (set.has(data.value[L].id)) {
        set.add(data.value[L].parentId);
    }
    L--;
}
const hasDealParentNode = (pre: number, cur: number, parentIdSet: Set<unknown>) => {
    // 當訪問到同一層級前已經有匹配時前一個已經處理過父節點了,不需要繼續訪問
    // 當訪問到第一父節點的childrenMatched為true的時,不再需要向上尋找,防止重複訪問
    return (
    (data.value[pre].parentId === data.value[cur].parentId && data.value[pre].isMatched) ||
    (parentIdSet.has(data.value[pre].id) && data.value[pre].childrenMatched)
    );
};

4.3 對於過濾功能,還需處理節點的顯示隱藏

同樣通過雙層迴圈、以及處理匹配資料時增加的isMatchedchildrenMatched屬性來共同決定節點的isHide屬性,詳見3.4中的程式碼、

通過對內層迴圈終止條件的優化,與設定 childrenMatched時的判斷有所區別。

const hasParentNodeMatched = (pre: number, cur: number, parentIdSet: Set<unknown>) => {
    return parentIdSet.has(data.value[pre].id) && data.value[pre].isMatched;
};

5 小結

雖然是一個元件下一個小特性的開發,但是從特性的互動分析開始,一步步到最終的功能實現,整個過程還是收穫滿滿。平時開發中很少能夠從方案設計到功能實現有一個整體的規劃,往往都是先上手程式碼,在開發過程中才發現方案選取不合理,就會走很多彎路。所以,剛開始的特性分析和方案設計就顯得尤為重要。
分析 --> 設計 --> 方案探討 --> 方案確定 --> 功能實現 --> 邏輯優化。每個過程都能鍛鍊提升自己的能力。

文/DevUI社群 daviForevel

相關文章