手写题集
几乎每轮都会手写 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 面试官在看什么
then返回新 Promise —— 不返回就无法链式调用,这是最核心的一点- pending 时把回调存起来 —— 异步 resolve 的场景能否处理
- 回调放进微任务 —— 保证
then永远异步执行 resolvePromise处理返回 Promise 的情况 —— 展开嵌套- 参数不传时的透传 —— 这解释了错误为什么能向下冒泡到
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
}
四个加分点:
WeakMap处理循环引用 —— 用WeakMap而非Map是因为它的键是弱引用,不阻止 GC- 特殊对象单独处理 ——
Date、RegExp、Map、Set Reflect.ownKeys—— 能拿到Symbol键(Object.keys拿不到)- 保持原型链 ——
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 的两个难点(这是区分度所在):
- 被
new调用时要忽略绑定的this—— 因为new绑定优先级最高 - 参数要能分两次传 ——
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 手写题的通用应对
- 先问清需求 —— 「需要处理循环引用吗」「要支持 Symbol 键吗」。这既是专业表现,也能缩小实现范围
- 先写最简版本再加边界 —— 不要一开始就追求完美,写出能跑的核心逻辑,再主动说「还可以补上 XX 情况」
- 边写边说思路 —— 面试官评估的是思维过程,沉默写代码即使写对了也减分
- 主动举例验证 —— 写完自己跑两个用例,包括一个边界用例 :::