前端数据结构应用:栈和队列(系列一)

177 阅读7分钟

前言

最近和朋友讨论图形功能如何开发的时候,提到了数据结构,我随即科普了下,但朋友听后还是茫然,不清楚学完数据结构到底能解决哪些前端问题。

本文不讲枯燥理论,而是通过真实工作场景,帮你梳理清楚数据结构怎么落地前端。代码大多是简化伪代码,重点是思路,别太纠结细节哈。

这一系列会从栈、队列入手,逐步聊到链表、树、图和哈希表。我们跳过大家熟知的数组,一步步拆解每个结构的使用特性、优点、实际场景和例子。这是第一篇,专注栈和队列,后面还会继续,敬请期待。

1. 栈 (Stack)

使用特性

栈是一种线性数据结构,遵循后进先出 (LIFO - Last In, First Out) 的原则。主要操作包括:

  • push: 将元素压入栈顶。
  • pop: 弹出栈顶元素。
  • peek: 查看栈顶元素而不弹出。

栈可以用数组模拟(JavaScript中常用pushpop),但要注意栈底不可直接访问。

优点

  • 简单高效: 插入/删除操作时间复杂度为 O(1),适合需要快速“回溯”的场景。
  • 内存友好: 自动管理顺序,避免复杂索引。

实际工作场景

在前端开发中,栈常用于“用户操作回溯”功能:

  • 表单编辑器的撤销/重做 (Undo/Redo):如在线表格或富文本编辑器(类似Notion或在线文档),用户修改内容后能一键撤销,栈存储操作历史。
  • 多步骤表单的“后退”功能:电商或注册流程的向导(wizard),用户填写多页表单,能后退到上一步而不丢失数据。
  • 画布或绘图工具的回退:如前端Canvas编辑器(设计工具),用户绘制形状后,能撤销最后一步。

这些场景中,如果只用简单数组的lengthslice..,你得手动处理复制、边界检查,容易出错(如pop错元素);栈则强制LIFO,简化逻辑。

示例1: 多步骤表单的后退功能

不用栈的简单数组方式:数组存储步骤,手动画index跟踪,易越界。

let steps = []; // 步骤历史
steps.push({ step: 1, data: { name: 'Alice' } });
steps.push({ step: 2, data: { email: 'alice@example.com' } });
// 后退(手动)
if (steps.length > 1) {
  steps.length--; // 直接修改length,但可能误删
}
console.log(steps[steps.length - 1]); // { step: 1, data: { name: 'Alice' } }

问题:修改length粗暴,易丢失数据;扩展到“前进”时,需额外数组。

用栈优化:栈自然处理后退,扩展性强。

class WizardStack {
  constructor() {
    this.stack = [];
    this.redoStack = []; // 额外栈用于前进(体现扩展优势)
  }
  push(stepData) {
    this.stack.push(stepData);
    this.redoStack = []; // 清空redo
  }
  back() {
    if (this.stack.length <= 1) return this.stack[0];
    const last = this.stack.pop();
    this.redoStack.push(last); // 支持前进
    return this.stack[this.stack.length - 1];
  }
  forward() {
    if (this.redoStack.length === 0) return null;
    const next = this.redoStack.pop();
    this.stack.push(next);
    return next;
  }
}

// 使用
const wizard = new WizardStack();
wizard.push({ step: 1, data: { name: 'Alice' } });
wizard.push({ step: 2, data: { email: 'alice@example.com' } });
console.log(wizard.back()); // { step: 1, data: { name: 'Alice' } }
console.log(wizard.forward()); // { step: 2, data: { email: 'alice@example.com' } }

优化点 & 优势:栈让后退/前进成对操作,一行代码搞定;相比length - 1,栈支持易扩展的redo,而不用手动管理两个数组。在电商表单中,这减少用户输入丢失,提升UX。

示例2: 画布绘图工具的回退功能

不用栈的简单数组方式:数组存储绘制动作,手动pop,但需小心引用。

let actions = []; // 绘制历史
actions.push({ type: 'drawLine', coords: [0, 0, 100, 100] });
actions.push({ type: 'drawCircle', coords: [50, 50, 30] });
// 回退(手动)
if (actions.length > 0) {
  actions.pop(); // 简单,但无封装,易在复杂逻辑中误用
}
console.log(actions[actions.length - 1]); // { type: 'drawLine', ... }

