Skip to content

18 · 手写题与代码设计 ​

本篇解决什么问题: 面试里"手写一个 XX"考的不是背代码,而是边界意识:你有没有考虑取消、并发、循环引用、错误分支。 本篇把高频手写题按难度递进组织,每道都给能直接用的实现 + 面试官追问的标准答案。

📌 与 A09(JavaScript 运行时)的分工:A09 讲机制(事件循环怎么转),本篇讲手写实现。 📌 与 A19(设计模式)的分工:本篇是代码级技巧,A19 是结构级模式。


开篇速览 ​

题目考察点面试频率难度
防抖 / 节流定时器与闭包,取消与立即执行⭐⭐⭐⭐⭐中
深拷贝循环引用、特殊类型、递归爆栈⭐⭐⭐⭐⭐中
Promise 系列异步编排、并发控制⭐⭐⭐⭐⭐中高
EventBus发布订阅、内存泄漏⭐⭐⭐⭐中
compose / 中间件函数式、洋葱模型⭐⭐⭐⭐高
LRU数据结构选型⭐⭐⭐⭐高

一、防抖与节流 ​

一句话区分:

防抖 debounce节流 throttle
语义最后一次说了算(等你不抖了再执行)固定频率执行(不管你触发多快)
类比电梯:有人进就重新计时地铁:到点就发车
典型场景搜索联想、表单校验、resize 结束滚动加载、鼠标移动、按钮防连点

1.1 防抖(支持取消 + 立即执行) ​

ts
interface DebounceOptions { leading?: boolean; trailing?: boolean }

function debounce<F extends (...args: any[]) => any>(
  fn: F, wait = 300, options: DebounceOptions = {}
) {
  const { leading = false, trailing = true } = options
  let timer: ReturnType<typeof setTimeout> | null = null

  function debounced(this: unknown, ...args: Parameters<F>) {
    const callNow = leading && timer === null

    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      timer = null
      if (trailing && !callNow) fn.apply(this, args)
    }, wait)

    if (callNow) fn.apply(this, args)
  }

  // ✅ 面试官追问:怎么取消?
  debounced.cancel = () => { if (timer) { clearTimeout(timer); timer = null } }
  return debounced as F & { cancel: () => void }
}

追问标准答案:

  • 要不要 this? 用 function + apply 保留上下文;不要用箭头函数(会丢 this)
  • leading / trailing 都开,且只触发一次时会执行两次吗? 上面的 callNow 判断保证了只执行一次
  • React 里怎么用? 用 useRef 保存 debounced,或用 useMemo,别在 render 里新建(每次都是新函数,防抖失效)

1.2 节流(时间戳版) ​

ts
function throttle<F extends (...args: any[]) => any>(fn: F, interval = 300) {
  let last = 0
  return function (this: unknown, ...args: Parameters<F>) {
    const now = Date.now()
    if (now - last >= interval) {
      last = now
      fn.apply(this, args)
    }
  } as F
}

时间戳版首次立即执行、最后一次可能丢失。要保证尾调用,改用定时器 + 时间戳双保险版。


二、深拷贝 ​

2.1 先说结论:能用 structuredClone 就用 ​

js
structuredClone(obj)   // ✅ 原生、支持循环引用、支持 Date/Map/Set/ArrayBuffer
// ❌ 不支持:函数、Symbol、DOM 节点、原型链

2.2 手写:必须处理循环引用 ​

ts
function deepClone<T>(source: T, cache = new WeakMap<object, any>()): T {
  // ① 基本类型 & 函数:直接返回
  if (source === null || typeof source !== 'object') return source

  // ② 循环引用:查缓存(用 WeakMap,不阻碍 GC)
  if (cache.has(source as object)) return cache.get(source as object)

  // ③ 特殊对象
  if (source instanceof Date) return new Date(source) as T
  if (source instanceof RegExp) return new RegExp(source) as T
  if (source instanceof Map) {
    const m = new Map()
    cache.set(source as object, m)
    source.forEach((v, k) => m.set(deepClone(k, cache), deepClone(v, cache)))
    return m as T
  }
  if (source instanceof Set) {
    const s = new Set()
    cache.set(source as object, s)
    source.forEach(v => s.add(deepClone(v, cache)))
    return s as T
  }

  // ④ 数组 / 普通对象:保持原型
  const target = new (source as object).constructor()
  cache.set(source as object, target)
  for (const key of Reflect.ownKeys(source as object)) {
    (target as any)[key] = deepClone((source as any)[key], cache)
  }
  return target as T
}

关键点(面试加分):

点说明
WeakMap 而非 Map缓存不应阻止源对象被回收
Reflect.ownKeys能拷贝 Symbol 键(Object.keys 不行)
保持 constructor保住原型链,类实例仍然可用
递归爆栈怎么办深层数据(>10000 层)改用迭代 + 显式栈,日常场景不必

面试陷阱:JSON.parse(JSON.stringify(obj)) → 丢 undefined、函数、Symbol;Date 变字符串;NaN/Infinity 变 null;循环引用直接抛错。


三、Promise 系列 ​

3.1 并发控制(最高频) ​

ts
/**
 * 并发池:始终最多 limit 个任务在跑,全部完成(或首个失败)后返回结果。
 * 保留输入顺序。
 */
