ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信小程序自研Tree树形组件:递归渲染与懒加载实战

微信小程序自研Tree树形组件:递归渲染与懒加载实战 在做微信小程序的项目时难免会遇到“多级分类”“组织架构”“权限菜单”这类型的需求背后都指向同一个东西tree 树形组件。翻官方基础库没有现成的 tree翻第三方组件库tree 组件不是交互不够用就是样式改起来让人抓狂。折腾过两三个项目之后我干脆自己写了一个可复用的微信小程序 tree 树形组件把递归渲染、选中联动、搜索过滤、懒加载这些能力都收拢到组件内部。这篇就完整记录一下我的实现思路和踩坑过程希望能给同样被树形结构折磨的人省点时间。1. 为什么微信小程序没有现成的 tree 组件可以用1.1 官方组件缺位第三方方案水土不服微信小程序官方基础库里组件数量不少但树形组件一直缺席。原因也不难理解小程序的渲染机制和 Web 端有差异递归组件、深度节点更新这些能力需要开发者自己组织官方更倾向于提供底层能力而不是上层业务组件于是 tree 这种看似常见的需求就留给了社区。社区方案我也试过几个包括市面上比较知名的组件库里的 Tree 组件。结论是展示两层、三层分类还能用一旦遇到懒加载、父子联动半选、节点按钮自定义、默认展开任意层级这类真实业务需求要么组件本身不支持要么为了绕过限制写一堆补丁代码最后改下来的成本比自己写还高。另外第三方组件大多带着自己的视觉风格想要完全贴合设计稿需要覆盖大量样式变量后期升级组件库还可能引发样式回归。自研组件的成本其实没有想象中高。核心渲染逻辑就是“递归套递归”配合自定义组件在小程序里天然支持自身引用代码量完全可以控制在一两个文件内。更关键的是自研之后树节点的状态、事件、样式都由自己掌控后续加需求不会受制于人这也是我最终选择自己写的最直接原因。1.2 一个完整的树形组件要解决哪些核心问题在动手前我把树形组件的需求先拆了一遍保证设计时不会漏东西。数据渲染接收嵌套树结构递归渲染出多层级节点每个节点支持缩进、箭头、文本。展开收起点击箭头或节点文本时当前节点的子级展开或折叠。节点选中支持单选和多选多选时父子节点状态联动父节点全选、子节点半选这些状态要能正确表达。异步加载点击展开时才向后端请求子节点而不是一次性渲染全量数据。搜索过滤在树中根据关键字过滤节点同时保留父子层级关系。外部控制父页面能够获取当前展开节点、选中节点能够主动展开/收起指定路径。把这些能力一次性想清楚再决定哪些放在节点组件内部哪些放在外层容器组件中写代码时就不会东一榔头西一棒子了。我在项目中采用的设计是tree-item 负责单个节点的渲染和内部展开状态外层 tree 组件负责数据组织、选中联动和事件分发职责边界清晰调试和维护都很顺手。2. 数据模型设计从扁平列表到树形结构2.1 后端返回的扁平数组怎么高效转成树后端接口返回的数据九成以上是扁平数组每条记录带 id 和 parentId靠这两个字段表达层级关系。前端拿到之后不能直接渲染要先转成嵌套结构。新手容易写出两层 filter 嵌套的写法在数据量不大的时候没感觉但分类数据上千条时就会明显变慢。我推荐的方案是用一次遍历加对象映射把时间复杂度从 O(n²) 降到 O(n)。function buildTree(list) { const map {}; const roots []; list.forEach((item) { map[item.id] { ...item, children: [] }; }); list.forEach((item) { const node map[item.id]; if (item.parentId map[item.parentId]) { map[item.parentId].children.push(node); } else { roots.push(node); } }); return roots; }这份代码有几个细节我用了很久觉得很重要。第一第一遍遍历把每个节点展开成带 children 的对象放入 map第二遍遍历做父子挂接这样即使后端返回的顺序里子节点在父节点前面也一样能正确组树。第二用展开运算符做了浅拷贝避免修改后续要追加的 expanded、selected 等字段时污染原始数组。第三根节点的判断条件是 parentId 不存在或者 parentId 指向的节点在 map 里找不到这种条件对脏数据的容忍度更高即使某条数据父级被删了也不会导致整棵组树崩溃。2.2 节点字段设计与默认展开状态处理组树之后每个节点上除了业务字段我通常还会挂几个控制字段集中管理渲染和交互状态。比较实用的是 level、expanded、selected、halfChecked 这几个都是在组树完成后由前端统一补齐不要让后端去管这些纯前端状态。默认展开状态有两种常见策略。第一是全部折叠适合节点数量多、层级深用户按需展开的场景比如大型组织架构第二是默认展开到指定层级让用户进来就能看到主分类和子分类体验上更友好。实现方式是组树后做一次递归遍历按照层级设置 expanded。function setDefaultExpand(tree, maxLevel, currentLevel 1) { tree.forEach((node) { node.expanded currentLevel maxLevel; if (node.children node.children.length) { setDefaultExpand(node.children, maxLevel, currentLevel 1); } }); }这个递归有个坑要注意如果后续要做搜索过滤或懒加载level 并不会自动跟着变化。比如过滤之后某条原本在第 3 层的节点被提升为根节点继续用旧的 level 做缩进就会出问题。我的方案是在每次遍历生成渲染数据时重新计算 level或者直接在节点上保存一条 path 路径链即祖先 id 序列需要展开某个节点时根据 path 来定位和设置父级状态这样能适配更加灵活的交互。3. 组件实现递归渲染与核心交互逻辑3.1 用自定义组件递归渲染树节点微信小程序里渲染树形结构有 template 模板递归和自定义组件递归两条路。template 递归写起来简单但事件处理和数据通信能力弱我不推荐在真实项目里用。自定义组件递归看起来前期配置多一些但后续扩展能力完全值得这点成本。先建一个 components/tree-item 目录组件关键在于 json 文件里注册组件自身这样 wxml 中才能递归引用。{ component: true, usingComponents: { tree-item: ./index } }组件的属性就两个node 表示当前节点对象level 表示当前节点所处深度渲染时直接通过 level 计算缩进。每个节点左侧箭头只在有子节点时显示点击整行切换展开收起。view classtree-item stylepadding-left: {{level * 24}}px view classtree-row bindtaponToggle text classtree-arrow {{expanded ? expanded : }} {{node.children node.children.length ? (expanded ? ▾ : ▸) : }} /text text classtree-label{{node.name}}/text /view block wx:if{{expanded node.children node.children.length}} tree-item wx:for{{node.children}} wx:keyid node{{item}} level{{level 1}} / /block /view这个写法的核心是每一层节点组件只关心自己的 childrenchildren 交给下一层 tree-item 继续渲染无限层级就自然成立了。有一点提醒一下wxml 模板里不能写复杂的 JavaScript 逻辑所以我尽量把字段在 js 中提前计算好再交给模板。如果节点类型复杂比如有图标、有状态标识建议在 properties 的 observer 里预先计算 hasChildren、isLeaf、label 等派生字段让 wxml 保持简单。3.2 展开收起、选中与父子联动机制展开收起的实现比较直接节点组件内部维护 expanded 状态每次点击就取反。Component({ properties: { node: Object, level: Number, }, data: { expanded: false, }, methods: { onToggle() { const expanded !this.data.expanded; this.setData({ expanded }); this.triggerEvent(toggle, { id: this.data.node.id, expanded, }); }, }, });要特别注意的是自定义组件内部 setData 不会自动同步到父组件的 data所以整棵树的展开状态其实是分散在各个节点组件里的。如果父页面需要知道哪些节点展开了或者要做状态回显就必须在展开时通过 triggerEvent 通知外层。我在外层组件里统一维护一个 expandedIds 数组展开状态以此为准节点组件内部的 expanded 只负责视觉呈现。选中逻辑比展开要复杂一些。单选场景用 selectedId 记录选中项就行多选场景外层维护 selectedIds 数组并通过 change 事件对外通知。父子联动是整个树组件里最考验细节的部分。规则基本是三类父节点选中则所有子孙节点全部选中父节点取消则所有子孙节点取消子节点部分选中父节点显示半选状态。实现联动前要先能拿到当前节点的所有子孙节点。function collectDescendantIds(node, result []) { if (node.children node.children.length) { node.children.forEach((child) { result.push(child.id); collectDescendantIds(child, result); }); } return result; }这里有个性能隐患如果每次点击都在整棵树上递归收集子孙节点节点多的时候会卡。我的优化方案是组树完成后把每个节点的子孙 id 列表缓存到一个映射表里联动选中时直接查表不需要重复递归。在商品类目、组织架构这类频繁勾选的场景下这个优化带来的体感提升非常明显。4. 实战扩展搜索过滤与懒加载4.1 分类树实时搜索过滤的两种做法我在最近一个商品类目项目里后端返回了四层分类总数接近两千需求之一就是搜索框实时过滤分类。最直接的方案是先把树拍平成数组按关键字过滤后再重新组树。function flattenTree(tree, result []) { tree.forEach((node) { result.push(node); if (node.children node.children.length) { flattenTree(node.children, result); } }); return result; } function filterTree(tree, keyword) { const flat flattenTree(tree); const filtered flat.filter((node) node.name.includes(keyword)); const list filtered.map((node) ({ id: node.id, parentId: node.parentId, name: node.name, })); return buildTree(list); }这种方案实现简单但存在一个语义问题如果父节点命中了关键字它下面的子节点即使没命中也会跟着全部显示出来。在“搜手机分类能看到手机相关全部分类”的场景下这个行为是符合预期的但如果要求“只显示命中的节点及其祖先”就需要额外过滤只保留命中节点本身以及它到根节点的所有祖先链。两种过滤策略没有绝对好坏取决于产品期望。还需要留意搜索和组件的状态交互。过滤后的树如果复用原来的 expanded 状态可能会出现展开状态指向不存在节点的问题。我在进入搜索模式时会重置所有节点的展开状态搜索结束后再恢复默认展开策略流程上更可控。4.2 数据量大时的懒加载方案与防抖细节两千个节点一次性渲染到页面上无论数据解析还是页面渲染都有压力尤其在小程序这种 WebView 渲染环境里体感更明显。懒加载是解决这类问题的标准手段节点没有 children 但 isLeaf 为 false 时点击展开后请求后端拿到子级数据再插入当前节点。懒加载会带来两个额外收益。第一是首屏数据量显著减小页面打开快第二是 setData 的单次数据体积变小展开某个大分类时不会出现长时间白屏或卡顿。要知道微信小程序的 setData 是全量更新的两千个节点的树一次性 setData 进去即使数据解析很快渲染层也会卡一下。分批加载后每次只插入几十个节点流畅度完全不是一个级别。懒加载过程中还要处理重复请求问题。用户手速快连续点同一个节点的展开箭头会触发多次网络请求。我维护了一个 requestingIds 集合请求未返回时再次点击直接忽略返回后再从集合中移除。这里也提醒一下节点加载子节点后要更新 hasChildren 状态否则已经展开过的节点下次打开还会再次请求。搜索场景和懒加载组合时有一个容易踩的坑搜索状态下如果节点仍然可以触发懒加载请求可能导致搜索结果中混入异步加载的数据出现“越搜越多”的诡异现象。我建议搜索时把树切到静态模式只基于已经加载到本地的节点做过滤搜索结束退出静态模式再恢复异步加载能力。这个状态切换不复杂但能避免很多交互上的奇怪问题。5. 常见问题与性能优化实录5.1 样式隔离与事件冒泡那些坑自定义组件在微信小程序里默认有样式隔离父页面写的样式对组件内部不生效。很多新手在这个地方卡住明明 class 名字写对了样式就是不出来原因就在 styleIsolation。解决方法有三种组件 json 里配置 styleIsolation、通过 externalClasses 暴露自定义样式类名、或者把样式直接写在组件内部。我推荐第二种做法组件的布局样式、交互样式写在自己内部颜色、边距这类需要主题化的部分通过 externalClasses 暴露给外部既保证组件自洽又方便不同页面差异化定制。事件冒泡是另一个高频问题。树节点行上绑定了 tap 展开事件节点内部如果还有删除、编辑这类操作按钮点击按钮会冒泡触发行的 tap导致误展开或误选中。解决办法是操作按钮用 catchtap 冒泡拦截或者在行事件里通过 data 属性区分点击来源。我遇到过一次比较隐蔽的问题checkbox 组件的 change 事件绑定在行元素上点击 checkbox 时先触发行 tap 再触发 change节点被展开又被选中看起来就像状态错乱排查到最后就是事件冒泡顺序的问题。5.2 setData 性能优化三板斧树形组件最容易在节点数量变大后出现卡顿setData 是主要瓶颈。我总结了三条实战经验按优先级排列。第一减少 setData 频率。展开收起这种高频交互只更新当前一个节点的 expanded 字段而不是把整个 treeData 重新 setData 一遍。这个需求天然适合“自定义组件拆分状态”的做法因为每个节点组件自己维护展开状态父组件根本不需要参与。第二缩小 setData 单次传输的数据体积。有些场景必须由父组件更新数据比如懒加载后要追加子节点。这时候不要直接 setData 整棵树而是用数据路径精准定位比如 setData 一个新增的子树到指定索引位置这样传输的数据量会大幅下降。第三避免不必要的响应式依赖。小程序页面的 data 每次 setData 都会进入渲染层 diff如果把临时变量、计算结果也挂在 data 上就会被动参与渲染。我发现把搜索缓存、请求标记这类数据放到组件实例的普通字段上不放进 data可以显著减少无意义的渲染计算。5.3 wx:key、调试技巧与收尾经验最后再说几个容易忽略的小问题。递归渲染时 wx:key 必须稳定。用 index 当 key 在静态展示时偶尔能跑通但一旦涉及懒加载、排序、搜索过滤节点顺序变化就会导致展开状态错乱表现为“A 节点展开了刷新列表后展开状态跑到 B 节点上”。排查这种问题非常耗时间所以从一开始就用业务 id 当 key没有 id 的临时节点也要生成一个唯一标识。第二微信开发者工具的渲染表现和真机有差异尤其在低端 Android 机型上展开收起树节点的流畅度可能和工具里完全两个样。性能验证一定要以真机为准工具里的 Performance 面板只做参考。第三调试递归组件时不要直接 console.log 整棵树。把树结构转化为带缩进的字符串再输出结构一眼就能看清。比如写一个简单的 printTree(root)在控制台输出目录式的结构挂载错误、层级错乱、循环引用这些问题会好定位得多。这些细节单独看都不起眼组合在一起才是一个真正可靠的树形组件。如果后续项目里遇到树节点数量特别大的场景还可以进一步考虑虚拟列表或者 canvas 渲染方案但常规业务中上面这套设计已经足够应付了。
返回列表