前端一面:高频手写题(深拷贝、发布订阅与 LRU 缓存)
很多前端一面最后一问是“手写一个 XXX”,这一篇挑三类非常常见的:深拷贝、发布订阅模式以及 LRU 缓存,重点放在思路和简化实现上。
深拷贝:浅拷贝 vs 深拷贝 & 一个够用的实现
首先要区分浅拷贝和深拷贝:
- 浅拷贝:只复制一层属性,嵌套对象仍然是引用同一个;
- 深拷贝:递归复制嵌套对象,得到完全独立的结构。
简单例子:
1const obj = { a: 1, b: { c: 2 } };
2const shallow = { ...obj };
3shallow.b.c = 3;
4console.log(obj.b.c); // 3,说明浅拷贝没有复制嵌套对象
在一面中,通常不要求覆盖所有边界情况(如函数、Symbol、循环引用等),
一个针对常见对象/数组的递归版本就足够展示你的思路。
简化版实现:
1function deepClone(value, seen = new WeakMap()) {
2 if (value === null || typeof value !== "object") {
3 return value;
4 }
5
6 if (seen.has(value)) {
7 return seen.get(value); // 处理循环引用
8 }
9
10 let result;
11 if (Array.isArray(value)) {
12 result = [];
13 } else {
14 result = {};
15 }
16 seen.set(value, result);
17
18 for (const key in value) {
19 if (Object.prototype.hasOwnProperty.call(value, key)) {
20 result[key] = deepClone(value[key], seen);
21 }
22 }
23
24 return result;
25}
答题时可以强调:
- 这里只处理了普通对象/数组和原始类型,足够应对大部分业务场景;
- 使用
WeakMap记录已拷贝对象,避免循环引用导致的递归爆栈。
发布订阅(Pub/Sub):解耦事件发送者与监听者
发布订阅模式的核心:
- 维护一个“事件名 → 回调列表”的映射;
- 订阅时往列表里加回调;
- 发布时依次执行这些回调;
- 可选提供取消订阅能力。
简化版实现:
1class EventEmitter {
2 constructor() {
3 this.events = new Map();
4 }
5
6 on(event, handler) {
7 if (!this.events.has(event)) {
8 this.events.set(event, []);
9 }
10 this.events.get(event).push(handler);
11 }
12
13 off(event, handler) {
14 const handlers = this.events.get(event);
15 if (!handlers) return;
16 const idx = handlers.indexOf(handler);
17 if (idx !== -1) {
18 handlers.splice(idx, 1);
19 }
20 }
21
22 emit(event, ...args) {
23 const handlers = this.events.get(event);
24 if (!handlers) return;
25 handlers.forEach((fn) => fn(...args));
26 }
27}
解释要点:
- 使用
Map存事件和对应回调列表; on负责订阅,emit负责发布,off负责取消订阅;- 面试时可以简单举例说明这种模式如何用于模块间解耦。
LRU 缓存:最近最少使用淘汰
LRU(Least Recently Used)缓存的常见面试要求:
- 用固定容量缓存 key-value;
- 每次访问某个 key 时把它标记为“最近使用”;
- 当容量满时,淘汰“最久未使用”的那个 key。
高效的实现通常用:
- 哈希表(Map)存 key → 双向链表节点;
- 双向链表维护最近使用顺序,头部是最近用过的,尾部是最久没用的。
在一面里,如果时间有限,可以写一个基于 Map 和“插入顺序”的简化版来说明思路:
简化版实现(利用 Map 的有序性):
1class LRUCache {
2 constructor(capacity) {
3 this.capacity = capacity;
4 this.map = new Map();
5 }
6
7 get(key) {
8 if (!this.map.has(key)) return -1;
9 const value = this.map.get(key);
10 // 重新插入一遍,移动到 Map 的末尾,表示最近使用
11 this.map.delete(key);
12 this.map.set(key, value);
13 return value;
14 }
15
16 put(key, value) {
17 if (this.map.has(key)) {
18 this.map.delete(key);
19 } else if (this.map.size >= this.capacity) {
20 const oldestKey = this.map.keys().next().value;
21 this.map.delete(oldestKey);
22 }
23 this.map.set(key, value);
24 }
25}
虽然这个实现依赖于 Map 的插入顺序,不如链表 + 哈希表那么通用,但足够应对大多数一面场景,也能让你把更多时间花在解释思想上。
常见面试题与参考答案
题 1:如何实现一个简化版的深拷贝函数?有哪些边界情况?
参考答案要点:
- 简化场景下,可以用递归处理对象和数组,对原始值直接返回;
- 需要注意循环引用,可以用
WeakMap做一层缓存; - 更复杂的边界情况包括:
- 日期、正则、Map/Set 等特殊对象;
- 函数、Symbol 等非普通数据。
实际一面通常不会要求全部覆盖,但可以提一下表示你意识到这些问题。
题 2:实现一个简单的发布订阅(事件总线)
可以用前面 EventEmitter 的实现,强调:
- 数据结构选择(Map + 数组)足以支撑 on/off/emit;
- 说明一个简单的使用示例:
1const bus = new EventEmitter();
2function onLogin(user) {
3 console.log("user login:", user);
4}
5bus.on("login", onLogin);
6bus.emit("login", { name: "Alice" });
7bus.off("login", onLogin);
面试官通常会通过这道题看你对事件驱动和解耦的理解。
题 3:说说你会如何实现一个 LRU 缓存?
参考答案要点:
- 理想实现:哈希表 + 双向链表,保证 get/put 都是 O(1);
- 如果用 JS 自带的 Map,可以利用它保持插入顺序的特性:
- 每次访问某个 key 时先删除再重新 set 一次;
- 容量满时删除
map.keys().next().value,即最早插入的那个 key;
- 说明清楚“最近使用的移动到队尾,最久未使用的淘汰”的策略,比具体代码更重要。
这类手写题的关键在于:先把核心思想讲清楚,再给出一个结构清晰的实现,而不是一上来就埋头写代码。