跳到主要内容

手写题集

几乎每轮都会手写 1~2 道。下面是出现频率最高的,每道都标了面试官真正在看什么。

🔥 手写 Promise​

最高频也最难的一道。完整版很长,面试通常只要求实现 then 的链式调用。

const PENDING = 'pending', FULFILLED = 'fulfilled', REJECTED = 'rejected'

class MyPromise {
constructor(executor) {
this.status = PENDING
this.value = undefined
this.reason = undefined
this.onFulfilledCbs = [] // 存 pending 期间注册的回调
this.onRejectedCbs = []

const resolve = (value) => {
if (this.status !== PENDING) return // 状态不可逆
this.status = FULFILLED
this.value = value
this.onFulfilledCbs.forEach(fn => fn())
}
const reject = (reason) => {
if (this.status !== PENDING) return
this.status = REJECTED
this.reason = reason
this.onRejectedCbs.forEach(fn => fn())
}

try {
executor(resolve, reject) // 构造函数同步执行
} catch (e) {
reject(e)
}
}

then(onFulfilled, onRejected) {
// 参数可选,不传就透传(这是 catch 能捕获上游错误的原因)
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v
onRejected = typeof onRejected === 'function' ? onRejected
: r => { throw r }

const p2 = new MyPromise((resolve, reject) => {
const handle = (cb, arg) => {
// 用微任务保证异步执行
queueMicrotask(() => {
try {
const x = cb(arg)
resolvePromise(p2, x, resolve, reject)
} catch (e) {
reject(e)
}
})
}

if (this.status === FULFILLED) handle(onFulfilled, this.value)
else if (this.status === REJECTED) handle(onRejected, this.reason)
else {
// pending:先存起来,等状态改变时再调
this.onFulfilledCbs.push(() => handle(onFulfilled, this.value))
this.onRejectedCbs.push(() => handle(onRejected, this.reason))
}
})
return p2 // 返回新 Promise,这是链式调用的关键
}

catch(onRejected) { return this.then(null, onRejected) }

finally(cb) {
return this.then(
v => MyPromise.resolve(cb()).then(() => v), // 透传值
r => MyPromise.resolve(cb()).then(() => { throw r })
)
}

static resolve(v) {
return v instanceof MyPromise ? v : new MyPromise(res => res(v))
}
static reject(r) { return new MyPromise((_, rej) => rej(r)) }
}

// 处理 then 回调返回值:可能是普通值、Promise、或 thenable
function resolvePromise(p2, x, resolve, reject) {
if (p2 === x) return reject(new TypeError('Chaining cycle detected'))
if (x && (typeof x === 'object' || typeof x === 'function')) {
let called = false // 防止重复调用
try {
const then = x.then
if (typeof then === 'function') {
then.call(x,
v => { if (called) return; called = true; resolvePromise(p2, v, resolve, reject) },
r => { if (called) return; called = true; reject(r) })
} else resolve(x)
} catch (e) {
if (!called) { called = true; reject(e) }
}
} else resolve(x)
}

:::tip 面试官在看什么

  1. then 返回新 Promise —— 不返回就无法链式调用,这是最核心的一点
  2. pending 时把回调存起来 —— 异步 resolve 的场景能否处理
  3. 回调放进微任务 —— 保证 then 永远异步执行
  4. resolvePromise 处理返回 Promise 的情况 —— 展开嵌套
  5. 参数不传时的透传 —— 这解释了错误为什么能向下冒泡到 catch

写不完整没关系,但要能说清这几点。 :::

静态方法​

MyPromise.all = (arr) => new MyPromise((resolve, reject) => {
const res = []
let count = 0
if (!arr.length) return resolve(res)
arr.forEach((p, i) => {
MyPromise.resolve(p).then(v => {
res[i] = v // 按索引赋值,保证顺序
if (++count === arr.length) resolve(res) // 用计数而非 res.length
}, reject) // 任一失败立刻 reject
})
})

MyPromise.race = (arr) => new MyPromise((resolve, reject) => {
arr.forEach(p => MyPromise.resolve(p).then(resolve, reject))
})

