Tree 組件搜索過濾功能實(shí)現(xiàn)干貨
1 Tree 組件搜索過濾功能簡介
本文源于 Vue DevUI 開源組件庫實(shí)踐。
樹節(jié)點(diǎn)的搜索功能主要是為了方便用戶能夠快速查找到自己需要的節(jié)點(diǎn)。過濾功能不僅要滿足搜索的特性,同時還需要隱藏掉與匹配節(jié)點(diǎn)同層級的其它未能匹配的節(jié)點(diǎn)。
搜索功能主要包括以下功能:
- 與搜索過濾字段匹配的節(jié)點(diǎn)需要進(jìn)行標(biāo)識,和普通節(jié)點(diǎn)進(jìn)行區(qū)分
- 子節(jié)點(diǎn)匹配時,其所有父節(jié)點(diǎn)需要展開,方便用戶查看層級關(guān)系
- 對于大數(shù)據(jù)量,采用虛擬滾動時,搜索過濾完成后滾動條需滾動至第一個匹配節(jié)點(diǎn)的位置
搜索會將匹配到的節(jié)點(diǎn)高亮:

過濾除了將匹配到的節(jié)點(diǎn)高亮之外,還會將不匹配的節(jié)點(diǎn)篩除掉:

2 組件交互邏輯分析
2.1 對于匹配節(jié)點(diǎn)的標(biāo)識如何呈現(xiàn)?
通過將節(jié)點(diǎn)與搜索字段相匹配的 label 部分文字進(jìn)行高亮加粗的方式進(jìn)行標(biāo)記。易于用戶一眼就能夠找到搜索到的節(jié)點(diǎn)。
2.2 用戶如何調(diào)用 tree 組件的搜索過濾功能?
通過添加searchTree方法,用戶通過ref的方式進(jìn)行調(diào)用。并通過option參數(shù)配置區(qū)分搜索、過濾。
2.3 對于匹配的節(jié)點(diǎn)其父節(jié)點(diǎn)及兄弟節(jié)點(diǎn)如何獲取及處理?
對于節(jié)點(diǎn)的獲取及處理是搜索過濾功能的核心。尤其在大數(shù)據(jù)量的情況下,帶來的性能消耗如何優(yōu)化,將在實(shí)現(xiàn)原理中詳情闡述。
3 實(shí)現(xiàn)原理和步驟
3.1 第一步:需要熟悉 tree 組件整個代碼及邏輯組織方式
tree組件的文件結(jié)構(gòu):
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 帶來的便利。邏輯層之間的分離,方便代碼組織及后續(xù)問題的定位。能夠讓開發(fā)者只專心于自己的特性,非常有利于后期維護(hù)。
添加文件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的接口定義,matchKey 與 pattern的配置增添了搜索的匹配方式多樣性。
export interface SearchFilterOption {
isFilter: boolean; // 是否是過濾節(jié)點(diǎn)
matchKey?: string; // node節(jié)點(diǎn)中匹配搜索過濾的字段名
pattern?: RegExp; // 搜索過濾時匹配的正則表達(dá)式
}
在tree.tsx主文件中添加文件use-search-fliter.ts的引用, 并將searchTree方法暴露給第三方調(diào)用者。
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數(shù)據(jù)結(jié)構(gòu)是怎樣的
nodes數(shù)據(jù)結(jié)構(gòu)直接決定如何訪問及處理匹配節(jié)點(diǎn)的父節(jié)點(diǎn)及兄弟節(jié)點(diǎn)
在use-core.ts文件中可以看出, 整個數(shù)據(jù)結(jié)構(gòu)采用的是扁平結(jié)構(gòu),并不是傳統(tǒng)的樹結(jié)構(gòu),所有的節(jié)點(diǎn)包含在一個一維的數(shù)組中。
const treeData = ref<IInnerTreeNode[]>(generateInnerTree(tree));
// 內(nèi)部數(shù)據(jù)結(jié)構(gòu)使用扁平結(jié)構(gòu)
export interface IInnerTreeNode extends ITreeNode {
level: number;
idType?: 'random';
parentId?: string;
isLeaf?: boolean;
parentChildNodeCount?: number;
currentIndex?: number;
loading?: boolean; // 節(jié)點(diǎn)是否顯示加載中
childNodeCount?: number; // 該節(jié)點(diǎn)的子節(jié)點(diǎn)的數(shù)量
// 搜索過濾
isMatched?: boolean; // 搜索過濾時是否匹配該節(jié)點(diǎn)
childrenMatched?: boolean; // 搜索過濾時是否有子節(jié)點(diǎn)存在匹配
isHide?: boolean; // 過濾后是否不顯示該節(jié)點(diǎn)
matchedText?: string; // 節(jié)點(diǎn)匹配的文字(需要高亮顯示)
}
3.3 第三步: 處理匹配節(jié)點(diǎn)及其父節(jié)點(diǎn)的展開屬性
節(jié)點(diǎn)中添加以下屬性,用于標(biāo)識匹配關(guān)系
isMatched?: boolean; // 搜索過濾時是否匹配該節(jié)點(diǎn) childrenMatched?: boolean; // 搜索過濾時是否有子節(jié)點(diǎn)存在匹配 matchedText?: string; // 節(jié)點(diǎn)匹配的文字(需要高亮顯示)
通過 dealMatchedData 方法來處理所有節(jié)點(diǎn)關(guān)于搜索屬性的設(shè)置。
它主要做了以下事情:
- 將用戶傳入的搜索字段進(jìn)行大小寫轉(zhuǎn)換
- 循環(huán)所有節(jié)點(diǎn),先處理自身節(jié)點(diǎn)是否與搜索字段匹配,匹配就設(shè)置
selfMatched = true。首先判斷用戶是否通過自定義字段進(jìn)行搜索 (matchKey參數(shù)),如果有,設(shè)置匹配屬性為node中自定義屬性,否則為默認(rèn)label屬性;然后判斷是否進(jìn)行正則匹配 (pattern參數(shù)),如果有,就進(jìn)行正則匹配,否則為默認(rèn)的忽略大小寫的模糊匹配。 - 如果自身節(jié)點(diǎn)匹配時, 設(shè)置節(jié)點(diǎn)
matchedText屬性值,用于高亮標(biāo)識。 - 判斷自身節(jié)點(diǎn)有無
parentId,無此屬性值時,為根節(jié)點(diǎn),無須處理父節(jié)點(diǎn)。有此屬性時,需要進(jìn)行內(nèi)層循環(huán)處理父節(jié)點(diǎn)的搜索屬性。利用set保存節(jié)點(diǎn)的parentId, 依次向前查找,找到parent節(jié)點(diǎn),判讀是否該parent節(jié)點(diǎn)被處理過,如果沒有,設(shè)置父節(jié)點(diǎn)的childrenMatched和expanded屬性為true,再將parent節(jié)點(diǎn)的parentId屬性加入set中,while循環(huán)重復(fù)這個操作,直到遇到第一個已經(jīng)處理過的父節(jié)點(diǎn)或者直到根節(jié)點(diǎn)停止循環(huán)。 - 整個雙層循環(huán)將所有節(jié)點(diǎn)處理完畢。
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;
// 需要向前找父節(jié)點(diǎn),處理父節(jié)點(diǎn)的childrenMatched、expand參數(shù)(子節(jié)點(diǎn)匹配到時,父節(jié)點(diǎn)需要展開)
if (selfMatched) {
data.value[i].matchedText = matchKey ? data.value[i].label : trimmedTarget;
if (!data.value[i].parentId) {
// 沒有parentId表示時根節(jié)點(diǎn),不需要再向前遍歷
continue;
}
let L = i - 1;
const set = new Set();
set.add(data.value[i].parentId);
// 沒有parentId時,表示此節(jié)點(diǎn)的縱向parent已訪問完畢
// 沒有父節(jié)點(diǎn)被處理過,表示時第一次向上處理當(dāng)前縱向父節(jié)點(diǎn)
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--;
}
// 循環(huán)結(jié)束時需要額外處理根節(jié)點(diǎn)一層
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>) => {
// 當(dāng)訪問到同一層級前已經(jīng)有匹配時前一個已經(jīng)處理過父節(jié)點(diǎn)了,不需要繼續(xù)訪問
// 當(dāng)訪問到第一父節(jié)點(diǎn)的childrenMatched為true的時,不再需要向上尋找,防止重復(fù)訪問
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 第四步: 如果是過濾功能時,需要將未匹配到的節(jié)點(diǎn)進(jìn)行隱藏
節(jié)點(diǎn)中添加以下屬性,用于標(biāo)識節(jié)點(diǎn)是否隱藏。
isHide?: boolean; // 過濾后是否不顯示該節(jié)點(diǎn)
同3.3中核心處理邏輯大同小異,通過雙層循環(huán), 節(jié)點(diǎn)的 isMatched 和 childrenMatched 以及父節(jié)點(diǎn)的 isMatched 設(shè)置自身節(jié)點(diǎn)是否顯示。
核心代碼如下:
const dealNodeHideProperty = () => {
data.value.forEach((item, index) => {
if (item.isMatched || item.childrenMatched) {
item.isHide = false;
} else {
// 需要判斷是否有父節(jié)點(diǎn)有匹配
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, 說明已經(jīng)訪問到當(dāng)前節(jié)點(diǎn)所在的根節(jié)點(diǎn)
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 第五步:處理匹配節(jié)點(diǎn)的高亮顯示
如果該節(jié)點(diǎn)被匹配,將節(jié)點(diǎn)的label處理成[preMatchedText, matchedText, postMatchedText]格式的數(shù)組。 matchedText添加 span標(biāo)簽包裹,通過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組件采用虛擬列表時,需將滾動條滾動至第一個匹配的節(jié)點(diǎn),方便用戶查看
先得到目前整個樹顯示出來的節(jié)點(diǎn),找到第一個匹配的節(jié)點(diǎn)下標(biāo)。調(diào)用虛擬列表組件的 scrollTo 方法滾動至該匹配節(jié)點(diǎn)。
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 方法定位至第一個匹配項(xiàng)效果圖:
原始樹結(jié)構(gòu)顯示圖:

過濾功能:

4 使用searchTree對Tree進(jìn)行搜索過濾
到這里 Tree 組件的搜索過濾功能就開發(fā)完了,我們來使用下吧。
<script setup lang="ts">
import { ref } from 'vue';
const treeRef = ref();
const data = ref([
{
label: 'parent node 1',
},
{
label: 'parent node 2',
children: [
{
label: 'child node 2-1',
children: [
{
label: 'child node 2-1-1',
},
{
label: 'child node 2-1-2',
},
],
},
{
label: 'child node 2-2',
children: [
{
label: 'child node 2-2-1',
},
{
label: 'child node 2-2-2',
},
],
},
],
},
]);
const onSearch = (keyword) => {
// 只需要調(diào)用 Tree 組件實(shí)例的 searchTree 方法即可實(shí)現(xiàn)搜索過濾
treeRef.value.treeFactory.searchTree(keyword);
};
</script>
<template>
<d-search @search="onSearch"></d-search>
<d-tree ref="treeRef" :data="data"></d-tree>
</template>
是不是非常簡單?
searchTree 方法一共有兩個參數(shù):
keyword 搜索關(guān)鍵字
options 配置選項(xiàng)
- isFilter 是否需要過濾
- matchKey node節(jié)點(diǎn)中匹配搜索過濾的字段名
- pattern 搜索過濾時匹配的正則表達(dá)式
5 遇到的難點(diǎn)問題
5.1 搜索的核心在于對匹配節(jié)點(diǎn)的所有父節(jié)點(diǎn)的訪問以及處理
整棵樹數(shù)據(jù)結(jié)構(gòu)就是一個一維數(shù)組,向上需要將匹配節(jié)點(diǎn)所有的父節(jié)點(diǎn)全部展開, 向下需要知道有沒有子節(jié)點(diǎn)存在匹配。傳統(tǒng)tree組件的數(shù)據(jù)結(jié)構(gòu)是樹形結(jié)構(gòu),通過遞歸的方式完成節(jié)點(diǎn)的訪問及處理。對于扁平的數(shù)據(jù)結(jié)構(gòu)應(yīng)該如何處理?
- 方案一:扁平數(shù)據(jù)結(jié)構(gòu) --> 樹形結(jié)構(gòu) --> 遞歸處理 --> 扁平數(shù)據(jù)結(jié)構(gòu) (NO)
- 方案二: node添加parent屬性,保存該節(jié)點(diǎn)父級節(jié)點(diǎn)內(nèi)容 --> 遍歷節(jié)點(diǎn)處理自身節(jié)點(diǎn)及parent節(jié)點(diǎn) (No)
- 方案三: 同過雙層循環(huán),第一層循環(huán)處理當(dāng)前節(jié)點(diǎn),第二層循環(huán)處理父節(jié)點(diǎn) (Yes)
方案一:通過數(shù)據(jù)結(jié)構(gòu)的轉(zhuǎn)換處理,不僅丟掉了扁平數(shù)據(jù)結(jié)構(gòu)的優(yōu)勢,還增加了數(shù)據(jù)格式轉(zhuǎn)換的成本,并帶來了更多的性能消耗。
方案二:parent屬性添加其實(shí)就是一種樹形結(jié)構(gòu)的模仿,增加內(nèi)存消耗,保存很多無用重復(fù)數(shù)據(jù)。循環(huán)訪問節(jié)點(diǎn)時也存在節(jié)點(diǎn)的重復(fù)訪問。節(jié)點(diǎn)越靠后,重復(fù)訪問越嚴(yán)重,無用的性能消耗。
方案三: 利用扁平數(shù)據(jù)結(jié)構(gòu)的優(yōu)勢,節(jié)點(diǎn)是有順序的。即:樹節(jié)點(diǎn)的顯示順序就是節(jié)點(diǎn)在數(shù)組中的順序,父節(jié)點(diǎn)一定是在子節(jié)點(diǎn)之前。父節(jié)點(diǎn)訪問處理只需要遍歷該節(jié)點(diǎn)之前的節(jié)點(diǎn),通過 childrenMatched屬性標(biāo)識該父節(jié)點(diǎn)有子節(jié)點(diǎn)存在匹配。 不用添加parent字段存取所有的父節(jié)點(diǎn)信息,不用通過數(shù)據(jù)轉(zhuǎn)換,再遞歸尋找處理節(jié)點(diǎn)。
5.2 處理父級節(jié)點(diǎn)時進(jìn)行優(yōu)化,防止內(nèi)層遍歷重復(fù)處理已經(jīng)訪問過的父級節(jié)點(diǎn),帶來性能提升
外層循環(huán),如果該節(jié)點(diǎn)沒有匹配搜索字段,將不進(jìn)行內(nèi)層循環(huán),直接跳過。 詳見3.3中的代碼
通過對內(nèi)層循環(huán)終止條件的優(yōu)化,防止重復(fù)訪問同一個父節(jié)點(diǎn)
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>) => {
// 當(dāng)訪問到同一層級前已經(jīng)有匹配時前一個已經(jīng)處理過父節(jié)點(diǎn)了,不需要繼續(xù)訪問
// 當(dāng)訪問到第一父節(jié)點(diǎn)的childrenMatched為true的時,不再需要向上尋找,防止重復(fù)訪問
return (
(data.value[pre].parentId === data.value[cur].parentId && data.value[pre].isMatched) ||
(parentIdSet.has(data.value[pre].id) && data.value[pre].childrenMatched)
);
};
5.3 對于過濾功能,還需處理節(jié)點(diǎn)的顯示隱藏
同樣通過雙層循環(huán)、以及處理匹配數(shù)據(jù)時增加的isMatched 、 childrenMatched屬性來共同決定節(jié)點(diǎn)的isHide屬性,詳見3.4中的代碼、
通過對內(nèi)層循環(huán)終止條件的優(yōu)化,與設(shè)置 childrenMatched時的判斷有所區(qū)別。
const hasParentNodeMatched = (pre: number, cur: number, parentIdSet: Set<unknown>) => {
return parentIdSet.has(data.value[pre].id) && data.value[pre].isMatched;
};
6 小結(jié)
雖然是一個組件下一個小特性的開發(fā),但是從特性的交互分析開始,一步步到最終的功能實(shí)現(xiàn),整個過程還是收獲滿滿。
平時開發(fā)中很少能夠從方案設(shè)計(jì)到功能實(shí)現(xiàn)有一個整體的規(guī)劃,往往都是先上手代碼,在開發(fā)過程中才發(fā)現(xiàn)方案選取不合理,就會走很多彎路。
所以,剛開始的特性分析和方案設(shè)計(jì)就顯得尤為重要。 分析 --> 設(shè)計(jì) --> 方案探討 --> 方案確定 --> 功能實(shí)現(xiàn) --> 邏輯優(yōu)化。每個過程都能鍛煉提升自己的能力。
以上就是Tree 組件搜索過濾功能實(shí)現(xiàn)干貨的詳細(xì)內(nèi)容,更多關(guān)于Tree 組件搜索過濾的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue3+element-plus?Dialog對話框的使用與setup?寫法的用法
這篇文章主要介紹了vue3+element-plus?Dialog對話框的使用?與?setup?寫法的使用,本文通過兩種方式結(jié)合實(shí)例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-04-04

