1数组扁平化 flatten

生活类比:拆快递——大箱子里有小盒子,小盒子里有更小的盒子,全部拆开摆成一排。

SVG 步骤图

原始嵌套数组 arr 1 2 3 4 5 递归 / 迭代拆解 逐层拆开 1 ← 元素,直接放入 [2,[3,4]] 递归展开 5 ← 元素 扁平化结果 1 2 3 4 5 = [1,2,3,4,5] 元素值 嵌套数组(虚线框) 递归深度 = 嵌套层数

IDE 代码窗口

flatten.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 方法1:递归版 function flatten(arr) { const result = []; for (const item of arr) { if (Array.isArray(item)) { result.push(...flatten(item)); } else { result.push(item); } } return result; } // 方法2:reduce 版 const flattenReduce = (arr) => arr.reduce((acc, cur) => acc.concat(Array.isArray(cur) ? flattenReduce(cur) : cur) , []); // 方法3:ES6 flat() const flattenES6 = (arr) => arr.flat(Infinity);
提示:递归版是最核心的理解,面试必须会手写。flat(Infinity) 虽然简单,但面试官更想看你对递归的掌握。

迭代版(栈实现)

flatten-iterative.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 方法4:迭代版(用栈模拟递归) function flattenIterative(arr) { const stack = [...arr]; const result = []; while (stack.length) { const item = stack.pop(); if (Array.isArray(item)) { // 展开后压回栈(注意顺序要反转) stack.push(...item.reverse()); } else { result.push(item); } } return result.reverse(); // 结果也需反转 } // 面试对比:递归简洁但可能栈溢出 // 迭代用显式栈,无栈溢出风险 // ES6 flat(Infinity) 底层也是迭代实现
注意:递归版在嵌套层级很深时可能栈溢出(Maximum call stack size exceeded)。如果面试官追问,就写迭代版。

2对象深拷贝 deepClone

生活类比:复印文件——浅拷贝=只复印封面(引用同一个内页),深拷贝=整本逐页复印。

SVG 步骤图

原对象 obj a: 1 b: → {c: 2} c: 2 浅拷贝 shallow a: 1 (独立) b: → 同一个 {c:2}! ⚠ b 仍指向原对象! 深拷贝 deep a: 1 (独立) b: → 新的 {c:2} ✓ c: 2 循环引用: A.b=B, B.a=A → 用 WeakMap 记录已拷贝对象,避免无限递归 WeakMap: key=原对象(弱引用,不阻止GC) → value=拷贝后的对象(遇到已存在的key直接返回)

IDE 代码窗口

deepClone.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function deepClone(obj, map = new WeakMap()) { // null 或非对象,直接返回 if (obj === null || typeof obj !== 'object') return obj; // 处理循环引用 if (map.has(obj)) return map.get(obj); // Date if (obj instanceof Date) return new Date(obj); // RegExp if (obj instanceof RegExp) return new RegExp(obj); // 数组 const clone = Array.isArray(obj) ? [] : {}; map.set(obj, clone); // Symbol 作为 key const keys = [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)]; for (const key of keys) { clone[key] = deepClone(obj[key], map); } return clone; }
注意:JSON.parse(JSON.stringify(obj)) 无法处理 undefined、函数、Symbol、循环引用,面试不要只写这个!

浅拷贝的几种方式

shallow-copy.js
1
2
3
4
5
6
7
8
9
10
// 浅拷贝方式一览 const obj = { a: 1, b: { c: 2 } }; // 1. Object.assign const copy1 = Object.assign({}, obj); // 2. 展开运算符 const copy2 = { ...obj }; // 3. Array.slice / concat (数组) const arr = [1, { x: 2 }]; const copy3 = arr.slice(); // 以上都是浅拷贝:b 仍指向同一个对象! copy1.b === obj.b // true ⚠️
记忆口诀:浅拷贝=只复制一层,深拷贝=全部复制。Object.assign 和展开运算符都是浅拷贝,嵌套对象仍共享引用。

3数组去重 unique

生活类比:检票口查重——每种票只留一张,重复的直接拦住。

SVG 步骤图

原始数组 [1, 2, 2, 3, 1, 4, 3] 1 2 2 3 1 4 3 红色=重复 Set 自动去重 Set 去重过程 1 → 加入 ✓ 2 → 加入 ✓ 2 → 已有 ✗ 3 → 加入 ✓ 1 → 已有 ✗ 去重结果 1 2 3 4

IDE 代码窗口

unique.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 方法1:Set(推荐) const unique1 = (arr) => [...new Set(arr)]; // 方法2:filter + indexOf const unique2 = (arr) => arr.filter((item, i) => arr.indexOf(item) === i); // 方法3:reduce const unique3 = (arr) => arr.reduce((acc, cur) => acc.includes(cur) ? acc : [...acc, cur], []); // 方法4:Map const unique4 = (arr) => { const map = new Map(); arr.forEach(item => map.set(item, true)); return [...map.keys()]; }

方法对比

方法时间复杂度空间复杂度可读性推荐度
SetO(n)O(n)★★★★★首选
filter+indexOfO(n²)O(n)★★★★理解原理
reduceO(n²)O(n)★★★练习用
MapO(n)O(n)★★★★需保序时

4树的遍历 Tree Traversal

生活类比:公司组织架构——DFS=从CEO一路追到最底层员工再回来,BFS=先看CEO,再看所有VP,再看所有总监。

SVG 步骤图

二叉树 [1,2,3,4,5,6,7] 1 2 3 4 5 6 7 四种遍历顺序 前序(根左右): 1 → 2 → 4 → 5 → 3 → 6 → 7 先访问根,再左子树,再右子树 中序(左根右): 4 → 2 → 5 → 1 → 6 → 3 → 7 BST中序 = 排序结果 后序(左右根): 4 → 5 → 2 → 6 → 7 → 3 → 1 最后访问根,用于释放资源 BFS层序: 1 → 2 → 3 → 4 → 5 → 6 → 7 按层从上到下,用队列实现

IDE 代码窗口

treeTraversal.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// DFS 递归三序 function preorder(root, res = []) { if (!root) return res; res.push(root.val); preorder(root.left, res); preorder(root.right, res); return res; } function inorder(root, res = []) { if (!root) return res; inorder(root.left, res); res.push(root.val); inorder(root.right, res); return res; } function postorder(root, res = []) { if (!root) return res; postorder(root.left, res); postorder(root.right, res); res.push(root.val); return res; } // DFS 迭代版(栈)— 以前序为例 function preorderIter(root) { const res = [], stack = [root]; while (stack.length) { const node = stack.pop(); if (!node) continue; res.push(node.val); stack.push(node.right); // 右先入栈 stack.push(node.left); } return res; } // BFS 层序(队列) function levelOrder(root) { if (!root) return []; const res = [], queue = [root]; while (queue.length) { const node = queue.shift(); res.push(node.val); if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } return res; }
提示:DFS 用栈(或递归),BFS 用队列。面试常考:前序+中序 → 还原二叉树。

中序迭代详解(最常考的迭代遍历)

inorder-iterative.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 中序遍历迭代版(最经典的栈写法) function inorderIterative(root) { const res = [], stack = []; let cur = root; while (cur || stack.length) { // 1. 沿左子树一路走到底,压栈 while (cur) { stack.push(cur); cur = cur.left; } // 2. 弹出栈顶 = 最左节点 cur = stack.pop(); res.push(cur.val); // 3. 转向右子树 cur = cur.right; } return res; // 输出: 4→2→5→1→6→3→7 } // 后序迭代技巧:前序(根右左) → 反转 // 即把前序的 left/right 交换,最后 reverse

5防抖与节流 Debounce & Throttle

生活类比:防抖=电梯等人(最后一个人进来后才开始关门),节流=地铁发车(固定时间间隔发一趟)。

SVG 步骤图

防抖 Debounce — 只在最后一次触发后执行 触发1 触发2 触发3 触发4 取消 取消 取消 执行! 等待 delay 后无人触发 → 执行 节流 Throttle — 每隔固定时间执行一次 执行1 执行2 执行3 执行4 防抖:连续触发 → 只执行最后一次 适用:搜索框输入、窗口resize 节流:连续触发 → 固定间隔执行 适用:滚动事件、拖拽、按钮防重复

IDE 代码窗口

debounce-throttle.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 防抖:最后一次触发后 delay ms 执行 function debounce(fn, delay) { let timer = null; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } // 节流:每隔 delay ms 最多执行一次 function throttle(fn, delay) { let last = 0; return function(...args) { const now = Date.now(); if (now - last >= delay) { last = now; fn.apply(this, args); } }; } // 使用示例 input.addEventListener('input', debounce(search, 300)); window.addEventListener('scroll', throttle(handleScroll, 200));

节流的定时器版(与时间戳版对比)

throttle-timer.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 节流定时器版:能保证最后一次触发也执行 function throttleTimer(fn, delay) { let timer = null; return function(...args) { if (timer) return; // 还在冷却中,跳过 timer = setTimeout(() => { fn.apply(this, args); timer = null; // 冷却结束 }, delay); }; } // 两种版本区别: // 时间戳版:首次立即执行,最后一次不执行 // 定时器版:首次延迟执行,最后一次会执行 // 面试加分:合并版(首尾都执行)
核心区别:防抖每次触发都重置定时器,节流只在冷却期结束后才允许新触发。防抖关注"最后一次",节流关注"固定节奏"。

6Promise 串行与并行控制

生活类比:串行=一个窗口排队办业务,并行=多个窗口同时办,限流=只开3个窗口。

SVG 步骤图

串行:依次执行 p1 p2 p3 p4 p5 并行:同时开始 p1 p2 p3 p4 p5 全部同时开始 限流 (concurrency=2):最多2个同时执行 t=0 p1 (执行中) p2 (执行中) ← 2个窗口占满 p1完 p3 (新开) p2 (仍在执行) ← p1完→p3补上 p2完 p3 (继续) p4 (新开) p5等p3或p4完→补上

IDE 代码窗口

promise-control.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 串行:reduce 依次执行 function runSerial(fns) { return fns.reduce((p, fn) => p.then(fn), Promise.resolve()); } // 并行:Promise.all 同时执行 function runParallel(fns) { return Promise.all(fns.map(fn => fn())); } // 限流:最多同时 concurrency 个 async function runLimit(fns, concurrency = 2) { const results = []; const executing = new Set(); for (const [i, fn] of fns.entries()) { const p = fn().then(r => { executing.delete(p); results[i] = r; }); executing.add(p); if (executing.size >= concurrency) { await Promise.race(executing); } } await Promise.all(executing); return results; }
提示:限流控制是高频面试题,核心思路是用 Set 追踪执行中的 Promise,配合 Promise.race 等待空位。

串行与并行对比

模式总耗时(5个任务各1s)适用场景核心API
串行5s (依次执行)有先后依赖的任务reduce + then
并行1s (全部同时)无依赖,全部完成即可Promise.all
限流(2)3s (2+2+1)请求太多需控制并发Set + Promise.race

7LRU 缓存

生活类比:书包容量有限——放新书时如果满了,先把最久没看的书拿出来。

SVG 步骤图

HashMap + 双向链表 结构 HashMap key:1 → Node(1,a) key:2 → Node(2,b) key:3 → Node(3,c) O(1) 查找 双向链表 (最近使用 → 最久未用) head↓ 1:a 3:c 2:b ↓tail 操作流程 (capacity=2) put(1,a) 1:a 链表: [1:a] put(2,b) 1:a 2:b 链表: [1:a ↔ 2:b] get(1) 2:b 1:a 1:a 移到头部! put(3,c) 容量满! 2:b 淘汰! 1:a 3:c 链表: [1:a ↔ 3:c] 关键:get 时移到头部,put 满时淘汰尾部,所有操作 O(1)

IDE 代码窗口

lru-cache.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Map 版 LRU(利用 Map 的插入顺序) class LRUCache { constructor(capacity) { this.capacity = capacity; this.cache = new Map(); } get(key) { if (!this.cache.has(key)) return -1; const val = this.cache.get(key); this.cache.delete(key); // 删除后重新插入→移到末尾(最新) this.cache.set(key, val); return val; } put(key, value) { if (this.cache.has(key)) this.cache.delete(key); this.cache.set(key, value); if (this.cache.size > this.capacity) { // Map.keys() 第一个 = 最久未用 const oldest = this.cache.keys().next().value; this.cache.delete(oldest); } } } // 双向链表版:面试白板更常见 // 思路:head↔...↔tail // get: delete node → insertAfterHead // put: if full → removeTail → insertAfterHead
提示:Map 版 15 行搞定,面试优先写 Map 版。Vue keep-alive 和 React LRUCache 都用此原理。

双向链表版核心思路

lru-doubly-linkedlist.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 双向链表节点 class Node { constructor(key, val) { this.key = key; this.val = val; this.prev = null; this.next = null; } } // 双向链表版 LRU(白板面试标配) class LRUCache2 { constructor(cap) { this.cap = cap; this.map = new Map(); // 哨兵节点简化边界处理 this.head = new Node(0, 0); this.tail = new Node(0, 0); this.head.next = this.tail; this.tail.prev = this.head; } // 删除节点 remove(node) { node.prev.next = node.next; node.next.prev = node.prev; } // 插入到 head 后面(最近使用) insertHead(node) { node.next = this.head.next; node.prev = this.head; this.head.next.prev = node; this.head.next = node; } get(key) { if (!this.map.has(key)) return -1; const node = this.map.get(key); this.remove(node); this.insertHead(node); return node.val; } put(key, val) { if (this.map.has(key)) this.remove(this.map.get(key)); const node = new Node(key, val); this.map.set(key, node); this.insertHead(node); if (this.map.size > this.cap) { // 淘汰 tail 前面的节点 const lru = this.tail.prev; this.remove(lru); this.map.delete(lru.key); } } }

8发布订阅模式 EventEmitter

生活类比:报纸订阅——有人订报纸,有新报纸就送给所有订户。

SVG 步骤图

事件映射表的构建过程 ① on('click', fn1) click: fn1 ② on('click', fn2) click: fn1 fn2 ③ emit('click') 触发 click → fn1() ✓ fn2() ✓ 按顺序执行所有回调 ④ off('click', fn1) click: fn1 fn2 ⑤ once('click', fn3) click: fn2 fn3(仅1次) 执行后自动off EventEmitter = { 事件名 → [回调函数数组] } on 订阅 | off 取消 | emit 发布 | once 一次性订阅

IDE 代码窗口

event-emitter.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class EventEmitter { constructor() { this.events = {}; } on(event, fn) { (this.events[event] ||= []).push(fn); return this; } off(event, fn) { if (!this.events[event]) return this; this.events[event] = this.events[event] .filter(f => f !== fn); return this; } emit(event, ...args) { (this.events[event] || []).forEach(fn => fn(...args)); return this; } once(event, fn) { const wrapper = (...args) => { fn(...args); this.off(event, wrapper); }; this.on(event, wrapper); return this; } } // 使用示例 const ee = new EventEmitter(); const handler = (data) => console.log('收到:', data); ee.on('msg', handler); ee.emit('msg', 'hello'); // 收到: hello ee.off('msg', handler);
注意:once 的实现关键是用 wrapper 包一层,执行完自动 off 掉 wrapper。不要忘记 off 的时候要能找到原函数!

观察者模式 vs 发布订阅模式

观察者模式 Subject Observer1 Observer2 Subject 直接通知 Observer 发布订阅模式 EventBus Publisher Subscriber 通过 EventBus 中转,双方不直接通信
区别:观察者模式中 Subject 直接持有 Observer 引用;发布订阅模式通过中间 EventBus 解耦,发布者和订阅者互不知道对方存在。Vue 的 EventBus 和 Node.js 的 EventEmitter 都是发布订阅模式。

前端算法对比总表

算法核心思路时间复杂度空间复杂度面试频率关键点
数组扁平化递归展开嵌套O(n)O(d) 栈深度★★★★递归+迭代+flat()
深拷贝递归拷贝+WeakMap防环O(n)O(n)★★★★★循环引用+类型判断
数组去重Set去重/哈希表O(n)~O(n²)O(n)★★★Set最简,面试理解原理
树遍历DFS栈/BFS队列O(n)O(h)~O(w)★★★★★递归三序+迭代+层序
防抖节流定时器控制执行频率O(1)O(1)★★★★★闭包+this指向
Promise控制reduce串行/race限流O(n)O(c) 并发数★★★★Promise.race限流
LRU缓存Map有序性/双向链表O(1)O(capacity)★★★★★get移头+put淘汰尾
EventEmitter事件→回调数组映射O(n)订阅数O(n)★★★★on/off/emit/once

面试优先级排序

最高频(必会):深拷贝 > 防抖节流 > LRU缓存 > 树遍历
中频(理解思路):Promise并发控制 > EventEmitter > 数组扁平化
基础(快速回答):数组去重(Set一句话即可)

面试速答

Q1: 手写深拷贝要注意什么?
处理循环引用(WeakMap记录已拷贝对象),判断类型(数组/Date/RegExp/null),Symbol作key也要拷贝,函数通常直接引用不深拷。JSON序列化无法处理undefined、函数、Symbol和循环引用。
Q2: 防抖和节流区别?分别用于什么场景?
防抖:连续触发只执行最后一次,用clearTimeout重置定时器,适合搜索框输入。节流:固定间隔执行一次,用时间戳判断,适合scroll/resize事件。核心都是闭包保存状态。
Q3: LRU缓存怎么实现?前端哪里用到了?
Map版:get时delete再set移到最新,put超容量时删keys().next()。双向链表版:维护head/tail,get移到head,put满删tail。Vue keep-alive用LRU管理缓存组件,React也有LRUCache。
Q4: Promise如何控制并发数?
用Set追踪执行中的Promise,入队后若size>=concurrency就await Promise.race()等一个完成,然后继续入队。最后await Promise.all确保全部完成。核心:race返回最快的,腾出一个位置。
Q5: 树的前中后序遍历递归和迭代分别怎么写?
递归:就是"访问根"的时机不同——前序先push再递归左右,中序先递归左再push再右,后序先递归左右再push。迭代:用栈模拟递归,前序最简单直接pop-push右左;中序用指针沿左走到底;后序可用前序的逆序(根右左→反转)。
Q6: EventEmitter的once怎么实现?
用wrapper函数包一层:wrapper执行时先调用原函数,再调用this.off(event, wrapper)移除自己。注意off按引用比较,所以once存的是wrapper不是原fn,off时也要用同一个wrapper引用。

实战建议

面试写深拷贝要注意什么?
1) 先写基础版(判断类型+递归),再补循环引用(WeakMap);
2) 别忘了数组判断用Array.isArray;
3) Date和RegExp要new返回新实例;
4) Symbol key用getOwnPropertySymbols获取;
5) 函数通常直接引用,不深拷(面试可说明原因);
6) 最后主动提JSON.parse的局限性,展示你对边界的理解。
LRU缓存面试怎么考?
LeetCode 146 是标准题。面试可能问:
1) Vue keep-alive:用LRU管理缓存组件实例,超出max时销毁最久未活跃的;
2) React LRUCache:React 18实验性API,用LRU策略管理服务端缓存;
3) 浏览器缓存策略中也有LRU思想;
4) Map版先写(快),链表版白板画图说明思路即可。
防抖节流在项目中怎么用?
1) 搜索框输入 → debounce(300ms),避免每次击键都发请求;
2) window.scroll/resize → throttle(200ms),避免高频触发重排;
3) 按钮防重复点击 → throttle(1000ms)或加loading状态;
4) 拖拽mousemove → throttle(16ms)≈60fps;
5) Lodash的debounce/throttle支持leading/trailing配置,面试能说出来加分。