MyPromise.allSettled = (arr) => MyPromise.all(
arr.map(p => MyPromise.resolve(p).then(
value => ({status: 'fulfilled', value}),
reason => ({status: 'rejected', reason})
))
)

all 的两个易错点:必须用计数器判断完成(不能用 res.length,因为提前赋值的索引会让 length 虚高);结果必须按输入顺序而非完成顺序。

🔥 防抖与节流​

// 防抖:n 秒内重复触发则重新计时,只执行最后一次
function debounce(fn, delay, immediate = false) {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
if (immediate && !timer) {
fn.apply(this, args) // 首次立即执行
}
timer = setTimeout(() => {
if (!immediate) fn.apply(this, args)
timer = null
}, delay)
}
}

// 节流:n 秒内只执行一次(时间戳版:首次立即执行,末次不执行)
function throttle(fn, delay) {
let last = 0
return function (...args) {
const now = Date.now()
if (now - last >= delay) {
last = now
fn.apply(this, args)
}
}
}

// 节流(定时器版:首次延迟执行,末次会执行)
function throttle2(fn, delay) {
let timer = null
return function (...args) {
if (timer) return
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}

区别与选型:

防抖节流
行为等停下来才执行固定频率执行
场景搜索框联想、表单校验、resize 完成后重算滚动加载、拖拽、鼠标移动、按钮防连点

必须 apply(this, args) —— 保持原函数的 this 和参数,这是面试官必看的点。用箭头函数包一层就错了(会丢失调用者的 this)。

🔥 深拷贝​

function deepClone(obj, map = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj // 原始类型和 null
if (map.has(obj)) return map.get(obj) // 处理循环引用

// 特殊对象
if (obj instanceof Date) return new Date(obj)
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags)
if (obj instanceof Map) {
const m = new Map(); map.set(obj, m)
obj.forEach((v, k) => m.set(deepClone(k, map), deepClone(v, map)))
return m
}
if (obj instanceof Set) {
const s = new Set(); map.set(obj, s)
obj.forEach(v => s.add(deepClone(v, map)))
return s
}

// 保持原型链
const clone = Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj))
map.set(obj, clone)

// Reflect.ownKeys 能拿到 Symbol 键和不可枚举属性
Reflect.ownKeys(obj).forEach(key => {
clone[key] = deepClone(obj[key], map)
})
return clone
}

四个加分点:

  1. WeakMap 处理循环引用 —— 用 WeakMap 而非 Map 是因为它的键是弱引用,不阻止 GC
  2. 特殊对象单独处理 —— Date、RegExp、Map、Set
  3. Reflect.ownKeys —— 能拿到 Symbol 键(Object.keys 拿不到)
  4. 保持原型链 —— Object.create(Object.getPrototypeOf(obj))

现代替代:structuredClone(obj) 浏览器原生支持,处理循环引用和大部分内置类型,但不支持函数、DOM 节点、原型链。

手写 call / apply / bind​

Function.prototype.myCall = function (ctx, ...args) {
ctx = ctx == null ? globalThis : Object(ctx) // null/undefined 指向全局,原始值装箱
const key = Symbol() // 用 Symbol 避免覆盖已有属性
ctx[key] = this
const res = ctx[key](...args)
delete ctx[key]
return res
}

Function.prototype.myApply = function (ctx, args = []) {
return this.myCall(ctx, ...args)
}

Function.prototype.myBind = function (ctx, ...args1) {
const fn = this
function bound(...args2) {
// 关键:如果被 new 调用,this 指向新实例,忽略绑定的 ctx
return fn.apply(this instanceof bound ? this : ctx, [...args1, ...args2])
}
bound.prototype = Object.create(fn.prototype) // 维持原型链
return bound
}

bind 的两个难点(这是区分度所在):

  1. 被 new 调用时要忽略绑定的 this —— 因为 new 绑定优先级最高
  2. 参数要能分两次传 —— fn.bind(ctx, 1)(2) 等价于 fn.call(ctx, 1, 2)(柯里化)

手写 new 和 instanceof​

function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype) // ①② 创建对象 + 连原型
const res = Ctor.apply(obj, args) // ③ 执行构造函数
// ④ 构造函数返回对象就用它,否则用新对象
return (res !== null && (typeof res === 'object' || typeof res === 'function'))
? res : obj
}

