JavaScript数据结构实战:栈、队列、树的实现与工程应用
2026/9/19 12:43:18 网站建设 项目流程

提到 JavaScript 里的数据结构,很多同学第一反应是“刷 LeetCode 才用得上”,平时写业务根本接触不到。但真去做全栈项目、面试大厂、优化性能瓶颈的时候,你会发现栈、队列、树这些概念无处不在:浏览器的事件循环靠队列、函数调用和递归靠栈、DOM 节点本身就是一棵树,甚至 V8 引擎里对象的属性访问也依赖某种树形结构。这篇文章我就用 JavaScript 把栈、队列和树从头实现一遍,再把它们对应的真实应用场景、工程实践和踩坑经验全部盘一遍。不管你是刚入门的前端新人,还是准备转全栈的开发者,这套内容都值得认真过一遍。

1. 栈:不只是函数调用的“幕后功臣”

1.1 用原生 JS 实现一个能用的栈

栈的特点一句话就能说清:后进先出(LIFO,Last In First Out)。就好比一摞盘子,你总是先拿最上面那个。在 JavaScript 里用数组模拟栈非常简单,很多人写过下面这样的代码:

class Stack { constructor() { this.items = []; } push(element) { this.items.push(element); } pop() { return this.items.pop(); } peek() { return this.items[this.items.length - 1]; } isEmpty() { return this.items.length === 0; } size() { return this.items.length; } clear() { this.items = []; } }

这段代码能跑,日常用也够。但我个人建议你在真实项目里至少补两个能力:一个是toString方法,方便调试时打印栈内元素;另一个是初始化时传入一个数组,方便从已知数据直接构建栈。另外,peek方法很多人会忽略,但它其实是栈应用里最高频的操作,比如编辑器撤销功能里,你需要先看一眼栈顶是什么,再决定要不要弹出来。

如果追求性能,也可以用对象来实现栈:

class Stack { constructor() { this.count = 0; this.items = {}; } push(element) { this.items[this.count] = element; this.count++; } pop() { if (this.isEmpty()) return undefined; this.count--; const result = this.items[this.count]; delete this.items[this.count]; return result; } peek() { if (this.isEmpty()) return undefined; return this.items[this.count - 1]; } isEmpty() { return this.count === 0; } size() { return this.count; } clear() { this.items = {}; this.count = 0; } toString() { if (this.isEmpty()) return ''; let objString = `${this.items[0]}`; for (let i = 1; i < this.count; i++) { objString = `${objString},${this.items[i]}`; } return objString; } }

为什么要用对象?因为数组方法在频繁增删时会有额外开销,对象配合计数器的方式在极限情况下性能更稳定,不过对于普通业务代码,两者差别并不大。对比文档里常见的实现,我建议你在项目里先选数组版本,清晰、好维护,等真压测出瓶颈了再优化也不晚。

1.2 三个高频场景:括号匹配、撤销回退、调用栈

栈的实际应用远比想象中广,我讲三个最常见的场景。

第一个是括号匹配。编译器、模板引擎、代码格式化工具里都会用到。思路很简单:遇到左括号就入栈,遇到右括号就出栈并检查是否匹配。如果最后栈为空且没有匹配失败,说明括号成对。

function isValidParentheses(str) { const stack = []; const map = { ')': '(', '}': '{', ']': '[' }; for (const char of str) { if (['(', '{', '['].includes(char)) { stack.push(char); } else if (map[char]) { if (stack.pop() !== map[char]) { return false; } } } return stack.length === 0; }

第二个是撤销与回退。编辑器、表单设计器、甚至浏览器前进后退都有栈的影子。撤销就是一个栈:每次操作都压入历史栈,执行撤销就从栈顶弹出最近的操作并执行反向逻辑。有些场景会再加一个“重做栈”,撤销时把弹出的操作塞进重做栈,重做时再弹回来。

第三个是函数调用栈——这是 JS 运行时最核心的机制。函数 A 调用函数 B,运行时就会把 A 的执行上下文压入调用栈,再压入 B。B 执行完,B 出栈,回到 A 继续执行。我们常说的“栈溢出(Stack Overflow)”,本质就是调用栈的容量被撑爆了。

注意:栈溢出不等于内存不够,而是超出了 V8 对调用栈的层级和大小限制。后面第 5 节我会专门讲怎么定位和修复这类问题。

1.3 栈内存溢出:递归没写好到底会发生什么

