← 全部材料 手写题
HANDWRITTEN CODE · 14 道必写题

手写题考的是细节,不是记忆

防抖、深拷贝、Promise 这些代码网上到处都有。区分度在追问上:深拷贝怎么处理循环引用、bind 被 new 调用时怎么办、race 能不能取消请求。这份每个实现后面都标了追问点。

01先写主干再补细节骨架对了就有分,边界和追问是加分项。
02记住每个实现的坑深拷贝的缓存位置、bind 的 new 语义、race 不取消——这些才是被问的地方。
03能说出适用场景会写不会用,等于只答了一半。
1

防抖与节流★

两者解决的不是同一个问题
防抖是等你停下来再做,节流是按固定频率做。搜索联想用防抖(停下来才请求),滚动和拖拽用节流(匀速响应)。答错的顺序通常是:先写代码,却说不清为什么这个场景选这个。
◐ 可交互:狂点这个按钮,看两种策略各自执行几次
窗口 500ms,横轴是最近 4 秒
debounce:停手 500ms 后才执行一次
throttle:每 500ms 最多执行一次
点击 debounce 执行 throttle 执行
点击次数0
debounce 执行0
throttle 执行0
点几下试试:手速越快,两者差得越明显。

防抖 debounce

  • 连续触发只执行最后一次
  • 适合:搜索联想、窗口 resize、表单校验
  • 时间轴:■■■■■■ → 只在最后 ■ 执行

节流 throttle

  • 固定间隔内最多执行一次
  • 适合:滚动监听、拖拽、鼠标移动
  • 时间轴:■ ■ ■ ■ → 等间隔执行
面试怎么答防抖是等最后一次触发后延迟执行,用于搜索联想这类"只需要最终结果"的场景;节流是固定频率执行,用于滚动监听这类需要持续反馈的场景。实现上都要保留 this 和参数,防抖要提供 cancel,节流的两种写法区别在于首次是否立即执行、末次是否保证执行。
2

深拷贝★

要处理的不只是嵌套对象
面试里写 JSON.parse(JSON.stringify(obj)) 是不够的,因为它丢函数、丢 undefined、丢 Symbol、把 Date 变字符串、处理不了循环引用。真正要写的是一个带 WeakMap 的递归。
JSON 方案的五个硬伤函数 / undefined / Symbol 直接丢掉 Date 变成 ISO 字符串 RegExp 变成 {} 循环引用 直接抛错 Map / Set 变成 {} 或 []
面试怎么答JSON 方案会丢函数、undefined 和 Symbol,把 Date 变成字符串,还处理不了循环引用,所以不够。正确做法是递归加 WeakMap 缓存:先判断基本类型直接返回,特殊类型(Date、RegExp、Map、Set)分别处理,普通对象用 Object.create 保持原型,缓存要在递归之前写入,否则循环引用会栈溢出。
3

手写 Promise★

状态机、then 链、thenable 三件事
Promise 的难点不在 API,在三个规范细节:状态只能变一次且不可逆、then 要返回新 Promise 支持链式、then 的回调必须异步执行。第三点没做,执行顺序题就会答错。
面试怎么答手写 Promise 要处理三件事:状态机只能从 pending 单向变一次;then 返回新 Promise,并且返回值要能被继续展开(thenable 处理);then 的回调必须放到微任务里执行,保证在同步代码之后。另外 executor 抛错要转成 reject,reject 之后的状态不能再被 resolve 覆盖。
4

Promise 的四个静态方法

区别全在"失败时怎么办"
四个方法只有两个维度:什么时候 settle、结果长什么样。all 是全部成功才成功,race 是第一个 settle 就 settle,allSettled 永远成功但每项带状态,any 是第一个成功就成功、全失败才失败。
一句话区分all 全成功 -> 成功数组;任一失败 -> 立刻失败 race 第一个 settle(无论成败)-> 采用它的结果 allSettled 从不失败;等全部结束,返回状态数组 any 第一个成功 -> 成功;全部失败 -> AggregateError
面试怎么答all 是全成功才成功、任一失败立刻失败;race 是第一个结束的说了算,不管成败;allSettled 永远成功,每项返回 status 和 value 或 reason,适合"等所有请求结束再统一处理";any 是第一个成功就成功,全部失败才报 AggregateError。
5