问题pop简单,但无边界保护;如果动作复杂(含子对象),需手动深复制。

用栈优化:栈添加保护和peek,适合Canvas重绘。

class CanvasUndoStack {
  constructor() {
    this.stack = [];
  }
  push(action) {
    this.stack.push(JSON.parse(JSON.stringify(action))); // 深复制
  }
  undo() {
    if (this.stack.length === 0) return null;
    return this.stack.pop();
  }
  peek() {
    return this.stack[this.stack.length - 1];
  }
}

// 使用(模拟Canvas)
const canvasStack = new CanvasUndoStack();
canvasStack.push({ type: 'drawLine', coords: [0, 0, 100, 100] });
canvasStack.push({ type: 'drawCircle', coords: [50, 50, 30] });
const undone = canvasStack.undo(); // 弹出最后动作,重绘时忽略它
console.log(undone); // { type: 'drawCircle', ... }
console.log(canvasStack.peek()); // { type: 'drawLine', ... } – 查看而不改

优化点 & 优势:栈的peek允许“预览”而不pop,方便Canvas重绘逻辑;相比length - 1,栈内置深复制和空栈检查,防止null错误。在设计工具项目中,这让回退更可靠,减少渲染bug。

栈在实际前端工作中的价值:不止是“能用数组代替”,而是让代码更专业、更少出错。


2. 队列 (Queue): 先进先出的“任务处理器”

使用特性

队列是一种线性数据结构,遵循先进先出 (FIFO - First In, First Out) 的原则。主要操作包括:

  • enqueue: 将元素加入队尾。
  • dequeue: 从队头移除并返回元素。
  • frontpeek: 查看队头元素而不移除。

JavaScript中常用数组的push(入队)和shift(出队)模拟,但shift是 O(n)(需移动元素),实际优化时可使用链表或专用类来实现 O(1) 操作。这确保了严格的顺序处理,避免跳过任务。

优点

  • 顺序保证: 任务按添加顺序执行,防止乱序或遗漏。
  • 并发友好: 适合异步/批量处理,减少UI卡顿。

实际工作场景

在前端开发中,队列常用于“顺序任务处理”功能,这些场景非常接地气,常出现在日常项目中:

  • 文件或图片上传队列:如多文件上传组件(电商App),按顺序上传,避免同时并发导致浏览器崩溃。
  • 动画序列或过渡效果:如轮播图、加载动画(Loading spinner),确保动画按序播放,不跳帧。
  • 消息/通知队列:如聊天App的WebSocket消息处理,或Toast通知队列,按收到顺序显示。
  • 其他:API请求队列(限流防刷),打印队列(日志输出)。

这些场景中,如果只用简单数组的shift,你得手动处理移位和空检查,性能差且代码乱;队列则强制FIFO,简化异步逻辑。

优化示例

下面提供3个优化示例,每个都对比“不用队列”(简单数组方式)和“用队列”的实现,突出队列如何简化代码、提升性能。示例假设集成到前端项目中。

示例1: 文件上传队列

不用队列的简单数组方式:用数组存储文件,手动shift,但每次出队 O(n),长队列时卡顿,且无封装易出错。

let uploadList = []; // 文件列表
uploadList.push('file1.jpg');
uploadList.push('file2.png');
// 处理(手动)
async function process() {
  while (uploadList.length > 0) {
    const file = uploadList.shift(); // O(n)移位,性能差
    console.log(`上传: ${file}`);
    await new Promise(resolve => setTimeout(resolve, 1000));
  }
}
process(); // 输出: 上传: file1.jpg (1s后) 上传: file2.png

问题shift每次移动所有元素,上传100文件时明显慢;无边界保护,易在并发中乱序。

用队列优化:封装成类,用链表实现 O(1) 出队,简化异步处理。

class Queue {
  constructor() {
    this.head = null;
    this.tail = null;
    this.size = 0;
  }
  enqueue(value) {
    const node = { value, next: null };
    if (!this.head) {
      this.head = this.tail = node;
    } else {
      this.tail.next = node;
      this.tail = node;
    }
    this.size++;
  }
  dequeue() {
    if (this.size === 0) return null;
    const value = this.head.value;
    this.head = this.head.next;
    this.size--;
    if (this.size === 0) this.tail = null;
    return value;
  }
  isEmpty() {
    return this.size === 0;
  }
}

