前端一面:高频手写题(深拷贝、发布订阅与 LRU 缓存)

很多前端一面最后一问是“手写一个 XXX”,这一篇挑三类非常常见的:深拷贝、发布订阅模式以及 LRU 缓存,重点放在思路和简化实现上。

首先要区分浅拷贝和深拷贝:

  • 浅拷贝:只复制一层属性,嵌套对象仍然是引用同一个;
  • 深拷贝:递归复制嵌套对象,得到完全独立的结构。

简单例子:

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 记录已拷贝对象,避免循环引用导致的递归爆栈。

发布订阅模式的核心:

  • 维护一个“事件名 → 回调列表”的映射;
  • 订阅时往列表里加回调;
  • 发布时依次执行这些回调;
  • 可选提供取消订阅能力。

简化版实现:

 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(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 的插入顺序,不如链表 + 哈希表那么通用,但足够应对大多数一面场景,也能让你把更多时间花在解释思想上。

参考答案要点:

  • 简化场景下,可以用递归处理对象和数组,对原始值直接返回;
  • 需要注意循环引用,可以用 WeakMap 做一层缓存;
  • 更复杂的边界情况包括:
    • 日期、正则、Map/Set 等特殊对象;
    • 函数、Symbol 等非普通数据。
      实际一面通常不会要求全部覆盖,但可以提一下表示你意识到这些问题。

可以用前面 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);

面试官通常会通过这道题看你对事件驱动和解耦的理解。

参考答案要点:

  • 理想实现:哈希表 + 双向链表,保证 get/put 都是 O(1);
  • 如果用 JS 自带的 Map,可以利用它保持插入顺序的特性:
    • 每次访问某个 key 时先删除再重新 set 一次;
    • 容量满时删除 map.keys().next().value,即最早插入的那个 key;
  • 说明清楚“最近使用的移动到队尾,最久未使用的淘汰”的策略,比具体代码更重要。

这类手写题的关键在于:先把核心思想讲清楚,再给出一个结构清晰的实现,而不是一上来就埋头写代码。