call / apply / bind★

bind 的重点是 new 的时候 this 失效
call 和 apply 只差传参形式,本质是"把函数临时挂到目标对象上调用"。bind 返回新函数,而且用 new 调用时绑定的 this 要被忽略,这一点不写出来,这道题只能算答一半。
面试怎么答call 和 apply 的区别只是参数形式,实现是把函数挂到目标对象上作为临时方法调用再删除,用 Symbol 做键避免属性冲突。bind 返回新函数并预设参数,关键点是 new 调用时绑定的 this 要失效、改用新对象,同时要维护原型链让 instanceof 正确。
6

new 与 instanceof

两个操作符各自做了什么
new 做四件事:创建对象、连原型、执行构造函数、根据返回值决定返回谁。instanceof 沿原型链往上找,所以它不能跨 iframe,也不认基本类型。
面试怎么答new 做四件事:创建空对象、把它的原型指向构造函数的 prototype、以它为 this 执行构造函数、如果构造函数返回对象就用返回值否则用新对象。instanceof 是沿原型链向上查找构造函数的 prototype,所以它不能跨 iframe,也判断不了基本类型。
7

柯里化与 compose

函数式两件套
柯里化把多参数函数变成一串单参数调用,核心是"参数够了就执行,不够就继续收集"。compose 把多个函数从右到左串起来,是中间件和 redux 的基础。
面试怎么答柯里化的实现是靠 fn.length 判断参数是否收齐,够了立即执行,不够返回新函数继续收集。compose 用 reduce 把函数折叠成一个,注意折叠方向决定执行顺序是从右到左还是从左到右,redux 的 applyMiddleware 就是这种结构。
8

发布订阅

比事件总线多一层解耦
发布订阅和观察者的差别在于中间有没有一个调度中心:观察者是主体直接通知观察者,发布订阅是通过事件中心转发,发布者和订阅者互相不认识。组件通信、跨模块解耦都靠它。
面试怎么答发布订阅有一个事件中心,发布者和订阅者互不认识,这是它和观察者模式的主要区别。实现上用一个 Map 存事件名到回调数组,emit 时遍历副本避免回调里增删导致漏执行,once 要先执行再移除,on 返回取消函数方便组件卸载时清理避免内存泄漏。
9

数组方法手写

map / filter / reduce / flat
这几个方法手写不难,难在细节:稀疏数组跳不跳过、reduce 不传初始值怎么办、flat 的深度怎么控制。面试官追问的往往就是这些。
面试怎么答map 和 filter 要注意稀疏数组:用 i in this 判断而不是比较 undefined,否则会把真实的 undefined 也跳过。reduce 不传初始值时用第一个非空元素当初始值。flat 用递归 reduce 实现,depth 减到 0 就停止。
10

继承与 class 的本质

class 只是语法糖,但不是完全等价
寄生组合继承是 ES5 的最优解:原型继承父类的原型,构造函数继承父类的实例属性。class 是它的语法糖,但有两处真实差异:class 不能当普通函数调用,且方法不可枚举。
几种继承的缺陷原型链继承 Child.prototype = new Parent() 父类引用属性被所有实例共享,一个改全都改 构造函数继承 Parent.call(this) 拿不到父类原型上的方法 组合继承 上面两个都用 父类构造函数被调用两次 寄生组合 原型用 Object.create,构造用 call 最优,没有多余调用也不共享引用
面试怎么答ES5 里最推荐寄生组合继承:构造函数里用 Parent.call(this) 继承实例属性,原型用 Object.create(Parent.prototype) 继承方法,再修正 constructor。它避免了原型链继承的引用共享和组合继承的两次调用。class 是语法糖,但 class 不能当普通函数调用,方法也不可枚举。
11

async/await 的本质

