手写题考的是细节,不是记忆
防抖、深拷贝、Promise 这些代码网上到处都有。区分度在追问上:深拷贝怎么处理循环引用、bind 被 new 调用时怎么办、race 能不能取消请求。这份每个实现后面都标了追问点。
01先写主干再补细节骨架对了就有分,边界和追问是加分项。
02记住每个实现的坑深拷贝的缓存位置、bind 的 new 语义、race 不取消——这些才是被问的地方。
03能说出适用场景会写不会用,等于只答了一半。
1
防抖与节流★
两者解决的不是同一个问题
防抖是等你停下来再做,节流是按固定频率做。搜索联想用防抖(停下来才请求),滚动和拖拽用节流(匀速响应)。答错的顺序通常是:先写代码,却说不清为什么这个场景选这个。
◐ 可交互:狂点这个按钮,看两种策略各自执行几次
窗口 500ms,横轴是最近 4 秒
debounce:停手 500ms 后才执行一次
throttle:每 500ms 最多执行一次
点击次数0
debounce 执行0
throttle 执行0
点几下试试:手速越快,两者差得越明显。
防抖 debounce
- 连续触发只执行最后一次
- 适合:搜索联想、窗口 resize、表单校验
- 时间轴:■■■■■■ → 只在最后 ■ 执行
节流 throttle
- 固定间隔内最多执行一次
- 适合:滚动监听、拖拽、鼠标移动
- 时间轴:■ ■ ■ ■ → 等间隔执行
- 要保留 this 和参数:用
fn.apply(this, args),不要写fn()。在类方法里用fn()会丢 this,这是最常见的扣分点。 - cancel 方法要能停掉:组件卸载时如果没取消防抖,回调会在组件销毁后执行,可能操作已卸载的节点。
- throttle 的两种实现差异在首尾:时间戳版首次立即执行、末次丢失;定时器版首次延迟、末次保证执行。要"首尾都执行"就得合并两种。
面试怎么答防抖是等最后一次触发后延迟执行,用于搜索联想这类"只需要最终结果"的场景;节流是固定频率执行,用于滚动监听这类需要持续反馈的场景。实现上都要保留 this 和参数,防抖要提供 cancel,节流的两种写法区别在于首次是否立即执行、末次是否保证执行。
2
深拷贝★
要处理的不只是嵌套对象
面试里写
JSON.parse(JSON.stringify(obj)) 是不够的,因为它丢函数、丢 undefined、丢 Symbol、把 Date 变字符串、处理不了循环引用。真正要写的是一个带 WeakMap 的递归。JSON 方案的五个硬伤函数 / undefined / Symbol 直接丢掉
Date 变成 ISO 字符串
RegExp 变成 {}
循环引用 直接抛错
Map / Set 变成 {} 或 []
- 缓存要写两遍:先 set 再递归子属性。如果放在递归之后,遇到 a.self = a 就会无限递归。
- WeakMap 而不是 Map:WeakMap 的键是弱引用,克隆完成后不会阻止垃圾回收,避免内存泄漏。
- 要不要拷贝不可枚举属性:
Object.keys只拿可枚举的自有属性,Reflect.ownKeys拿全部(含 Symbol 和不可枚举)。面试里说出来是加分项。
面试怎么答JSON 方案会丢函数、undefined 和 Symbol,把 Date 变成字符串,还处理不了循环引用,所以不够。正确做法是递归加 WeakMap 缓存:先判断基本类型直接返回,特殊类型(Date、RegExp、Map、Set)分别处理,普通对象用 Object.create 保持原型,缓存要在递归之前写入,否则循环引用会栈溢出。
3
手写 Promise★
状态机、then 链、thenable 三件事
Promise 的难点不在 API,在三个规范细节:状态只能变一次且不可逆、then 要返回新 Promise 支持链式、then 的回调必须异步执行。第三点没做,执行顺序题就会答错。
- 为什么要有 callbacks 数组:同一个 Promise 可以挂多个 then,而且可能在 pending 时挂多个。只有一个回调槽位就会丢。
- 透传规则:
then不传函数时,值要原样传给下一个 then。p.then().then(v => ...)能拿到值就是靠这个。 - executor 同步执行、回调异步执行:这两件事经常被混在一起。executor 是在构造函数里立刻跑的,而 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 要被忽略,这一点不写出来,这道题只能算答一半。- 为什么用 Symbol 当键:如果用
fn这种字符串,可能覆盖目标对象上已有的同名属性,调用完还 delete 掉,等于破坏了原对象。 - ctx 是 null 时要指向全局:非严格模式下
fn.call(null)的 this 是 window/globalThis,严格模式才是 null。实现里通常按非严格模式处理。 - bind 可以被 new 两次:
bound.prototype要指向一个继承自原函数原型的对象,否则instanceof会断。
面试怎么答call 和 apply 的区别只是参数形式,实现是把函数挂到目标对象上作为临时方法调用再删除,用 Symbol 做键避免属性冲突。bind 返回新函数并预设参数,关键点是 new 调用时绑定的 this 要失效、改用新对象,同时要维护原型链让 instanceof 正确。
6
new 与 instanceof
两个操作符各自做了什么
new 做四件事:创建对象、连原型、执行构造函数、根据返回值决定返回谁。instanceof 沿原型链往上找,所以它不能跨 iframe,也不认基本类型。- 第 3 步最容易被漏:构造函数如果
return {a: 1},new 出来的是那个对象;如果 return 一个基本值,忽略它。这条不写,这题只能算及格。 - instanceof 判不了基本类型:
1 instanceof Number是 false,因为基本类型没有原型链可查。要判类型用Object.prototype.toString.call()。 - Symbol.hasInstance 可以改写:ES6 之后
instanceof会先找Ctor[Symbol.hasInstance],所以它其实是可以被自定义的行为。
面试怎么答new 做四件事:创建空对象、把它的原型指向构造函数的 prototype、以它为 this 执行构造函数、如果构造函数返回对象就用返回值否则用新对象。instanceof 是沿原型链向上查找构造函数的 prototype,所以它不能跨 iframe,也判断不了基本类型。
7
柯里化与 compose
函数式两件套
柯里化把多参数函数变成一串单参数调用,核心是"参数够了就执行,不够就继续收集"。compose 把多个函数从右到左串起来,是中间件和 redux 的基础。
- fn.length 不包含默认参数和 rest:
(a, b = 1) => {}的 length 是 1,柯里化会提前触发。这是柯里化在生产代码里的真实局限。 - reduce 的方向决定顺序:
reduce((a,b)=>(...args)=>a(b(...args)))从右到左;换成b(a(...args))就是 left-to-right。 - 占位符柯里化:lodash 的 curry 支持
_占位,实现要复杂得多,面试一般只要求基础版加说明。
面试怎么答柯里化的实现是靠 fn.length 判断参数是否收齐,够了立即执行,不够返回新函数继续收集。compose 用 reduce 把函数折叠成一个,注意折叠方向决定执行顺序是从右到左还是从左到右,redux 的 applyMiddleware 就是这种结构。
8
发布订阅
比事件总线多一层解耦
发布订阅和观察者的差别在于中间有没有一个调度中心:观察者是主体直接通知观察者,发布订阅是通过事件中心转发,发布者和订阅者互相不认识。组件通信、跨模块解耦都靠它。
- emit 时要遍历副本:如果回调里调用了
off,直接遍历原数组会导致下标错位、漏执行。这是最容易忽略的实现细节。 - once 要在执行后移除,而且要先执行再移除,否则回调里再 emit 同一个事件会死循环。
- 内存泄漏:订阅了却没取消,组件卸载后回调还在,引用的闭包和外层变量都释放不掉。所以 on 返回取消函数是个好设计。
面试怎么答发布订阅有一个事件中心,发布者和订阅者互不认识,这是它和观察者模式的主要区别。实现上用一个 Map 存事件名到回调数组,emit 时遍历副本避免回调里增删导致漏执行,once 要先执行再移除,on 返回取消函数方便组件卸载时清理避免内存泄漏。
9
数组方法手写
map / filter / reduce / flat
这几个方法手写不难,难在细节:稀疏数组跳不跳过、reduce 不传初始值怎么办、flat 的深度怎么控制。面试官追问的往往就是这些。
- 稀疏数组用
i in this判断:[1,,3].map(x => x)结果是[1, empty, 3],长度不变但空位不执行回调。用this[i] !== undefined判断会把真 undefined 也跳过。 - reduce 不传初始值时,空数组要报错:原生行为是抛 TypeError,实现里可以直接返回 undefined 或抛出,说清楚取舍。
- flat 的深度:默认 1 层,
Infinity是全部展开。用递归 reduce 实现最简洁,但深数组会有栈风险。
面试怎么答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 不生效"这类问题就都能解释。
- await 之后的代码是微任务:因为它被包在
then的回调里。所以 await 一个已 resolved 的 Promise,代码也不会同步执行。 - forEach 里 await 不生效:forEach 不等待回调的返回值,它只是把每个回调调一遍就继续。要串行用
for...of,要并行用Promise.all(arr.map(...))。 - try/catch 能抓到 await 的拒绝:因为 await 把 reject 转成了 throw。但没被 await 的 Promise 拒绝只能靠 unhandledrejection 兜。
面试怎么答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 复用子节点。把这三件事写对,就是可用的最小实现。
- key 的意义在这里能看见:没有 key 就只能按下标复用,中间插入一项会让后面所有节点都白白 patch 一遍,状态还可能串到错误的行上。
- diff 是"同层比较":不跨层级移动节点,所以把一棵树整体挪到另一层,会被当成删除 + 新建。这是 O(n) 复杂度的代价。
- 真实框架更复杂:React 的 Fiber 是为了可中断,Vue3 的 Block Tree 是为了跳过静态节点。它们优化的都不是"比较算法",而是"少比较"。
面试怎么答虚拟 DOM 是用对象描述节点,diff 是比较新旧两棵树算出最小改动。核心步骤是创建真实节点、更新属性、按 key 复用子节点。没有 key 就按下标复用,插入时会错位。diff 只做同层比较,跨层级移动会被当成删除加新建,这是把复杂度降到 O(n) 的代价。
14
场景题:并发、重试、超时★
这类题考的是工程判断
三个高频场景题其实都在考同一件事:控制 Promise 的节奏。并发控制要排队的池子,重试要注意次数和退避,超时要能真正中断(fetch 需要 AbortController)。
- 并发池的取号要同步:
cursor++必须在 await 之前完成,否则多个 worker 会拿到同一个下标。这是唯一的技术难点。 - Promise.race 不取消原任务:它只是先返回,底层请求还在跑。要真取消必须 AbortController,否则会造成"超时了但请求仍在继续"的假象。
- 重试要区分错误类型:4xx 不该重试(参数错了,重试也不会好),5xx 和网络错误才值得重试。答出这一点比写对代码更有价值。
面试怎么答并发控制用固定数量的 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。