This article originates from the practice of Vue DevUI open source component library.
1 Introduction to the search and filtering function of the Tree component
The search function of tree nodes is mainly for the convenience of users to quickly find the nodes they need. The filtering function not only needs to meet the characteristics of the search, but also needs to hide other unmatched nodes at the same level as the matching node.
The search function mainly includes the following functions:
- Nodes that match the search filter field need to be identified to distinguish them from ordinary nodes
- When a child node matches, all its parent nodes need to be expanded to facilitate users to view the hierarchical relationship
- For a large amount of data, when virtual scrolling is used, the scroll bar needs to scroll to the position of the first matching node after the search filtering is completed.
The search will highlight matching nodes:
In addition to highlighting the matching nodes, filtering will also filter out the unmatched nodes:
2 Component interaction logic analysis
2.1 How is the identity of the matching node presented?
Mark by highlighting and bolding part of the text that matches the node with the search field label
. It is easy for users to find the searched node at a glance.
2.2 How can the user invoke the search filter function of the tree
component?
By adding the searchTree
method, the user can call by ref. And through the option
parameter configuration to distinguish search and filter.
2.3 How to obtain and process the parent node and sibling node of the matching node?
The acquisition and processing of nodes is the core of the search filtering function. Especially in the case of large data volume, how to optimize the performance consumption will be explained in detail in the implementation principle.
3 Implementation principles and steps
3.1 The first step: need to be familiar with tree
the entire code and logical organization of the component
tree
The file structure of the component:
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
It can be seen that the convenience brought by vue3.0 composition-api
. The separation between logical layers facilitates code organization and subsequent problem positioning. It allows developers to focus only on their own features, which is very beneficial for post-maintenance.
Add the file use-search-filter.ts
, the method defined in the file 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,
};
}
}
The interface definitions of ---cfe6674202dcfba3781cbfc761640548 SearchFilterOption
, matchKey
and pattern
increase the diversity of search matching methods.
export interface SearchFilterOption {
isFilter: boolean; // 是否是过滤节点
matchKey?: string; // node节点中匹配搜索过滤的字段名
pattern?: RegExp; // 搜索过滤时匹配的正则表达式
}
Add a reference to the file use-search-fliter.ts
in the tree.tsx
main file, and expose the searchTree
method to third-party callers.
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 The second step: You need to be familiar with tree
what is the entire node data structure of the component. The nodes data structure directly determines how to access and process the parent and sibling nodes of the matching node
It can be seen in the use-core.ts
file that the entire data structure adopts a flat structure, not a traditional tree structure, and all nodes are contained in a one-dimensional array.
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 Step 3: Process the expanded properties of matching nodes and their parent nodes
Add the following properties to the node to identify the matching relationship
isMatched?: boolean; // 搜索过滤时是否匹配该节点
childrenMatched?: boolean; // 搜索过滤时是否有子节点存在匹配
matchedText?: string; // 节点匹配的文字(需要高亮显示)
The dealMatchedData
method is used to process the setting of all nodes' search properties.
It mainly does the following things:
- Convert the search field passed in by the user to case
- Loop through all nodes, first process whether its own node matches the search field, and set
selfMatched = true
if it matches. First determine whether the user searches through the custom field (matchKey
parameter), if so, set the matching attribute to the custom attribute in node, otherwise it is the defaultlabel
attribute; then judge whether to perform Regular matching (pattern
parameter), if there is, it will be regular matching, otherwise it will be the default fuzzy matching ignoring case. - If the own node matches, set the attribute value of the node
matchedText
for highlighting. - Determine whether the own node has
parentId
, if there is no such attribute value, it is the root node, and no need to deal with the parent node. When this property is present, an inner loop is required to process the search property of the parent node. Use set to save the node'sparentId
, search forward in turn, find the parent node, and judge whether the parent node has been processed. If not, set the parent node'schildrenMatched
andexpanded
The attribute is true, and then theparentId
attribute of the parent node is added to the set, and the while loop repeats this operation until it encounters the first processed parent node or until the root node stops the loop. - The entire double-layer loop processes all nodes.
dealMatchedData
core code is as follows:
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 Step 4: If it is a filtering function, the unmatched nodes need to be hidden
Add the following properties to the node to identify whether the node is hidden or not.
isHide?: boolean; // 过滤后是否不显示该节点
The core processing logic is similar to that in 3.3. Through the double-layer loop, the node's isMatched
and childrenMatched
and the parent node's isMatched
set whether to display its own node.
The core code is as follows:
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 Step 5: Handle the highlighting of matching nodes
If the node is matched, process the node's label
into an array of [preMatchedText, matchedText, postMatchedText]
format. matchedText
Added span
tag wrapping, showing highlight effect through CSS style.
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 Step 6: When the tree component uses a virtual list, the scroll bar needs to be scrolled to the first matching node, which is convenient for users to view
First get the nodes displayed in the entire tree at present, and find the first matching node subscript. Call the scrollTo
method of the virtual list component to scroll to the matching node.
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);
Through the scrollTo
method to locate the first matching item renderings:
The original tree structure display diagram:
Filter function:
4 Difficult problems encountered
4.1 The core of the search lies in the access and processing of all parent nodes of the matching node
The entire tree data structure is a one-dimensional array. Upwards, all parent nodes of the matching node need to be expanded, and downwards need to know whether there is a matching child node. Traditional tree
The data structure of the component is a tree structure, and the access and processing of nodes are completed in a recursive way. What to do with flat data structures?
- Scheme 1: Flat data structure --> tree structure --> recursive processing --> flat data structure (NO)
- Option 2: Add the parent attribute to the node and save the content of the parent node of the node --> Traverse the node to process its own node and parent node (No)
- Option 3: The same two-layer loop, the first layer loops to process the current node, and the second layer loops to the parent node (Yes)
Option 1: Through data structure conversion processing, not only the advantages of flat data structure are lost, but also the cost of data format conversion is increased, and more performance consumption is brought.
Option 2: Adding the parent attribute is actually an imitation of a tree structure, increasing memory consumption and saving a lot of useless duplicate data. There is also repeated access of nodes when iterating through nodes. The further back the node is, the more serious the repeated access is, and the useless performance is consumed.
Option 3: Taking advantage of the flat data structure, the nodes are ordered. That is: the display order of tree nodes is the order of the nodes in the array, and the parent node must be before the child node. The parent node access processing only needs to traverse the node before the node, and the childrenMatched
attribute identifies that the parent node has a child node that matches. There is no need to add parent fields to access all parent node information, and no need to recursively search for processing nodes through data conversion.
4.2 Optimize when processing parent nodes to prevent inner traversal from repeatedly processing parent nodes that have been visited, resulting in improved performance
In the outer loop, if the node does not match the search field, the inner loop will not be performed and will be skipped directly. See the code in 3.3 for details
Prevent repeated visits to the same parent node by optimizing the termination condition of the inner loop
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 For the filtering function, it is also necessary to deal with the display and hiding of nodes
Also through the double-layer loop and the isMatched
and childrenMatched
attributes added when processing matching data to jointly determine the isHide
attributes of the node, see the code in 3.4 for details.
By optimizing the termination condition of the inner loop, it is different from the judgment when setting childrenMatched
.
const hasParentNodeMatched = (pre: number, cur: number, parentIdSet: Set<unknown>) => {
return parentIdSet.has(data.value[pre].id) && data.value[pre].isMatched;
};
5 Summary
Although it is the development of the next small feature of a component, the whole process is still full of rewards, starting from the interactive analysis of the feature, step by step to the final function implementation. In normal development, it is rarely possible to have an overall plan from scheme design to function implementation. Often, people get started with the code first, and then they find that the scheme selection is unreasonable during the development process, and they will take many detours. Therefore, the characteristic analysis and scheme design at the beginning are particularly important.
Analysis --> Design --> Scheme discussion --> Scheme determination --> Function implementation --> Logic optimization. Each process can improve their ability to exercise.
Text/DevUI Community daviForevel
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。