阅读视图

发现新文章,点击刷新页面。

JavaScript 列表转树(List to Tree)详解:前端面试中如何从递归 O(n²) 优化到一次遍历 O(n)

前言:Offer 是怎么没的?

在前端面试的江湖里,「列表转树(List to Tree)」 是一道妥妥的高频题。

很多同学一看到这道题,内心 OS 都是:

😎「简单啊,递归!」

代码写完,自信抬头。
面试官却慢悠悠地问了一句:

🤨「如果是 10 万条数据 呢?
👉 时间复杂度多少?
👉 会不会栈溢出?」

空气突然安静。

今天这篇文章,我们就把这道题彻底拆开:
从「能写」到「写得对」,再到「写得漂亮」。


一、为什么面试官总盯着这棵“树”?

因为在真实业务中,后端给你的几乎永远是扁平数据

例如:

const list = [
  { id: 1, parentId: 0, name: '北京市' },
  { id: 2, parentId: 1, name: '顺义区' },
  { id: 3, parentId: 1, name: '朝阳区' },
  { id: 4, parentId: 2, name: '后沙峪' },
  { id: 121, parentId: 0, name: '江西省' },
  { id: 155, parentId: 121, name: '抚州市' }
];

而前端组件(Menu、Tree、Cascader)要的却是👇

省
 └─ 市
     └─ 区

🎯 面试官的真实考点

  • 数据结构理解:是否真正理解 parentId
  • 递归意识 & 代价:不只会写,还要知道坑在哪
  • 性能优化能力:能否从 O(n²) 优化到 O(n)
  • JS 引用理解:是否理解对象在内存中的表现

二、第一重境界:递归法(能写,但不稳)

1️⃣ 最基础的递归写法

function list2tree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,
      children: list2tree(list, item.id)
    }));
}

逻辑非常直观:

  • 找当前 parentId 的所有子节点
  • 对每个子节点继续递归
  • 没有子节点时自然退出

三、进阶:ES6 优雅写法(看起来很高级)

如果你在面试中写出下面这段代码👇
面试官大概率会先点头。

const list2tree = (list, parentId = 0) =>
  list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,              // 解构赋值,保持原对象纯净
      children: list2tree(list, item.id)
    }));

这一版代码:

  • ✅ 箭头函数
  • filter + map 链式调用
  • ✅ 解构赋值,不污染原数据
  • ✅ 可读性很好,看起来很“ES6”

👉 很多同学到这一步就觉得稳了。


🤔 面试官的经典追问

「这个方案,有什么问题?」


🎯 标准回答(一定要说出来)

「这个方案的本质是 嵌套循环
每一层递归,都会遍历一次完整的 list

👉 时间复杂度是 O(n²)
👉 如果层级过深,还可能导致 栈溢出(Stack Overflow) 。」

📌 一句话总结

ES6 写法只是“看起来优雅”,
性能问题不会因为代码好看就自动消失。


四、第二重境界:Map 优化(面试及格线)

既然慢,是因为反复遍历找父节点
那就用 Map 建立索引

👉 典型的:空间换时间


核心思路

  1. 第一遍:把所有节点放进 Map
  2. 第二遍:通过 parentId 直接挂载
  3. 利用 JS 对象引用,自动同步树结构

代码实现

