18 · 手写题与代码设计
本篇解决什么问题: 面试里"手写一个 XX"考的不是背代码,而是边界意识:你有没有考虑取消、并发、循环引用、错误分支。 本篇把高频手写题按难度递进组织,每道都给能直接用的实现 + 面试官追问的标准答案。
📌 与 A09(JavaScript 运行时)的分工:A09 讲机制(事件循环怎么转),本篇讲手写实现。 📌 与 A19(设计模式)的分工:本篇是代码级技巧,A19 是结构级模式。
开篇速览
| 题目 | 考察点 | 面试频率 | 难度 |
|---|---|---|---|
| 防抖 / 节流 | 定时器与闭包,取消与立即执行 | ⭐⭐⭐⭐⭐ | 中 |
| 深拷贝 | 循环引用、特殊类型、递归爆栈 | ⭐⭐⭐⭐⭐ | 中 |
| Promise 系列 | 异步编排、并发控制 | ⭐⭐⭐⭐⭐ | 中高 |
| EventBus | 发布订阅、内存泄漏 | ⭐⭐⭐⭐ | 中 |
| compose / 中间件 | 函数式、洋葱模型 | ⭐⭐⭐⭐ | 高 |
| LRU | 数据结构选型 | ⭐⭐⭐⭐ | 高 |
一、防抖与节流
一句话区分:
| 防抖 debounce | 节流 throttle | |
|---|---|---|
| 语义 | 最后一次说了算(等你不抖了再执行) | 固定频率执行(不管你触发多快) |
| 类比 | 电梯:有人进就重新计时 | 地铁:到点就发车 |
| 典型场景 | 搜索联想、表单校验、resize 结束 | 滚动加载、鼠标移动、按钮防连点 |
1.1 防抖(支持取消 + 立即执行)
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 节流(时间戳版)
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 就用
structuredClone(obj) // ✅ 原生、支持循环引用、支持 Date/Map/Set/ArrayBuffer
// ❌ 不支持:函数、Symbol、DOM 节点、原型链2.2 手写:必须处理循环引用
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 并发控制(最高频)
/**
* 并发池:始终最多 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
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(发布订阅)
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 与中间件(洋葱模型)
// 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 极简实现(利用"重新插入即移到末尾")。
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) |
动手练习
- 给
debounce加单元测试:验证"连续调用 10 次只执行 1 次"以及cancel()生效。 - 用
asyncPool(3, ...)并发下载 20 张图,打印完成顺序,确认结果数组顺序与输入一致。 - 实现一个
memoize(fn),要求支持过期时间与手动失效。
相关文档
- 事件循环与内存机制 → A09 · JavaScript 运行时
- 结构级模式 → A19 · 前端设计模式
- React/Vue 里的真实映射 → A05 · React 与 Vue 核心机制对比