function myInstanceof(left, right) {
if (left === null || (typeof left !== 'object' && typeof left !== 'function')) {
return false
}
let proto = Object.getPrototypeOf(left)
const prototype = right.prototype
while (proto) {
if (proto === prototype) return true
proto = Object.getPrototypeOf(proto) // 顺原型链往上找
}
return false
}

柯里化​

function curry(fn) {
return function curried(...args) {
// 参数够了就执行,不够就返回新函数继续收集
return args.length >= fn.length
? fn.apply(this, args)
: (...rest) => curried.apply(this, [...args, ...rest])
}
}

const add = curry((a, b, c) => a + b + c)
add(1)(2)(3) // 6
add(1, 2)(3) // 6
add(1)(2, 3) // 6

关键是 fn.length —— 函数声明的形参个数。注意有默认值或剩余参数的形参不计入 length。

数组扁平化与去重​

// 扁平化:递归
function flat(arr, depth = 1) {
return depth > 0
? arr.reduce((acc, cur) =>
acc.concat(Array.isArray(cur) ? flat(cur, depth - 1) : cur), [])
: arr.slice()
}

// 一行版(无限深度)
const flatAll = arr => arr.reduce((a, c) =>
a.concat(Array.isArray(c) ? flatAll(c) : c), [])

// 去重(对象数组按某字段去重)
const uniqBy = (arr, key) => {
const seen = new Set()
return arr.filter(x => !seen.has(x[key]) && seen.add(x[key]))
}

并发控制​

实用性最强的一道,常考「同时最多 N 个请求」:

async function asyncPool(limit, tasks) {
const results = []
const executing = new Set()

for (const [i, task] of tasks.entries()) {
const p = Promise.resolve().then(() => task())
results[i] = p
executing.add(p)
p.finally(() => executing.delete(p))

if (executing.size >= limit) {
await Promise.race(executing) // 等任意一个完成再继续
}
}
return Promise.all(results)
}

// 用法
await asyncPool(3, urls.map(u => () => fetch(u)))

核心是 Promise.race —— 等「任意一个完成」而不是「全部完成」,腾出名额就塞新任务。

其他常考小题​

// 事件总线(发布订阅)
class EventEmitter {
constructor() { this.events = {} }
on(name, fn) { (this.events[name] ||= []).push(fn); return this }
off(name, fn) {
if (!fn) delete this.events[name]
else this.events[name] = (this.events[name] || []).filter(f => f !== fn)
return this
}
emit(name, ...args) {
(this.events[name] || []).slice().forEach(fn => fn(...args))
return this
}
once(name, fn) {
const wrap = (...args) => { fn(...args); this.off(name, wrap) }
return this.on(name, wrap)
}
}

// LRU 缓存(Map 天然有序)
class LRUCache {
constructor(capacity) { this.cap = capacity; this.map = new Map() }
get(key) {
if (!this.map.has(key)) return -1
const v = this.map.get(key)
this.map.delete(key); this.map.set(key, v) // 删后重插 = 移到最新
return v
}
put(key, value) {
if (this.map.has(key)) this.map.delete(key)
this.map.set(key, value)
if (this.map.size > this.cap) {
this.map.delete(this.map.keys().next().value) // 删最旧的
}
}
}

// 带重试的请求
async function retry(fn, times = 3, delay = 1000) {
for (let i = 0; i < times; i++) {
try { return await fn() } catch (e) {
if (i === times - 1) throw e
await new Promise(r => setTimeout(r, delay * 2 ** i)) // 指数退避
}
}
}

:::tip 手写题的通用应对

  1. 先问清需求 —— 「需要处理循环引用吗」「要支持 Symbol 键吗」。这既是专业表现,也能缩小实现范围
  2. 先写最简版本再加边界 —— 不要一开始就追求完美,写出能跑的核心逻辑,再主动说「还可以补上 XX 情况」
  3. 边写边说思路 —— 面试官评估的是思维过程,沉默写代码即使写对了也减分
  4. 主动举例验证 —— 写完自己跑两个用例,包括一个边界用例 :::