function listToTreeWithMap(list) {
  const map = new Map();
  const tree = [];

  // 初始化
  for (const item of list) {
    map.set(item.id, { ...item, children: [] });
  }

  // 构建树
  for (const item of list) {
    const node = map.get(item.id);
    if (item.parentId === 0) {
      tree.push(node);
    } else {
      const parent = map.get(item.parentId);
      parent && parent.children.push(node);
    }
  }

  return tree;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

📌 到这一步,已经可以应付大多数面试了。


五、终极奥义:一次遍历 + 引用魔法(Top Tier)

面试官:
「能不能只遍历一次?」

答案是:能,而且这才是天花板解法。


核心精髓:占位 + 引用同步

  • 子节点可能先于父节点出现
  • 先在 Map 里给父节点 占位
  • 后续再补全数据
  • 引用地址始终不变,树会“自己长好”

代码实现(一次遍历)

function listToTreePerfect(list) {
  const map = new Map();
  const tree = [];

  for (const item of list) {
    const { id, parentId } = item;

    if (!map.has(id)) {
      map.set(id, { children: [] });
    }

    const node = map.get(id);
    Object.assign(node, item);

    if (parentId === 0) {
      tree.push(node);
    } else {
      if (!map.has(parentId)) {
        map.set(parentId, { children: [] });
      }
      map.get(parentId).children.push(node);
    }
  }

  return tree;
}

🏆 为什么这是王者解法?

  • ✅ 一次遍历,O(n)
  • ✅ 支持乱序数据
  • ✅ 深度理解 JS 引用机制
  • ✅ 面试官一眼就懂你是“真会”

六、真实开发中的应用场景

  • 🔹 权限 / 菜单树(Ant Design / Element)
  • 🔹 省市区 / Cascader
  • 🔹 文件目录结构(云盘、编辑器)

七、面试总结 & 避坑指南

方案 时间复杂度 评价
递归 O(n²) 能写,但危险
Map 两次遍历 O(n) 面试合格
一次遍历 O(n) 面试加分

面试加分表达

  • 主动提 空间换时间
  • 点出 JS 对象是引用类型
  • 询问 parentId 是否可能为 null
  • 说明是否会修改原数据(必要时深拷贝)

结语

算法不是为了为难人,
而是为了在复杂业务中,
选出那条最稳、最优雅的路。

如果这篇文章对你有帮助👇
👍 点个赞
💬 评论区聊聊你在项目里遇到过的奇葩数据结构

React Hooks 深度理解:useState / useEffect 如何管理副作用与内存

🤯你以为 React Hooks 只是语法糖?

不——它们是在帮你对抗「副作用」和「内存泄漏」

如果你只把 Hooks 当成“不用 class 了”,
那你可能只理解了 React 的 10%。


🚀 一、一个“看起来毫无问题”的组件

我们先从一个你我都写过无数次的组件开始:

function App() {
  const [num, setNum] = useState(0)

  return (
    <div onClick={() => setNum(num + 1)}>
      {num}
    </div>
  )
}

看起来非常完美:

  • ✅ 没有 class
  • ✅ 没有 this
  • ✅ 就是一个普通函数

但问题是:

React 为什么要发明 Hooks?
useState / useEffect 到底解决了什么“本质问题”?

答案其实只有一个关键词👇


💣 二、React 世界的终极敌人:副作用(Side Effect)

React 背后有一个很少被明说,但极其重要的信仰

组件 ≈ 纯函数

🧠 什么是纯函数?

  • 相同输入 → 永远相同输出
  • 不依赖外部变量
  • 不产生额外影响(I/O、定时器、请求)
function add(a, b) {
  return a + b
}

而理想中的 React 组件是:

(props + state) → JSX

React 希望你“只负责算 UI”,
而不是在渲染时干别的事。


⚠️ 但现实是:你必须干“坏事”

真实业务中,你不可避免要做这些事:

  • 🌐 请求接口
  • ⏱️ 设置定时器
  • 🎧 事件监听
  • 📦 订阅 / 取消订阅
  • 🧱 操作 DOM

这些行为有一个共同点👇

它们都不是纯函数行为
它们都是副作用

如果你直接把副作用写进组件函数,会发生什么?

function App() {
  fetch('/api/data') // ❌
  return <div />
}

👉 每一次 render 都请求
👉 状态更新 → 再 render → 再请求
👉 组件直接失控


🧯 三、useEffect:副作用的“隔离区”

useEffect 的存在,本质只干一件事:

把副作用从“渲染阶段”挪走

useEffect(() => {
  // 副作用逻辑
}, [])

💡 一句话理解:

render 阶段必须纯,
effect 阶段允许脏。


📦 四、依赖数组不是细节,而是“副作用边界”

1️⃣ 只执行一次(挂载)

useEffect(() => {
  console.log('mounted')
}, [])
  • 只在组件挂载时执行
  • 类似 Vue 的 onMounted

2️⃣ 依赖变化才执行

useEffect(() => {
  console.log(num)
}, [num])
  • num 变化 → 执行
  • 不变 → 不执行

依赖数组的本质是:
“这个副作用依赖谁?”


3️⃣ 不写依赖项?

useEffect(() => {
  console.log('every render')
})

👉 每次 render 都执行
👉 99% 的时候是性能陷阱


💥 五、90% 新手都会踩的坑:内存泄漏

来看一个极其经典的 Hooks 错误写法👇

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num)
  }, 1000)
}, [num])

你觉得这段代码有问题吗?

有,而且非常致命。


❌ 问题在哪里?

  • num 每变一次
  • effect 重新执行
  • 新建一个定时器
  • ❗旧定时器还活着

结果就是:

  • ⏱️ 定时器越来越多
  • 📈 内存持续上涨
  • 💥 控制台疯狂打印
  • 🧠 内存泄漏

🧹 六、useEffect return:副作用的“善终机制”

React 给你准备了一个官方清理通道👇

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num)
  }, 1000)

  return () => {
    clearInterval(timer)
  }
}, [num])

⚠️ 重点来了

return 的函数不是“卸载时才执行”

而是:

下一次 effect 执行前,一定会先执行它

React 内部顺序是这样的:

  1. 执行上一次 effect 的 cleanup
  2. 再执行新的 effect

👉 这就是 Hooks 防内存泄漏的核心设计


🧠 七、useState:为什么初始化不能异步?

你在学习 Hooks 时,一定问过这个问题👇

❓ 我能不能在 useState 初始化时请求接口?

useState(async () => {
  const data = await fetchData()
  return data
})

答案很干脆:

不行


🤔 为什么不行?

因为 React 必须保证:

  • 首次 render 立即有确定的 state
  • 异步结果是不确定的
  • state 一旦初始化,必须是同步值

React 允许的只有这种👇

useState(() => {
  const a = 1 + 2
  const b = 2 + 3
  return a + b
})

💡 这叫 惰性初始化
💡 但前提是:同步 + 纯函数


🌐 八、那异步请求到底该写哪?

答案只有一个地方:

useEffect

useEffect(() => {
  async function query() {
    const data = await queryData()
    setNum(data)
  }
  query()
}, [])

🎯 这是 React 官方推荐模式

  • state 初始化 → 确定
  • 异步请求 → 副作用
  • 数据回来 → 更新状态

🔄 九、为什么 setState 可以传函数?

setNum(prev => prev + 1)

这不是“花里胡哨”,而是并发安全设计

React 内部可能会:

  • 合并多次更新
  • 延迟执行 setState

如果你直接用 num + 1,很可能拿到的是旧值。

函数式 setState = 永远安全


🏁 十、Hooks 的真正价值(总结)

如果你只把 Hooks 当成:

“不用写 class 了”

那你只看到了表面。

Hooks 真正解决的是:

  • 🧩 状态如何在函数中稳定存在
  • 🧯 副作用如何被精确控制
  • 🧠 生命周期如何显式建模
  • 🔒 内存泄漏如何被主动规避

✨ 最后的掘金金句

useState 解决的是:数据如何“活着”
useEffect 解决的是:副作用如何“善终”

React Hooks 不只是语法升级,
而是一场从“命令式生命周期”
到“声明式副作用管理”的革命。

❌