// 使用
const uploader = new Queue();
uploader.enqueue('file1.jpg');
uploader.enqueue('file2.png');

async function processQueue() {
  while (!uploader.isEmpty()) {
    const file = uploader.dequeue(); // O(1)出队
    console.log(`上传: ${file}`);
    await new Promise(resolve => setTimeout(resolve, 1000));
  }
}
processQueue(); // 同上输出,但性能更好

优化点 & 优势:队列用链表实现 O(1) 出队,避免数组shift的 O(n) 开销;封装让代码干净,适合 React 组件(用useEffect处理队列)。在工作中,多文件上传时,这防止浏览器卡住,提升用户体验。

示例2: 动画序列处理

不用队列的简单数组方式:数组存储动画,手动索引或shift,但易乱序,动画多时管理复杂。

let animations = ['fadeIn', 'slideUp', 'zoomOut'];
let index = 0;
// 处理(手动循环)
async function play() {
  while (index < animations.length) {
    const anim = animations[index]; // 手动索引,易越界
    console.log(`播放: ${anim}`);
    index++;
    await new Promise(resolve => setTimeout(resolve, 500));
  }
}
play(); // 输出: 播放: fadeIn (0.5s后) 播放: slideUp 等

问题:需跟踪index,如果中途添加动画,需重置;不适合动态队列。

用队列优化:队列自然处理顺序,易添加新动画。

// 使用上面的Queue类
const animQueue = new Queue();
animQueue.enqueue('fadeIn');
animQueue.enqueue('slideUp');
animQueue.enqueue('zoomOut');

async function playQueue() {
  while (!animQueue.isEmpty()) {
    const anim = animQueue.dequeue(); // FIFO顺序
    console.log(`播放: ${anim}`);
    await new Promise(resolve => setTimeout(resolve, 500));
  }
}
playQueue(); // 同上输出
// 中途添加(动态)
animQueue.enqueue('rotate'); // 自动加入队尾,按序播放

优化点 & 优势:队列无需手动index,一行enqueue动态添加;相比简单数组,队列确保不跳帧,适合 Vue 过渡或 CSS 动画序列。在 App 加载屏中,这让动画流畅,减少调试时间。

示例3: 消息通知队列

不用队列的简单数组方式:数组存储消息,直接for循环,但无顺序保证,异步时易重复。

let messages = [];
messages.push({ id: 1, text: '新消息1' });
messages.push({ id: 2, text: '新消息2' });
// 处理(手动)
for (let i = 0; i < messages.length; i++) {
  console.log(`显示: ${messages[i].text}`);
  // 无异步控制,全部瞬间显示
}
messages = []; // 手动清空,易遗漏

问题for循环不适合异步(如延时显示),且清空手动,消息多时乱。

用队列优化:队列结合定时器,按序显示。

// 使用Queue类
const notificationQueue = new Queue();
notificationQueue.enqueue({ id: 1, text: '新消息1' });
notificationQueue.enqueue({ id: 2, text: '新消息2' });

async function showNotifications() {
  while (!notificationQueue.isEmpty()) {
    const msg = notificationQueue.dequeue();
    console.log(`显示: ${msg.text}`);
    await new Promise(resolve => setTimeout(resolve, 2000)); // 每2s显示一个
  }
}
showNotifications(); // 输出: 显示: 新消息1 (2s后) 显示: 新消息2

优化点 & 优势:队列自动清空(dequeue移除),O(1)操作;相比for循环,队列处理异步自然,防止通知堆积。在聊天 App 中,这确保消息不乱序,提升可读性。


总结对比

数据结构特点适用场景前端应用实例
栈 (Stack)LIFO需要回溯的场景撤销操作、路由历史、函数调用栈
队列 (Queue)FIFO需要顺序处理的场景任务调度、消息处理、异步流程控制

希望本文能帮助你理解数据结构在前端开发中的实际价值。如果有任何问题或建议,欢迎留言讨论!