Skip to main content
我准备手写题时不再死背代码,而是先把每个 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 也会被强制指向新实例。
手写题解决的核心问题:
会调用 API 就像会开车——你知道踩油门车就走,踩刹车车就停。但手写题考的是你能不能造一辆车——发动机怎么工作、变速箱怎么换挡、刹车系统怎么设计。你不需要从炼钢开始,但你得理解每个零件的作用。比如手写 bind,你得知道它要绑定 this,返回新函数,还要处理 new 的优先级——这些细节,光开车是体会不到的。

🔧 核心能力

手写题考察的六大底层能力: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 的 key 是弱引用,当源对象被垃圾回收时,WeakMap 中的引用也会被释放,不会造成内存泄漏。Map 是强引用,会阻止垃圾回收。

循环引用的处理策略

循环引用是深拷贝最核心的难点。用 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 的 Function.prototype.bind 实现(src/builtins/function-bind.tq)比手写版本复杂得多。它创建一个 BoundFunction 对象,内部有三个关键字段(详见 V8 源码:src/builtins/function-bind.tq):
  • [[BoundTargetFunction]]:原始函数
  • [[BoundThis]]:绑定的 this
  • [[BoundArguments]]:预设的参数
当 BoundFunction 被 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 })。

常见追问

答题要点: 说出状态机、回调队列、异步调度、thenable 展平四个核心。高分回答: “手写 Promise 的关键不是背代码,而是理解四件事:
  1. 状态机:pending → fulfilled/rejected,状态只能改变一次(不可逆)
  2. 回调队列:pending 状态时把 then 的回调存起来,状态改变后批量执行
  3. 异步调度:then 的回调必须用 queueMicrotask 异步执行,防 Zalgo 现象
  4. thenable 展平:resolvePromise 递归处理嵌套的 Promise 和 thenable 对象”
答题要点: 从行为差异和实现原理两个维度对比。高分回答: “防抖是’等你安静下来再执行’——事件触发后延迟执行,延迟期间再次触发则重新计时。节流是’每隔一段时间执行一次’——在一段时间内只执行一次,不管触发了多少次。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,丢失了原对象可能自定义的原型。”
答题要点: 说出空函数 + 原型赋值的核心思路。高分回答:
“核心思路:创建一个空函数 F,把 F.prototype 指向 proto,然后 new F() 就得到了一个原型指向 proto 的对象。Object.create(null) 创建的对象没有原型链,常用于创建纯净的 map 对象。”
答题要点: 在返回的函数上挂载 cancel 方法,清除定时器。高分回答: “支持取消的防抖函数需要在返回的函数上挂载 cancel 方法:
实际场景:搜索框输入时,用户切换页面后需要取消未完成的搜索请求,避免竞态条件。”
答题要点: 沿着原型链查找,直到找到 Constructor.prototype 或到达 null。高分回答: “instanceof 的本质是检查构造函数的 prototype 是否在对象的原型链上:
注意边界情况:
  1. null instanceof Object 应该返回 false
  2. 基本类型直接返回 false(1 instanceof Number 是 false)
  3. 跨 iframe 时 instanceof 可能失效,用 Array.isArray() 更可靠”
答题要点: 用计数器跟踪完成数量,全部完成才 resolve,有一个失败就 reject。高分回答: “Promise.all 的核心是计数器 + 结果数组:
关键细节:
  1. 用 Promise.resolve(p) 包装,兼容非 Promise 值
  2. 用 results[i] = val 保证结果顺序与输入一致
  3. 有一个 reject 就立即 reject(fail-fast)
  4. 空数组直接 resolve([])”
答题要点: 用 Map 的有序性实现 O(1) 的 get/put。高分回答: “LRU(Least Recently Used)缓存的核心是:访问时把元素移到最前面,容量满时删除最久未使用的(最后面的)。
为什么用 Map?因为 Map 的迭代顺序是插入顺序,keys().next().value 就是最久未使用的元素。“

延伸阅读与引用参考