我准备手写题时不再死背代码,而是先把每个 API 的输入输出和边界条件写清楚,再动手补实现。
Q1: 手写 call / apply / bind
1
核心知识点: this 绑定
call/apply/bind 的本质是把函数挂在目标对象上执行,利用”函数作为对象的方法调用时 this 指向该对象”的规则。
2
call 的实现
把函数作为目标对象的临时方法执行,执行完删除。用 Symbol 避免属性冲突。
3
apply 的实现
和 call 一样,只是参数是数组。
4
bind 的实现
返回一个新函数,绑定 this 和部分参数。关键:如果被 new 调用,this 应该指向新实例。
5
关键洞察: bind + new 的优先级
new 的优先级高于 bind——即使函数已经被 bind 绑定了 this,new 调用时 this 也会被强制指向新实例。
🔧 核心能力
手写题考察的六大底层能力:this 绑定(call/apply/bind)、深拷贝(循环引用+特殊对象)、事件处理(防抖/节流)、函数式(柯里化)、设计模式(EventEmitter)、异步编程(async/await 本质)。
📐 解题框架
每道手写题都遵循同一套框架:1)说出核心原理;2)写出基本实现;3)处理边界情况;4)对比原生实现的差异。先写骨架,再补细节,最后加防御性判断。
call 的实现图解
call 的核心原理只有一句话:把函数变成目标对象的临时方法,然后通过obj.fn() 的方式调用。
apply 的实现图解
apply 和 call 的唯一区别:参数是数组。bind 的实现图解
bind 是三者中最复杂的——它返回一个新函数,而且要处理new 的优先级问题。
call/apply/bind 的本质是利用”对象方法调用”的 this 规则,bind 额外需要处理 new 的优先级。call 立即执行,apply 立即执行(参数是数组),bind 返回新函数(延迟执行)。
call/apply/bind 三者对比速查表
Q2: 手写深拷贝
深拷贝的核心实现
循环引用的处理策略
循环引用是深拷贝最核心的难点。用 WeakMap 记录已拷贝的对象,遇到重复引用直接返回已拷贝的版本。structuredClone vs 手写深拷贝
浏览器原生提供了structuredClone API,原生支持循环引用,但不支持函数、DOM 节点、Symbol 属性。
Q3: 手写防抖(debounce)和节流(throttle)
防抖的核心实现
leading 和 trailing 的区别
防抖和节流都支持leading(首次立即执行)和 trailing(最后一次延迟执行)选项。
防抖和节流的实际应用场景
Q4: 手写柯里化(curry)
柯里化的核心实现
柯里化的实际应用
柯里化与偏函数(Partial Application)的区别
柯里化是将多参数函数转为一系列单参数函数,偏函数是固定部分参数。两者常被混淆。Q5: 手写 EventEmitter
EventEmitter 的核心实现
once 的实现细节
once 方法的关键是包装一个 wrapper 函数,wrapper 内部在执行原始回调后立即解绑自己。_original 属性用于 off 方法识别原始回调。
发布订阅 vs 观察者模式
EventEmitter 是发布订阅模式的实现,和观察者模式有本质区别。Q6: 手写 async/await 的本质
Generator + 自动执行器的核心实现
Generator 的暂停与恢复机制
Generator 被 V8 编译为状态机。每次yield 暂停时,V8 保存当前的执行状态(局部变量、程序计数器)到 Generator 对象的 context 中。next() 恢复时,从 context 中恢复状态继续执行。
错误处理:try/catch 与 Generator.throw
底层源码:V8 的 bind 实现
V8 的 bind 实现和手写版本有什么区别?
V8 的 Function.prototype.bind 实现(src/builtins/function-bind.tq)比手写版本复杂得多。它创建一个 BoundFunction 对象,内部有三个关键字段(详见 V8 源码:src/builtins/function-bind.tq):
[[BoundTargetFunction]]:原始函数[[BoundThis]]:绑定的 this[[BoundArguments]]:预设的参数
new 调用时,V8 的 [[Construct]] 内部方法会检查 new.target,如果有就用 [[BoundTargetFunction]] 的构造逻辑,[[BoundThis]] 被忽略。这就是为什么 new (fn.bind(ctx))() 中 this 指向新实例而不是 ctx。
十、及格回答 vs 满分回答
问题:“手写一个 Promise / 防抖节流 / 深拷贝”
及格回答(60-70分):“能写出正确的实现,通过基本的测试用例。”问题出在:能跑但不理解设计。 满分回答(90-100分):
手写题的本质不是背代码,而是考察你对底层原理的理解深度。每道手写题背后都有一个核心知识点:call/apply/bind 考察 this 绑定机制和new的优先级(new能覆盖bind,因为底层[[Construct]]初始化新对象后会强制覆盖this上下文);Promise 考察状态机模型(pending → fulfilled/rejected,状态只能改变一次)和异步调度(then 回调强制用queueMicrotask异步执行,防 Zalgo 现象);深拷贝考察递归引用处理(用 WeakMap 检测循环引用,弱引用不阻止 GC 避免内存泄漏)。[1]
手写 Promise 的关键不是背代码,而是理解四件事:状态机(pending → fulfilled/rejected,状态只能改变一次)、回调队列(pending 状态时把 then 的回调存起来,状态改变后批量执行)、异步调度(then 的回调必须用queueMicrotask异步执行)、thenable 展平(resolvePromise递归处理嵌套的 Promise 和 thenable 对象,必须检测循环引用if (promise2 === x)否则会无限递归)。手写 bind 的关键是要兼容new调用——检查new.target,有则用原函数构造,忽略绑定的 context。[2]
手写题和原生实现的差距:V8 的Function.prototype.bind创建一个BoundFunction对象,内部有[[BoundTargetFunction]]、[[BoundThis]]、[[BoundArguments]]三个关键字段。当 BoundFunction 被new调用时,V8 的[[Construct]]内部方法会检查new.target,如果有就用[[BoundTargetFunction]]的构造逻辑,[[BoundThis]]被忽略。手写版本通常用instanceof检测,不如 V8 的new.target精确。structuredClone 和手写深拷贝的区别:structuredClone 不支持函数、DOM、Symbol,但原生支持循环引用。[3]
实际项目中的建议:不要在面试中直接默写完整代码——先说清楚设计思路和核心难点,再逐步写出关键代码。想听的是你的思考过程,而不是背诵结果。防抖和节流的区别:防抖是”等你安静下来再执行”,节流是”每隔一段时间执行一次”。lodash 的throttle实际上就是debounce的封装:throttle(fn, wait)等价于debounce(fn, wait, { maxWait: wait })。
常见追问
手写 Promise 的核心难点是什么?
手写 Promise 的核心难点是什么?
答题要点:
说出状态机、回调队列、异步调度、thenable 展平四个核心。高分回答:
“手写 Promise 的关键不是背代码,而是理解四件事:
- 状态机:pending → fulfilled/rejected,状态只能改变一次(不可逆)
- 回调队列:pending 状态时把 then 的回调存起来,状态改变后批量执行
- 异步调度:then 的回调必须用
queueMicrotask异步执行,防 Zalgo 现象 - thenable 展平:
resolvePromise递归处理嵌套的 Promise 和 thenable 对象”
防抖和节流的区别?lodash 的 throttle 是怎么实现的?
防抖和节流的区别?lodash 的 throttle 是怎么实现的?
答题要点:
从行为差异和实现原理两个维度对比。高分回答:
“防抖是’等你安静下来再执行’——事件触发后延迟执行,延迟期间再次触发则重新计时。节流是’每隔一段时间执行一次’——在一段时间内只执行一次,不管触发了多少次。lodash 的
throttle 实际上就是 debounce 的封装:throttle(fn, wait) 等价于 debounce(fn, wait, { maxWait: wait })。lodash 的 debounce 支持 maxWait 参数,保证函数在一定时间内一定会执行一次。”深拷贝如何处理原型链?
深拷贝如何处理原型链?
答题要点:
说出 Object.create(Object.getPrototypeOf(source)) 的作用。高分回答:
“深拷贝时用
Object.create(Object.getPrototypeOf(source)) 创建对象,这样克隆出来的对象保留了原对象的原型链。如果用 {} 或 [] 创建,克隆出来的对象的原型是 Object.prototype 或 Array.prototype,丢失了原对象可能自定义的原型。”🔥 手写 Object.create 的实现?
🔥 手写 Object.create 的实现?
答题要点:
说出空函数 + 原型赋值的核心思路。高分回答:“核心思路:创建一个空函数 F,把 F.prototype 指向 proto,然后 new F() 就得到了一个原型指向 proto 的对象。
Object.create(null) 创建的对象没有原型链,常用于创建纯净的 map 对象。”如何实现一个支持取消的防抖函数?
如何实现一个支持取消的防抖函数?
答题要点:
在返回的函数上挂载 cancel 方法,清除定时器。高分回答:
“支持取消的防抖函数需要在返回的函数上挂载 实际场景:搜索框输入时,用户切换页面后需要取消未完成的搜索请求,避免竞态条件。”
cancel 方法:手写 instanceof 的实现?
手写 instanceof 的实现?
答题要点:
沿着原型链查找,直到找到 Constructor.prototype 或到达 null。高分回答:
“instanceof 的本质是检查构造函数的 prototype 是否在对象的原型链上:注意边界情况:
null instanceof Object应该返回false- 基本类型直接返回
false(1 instanceof Number是false) - 跨 iframe 时
instanceof可能失效,用Array.isArray()更可靠”
🔥 如何实现一个 Promise.all?
🔥 如何实现一个 Promise.all?
答题要点:
用计数器跟踪完成数量,全部完成才 resolve,有一个失败就 reject。高分回答:
“Promise.all 的核心是计数器 + 结果数组:关键细节:
- 用
Promise.resolve(p)包装,兼容非 Promise 值 - 用
results[i] = val保证结果顺序与输入一致 - 有一个 reject 就立即 reject(fail-fast)
- 空数组直接 resolve([])”
手写一个 LRU 缓存?
手写一个 LRU 缓存?
答题要点:
用 Map 的有序性实现 O(1) 的 get/put。高分回答:
“LRU(Least Recently Used)缓存的核心是:访问时把元素移到最前面,容量满时删除最久未使用的(最后面的)。为什么用 Map?因为 Map 的迭代顺序是插入顺序,
keys().next().value 就是最久未使用的元素。“延伸阅读与引用参考
- ECMAScript 规范:Promise Objects:Promise 对象行为的权威定义。 [1]
- V8 Blog: Fast async/await:V8 团队对 async/await 底层优化的官方博文。 [2]
- BFE.dev JavaScript Quizzes:前端刷题网站,含大量手写题与输出题。 [3]