generator 加自动执行器
async/await 是生成器 + 自动执行器的语法糖。理解这一点,"await 之后的代码为什么在微任务里执行"、"为什么 forEach 里 await 不生效"这类问题就都能解释。
面试怎么答async/await 本质是 generator 加一个自动执行器,yield 的值被包进 Promise.then 里,所以 await 之后的代码总在微任务中执行。这也能解释为什么 forEach 里 await 不生效——forEach 不等回调返回。要串行用 for of,要并行用 Promise.all 加 map。
12

响应式原理★

defineProperty 与 Proxy 的差别
Vue2 用 defineProperty 逐个属性装 getter/setter,所以新增、删除属性以及数组下标赋值都监听不到。Vue3 换 Proxy 拦截整个对象,这三个洞才补上。手写 mini 版能直观看到差别。
三个窗口期只能靠补丁Vue2 的盲区 Vue3 的表现 obj.newKey = 1 能被拦到 delete obj.key 能被拦到 arr[0] = 'x' 能被拦到(Proxy 拦的是索引属性) arr.length = 0 能被拦到
面试怎么答Vue2 用 defineProperty 给已有属性装拦截器,所以新增和删除属性、以及数组下标赋值都监听不到,只能靠 $set 和重写数组方法打补丁。Vue3 用 Proxy 拦截整个对象,可以拦到 get、set、deleteProperty,这三个窗口期就补上了,而且不需要初始化时递归遍历所有属性。
13

虚拟 DOM 与 diff

简化版能跑起来就够了
虚拟 DOM 是用 JS 对象描述节点,diff 是比较两棵旧新树求最小改动。手写层面只要实现:创建真实节点、更新属性、按 key 复用子节点。把这三件事写对,就是可用的最小实现。
面试怎么答虚拟 DOM 是用对象描述节点,diff 是比较新旧两棵树算出最小改动。核心步骤是创建真实节点、更新属性、按 key 复用子节点。没有 key 就按下标复用,插入时会错位。diff 只做同层比较,跨层级移动会被当成删除加新建,这是把复杂度降到 O(n) 的代价。
14

场景题:并发、重试、超时★

这类题考的是工程判断
三个高频场景题其实都在考同一件事:控制 Promise 的节奏。并发控制要排队的池子,重试要注意次数和退避,超时要能真正中断(fetch 需要 AbortController)。
面试怎么答并发控制用固定数量的 worker 循环取任务,取号必须在 await 之前,否则会有竞态。重试用指数退避并限制次数,要区分错误类型,4xx 不重试。超时用 Promise.race 只能提前返回,真正取消请求要用 AbortController,否则底层请求还在跑。
15

速答清单

十句话收口
01防抖和节流分别用在哪?
防抖用于搜索联想、resize 这类只需要最终结果的场景;节流用于滚动、拖拽这类需要持续反馈的场景。
02深拷贝为什么不能用 JSON?
会丢函数、undefined、Symbol,Date 变字符串,RegExp 变空对象,还处理不了循环引用。
03深拷贝的缓存为什么要在递归前写入?
否则遇到 a.self = a 会无限递归直到栈溢出。用 WeakMap 避免阻止垃圾回收。
04手写 Promise 三个关键点?
状态单向不可逆;then 返回新 Promise 且能展开 thenable;then 的回调必须异步执行。
05Promise.all 和 allSettled 的区别?
all 是任一失败就整体失败;allSettled 永远成功,返回每项的 status 和 value/reason。
06bind 有什么特别之处?
返回新函数,预设参数,而且被 new 调用时绑定的 this 要失效、改用新对象,同时要维护原型链。
07new 的三个步骤?
创建对象并连原型、以它为 this 执行构造函数、根据构造函数返回值决定返回谁。
08forEach 里为什么 await 不生效?
forEach 不等待回调的返回值。串行要用 for of,并行用 Promise.all 加 map。
09Proxy 比 defineProperty 好在哪?
能拦截新增、删除属性以及数组下标赋值,而且不需要初始化时递归遍历所有属性。
10Promise.race 能取消请求吗?
不能。它只是先返回结果,底层请求仍在跑。要真正取消得用 AbortController。