本文来源于VueDevUI开源组件库的实践。1Tree组件的搜索过滤功能介绍树节点的搜索功能主要是为了方便用户快速找到自己需要的节点。过滤功能不仅要满足搜索的特点,还需要隐藏与匹配节点同级的其他未匹配节点。搜索功能主要包括以下功能:需要识别匹配搜索过滤字段的节点,在区分子节点和普通节点时,需要展开所有父节点,方便用户查看层级关系。对于大数据量,使用虚拟滚动时,搜索过滤完成后,滚动条需要滚动到第一个匹配节点的位置。搜索会高亮匹配的节点:过滤除了会高亮匹配的节点外,还会过滤掉不匹配的节点:2组件交互逻辑分析2.1如何呈现匹配节点的标识?通过突出显示和加粗与搜索字段匹配的节点标签部分的文本来进行标记。用户很容易一眼就找到搜索到的节点。2.2用户如何调用树组件的搜索过滤功能?通过添加searchTree方法,用户通过ref调用它。并通过option参数配置来区分搜索和过滤。2.3如何获取并处理匹配节点的父节点和兄弟节点?节点的获取和处理是搜索过滤功能的核心。尤其是在数据量很大的情况下,如何优化性能消耗,在实现原理中会详细说明。3实现原理和步骤3.1第一步:你需要熟悉树组件的整个代码和逻辑组织。树组件的文件结构:tree├──index.ts├──src|├──组件||├──树节点.tsx||├──...|├──组合件||├──use-check.ts||├──use-core.ts||├──use-disable.ts||├──use-merge-nodes.ts||├──use-operate.ts||├──use-select.ts||├──use-toggle.ts||├──...|├──树.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';exportdefaultfunction(){returnfunctionuseSearchFilter(data:Ref,core:IUseCore):IUseSearchFilter{constsearchTree=(target:string,option:SearchFilterOption):void=>{//主要搜索逻辑};返回{virtualListRef,searchTree,};}}SearchFilterOption接口定义、matchKey和pattern配置增加了搜索匹配方式的多样性。导出接口SearchFilterOption{isFilter:boolean;//是否为过滤器节点matchKey?:string;//匹配搜索过滤模式的node节点的字段名?:RegExp;//与搜索过滤器匹配的正则表达式}在tree.tsx中添加对文件use-search-fliter.ts的引用,并将searchTree方法暴露给第三方调用者。从“./composables/use-search-filter”导入useSearchFilter;setup(props:TreeProps,context:SetupContext){constuserPlugins=[useSelect(),useOperate(),useMergeNodes(),useSearchFilter()];consttreeFactory=useTree(data.value,userPlugins,context);公开({treeFactory,});}3.2第二步:熟悉树组件的整个nodes数据结构。节点数据结构直接决定了如何访问和处理匹配节点的父节点和兄弟节点。在use-core.ts文件中可以看到,整个数据结构采用了扁平结构,而不是传统的树形结构。所有节点都包含在一个一维数组中。consttreeData=ref(generateInnerTree(tree));//内部数据结构使用平面结构导出接口IInnerTreeNodeextendsITreeNode{level:number;idType?:'随机';父母身份?:字符串;isLeaf?:布尔值;parentChildNodeCount?:数字;当前索引?:数字;加载?:布尔值;//节点是否显示loadingchildNodeCount?:number;//节点的子节点个数//SearchfilterisMatched?:boolean;//搜索过滤器搜索时是否匹配到节点?childrenMatched?:布尔值;//查找过滤时是否有匹配的子节点isHide?:boolean;//过滤后不显示节点matchedText?:string;//节点匹配的文本(需要高亮显示)}3.3第三步:在处理匹配节点及其父节点的扩展属性节点中添加如下属性,标识匹配关系isMatched?:boolean;//搜索过滤childrenMatched时是否匹配节点?:boolean;//搜索过滤时,是否有匹配到matchedText的子节点?:string;//节点匹配到的文本(需要高亮显示)用于通过dealMatchedData方法处理与搜索属性相关的所有节点的设置。它主要做了以下几件事:转换用户传入的搜索字段的大小写,循环遍历所有节点,首先处理自己的节点是否与搜索字段匹配,如果匹配则设置selfMatched=true。首先判断用户是否通过自定义字段(matchKey参数)进行搜索,如果是,则将匹配属性设置为node中的自定义属性,否则为默认标签属性;然后判断是否进行正则匹配(pattern参数),如果是则进行正则匹配,否则默认模糊匹配忽略大小写。如果自身节点匹配,设置节点matchedText属性值高亮显示。判断自己的节点是否有parentId。如果没有该属性值,则为根节点,无需处理父节点。当存在该属性时,需要一个内循环来处理父节点的搜索属性。使用set保存节点的parentId,逐个向前查找,找到父节点,判断父节点是否处理完毕。如果不是,则将父节点的childrenMatched和expanded属性设置为true,然后将父节点的parentId属性添加到集合中。while循环重复此操作,直到遇到第一个已处理的父节点或直到根节点停止循环。整个双层循环处理所有节点。dealMatchedData核心代码如下:constdealMatchedData=(target:string,matchKey:string|undefined,pattern:RegExp|undefined)=>{consttrimmedTarget=trim(target).toLocaleLowerCase();for(leti=0;i=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;}}}};consthasDealParentNode=(pre:number,cur:number,parentIdSet:Set)=>{//当访问同层之前有匹配时,前一个已经处理过parent了node没有了,不需要继续访问//当第一个父节点的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中的核心处理逻辑类似。通过一个双层循环,节点的isMatched和childrenMatched以及父节点的isMatched设置是否显示节点自身。核心代码如下:constdealNodeHideProperty=()=>{data.value.forEach((item,index)=>{if(item.isMatched||item.childrenMatched){item.isHide=false;}else{//required判断是否有匹配的父节点if(!item.parentId){item.isHide=true;return;}letL=index-1;constset=newSet();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;}}});};consthasParentNodeMatched=(pre:number,cur:number,parentIdSet:Set)=>{returnparentIdSet.has(data.value[pre].id)&&data.value[pre].isMac复制代码hed;};3.5第五步:匹配节点高亮处理如果匹配到节点,则将节点的label处理成数组,格式为[preMatchedText,matchedText,postMatchedText]matchedText添加一个span标签包,并显示通过CSS样式Effect高亮显示。constmatchedContents=computed(()=>{constmatchItem=data.value?.matchedText||'';constlabel=data.value?.label||'';constreg=(str:string)=>str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,'\\$&');constregExp=newRegExp('('+reg(matchItem)+')','gi');returnlabel.split(regExp);});{!data.value?.matchedText&&data.value?.label}{数据.value?.matchedText&&matchedContents.value.map((item:string,index:number)=>(index%2===0?item:{item}))}3.6第六步:树组件使用虚拟列表时,需要将滚动条滚动到第一个匹配的节点,方便用户查看整个树中显示的节点,找到第一个匹配的节点节点索引。调用虚拟列表组件的scrollTo方法滚动到匹配的节点。constgetFirstMatchIndex=():number=>{让索引=0;constshowTreeData=getExpendedTree().value;while(index<=showTreeData.length-1&&!showTreeData[index].isMatched){index++;}返回索引>=showTreeData.length?0:索引;};constscrollIndex=getFirstMatchIndex();virtualListRef.value.scrollTo(scrollIndex);通过scrollTo方法定位到第一个匹配项效果图:原始树结构展示图:Filter函数:4遇到的困难4.1查找的核心在于获取匹配节点的所有父节点和整棵树的处理。数据结构是一维数组。向上,需要展开匹配节点的所有父节点,向下,需要知道是否有匹配的子节点。传统的树组件的数据结构是树形结构,节点的访问和处理都是通过递归的方式完成的。平面数据结构应该做什么?方案一:平面数据结构-->树结构-->递归处理-->平面数据结构(NO)方案二:给节点添加parent属性,保存节点父节点的内容-->遍历节点进行处理自身节点和父节点(否)方案三:同双层循环,第一个循环处理当前节点,第二个循环处理父节点(是)方案一:通过数据结构的转换处理,不仅失去了扁平化数据结构的优势,还增加了数据格式转换的成本,带来更多的性能消耗。方案二:添加parent属性其实是在模仿树结构,增加了内存消耗,节省了很多无用的重复数据。迭代访问节点时也存在节点的重复访问。节点越靠后,重复访问越严重,会消耗无用的性能。方案三:利用扁平数据结构,节点有序。即:树节点的显示顺序是数组中节点的顺序,父节点必须在子节点之前。父节点访问处理只需要遍历该节点之前的节点,使用childrenMatched属性标识父节点有匹配的子节点即可。无需添加parent字段即可访问所有父节点信息,无需经过数据转换,再递归搜索处理节点。4.2优化父节点的处理,防止内遍历重复处理已经访问过的父节点,从而提高性能。在外层循环中,如果节点与搜索字段不匹配,则不执行内层循环,直接跳过。详见3.3中的代码。通过优化内循环的终止条件,防止重复访问同一个父节点letL=index-1;constset=newSet();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--;}consthasDealParentNode=(pre:number,cur:number,parentIdSet:Set)=>{//当访问同级前有匹配前一个已经处理过父节点,所以不需要继续访问//当第一个父节点的childrenMatched为真时,就不再需要向上查找,防止重复访问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对于过滤功能,还需要处理节点的显示和隐藏。节点的isHide属性也是通过双层循环确定的,在处理匹配数据时添加了isMatched和childrenMatched属性。详见3.4中的代码,终止内层循环conditions的优化与设置childrenMatched时的判断不同。consthasParentNodeMatched=(pre:number,cur:number,parentIdSet:Set)=>{returnparentIdSet.has(data.value[pre].id)&&data.value[pre].isMatched;};5总结虽然是一个组件下的一个小功能的开发,但是整个过程还是收获满满的,从功能的交互分析开始,一步步到最后的功能实现。在平时的开发中,很少有从方案设计到功能实现的整体规划,往往是先从代码入手,开发过程中才发现方案选择不合理,会走很多弯路。因此,初期的特性分析和方案设计就显得尤为重要。分析-->设计-->方案讨论-->方案确定-->功能实现-->逻辑优化。每一个过程都可以锻炼和提高自己的能力。文/DevUI社区daviForevel