前端面试12大考点:闭包、this、事件循环与原型链全解析 前端面试前临时抱佛脚背了五十道题结果面试官随口一句“那你讲讲闭包到底闭的是什么”就把人问懵了——这种场面我见过太多次了。作为在前端岗位干了些年、也坐在面试官这侧筛过不少候选人的人说实话90%的面试翻车都不是死在框架 API 上而是死在 JS 语言本身的这几个核心点上。谁也没真统计过“90%”这个数字但以我观察到的面试记录来看反复出现的大概率就是这 12 个点作用域与闭包、this 指向、原型链与继承、手写 new、事件循环、Promise 与异常处理、setTimeout 的坑、深浅拷贝、数组方法、字符串与 URL 处理、事件委托、防抖与节流。这篇文章我按底层逻辑把它们归成六组来聊每组解决一类问题。你会发现很多题表面问法不同本质上考的却是同一套东西作用域链、调用栈、对象模型、事件机制。下面直接开讲。1. 作用域、闭包与 this 指向面试第一阶段最常翻车的三道连环题1.1 作用域链与闭包从计数器题看本质先看一段几乎所有前端都写过的代码function createCounter() { let count 0; return function () { count; return count; }; } const counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2面试官问你“为什么两次执行结果不一样count 明明在函数内部怎么没被重置”这就是闭包在起作用。要解释清楚得分三层说。第一JS 里作用域分全局、函数、块级三种而且变量的可访问范围在代码编写时就确定了这叫词法作用域。第二函数 createCounter 执行时内部 return 的函数虽然没立即执行但它把 createCounter 的整个作用域链“打包带走”了。第三count 变量并不会在 createCounter 执行结束后被销毁因为还有另一个函数在引用它这个引用关系让 count 一直活在内存里。我面试时特别喜欢追问一句“你觉得闭包是闭住了什么”很多人答“闭住了变量”其实更准确的说法是“闭住了对变量所在词法环境的引用”。同一份作用域可以被多个闭包共享这才是后面 for 循环坑的根源。比如这个经典题for (var i 0; i 3; i) { setTimeout(function () { console.log(i); }, 0); } // 输出3 3 3var 声明的 i 是函数级变量三个 setTimeout 回调闭住的不是 i 的值而是同一个 i 的引用。等到定时器执行时循环早跑完了i 已经变成 3。解决方案不外乎两种把 var 改成 let让每一轮循环生成独立的块级作用域或者用 IIFE 把 i 作为参数传进一个独立的函数作用域。闭包的实际应用场景比很多人想象的多防抖节流函数内部的状态保存、模块化隔离私有变量、组件库中让数据对外只读等。面试官让你“写一个实际使用的闭包场景”最稳妥的回答不是背定义而是直接写一个防抖或节流函数把闭包对持久状态的控制展示出来。还有一个追问频率很高的点“闭包会不会造成内存泄漏”答案是“不合理使用才会”。闭包真正持有的不是变量值而是整个作用域链如果你不小心把一个超大对象挂在闭包能访问的位置该对象就一直不会被回收。我之前排查过一个后台系统卡顿的问题最后定位就是一个可以无限滚动的表格里事件回调闭包了整行数据对象表格数据列表在内存里越攒越多。1.2 this 指向的四条绑定规则与箭头函数的例外this 是秋招面试第二高发区的重灾区。很多人喜欢背口诀“谁调用指向谁”这口诀只对了一半真正标准说法是“看调用位置的绑定模式”一共有四种。默认绑定函数直接调用this 指向全局对象严格模式下是 undefined。function say() { console.log(this); } say(); // window严格模式下是 undefined隐式绑定通过对象属性调用时this 指向该对象。这里有个经典陷阱——把方法拆出来单独调用绑定就丢了const obj { name: obj, getName: function () { console.log(this.name); }, }; const fn obj.getName; fn(); // undefined因为此时是普通函数调用 obj.getName(); // obj符合隐式绑定显式绑定call、apply、bind 都能强制指定 this。区别在于 call 和 apply 会立即执行函数bind 只返回一个绑定了 this 的新函数。new 绑定构造函数通过 new 调用时this 指向新建的对象优先级最高。四条规则的优先级顺序是new 显式绑定 隐式绑定 默认绑定。箭头函数是最大的例外它自己没有 thisthis 是在定义时从外层作用域继承的。这个特性非常容易被组合进面试题里const obj { name: obj, wait: function () { setTimeout(function () { console.log(this.name); // undefined普通函数有自己的 this }, 0); }, waitArrow: function () { setTimeout(() { console.log(this.name); // obj箭头函数从 waitArrow 的词法作用域拿到 this }, 0); }, }; obj.wait(); obj.waitArrow();为什么普通函数会输出 undefined因为 setTimeout 内部对回调是直接调用this 落入默认绑定浏览器环境下指向 windowwindow.name 不是 obj。箭头函数则是“我不管 setTimeout 怎么调我我的 this 在定义时就固定为外层 waitArrow 的 this”。React 类组件里事件回调丢失 this 的经典坑本质就是隐式绑定丢失解法也是箭头函数或 bind。1.3 面试官追问模式闭包 this 的组合输出题单独的闭包题和单独的 this 题都难不倒大家组合起来就容易翻车。比如我面试时经常给候选人出这样的题目let count 0; const obj { count: 10, fn: function () { let count 100; const inner () { console.log(this.count, count); }; inner(); }, }; obj.fn(); // 输出什么先看箭头函数 inner它没有自己的 thisthis 从定义它的外层函数 fn 那里继承。fn 由 obj.fn() 调用是隐式绑定所以 fn 的 this 是 obj于是 this.count 是 10。再看 count 变量inner 自身没有局部变量 count沿作用域链向上找先找到 fn 里的 let count 100所以输出 100。结果就是 10 100。这道题一次性考了三个知识点箭头函数 this 的词法传导、隐式绑定规则、作用域链查找顺序。多数背答案的候选人会在这里卡住因为他们可能把 this 和变量查找混为一谈误以为 inner 里访问 this.count 也应该先看 fn 内部。我的建议是准备面试时多做这种组合型的输出题并且养成“先画作用域链再画 this 绑定最后再执行”的习惯而不是凭直觉写答案。另一道常见组合题是“构造函数里 setTimeout 里用 function 和箭头函数补 this”本质上和图里的逻辑一样只是换了一层壳。2. 原型链、继承与 ES6 class背熟和真正理解的差别2.1 原型链的本质与 instanceof 的实现原理JS 的对象继承机制和 Java 的类继承完全是两码事。每个普通函数都有 prototype 属性这个属性指向一个对象通过该函数 new 出来的实例会有一个内部原型指针指向这个 prototype 对象。这个指针在浏览器里可以通过proto访问但更规范的说法是 Object.getPrototypeOf。所谓原型链就是从一个对象出发沿proto一路往上找的过程。比如数组 arrarr.proto是 Array.prototypeArray.prototype.proto是 Object.prototypeObject.prototype.proto是 null链条结束。instanceof 运算符的底层逻辑就是沿原型链找a instanceof B等价于“B.prototype 是否出现在 a 的整条原型链上”。我经常让候选人手写一个 instanceof目的是看他是否理解原型链function myInstanceof(left, right) { let proto Object.getPrototypeOf(left); while (proto) { if (proto right.prototype) return true; proto Object.getPrototypeOf(proto); } return false; }注意一个很容易钻进去的弯数组实例直接测 instanceof Object 也会返回 true因为链条没断能一路摸到 Object.prototype。这恰恰说明 JS 里“一切皆对象”不是一句口号而是由原型链这个物理结构保证的。hasOwnProperty 和 in 是原型链上一组容易混的兄弟hasOwnProperty 只看自身属性in 会连原型链上的属性一起算。面试题经常问“for...in 遍历对象如何只拿自身属性”答案就是配合 hasOwnProperty 做过滤。Object.keys 则天生只返回自身可枚举属性不需要过滤。2.2 组合继承、寄生组合继承与 class 语法糖原型链继承本身有个大坑就是引用类型属性会被所有实例共享。经典例子function Parent() { this.list []; } function Child() {} Child.prototype new Parent(); const a new Child(); a.list.push(1); const b new Child(); console.log(b.list); // [1]被影响了这题考过无数次原因在于 Child.prototype 指向了一个 Parent 实例而 list 是挂在实例上的属性被所有 Child 实例共享。解决方案是组合继承function Parent(name) { this.name name; this.list []; } Parent.prototype.say function () { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); this.age age; } Child.prototype new Parent(); Child.prototype.constructor Child;组合继承的问题在于 Parent 被调用了两次一次在 Parent.call(this)一次在 new Parent() 作为 Child.prototype。第一次把属性放到了子实例上第二次把属性放到了原型上。结果子实例同时拥有自己的属性原型上还残留一份共享的属性副本虽然访问没问题但冗余了。现代面试题更喜欢问寄生组合继承因为它的思路和 class extends 编译出来的 ES5 结果非常接近function Child(name, age) { Parent.call(this, name); this.age age; } Child.prototype Object.create(Parent.prototype); Child.prototype.constructor Child;Object.create(Parent.prototype) 创建一个以父类原型为原型的新对象子类实例沿原型链既能找到 Parent.prototype 的方法又不会触发父构造函数去生成冗余属性。class 的 extends、super、static 关键字本质都是这套原型逻辑之上的语法糖而不是什么新机制。我在面试时一般不会问“你背一下寄生组合继承的代码”而是直接给一段 class 继承代码问它在 ES5 环境下大概被编译成什么结构。能答出“子类原型通过 Object.create 关联父类原型实例属性通过 super() 里的函数调用完成”这句话就说明真的把原型链和继承打通了。2.3 手写 new 的过程一道很多人卡住的送分题手写 new 是面试里被点单率极高的题因为它能同时考察 this、原型链和对象操作三类知识。new 一个构造函数发生的四件事分别是创建一个空对象把空对象内部原型指向构造函数的 prototype让构造函数内部的 this 指向这个新对象并执行构造函数根据构造函数返回值类型决定最终返回什么。编码实现function myNew(fn, ...args) { const obj Object.create(fn.prototype); const result fn.apply(obj, args); return result (typeof result object || typeof result function) ? result : obj; }最后一步是关键。JavaScript 语言规范规定如果构造函数返回的是对象类型new 表达式会返回那个对象如果返回的是基本类型或没有返回值则忽略它返回创建出来的 obj。这一条也直接解释了为什么构造函数里如果主动 return 一个对象会导致 new 出来的实例不是你的预期对象。还有一种追问方式是“如果不写 new直接用构造函数会怎样”。比如function Person(name) { this.name name; } Person(张三); // 在严格模式下 this 是 undefined直接报错不写 new 时Person 就是普通函数调用this 落入默认绑定。严格模式下是 undefined试图给 undefined 挂 name 属性直接抛错非严格模式下 this 指向 windowname 会被挂到 window 上。这种题在团队代码 review 场景里很常见所以答案要背得清清楚楚构造函数必须用 new 调用或者内部加 instanceof 判断做保护。3. 异步编程事件循环、Promise 与 setTimeout 的那些坑3.1 宏任务与微任务的执行顺序现在几乎每面必问事件循环。我怎么把这事讲得让新手也能记住一句话先同步再微任务最后宏任务整个队列一轮一轮跑。JS 是单线程语言同一时刻主线程只能干一件事。遇到 setTimeout、DOM 事件回调这些异步任务时浏览器会先把它们交给对应的线程或队列等到当前调用栈空了再回来处理。但异步任务内部还要分类Promise.then、queueMicrotask 这类属于微任务优先级比 setTimeout、事件回调这类宏任务更高。执行规则是每一次宏任务跑完后先把当前所有微任务队列清空再取下一个宏任务。经典输出题console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); Promise.resolve() .then(() { console.log(promise 1); }) .then(() { console.log(promise 2); }); console.log(script end);输出顺序是 script start、script end、promise 1、promise 2、setTimeout。processconsole.log 是同步任务按顺序执行setTimeout 被放进了宏任务队列Promise.then 进入了微任务队列主线程同步代码跑完调用栈清空浏览器开始清空微任务队列promise 1 和 promise 2 依次输出微任务清空后才从宏任务队列里取出 setTimeout。还有一个小细节promise 2 的 then 是在 promise 1 的回调执行中才注册进去的但因为它还是微任务仍然排在宏任务前面。关于 async/await 也有个高频误区。请记住await 后面的代码相当于被包进了 Promise.then 的回调里属于微任务。所谓“await 会让出线程”不是指去执行宏任务而是指先把控制权还给事件循环让微任务有机会先行。小技巧面试被问输出顺序时直接拿“同步代码 → 整条微任务链 → 下一个宏任务”这个顺序推演比死记例子稳得多。我还建议自己动手跑几个 Node 环境的输出题注意 Node 的 process.nextTick 比 Promise.then 还优先这是浏览器环境没有的差异也是 Node 方向面试的潜在考点。3.2 Promise 链式调用与异常处理Promise 的本质是一个状态机只有三种状态pending、fulfilled、rejected。状态一旦从 pending 转换出去就不可逆这是设计上最重要的特性也是“为什么 Promise 只能被 resolve 一次”的答案。then 方法永远返回一个新的 Promise这是链式调用的基础。如果你在 then 里返回一个普通值它会作为下一个 then 的入参如果你返回一个 Promise下一个 then 会等待这个 Promise 落定后再取它的值。返回值穿透是很多新人的盲区Promise.resolve(a) .then() .then((v) console.log(v)); // a中间的 then 没有传回调值会被原样透传到下一个 then。这在小册子题里出现过不止一次。异常处理是另一个高频考点。常见的误解是“加了 catch 就万事大吉”其实要看你把 catch 放在哪里Promise.reject(err);注意一个没有被捕获的 rejected Promise 在某些环境下会直接报 UnhandledPromiseRejection。更隐蔽的是链式里的 catch 位置promise .then(fn1) .catch(fn2) .then(fn3);如果 fn1 抛错fn2 能捕获到并且捕获后返回 undefined后续的 fn3 依然会执行。如果 fn2 也抛错那么 fn3 不会执行。这跟 try/catch 的“捕获后继续往下走”直觉是一致的但很多人面试时会答错。Promise 静态方法里Promise.all 是“全部成功才算成功一个失败立即失败”Promise.race 是“第一个落定的结果获胜不管成功失败”Promise.allSettled 是“等所有都落定逐个返回状态和值永远不 reject”。实际开发中并行请求用 all超时控制用 race批量上报不关心成败用 allSettled这个选择能直接体现工程经验。手写 Promise.all 也是常见题核心逻辑实现一下function promiseAll(promises) { return new Promise((resolve, reject) { const result []; let count 0; promises.forEach((p, index) { Promise.resolve(p) .then((value) { result[index] value; count; if (count promises.length) resolve(result); }) .catch(reject); }); }); }注意两点不能用数组 push 直接收集结果因为并发完成顺序不确定必须按 index 占位空数组要直接 resolve否则永远卡在 pending。3.3 setTimeout 返回值与异步陷阱setTimeout 这个 API 表面简单但面试能挖出的坑一点都不少。第一个是返回值问题浏览器环境下setTimeout 返回一个正整数 ID从 1 开始递增同一页面中不会重复用于传给 clearTimeout 取消定时器。所以热搜里“setTimeout 返回值的范围有 0 存在么”的答案是浏览器环境中 ID 从 1 开始取循环回绕前不会有 0一般情况下拿不到 0。Node.js 环境则不太一样返回的是一个 Timeout 对象不是数字这也是区分浏览器和 Node 环境的一个细节点。第二个高频坑是 setTimeout(0) 并不等于立即执行。它的真实含义是“当前任务执行完后再把这个回调推进任务队列”只要当前调用栈里还有同步代码它就得排队。把 setTimeout 和前面的事件循环放在一起理解这就是“最少等 4ms”这个民间说法的来源——浏览器对嵌套层级过高的 setTimeout 还会做额外的时间钳制。第三个坑是大延时问题。setTimeout 的延时参数最大临界值大约是 2147483647ms差不多是 24.8 天。如果超过这个值定时器会立刻执行相当于延时失效。我确实在业务中见过有人给一个自动刷新逻辑写了 30 天的定时器结果 setTimeout 没按预期工作排查了半天才想到是这个原因。第四个是 this 陷阱和回调陷阱这在业务代码中经常踩。写法类似const obj { name: obj, init() { setTimeout(function () { console.log(this.name); // undefined }, 100); }, }; obj.init();普通函数在 setTimeout 内部调用时this 指向全局对象或 undefined。解决方法和之前说的一样用箭头函数或提前 const self this。工程上我还有一个习惯需要在组件销毁时清理的定时器务必把 id 存起来离开页面时 clearTimeout。否则定时器回调里如果引用了 DOM 或组件状态轻则内存泄漏重则报错。高频地创建 setTimeintervals 而不清理是我排查线上内存问题时见过最多的模式之一。4. 数据操作能力深浅拷贝、数组高阶方法与字符串实用判断4.1 深拷贝为什么 JSON.parse(JSON.stringify()) 只能拿来凑合如果面试官问深浅拷贝最常见的回答是“浅拷贝只拷贝第一层深拷贝全部层用 JSON.parse(JSON.stringify()) 就行了”。这个答案最多拿及格分因为 JSON 方案有一堆你未必意识到的坑。先看代码const obj { name: test, age: 30, address: { city: beijing, }, fn: function () {}, date: new Date(), regex: /test/, symbol: Symbol(s), }; const jsonCopy JSON.parse(JSON.stringify(obj)); console.log(jsonCopy); // fn、symbol、regex 丢失date 变成字符串JSON.stringify 在遇到 undefined、函数、Symbol 时会把它们丢弃Date 会被转成字符串正则会被转成空对象NaN 和 Infinity 会变成 null。最致命的是循环引用一构造对象就抛 “Converting circular structure to JSON” 错误。面试官让你手写深拷贝时核心点在于实现“递归 处理多数据类型 解决循环引用”。一个能拿高分的版本function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) return target; if (map.has(target)) return map.get(target); const result Array.isArray(target) ? [] : {}; map.set(target, result); for (const key of Reflect.ownKeys(target)) { result[key] deepClone(target[key], map); } return result; }用 WeakMap 记录拷贝过的源对象遇到同一个引用直接取回既解决了循环引用也保证了“两个属性指向同一个对象时拷贝后仍然指向同一个对象”的结构一致性。现代浏览器也提供了原生 structuredClone API它支持 ArrayBuffer、Map、Set、Date 等类型也能处理循环引用。但面试时手写是为了看你是否理解对象模型不要拿“我用 structuredClone”当万能答案。顺带说说深浅拷贝的边界使用场景不同。浅拷贝适合那些只有一层数据的配置对象展开运算符就够了深拷贝适合复杂嵌套的响应式数据。但深拷贝有成本能浅则浅是我做性能优化时的一个重要原则。4.2 数组高频方法map、filter、find、reduce 及手写实现数组方法是前端面试里性价比最高的一类题它能很直接地区分“会写业务”和“理解数据处理”两种候选人。mmp、filter、find、reduce 这四个是出镜率最高的面试官一般先问区别再让你手写。map 和 forEach 的区别是第一道关卡。map 返回一个新数组forEach 返回 undefinedmap 适合做数据变形forEach 适合执行副作用。很多人面试时还会把 forEach 当遍历万能工具其实当你想中途退出循环时forEach 是做不到的它没有 break 机制。正确的做法是用 some 返回 true 来提前结束或者直接改 for 循环。reduce 是地基。手写一个 reduce 能看懂你对数组协议、初始值、空数组边界是否清楚Array.prototype.myReduce function (callback, initialValue) { const arr this; let acc initialValue; let startIndex 0; if (initialValue undefined) { if (arr.length 0) { throw new TypeError(Reduce of empty array with no initial value); } acc arr[0]; startIndex 1; } for (let i startIndex; i arr.length; i) { acc callback(acc, arr[i], i, arr); } return acc; };注意两个考察点第一不传初始值时reduce 会把数组第一个元素作为初始值并从下标 1 开始遍历第二数组为空且不传初始值时reduce 会直接抛 TypeError这也是模型里规定的行为。实际面试题里reduce 常见的应用有数组求和、统计次数、扁平化嵌套数组、按某字段分组、map 和 filter 组合实现等。我建议你熟练到可以闭眼写出来这些场景因为很多公司面试不只问一遍 reduce面试官会顺着你写的代码继续追问“如果数组有空洞呢”。JS 的数组方法对稀疏数组的处理各有不同map 会保留空洞forEach 会跳过空洞reduce 同样跳过。能把这个差异讲清楚属于加分项。4.3 字符串处理包含判断、忽略大小写、URL 验证热搜词里“js 判断字符串是否包含”和“js 忽略大小写”出现频率非常高说明即使是基础 API在实际业务里也存在大量使用误区。判断包含最常用的四个方法indexOf、includes、startsWith、endsWith。用表格看得更清楚方法判断语义返回值大小写敏感indexOf(a)查找子串位置找到返回下标否则 -1是includes(a)是否包含子串booleantrue / false是startsWith(a)是否以子串开头boolean是endsWith(a)是否以子串结尾boolean是很多新人还用 indexOf ! -1 判断包含其实 includes 就是更语义化的替代还不会出现“indexOf 找不到时返回 -1但 -1 也是 falsy 之外的数导致条件判断出错”的混淆。忽略大小写判断的核心原则是“统一比对标准”。最简单可靠的做法是比对前把两个字符串都转成大写或小写function includesIgnoreCase(str, search) { return str.toUpperCase().includes(search.toUpperCase()); }不建议用 toLowerCase 还是 toUpperCase 纠结只要两边一致就行。如果想做更精细的规则比如只忽略 ASCII 大小写而保留非英文字符差异可以用正则加 /i 标志/hello/i.test(str)。但正则方案要小心特殊字符转义日常场景统一大小写是更稳妥的选择。URL 验证是另一个高频需求。最省事的方案是用 URL 构造函数function isValidUrl(url) { try { new URL(url); return true; } catch { return false; } }new URL 能在解析失败时抛错所以用 try/catch 包一层就能完成基本校验。但要注意它比较宽松像http://这样有协议头但不完整的地址也能通过解析。正则方案更可控比如常见的return /^https?:\/\/([\w-]\.)[\w-]/i.test(url)。实际业务中我一般按需求拆只想判断“是不是合法 URL”用 URL 构造函数想要“必须是 http/https 且带域名”用正则。5. 高频业务场景事件委托、防抖节流实战细节5.1 事件委托从冒泡聊到动态列表性能优化事件机制在 JS 里分两个方向理解传播三阶段和绑定方式。标准规定事件传播分捕获、目标、冒泡三个过程捕获是从 window 往下走到目标元素冒泡是从目标元素往上走回到 window。addEventListener 的第三个参数设为 true 就会让回调在捕获阶段触发设为 false 则在冒泡阶段触发默认是 false。事件委托利用的就是冒泡机制与其给子元素逐个绑定事件不如把事件绑定在它们的共同父元素上让事件通过冒泡到达父元素再统一处理。典型场景是动态列表const list document.getElementById(list); list.addEventListener(click, (e) { const target e.target.closest(li); if (!target) return; console.log(target.dataset.id); });e.target 是真正点击的元素e.currentTarget 是绑定事件的元素也就是父容器。很多人把两者混用导致事件委托里判断失效。还有一个高频细节children 节点是动态渲染的如果给每个子节点都绑定事件每次增删都要重新绑定用委托就只要绑定父节点一次新节点自动继承事件性能和代码维护成本都更优。也别忘了 JS 里有些事件根本不冒泡。scroll、resize、mouseenter、blur 这类事件无法通过委托方式统一处理。如果遇到用 mouseover 替代 mouseenter 的旧代码还要额外处理“子元素进出导致误触发”的问题。这个点我在业务 review 时经常强调。顺便说一句React 17 之前的事件系统是在根容器上统一委托的17 之后改为向原生事件对齐委托点从 document 换到了 root 容器。面试如果聊到 React 事件机制能补上这段演变会很明显体现出你读过源码或者至少跟踪过技术动态。5.2 防抖与节流原理、场景、手写与区别防抖和节流是面试手写题里命中率最高的两道之一而且经常放在一起问因为两者都涉及高频触发场景的频控。先用一个生活中的类比区分防抖像电梯关门。有人进来电梯门就重新打开直到最后一个人进来后等了一会儿再关。节流像水龙头滴水。不管水流多大滴水频率固定每过一段时间必然会滴下一滴。防抖是“高频触发中只执行最后一次”。典型的场景是搜索框输入用户连续打字时不需要每敲一个字就请求一次接口而是在用户停下来后的 300ms 内再发请求。实现如下function debounce(fn, wait, immediate) { let timer null; return function (...args) { const callNow immediate !timer; clearTimeout(timer); timer setTimeout(() { timer null; if (!immediate) fn.apply(this, args); }, wait); if (callNow) fn.apply(this, args); }; }immediate 参数控制“首次触发是否立即执行”。如果你在做提交按钮防重复点击immediate 为 true 更合适第一次点击立即执行后续点击被拦截直到 wait 过后才恢复。节流是“固定时间间隔内只执行一次”保证最小执行频率。常见场景是滚动加载、拖拽、缩放。实现也有两种模式// 时间戳版 function throttle(fn, delay) { let last 0; return function (...args) { const now Date.now(); if (now - last delay) { last now; fn.apply(this, args); } }; }时间戳版保证首次执行能立即生效但最后一次触发可能被“吃掉”定时器版则会在最后一次补执行两者各有取舍。业务中做滚动加载一般用时间戳版做动画末尾收尾则定时器版更好。两个版本都建议亲手写一遍因为面试官很可能会追问“节流结束前最后一次触发你们怎么处理”。手写防抖节流时最容易翻车的地方有两个一是忘记在内部函数里用 fn.apply(this, args) 保证上下文否则外部通过对象调用时会丢失 this二是事件对象 e 的透传如果不把参数转发给 fn拿不到 event 对象做 stopPropagation 之类操作。这两点我在代码 review 里几乎每次都能看到。5.3 ES Module 与 CommonJS模块化的核心差异模块化虽然不在上面 12 个点清单里却是任何现代前端项目都绕不开的面试题。ES Module 和 CommonJS 的差异本质上体现了两种设计哲学静态 vs 动态。ES Module 的 import/export 是静态的写在代码顶层在解析阶段就能确定依赖关系所以工具可以在打包时做 tree shaking删除没被引用的导出。CommonJS 的 require 是动态的可以在 if 条件里写 require也可以把 require 写在函数内部因为它是运行时执行的函数调用。这就是“为什么 import 不能写在条件块里而 require 可以”的原因。依赖循环的问题也有体现。CommonJS 遇到循环依赖时某个模块可能拿到另一个模块未执行完的部分导出ES Module 则通过“活绑定”的方式在模块间保持引用有效性最终执行时拿到的值可能已更新。这个差异在前端笔试题里反复被考到给一个循环依赖代码执行并输出运行时结果。想保证答对关键是要能画出模块的执行顺序先执行谁、打印谁、exports 里此刻有谁。Tree shaking 还有一个日常相关的体现从组件库里按需引入。像 antd 这类大型组件库用import { Button } from antd配合打包器的 tree shaking只打包用到的部分如果项目用了 CommonJS 格式的第三方库tree shaking 往往失效。这也是为什么现在新库都优先输出 ESM 产物。面试官如果追问“那 Node.js 现在支持 ESM 吗”答案是支持但 package.json 的 type 字段和 .mjs / .cjs 后缀决定了解析方式Node 环境下两者可以共存。能把“为什么需要 babel 和打包器”到“Node 原生如何处理这两种规范”串起来讲一遍就是一道很完整的模块化大通关。6. 加分题新语法边界、内存管理与工程细节6.1 可选链、空值合并、解构与展开的实战边界ES6 新语法也是面试常青树但考的是使用边界不是你见过哪些 API。这几个最容易踩坑的点值得单独拿出来说。可选链操作符 ?. 很香但用多了会产生“防御性编程副作用”。比如user?.address?.city一旦 user 为 null 就不会抛错直接返回 undefined。问题在于本该暴露的 bug 也被吞掉了。比如调用一个对象的方法合理预期对象一定存在如果它不存在应该立刻报错而不是让后面所有逻辑静默失效。我在团队里定的规则是外部不可控数据接口返回值、localStorage 解析结果可以用 ?.内部业务代码里不要滥用。空值合并运算符 ?? 和 || 的区别是面试官非常爱挖的点。||对 falsy 值生效也就是遇到 0、、NaN、false 都取右边??只对 null 和 undefined 生效。比如const a 0; console.log(a || 10); // 10 console.log(a ?? 10); // 0业务中如果你有一个“count 为 0 时也要展示 0”的需求用||做兜底就会把 0 替换成默认值这就是 bug 源。写出“dont use || when you mean ??”这个意识比记住语法更重要。解构赋值默认值有隐藏陷阱默认值只在属性值是 undefined 时生效null 不会被默认值覆盖const { name 默认 } { name: null }; console.log(name); // null不是 默认展开运算符的边界是“它只浅拷贝一层”。{ ...obj }复制完第一层后嵌套对象依然是同一引用。这正好呼应深浅拷贝那节deep clone 的需求永远不能被展开运算符替代。很多新人写完const newObj { ...oldObj }就以为 newObj 完全独立了结果修改 newObj.a.b 时把 oldObj.a.b 也改了。6.2 垃圾回收与内存泄漏低频但能拉开差距的题这一组题出现频率不高但一旦出现面试官往往想通过它筛掉多数人。JS 引擎的垃圾回收主流算法是标记清除从根对象出发遍历所有可达对象打上标记剩下的没标记的就是垃圾统一回收。老一代浏览器里的引用计数算法已经被淘汰因为没法解决循环引用。面试常见的四个内存泄漏场景每个都很具体第一个是隐式全局变量。在函数内给未声明变量赋值它会变成全局变量永远留在 window 上。严格模式能防住这个。第二个是定时器未清理。setInterval 的回调里如果引用了大量数据又没在生命周期销毁时 clearInterval数据会一直驻留。第三个是事件监听器泄漏。SPA 页面频繁销毁和挂载组件如果组件销毁时没有 removeEventListener旧组件就通过事件回调被长期持有。第四个是分离的 DOM 节点。你移除了一个 DOM 节点但 JS 变量还持有它的引用节点就无法被回收。怎么规避记忆一个简单原则谁创建、谁回收。在 useEffect 里设置的定时器和绑定的监听器都在清理函数里取消解绑。闭包里的临时数据只要不再被引用就能释放问题往往出在“引用没断”而不是“闭包本身有毒”。WeakMap 和 WeakSet 的存在本来就是为了解决内存回收问题WeakMap 的 key 是弱引用不阻止垃圾回收。做对象级的存取时比如给 DOM 元素挂缓存数据用 WeakMap 能避免缓存数据跟着元素一起残留。最后提一个和性能相关的延伸场景前端用 Web Worker 做大文件上传其实就是把耗时任务放到独立线程避免主线程阻塞。这个方向在面试中经常被作为“异步优化”的进阶题背后逻辑还是事件循环和内存管理那套体系主线程空闲才能保证 UI 流畅复杂的计算任务外包给 Worker数据交换通过 postMessage 完成。把这套思路讲清楚比背十个 API 更能证明你的工程基础扎实。真到面试的时候我自己的习惯是每题先想“这次面试官真正想问的是什么”再组织回答。闭包问的是引用关系this 问的是调用点Promise 问的是任务队列原型链问的是对象模型。把这几个底层模型在脑子里串成一张网比硬背一百道题都有用。这也是我平时带新人时最常强调的一点——碰到不会的题别慌先把它往已懂的原理上靠从根上推理八九不离十。