结论:应优先采用迭代+栈模拟递归解析嵌套列表,而非纯递归;因DOM深度超20层易爆栈,且需精准定位每个li下首个ul作为子容器、隔离递归状态、清理文本、生成唯一id,并考虑扁平化结构替代深层嵌套。

直接说结论:用递归解析 <ul> 和 <li> 生成树状 JSON,关键不是“快”,而是“不丢节点、不乱层级、不爆栈”。浏览器里 DOM 深度超 50 层就容易触发递归调用栈限制,而真实页面中嵌套列表常达 8–12 层——这时候靠纯递归极易失败。
如何安全提取 <li> 的父子关系
DOM 树和 JSON 树结构不等价:<li> 可能自带文本、链接、图标等杂内容,且子 <ul> 不一定紧邻其后。不能只靠 children 遍历,必须显式定位每个 <li> 下第一个 <ul> 作为其子节点容器。
- 先用
querySelectorAll('li')获取全部<li>,再按 DOM 顺序逐个处理,避免因 CSS display:none 或 JS 动态插入导致遗漏 - 对每个
<li>,执行nextElementSibling向下查找最近的<ul>,而不是li.querySelector('ul')—— 后者会误抓兄弟节点下的子菜单 - 若找到对应
<ul>,则递归解析它;否则设children: []
parseListToTree() 函数必须隔离作用域与状态
常见错误是把 result 数组或 currentNode 对象传进递归函数内部做累加,结果所有层级共用同一引用,导致子节点被重复 push 到父节点两次。正确做法是让每层递归返回新数组,由上层决定是否合并。
- 函数签名应为
function parseListToTree(ulElement) { ... return childrenArray; },不接受外部变量注入 - 每个
<li>的文本内容需清理:剔除换行、多余空格、,再用textContent.trim()而非innerText(后者受 CSS 影响) - 若需保留原始 HTML 片段(如含 icon 标签),用
innerHTML.replace(/<\/?ul[^]*?>/gi, '')剥离包裹标签,但要警惕 XSS,建议白名单过滤
深度超过 20 层时改用迭代 + stack 模拟递归
Chrome V8 默认调用栈限制约 10000 帧,但实际测试中,DOM 深度 > 20 就可能触发 RangeError: Maximum call stack size exceeded,尤其在旧版 Safari 中更敏感。此时必须切换策略。
立即学习“前端免费学习笔记(深入)”;
- 用数组模拟栈:
const stack = [{ ul: rootUl, depth: 0, parent: null }];,每次 pop 一个节点,处理其<li>并 push 子<ul>入栈 - 每层生成的节点对象必须带
id字段(可用Math.random().toString(36).substr(2, 9)生成轻量唯一键),避免后续无法关联父子 - 迭代版本性能略低(多一次循环),但内存可控、无栈溢出风险,适合 CMS 导出菜单、文档大纲等不可控深度场景
真正难的不是写递归,是判断什么时候不该递归——DOM 层级越深,越要怀疑“是不是该用 flat list + depth 字段替代嵌套 JSON”。很多前端树组件(比如 antd Tree)内部其实早把数据扁平化了,渲染时才按 depth 计算缩进。这点容易被忽略,但影响长期维护成本。



















