高性能递归算法:HTML嵌套列表转树状JSON
先说结论:处理嵌套列表解析,核心策略应为“优先用迭代+栈模拟递归,而非纯递归” 理由很直接:浏览器中DOM深度一旦超过20层,递归就容易导致调用栈溢出。这不是理论问题,而是真实场景中实实在在的陷阱。目标不是追求运行速度,而是“不丢节点、不乱层级、不爆栈”。 浏览器里DOM深度超过50层时容易触发递归
先说结论:处理嵌套列表解析,核心策略应为“优先用迭代+栈模拟递归,而非纯递归”
理由很直接:浏览器中DOM深度一旦超过20层,递归就容易导致调用栈溢出。这不是理论问题,而是真实场景中实实在在的陷阱。目标不是追求运行速度,而是“不丢节点、不乱层级、不爆栈”。
浏览器里DOM深度超过50层时容易触发递归调用栈限制,而真实页面中嵌套列表常常出现在8到12层之间。此时,纯粹依赖递归基本无法完成任务。
如何安全提取 的父子关系
需要注意,DOM树和JSON树的结构并不等价。 中可能混有文本、链接、图标等各种杂内容,而且其子 不一定紧跟在后面。这意味着不能简单只靠 children 遍历,必须显式定位每个 下的第一个 ,将其作为子节点容器。
具体操作上:
- 先用
querySelectorAll('li')获取所有,并按DOM顺序逐个处理。这样做能避免因CSSdisplay:none或JavaScript动态插入导致的节点遗漏。 - 对每个
,用nextElementSibling向下查找最近的,而不是用li.querySelector('ul')。后者容易误抓兄弟节点下的子菜单,造成层级混乱。 - 如果找到对应的
,则递归解析它;找不到,则将children设为空数组。
parseListToTree() 函数必须隔离作用域与状态
一个常见的翻车点:把 result 数组或 currentNode 对象传进递归函数中做累加,结果导致所有层级共用一个引用,子节点被重复推送到父节点两次。正确做法是:让每一层递归都返回一个全新的数组,由上层决定是否合并。
函数签名应设计为 function parseListToTree(ulElement) { ... return childrenArray; },坚决不接受外部变量注入。
- 每个
的文本内容需要清理:剔除换行、多余空格、,然后用textContent.trim()而不是innerText(后者受CSS影响,结果可能不准确)。 - 如果需要保留原始HTML片段(例如包含图标标签),可以用
innerHTML.replace(/< \/?ul[^]*?>/gi, '')剥离包裹标签。不过要注意XSS风险,建议加入白名单过滤。
深度超过20层时,改用迭代+栈模拟递归
尽管Chrome V8的默认调用栈限制大约10000帧,但实际测试中,DOM深度一旦超过20层,就可能触发 RangeError: Maximum call stack size exceeded,尤其在旧版Safari中更为敏感。这就必须切换策略。
用数组模拟栈:const stack = [{ ul: rootUl, depth: 0, parent: null }]; 每次弹出一个节点,处理它下面的 ,然后将子 推入栈中。
- 每一层生成的对象都必须带上
id字段(可用Math.random().toString(36).substr(2, 9)生成轻量唯一键),否则后续无法关联父子关系。 - 迭代版本的性能会略低一些(多一次循环),但内存可控,没有栈溢出风险。特别适合CMS导出菜单、文档大纲等深度不可控的场景。
真正的核心难点不在于递归怎么写,而在于决定什么时候不该递归。DOM层级越深,就越要考虑:是不是该用平面列表加depth字段来替代嵌套JSON?很多前端树组件(例如antd Tree)内部早已将数据扁平化,渲染时才根据depth计算缩进。这一点容易被忽视,但对长期维护影响很大。判断建议是:与其在20层以上硬扛递归,不如一开始就向扁平化结构转型。