栈溢出最常见的诱因是无限递归。比如你写了个递归函数遍历目录,忘记处理符号链接,就可能无限递归下去:

function traverseDir(path) { const children = fs.readdirSync(path); for (const child of children) { const fullPath = `${path}/${child}`; const stat = fs.statSync(fullPath); if (stat.isDirectory()) { traverseDir(fullPath); // 忘记处理循环引用 } } }

在浏览器里,更典型的是递归组件渲染出循环引用的树形数据,比如一个菜单的children属性不小心指向了父级对象。报错信息通常是RangeError: Maximum call stack size exceeded

解决栈溢出有几个层面。第一,检查递归终止条件是否覆盖所有边界情况;第二,把递归改成迭代,很多尾递归可以用栈或队列模拟;第三,对于明确有深度上限的场景(比如树形组件),在上层就校验数据层级,超过阈值直接截断或报错。

我个人的习惯是:写递归前先问自己三句话——“终止条件是什么?”“最坏递归深度是多少?”“这个深度会不会超过可用栈空间?”想清楚这三个问题,大部分栈溢出都能在写代码阶段就避免掉。

2. 队列:从事件循环到消息队列的必备模型

2.1 用数组实现队列,以及隐藏的坑

队列和栈正好相反,是先进先出(FIFO,First In First Out)。想象一下超市结账排队,先来的人先被服务。用数组实现队列最直接的方式是push入队、shift出队:

class Queue { constructor() { this.items = []; } enqueue(element) { this.items.push(element); } dequeue() { return this.items.shift(); } front() { return this.items[0]; } isEmpty() { return this.items.length === 0; } size() { return this.items.length; } }

这个写法最大的坑在shift。数组的shift方法会移除第一个元素并让后面所有元素前移一位,时间复杂度是 O(n)。如果你的队列里存了几万条数据,每次出队都触发大规模元素移动,性能就会急剧下降。

在刷题和面试里,如果要求实现一个队列,你最好用对象加双指针来设计:

class Queue { constructor() { this.items = {}; this.head = 0; this.tail = 0; } enqueue(element) { this.items[this.tail] = element; this.tail++; } dequeue() { if (this.isEmpty()) return undefined; const item = this.items[this.head]; delete this.items[this.head]; this.head++; return item; } front() { return this.items[this.head]; } isEmpty() { return this.tail - this.head === 0; } size() { return this.tail - this.head; } }

这种实现方式出队时不需要移动元素,只是把头部指针往后移,整体摊还复杂度接近 O(1),性能上比我周围很多同事用的数组版本要稳得多。

2.2 循环队列:解决空间浪费的经典方案

如果你在处理固定大小的缓冲任务,比如日志记录、数据采样、音视频帧缓冲,上面那种动态增长的队列就不太合适了。这时更经典的方案是循环队列(Circular Queue)

循环队列的核心思想是复用数组空间:入队时尾指针加一,出队时头指针加一,当指针到达数组末尾时绕回开头。判空和判满需要单独处理,通常用head === tail表示空,用(tail + 1) % capacity === head表示满,也就是牺牲一个存储单元来区分空和满。

class CircularQueue { constructor(capacity) { this.capacity = capacity; this.items = new Array(capacity); this.head = 0; this.tail = 0; } enqueue(element) { if (this.isFull()) return false; this.items[this.tail] = element; this.tail = (this.tail + 1) % this.capacity; return true; } dequeue() { if (this.isEmpty()) return undefined; const item = this.items[this.head]; this.head = (this.head + 1) % this.capacity; return item; } isFull() { return (this.tail + 1) % this.capacity === this.head; } isEmpty() { return this.head === this.tail; } }

使用循环队列最常见的 bug 就是“空”和“满”的判断条件搞反。我记得有一次在数据采集服务里,因为少写了一个+1,导致队列还没装满就开始覆盖旧数据,排查了半天。这里分享个经验:写完后一定要用“空队列入一个再出一个”和“满队列再入一个”这两组边界用例去验证。

2.3 浏览器事件循环与任务队列

队列在 JavaScript 里最有代表性的应用,就是浏览器的事件循环(Event Loop)。JavaScript 是单线程语言,所有同步任务都在主线程上执行,异步任务则会被放进任务队列。这里有几个容易混淆的概念:

  • 同步任务直接执行;
  • 微任务(Promise.then、MutationObserver、queueMicrotask)在本次宏任务结束后立即执行;
  • 宏任务(setTimeout、setInterval、I/O 回调)进入宏任务队列,等下一个循环再取出来执行。

我之前在项目里遇到过一个经典问题:for循环里连续setTimeout(…, 0),结果所有回调都按顺序执行但页面明显卡顿。后来排查发现,在宏任务队列里塞了太多任务,导致每一帧之间主线程一直在处理回调,没有给渲染留出空档。解决办法是把任务拆到requestIdleCallback或者用队列控制并发数量,这本质上就是在做“队列调度”。

提示:事件循环本身不是题目的主角,但它是“队列”思想在 JS 运行时里最真实的体现。理解了这个,你才能解释为什么setTimeout的延时并不精确,为什么Promise的回调顺序总是先于同级setTimeout

2.4 消息队列的重复消费问题:幂等性设计

如果跳出来看前端,消息队列在后端和全栈项目里同样举足轻重。很多同学做全栈项目时会用到 RabbitMQ、Kafka 或者 Redis 的 Stream,而“重复消费”是消息队列里最经典的坑之一。

重复消费的根源是:消费者处理完消息后,还没来得及提交 ack,进程就挂了。等恢复后,队列会重新把这条未提交的消息再投递一次。这时候如果你的业务不是幂等的,就会出现重复插入数据库、重复扣款、重复发短信等问题。

我见过一个团队对接订单系统时,因为没做幂等,线上出现了一批双写的订单。排查后发现,他们的消费逻辑是这样的:

// 错误示范:没有幂等校验 async function consumeOrderMessage(msg) { await saveOrder(msg.orderId, msg.userId, msg.amount); }

后来从架构上做了两层防护:一是数据库对orderId加唯一索引,二是消费前先查一笔订单是否已存在:

// 加幂等校验 async function consumeOrderMessage(msg) { const exists = await checkOrderExists(msg.orderId); if (exists) { return; } await saveOrder(msg.orderId, msg.userId, msg.amount); }

这个问题的本质,就是“队列的投递语义无法保证恰好一次,只能保证至少一次”。所以设计消费者逻辑时,默认每条消息都可能被处理多次,是最稳妥的心态。

3. 树:前端绕不开的抽象结构

3.1 二叉树在 JS 里的定义和遍历套路

树是 JavaScript 里应用范围最广、也最容易让人犯迷糊的数据结构之一。从 DOM 节点到目录结构,从 Vue 的虚拟 DOM 到编译器的 AST(抽象语法树),到处都是树。最简单的树是二叉树,每个节点最多两个子节点。

在 JS 里定义一个二叉树节点非常简洁:

class TreeNode { constructor(value) { this.value = value; this.left = null; this.right = null; } }

遍历二叉树有四种经典方式:前序、中序、后序、层序。前三种用递归写非常直观:

function preorder(node, result = []) { if (!node) return result; result.push(node.value); preorder(node.left, result); preorder(node.right, result); return result; } function inorder(node, result = []) { if (!node) return result; inorder(node.left, result); result.push(node.value); inorder(node.right, result); return result; } function postorder(node, result = []) { if (!node) return result; postorder(node.left, result); postorder(node.right, result); result.push(node.value); return result; }

层序遍历则需要借助队列来做:把根节点入队,循环出队并把左右子节点入队,这样就能一层一层地访问。这个套路也是“队列”知识点的延续,很多面试题都从这里展开。

3.2 之字形遍历:一道高频算法题的推演

有一道很经典的面试题让不少同学头疼:树的之字形(Zigzag)遍历。要求第一层从左到右,第二层从右到左,第三层又反过来,交替进行。

我第一次实现这道题,直接用层序遍历加reverse

function zigzagLevelOrder(root) { if (!root) return []; const queue = [root]; const result = []; let leftToRight = true; while (queue.length) { const levelSize = queue.length; const level = []; for (let i = 0; i < levelSize; i++) { const node = queue.shift(); if (leftToRight) { level.push(node.val); } else { level.unshift(node.val); } if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } result.push(level); leftToRight = !leftToRight; } return result; }

这段代码能过,但效率不够好,因为对数组做unshift会有元素移动开销。更优雅的写法是:先得到这一层的节点值数组,如果是偶数层就reverse。但reverse也是额外 O(n)。最干净的做法是初始化时预留好长度,根据方向决定从头部填还是从尾部填。

之字形遍历的价值不只在面试。“通过这一道题,你会把层序遍历、双端操作、方向切换这几个点全部串起来,”我经常跟团队新人说,“它是检验你到底是背模板还是真的理解树的绝佳题目。”

3.3 从字典树到 B 树:不同形态的树解决不同问题

树不是一个单一结构,而是一大家子。下面几种形态在工程里经常出现:

字典树(Trie)用于字符串前缀匹配。输入法联想、搜索引擎自动补全、敏感词过滤,都是字典树的典型场景。它的核心思想是沿着树的边来表示字符,公共前缀共享同一条路径。

B 树/B+ 树是数据库和文件系统最常用的索引结构。普通二叉树在数据量大时会退化成链表,B 树则通过一个节点存储多个 key 和多棵子树,保证树的高度可控,从而减少磁盘 I/O。比如 MySQL 的 InnoDB 引擎,B+ 树的叶子节点会串联成链表,非常适合范围查询。

红黑树是一种平衡二叉查找树,Java 的TreeMap、C++ 的std::map底层都用到它。在 JS 里,MapSet底层原理里也会涉及哈希与树结构的权衡。V8 引擎在对象属性访问时用到的隐藏类(Hidden Class)也和树形查找有一定关联,这属于比较底层的话题了。

Merkle 树Merkle Patricia Tree则常见于区块链和分布式系统,用来校验数据完整性。你只要记住“把子节点哈希两两合并,最终得到一个根哈希”这个套路,以后看到相关代码就不会懵。

注意:面试时讲树的形态,如果只说出“二叉树、平衡二叉树”通常不够,能把字典树和 B 树解决什么问题讲清楚,面试观感会明显不一样。

3.4 红黑树、堆和排序:树在系统底层的样子

堆(Heap)本质上是一棵完全二叉树,但它在工程里更多以“优先队列”的形式出现。比如定时任务系统里要不断取最近的过期时间点,用最小堆每次取堆顶就是 O(1),插入是 O(log n),比数组反复排序高效得多。

堆排序也是基于树的思想:先把数组构建成一个最大堆,然后把堆顶元素与末尾元素交换,缩小堆的范围再调整。虽然在面试里写堆排序的人少了,但你理解了堆结构,再去读很多中间件源码时会有“豁然开朗”的感觉。

红黑树在系统底层出现频率很高。它有两条硬性规则:节点非红即黑、从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。这两条规则保证了树的左右子树高度差不会超过一倍,从而避免了极端不平衡导致的性能退化。

我整理一个树相关的对比表,方便大家跳转复习:

结构典型实现核心优势常见场景
二叉树手写 TreeNode结构简单,适合教学和递归处理AST、DOM、算法题
字典树节点存 child map前缀匹配快自动补全、敏感词过滤
B+ 树多路搜索树减少磁盘 I/O数据库索引
红黑树自平衡二叉查找树最坏情况也有良好复杂度语言运行时、中间件
完全二叉树数组存储快速取最值优先队列、定时器、堆排序
Merkle 树哈希树数据完整性校验区块链、分布式存储

4. 从数据结构到全栈:它们如何放大你的工程能力

4.1 数据结构关心的不是“会写”,而是“选型”

在我做技术评审的时候,经常能见到有人把 JavaScript 的对象当成万能容器来用。比如一个需要按时间顺序展示的列表,直接用对象存储然后遍历时排序,这在小规模数据下没问题,数据一多、实时性一高就会出问题。

选型的核心其实是“操作频率”和“数据规模”:

  • 如果“插入多、查找少”,用数组或者链表合适;
  • 如果“查找多、插入少”,哈希表(JS 的 Map/Set)或二叉树更合适;
  • 如果“永远只关心最大/最小值”,堆就是你该考虑的选项;
  • 如果“需要先进先出”,队列天然匹配;
  • 如果“需要后进先出”,栈就是标准答案。

实际开发里,我见过一个很典型的案例,一个实时监控页面需要展示最近 5 分钟的日志,每秒钟产生几十条数据。一开始用的是数组push后 slice 截断,高峰期页面明显卡顿。后来我改成循环队列,固定容量,写入时覆盖旧数据,页面瞬间丝滑。这就是数据结构选型对性能的直接影响。

4.2 前端转全栈需要建立的几个结构视角

很多前端同学转全栈后写 Node.js 服务,会踩过这些坑:

第一个是任务调度。后端常见场景是批量处理用户导入的 Excel:不能一下子几万条同时处理,内存扛不住,数据库连接也扛不住。解法就是用一个队列把任务按批次放入,控制并发数,每批处理完再拉下一批。这个队列可以用p-queue这类库,也可以自己用数组和Promise实现。

第二个是缓存淘汰策略。LRU(Least Recently Used)缓存淘汰算法在很多后端项目里都有应用,它的实现就结合了哈希表和双向链表。虽然 JS 里没有内置双向链表,但你可以用Map间接实现 LRU,因为Map会保持迭代顺序:

class LRUCache { constructor(capacity) { this.capacity = capacity; this.map = new Map(); } get(key) { if (!this.map.has(key)) return -1; const value = this.map.get(key); this.map.delete(key); this.map.set(key, value); return value; } put(key, value) { if (this.map.has(key)) { this.map.delete(key); } this.map.set(key, value); if (this.map.size > this.capacity) { this.map.delete(this.map.keys().next().value); } } }

第三个是路由与中间件模型。Express 和 Koa 的中间件模型本质上就是“洋葱圈”结构,和栈的后进先出很像。理解调用栈和队列,能帮你更好地理解一个请求从进入到响应,中间件到底按什么顺序被执行,错误处理为什么必须写在后半段。

5. 常见问题与排查技巧实录

5.1 栈溢出的定位与修复

报错信息为RangeError: Maximum call stack size exceeded时,第一反应先打开 DevTools 的 Console 看完整调用栈,通常最顶层就是递归犯案现场。如果调用栈被优化吞掉了部分信息,可以用二分法注释代码快速定位:把疑似递归的函数改为非递归实现,看问题是否消失。

修复手段大概四种:

  1. 修复递归终止条件,处理环状引用;
  2. 递归改迭代,用栈模拟:
function preorderIterative(root) { if (!root) return []; const stack = [root]; const result = []; while (stack.length) { const node = stack.pop(); result.push(node.value); if (node.right) stack.push(node.right); if (node.left) stack.push(node.left); } return result; }
  1. 增加数据层级校验,防止导入异常数据;
  2. 调整递归算法的“深度优先”策略,一些场景可以改队列做“广度优先”,降低调用栈压力。

5.2 队列空/满判断的边界问题

我在面试和带新人时,经常让候选者写一个循环队列,然后当场测试这三个用例:空队列出队、满队列入队、入满后再出队再入队。看起来简单,但能一次写对的人并不多。

最常见的错误是“满”的判断写成tail === head。因为初始状态下tail === head就表示空,如果满也这么判断,就会出现空满不分的严重问题。建议在写循环队列时把判断条件单独提炼成函数,然后逐一验证:

const q = new CircularQueue(3); console.log(q.isEmpty()); // true console.log(q.isFull()); // false q.enqueue(1); q.enqueue(2); q.enqueue(3); // (tail + 1) % 3 === 0,此时认为是满 console.log(q.isFull()); // true q.dequeue(); q.enqueue(4); // 可以正常入队

5.3 树的遍历结果不对?先检查这三个地方

树的遍历写起来不难,但很容易栽在细节上。如果你发现结果不对,按这个顺序排查一下。

第一,递归终止条件写对了吗?有些同学把终止条件写成if (node === null) return;,确实没问题,但要确保 root 本身为空时不会报错。第二,节点值 vs 节点本身push(node)push(node.value)只差一个属性,输出却完全不同。第三,左子树和右子树的入队/入栈顺序。层序遍历先左后右,前序也是先左后右,但栈模拟前序遍历时,因为栈是后进先出,所以要先把右子树入栈,再入左子树,否则顺序就反了。

关于树的调试,我还有一个土办法:写一个把树转成数组的辅助函数,打印出来看结构:

function treeToArray(root) { if (!root) return []; const result = []; const queue = [root]; while (queue.length) { const node = queue.shift(); result.push(node ? node.value : null); if (node) { queue.push(node.left); queue.push(node.right); } } return result; }

这样就能直观看到每一步遍历后树的结构是否符合预期。


说到底,数据结构不是面试完就丢掉的纸上谈兵。栈、队列、树这三个结构,一个是后进先出,一个是先进先出,一个是层级关系,它们几乎覆盖了日常开发里一半以上的逻辑组织方式。我自己的体会是,真正让这些结构产生价值的,不是背下实现代码,而是遇到问题时能下意识地想到“这里是不是可以用一个栈来倒序?”、“这个实时数据流要不要用队列削峰?”、“这个目录关系天然就是一棵树,我直接用递归处理会不会太深了?”带着这种意识去看业务代码,慢慢就会发现数据结构其实就在你写的每一行逻辑里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询