async function asyncPool<T, R>(
  limit: number, tasks: T[], iterator: (task: T, index: number) => Promise<R>
): Promise<R[]> {
  const results: R[] = new Array(tasks.length)
  const executing = new Set<Promise<void>>()

  for (let i = 0; i < tasks.length; i++) {
    const p = Promise.resolve(iterator(tasks[i], i))
      .then(r => { results[i] = r })
      .finally(() => executing.delete(p))
    executing.add(p)
    if (executing.size >= limit) await Promise.race(executing)
  }
  await Promise.all(executing)
  return results
}

// 用法:100 个请求,最多同时 5 个
const data = await asyncPool(5, urls, url => fetch(url).then(r => r.json()))

3.2 带重试的 Promise ​

ts
async function retry<T>(
  fn: () => Promise<T>, times = 3, delay = 500
): Promise<T> {
  try { return await fn() } catch (e) {
    if (times <= 1) throw e
    await new Promise(r => setTimeout(r, delay))
    return retry(fn, times - 1, delay * 2)   // 指数退避
  }
}

3.3 手写 Promise.all(思路) ​

要点:Promise.all 返回新 Promise;结果顺序 = 输入顺序(不是完成顺序); 任一 reject 立即 reject;传入非 Promise 值用 Promise.resolve 包一层。

顺带记住三个变体的差异:all(全成功 / 快速失败)、allSettled(等全部,给状态)、 race(第一个敲定的,reject 也算)、any(第一个成功的,全失败才 AggregateError)。


四、EventBus(发布订阅) ​

ts
type Handler<T = any> = (payload: T) => void

class EventBus {
  private map = new Map<string, Set<Handler>>()

  on(event: string, handler: Handler) {
    if (!this.map.has(event)) this.map.set(event, new Set())
    this.map.get(event)!.add(handler)
    // ✅ 返回取消函数,避免调用方手撕闭包
    return () => this.off(event, handler)
  }

  off(event: string, handler: Handler) {
    this.map.get(event)?.delete(handler)
  }

  once(event: string, handler: Handler) {
    const onceHandler: Handler = p => { this.off(event, onceHandler); handler(p) }
    return this.on(event, onceHandler)
  }

  emit(event: string, payload?: unknown) {
    this.map.get(event)?.forEach(h => {
      try { h(payload) } catch (e) { console.error(`[bus] ${event}`, e) }
    })
  }

  clear(event?: string) { event ? this.map.delete(event) : this.map.clear() }
}

为什么用 Set 而不是数组:去重(同一 handler 不会注册两次)+ delete 是 O(1)。 为什么 emit 里 try/catch:一个订阅者报错不该中断其他人。


五、compose 与中间件(洋葱模型) ​

ts
// Redux 风格:从右到左
const compose = (...fns: Function[]) =>
  fns.reduce((a, b) => (...args: any[]) => a(b(...args)))

// Koa 洋葱模型:next 把控制权交给下一层
type Middleware = (ctx: any, next: () => Promise<void>) => Promise<void>

function composeMiddleware(middlewares: Middleware[]) {
  return (ctx: any) => {
    const dispatch = (i: number): Promise<void> => {
      const fn = middlewares[i]
      if (!fn) return Promise.resolve()
      return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
    }
    return dispatch(0)
  }
}
请求进入 ──► mw1 前半段 ──► mw2 前半段 ──► 核心逻辑
                                            │
响应返回 ◄── mw1 后半段 ◄── mw2 后半段 ◄────┘

这就是 Koa / Redux / Axios 拦截器 / Express 的共同骨架。


六、LRU 缓存 ​

选型要点:Map 保持插入顺序,且 get/set/delete 都是 O(1)—— 这决定了 LRU 可以用 Map 极简实现(利用"重新插入即移到末尾")。

ts
class LRUCache<K, V> {
  private cache = new Map<K, V>()
  constructor(private capacity: number) {}

  get(key: K): V | undefined {
    if (!this.cache.has(key)) return undefined
    const v = this.cache.get(key)!
    this.cache.delete(key)      // 删除后重新插入 → 移到最新
    this.cache.set(key, v)
    return v
  }

  put(key: K, value: V) {
    if (this.cache.has(key)) this.cache.delete(key)
    this.cache.set(key, value)
    // 超容量:Map 的迭代器是插入顺序,第一个就是最久未用
    if (this.cache.size > this.capacity) {
      this.cache.delete(this.cache.keys().next().value)
    }
  }
}

追问:"为什么不用 Object?" → Object 不保证顺序(有整数键时尤甚),且无 .size。


自检清单 ​

  • [ ] 30 秒内写出可用的防抖,并能说出 leading/trailing 与取消怎么处理
  • [ ] 手写的深拷贝能过循环引用、Symbol 键、Map/Set 三关
  • [ ] 能写出并发池,且保持结果顺序
  • [ ] 能说清 Promise.all / allSettled / race / any 四者差异
  • [ ] 能用 Map 写出 LRU,并解释为什么 Map 比 Object 合适

常见误区 ​

❌ 误区✅ 事实
"防抖节流是一回事"防抖最后一次说了算,节流固定频率
"JSON.parse(JSON.stringify()) 就是深拷贝"丢类型、丢 undefined、循环引用直接抛错
"Promise.all 结果按完成顺序"按输入顺序
"闭包会内存泄漏"闭包只持有引用;泄漏是因为没解绑(见 A09)

动手练习 ​

  1. 给 debounce 加单元测试:验证"连续调用 10 次只执行 1 次"以及 cancel() 生效。
  2. 用 asyncPool(3, ...) 并发下载 20 张图,打印完成顺序,确认结果数组顺序与输入一致。
  3. 实现一个 memoize(fn),要求支持过期时间与手动失效。

相关文档 ​