ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

JS面试进阶:从闭包到Promise,原理讲透不止背答案

JS面试进阶:从闭包到Promise,原理讲透不止背答案 1. 从“背答案”到“讲原理”这套JS八股文到底该怎么用先说点实在的。前端面试走到今天早就不是“背出几个API名字就能过”的年代了。我这些年面过不少人也帮朋友做过模拟面试最直观的感受是候选人分两种一种是把八股文背得滚瓜烂熟但一问“为什么”就卡壳另一种是代码写得挺顺但让他讲思路讲得七零八落。这个系列的文章就是为了解决这两种问题。系列第一篇聊了JS的基础数据类型、深浅拷贝、原型链这些地基这一篇接着往上盖楼。这篇聚焦的是面试里出场率最高的几类JS题目作用域与闭包、this指向、事件循环与Promise、数组的高频API、手写实现以及一些经典场景题。每个部分我都按“面试官到底想问什么”来拆再给出一套能直接用的回答思路和代码确保你不是死记硬背而是真正理解背后那层逻辑。这套内容适合谁准备跳槽的前端、刚转行想系统补基础的初学者、以及带团队时需要给新人做培训的技术Leader都可以直接拿去参考。面试题这东西网上到处都是但能把题目背后的原理讲透、把回答的“加分点”说清楚的内容才是真正值钱的。这篇我尽量做到这点。我先把话放这儿这一篇里没有任何一道题是“背下来就行”的每一道我都给了面试官想听到的推导过程。你把推导过程理解了题目怎么变形都不怕。2. 作用域与闭包面试官真正想听的不是“定义”2.1 经典闭包题之外的另一层理解面试题库里闭包几乎是必考题最常见的出法是这样for (var i 0; i 5; i) { setTimeout(function() { console.log(i); }, 1000); }问输出什么为什么怎么改才能输出0、1、2、3、4你要是只回答“因为var没有块级作用域”那只是第一层。面试官真正想听的是闭包捕获的是变量的引用而不是值。这个理解到位了后面所有的变形题都好解。我建议的回答路径分三步走。第一步指出setTimeout的回调函数在1秒后才执行那时循环早就跑完了i已经变成5第二步解释为什么是5而不是0到4——因为var声明的i属于函数作用域五个回调函数共享同一个i它们捕获的是同一个变量绑定第三步给出修复方案至少要说出两种// 方案一用 let 替代 var for (let i 0; i 5; i) { setTimeout(() console.log(i), 1000); } // 方案二用 IIFE 制造独立作用域 for (var i 0; i 5; i) { (function(j) { setTimeout(function() { console.log(j); }, 1000); })(i); } // 方案三用 bind 绑定参数 for (var i 0; i 5; i) { setTimeout(console.log.bind(null, i), 1000); }这里有个细节值得多说一句let的方案之所以可行是因为它在每次迭代时都会创建一个新的绑定这是ES6规范里明确写的。但如果你只是知道“let能行”却说不清let和var在循环里的区别面试官还是不会给你打高分。2.2 闭包在实战中的典型场景背定义没有用你得知道闭包在真实项目里长什么样。我最常举的例子是防抖函数因为这是闭包最典型的应用之一function debounce(fn, delay) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }这里timer就是被返回的函数闭包捕获的变量。每次调用返回的函数都能访问上一次调用时留下的timer这就是闭包“记住”了上次状态的能力。如果你面试时能主动举这个例子会比你干巴巴地背“闭包就是函数能够访问其外部作用域变量的能力”要好得多。另一个高频场景是模块化模式用闭包隐藏内部状态const counter (function() { let count 0; return { increment: () count, decrement: () --count, getCount: () count }; })();这里count对外不可见只能通过返回的方法操作。这就是闭包实现“私有变量”的经典方式。我面试时会追问一句为什么这样写能实现私有你如果能答出“因为count被返回的函数引用形成闭包外部作用域无法直接访问它”这题就过了。2.3 作用域链的查找机制闭包离不开作用域链。面试里经常出现这类题function outer() { var a 1; function inner() { var b 2; console.log(a b); } return inner; }问inner函数里访问a的过程是什么样的这里要讲清楚的是当inner内部找不到a时会沿着定义时的作用域链向外查找先在outer的作用域里找到a而不是在调用时的作用域里找。这就是词法作用域的含义——函数的作用域在定义时就确定了跟在哪里调用没关系。面试官如果想加深难度会考这个var name window; var obj { name: obj, getName: function() { return function() { return this.name; }; } }; console.log(obj.getName()()); // 输出什么答案是window。因为返回的匿名函数在调用时this指向的是全局对象。这个题考查的是“闭包中的this指向”和“词法作用域”的区分。很多候选人在这里混成一团把this的规则和作用域的规则搞混。记住一句话this是调用时确定的作用域是定义时确定的。这两者不要混为一谈。3. this指向四条判定规则一次讲透3.1 别背复杂案例先掌握优先级this指向是JS面试的“重灾区”因为规则多而且不同场景下表现不一样。我给候选人培训时习惯把规则压缩成四句话按优先级从高到低排new绑定用new调用构造函数时this指向新创建的对象。call/apply/bind绑定显式指定this的指向。方法调用obj.method()这种形式this指向obj。默认绑定普通函数调用this指向undefined严格模式或全局对象非严格模式。只要记住了这个优先级绝大部分this题都能推出来。尤其是下面这种经典题var name window; var obj { name: obj, sayName: function() { console.log(this.name); } }; var fn obj.sayName; fn(); // 输出什么答案是window。因为当fn()被调用时调用方式是普通函数调用不是obj.sayName()这种形式。很多人以为“函数是从obj里拿出来的this应该指向obj”这就是没理解“调用方式决定this”这条核心逻辑。3.2 箭头函数的特殊之处箭头函数是this题里的“气氛组”经常和普通函数混在一起出题。核心规则只有一条箭头函数没有自己的this它继承定义时外层作用域的this。看这个例子var name window; var obj { name: obj, sayName: () { console.log(this.name); } }; obj.sayName(); // 输出什么答案是window。因为sayName是箭头函数它的this不是由调用方式决定的而是由定义位置决定——它定义在obj内部但obj是对象字面量不构成作用域所以this继承了全局作用域的this。如果换个写法var obj { name: obj, sayName: function() { const fn () { console.log(this.name); }; fn(); } }; obj.sayName(); // 输出什么答案是obj。因为箭头函数定义在sayName函数内部继承了sayName的this而sayName是通过obj.sayName()调用的this指向obj。这两道题放一起就能完整考查对箭头函数this继承机制的理解。我在实际面试中经常用这两道题来区分“背过箭头函数”和“真懂箭头函数”的候选人。3.3 bind、call、apply的区别与实现这三个方法也是高频考点。最简洁的总结是call和apply都是立即调用函数区别在于传参方式——call用逗号分隔参数apply用数组传参bind不立即调用而是返回一个新函数且新函数的this被永久绑定。function greet(greeting, punctuation) { console.log(greeting , this.name punctuation); } const person { name: Alice }; greet.call(person, Hello, !); greet.apply(person, [Hello, !]); const boundGreet greet.bind(person); boundGreet(Hello, !);面试官如果要考手写大概率会考bind的实现。下面是个简化但可用的版本Function.prototype.myBind function(context, ...args) { const fn this; return function(...innerArgs) { return fn.apply(context, [...args, ...innerArgs]); }; };这个实现里有两个注意点一是要把fn存下来避免this在返回的函数里丢失二是要把绑定时的参数和调用时的参数合并这样才能支持柯里化用法。你要是能现场写出这个版本再解释清楚思路这题就稳了。4. 事件循环与Promise异步题的答题套路4.1 宏任务与微任务的执行顺序异步这道题面试官的出题套路非常固定就是给你一段混合了setTimeout、Promise、async/await的代码让你说出输出顺序。这类题考察的是事件循环的理解。我先给一套万能答题框架执行当前同步代码遇到微任务Promise.then、queueMicrotask、MutationObserver就放入微任务队列遇到宏任务setTimeout、setInterval、I/O就放入宏任务队列。同步代码执行完后清空微任务队列执行所有微任务。从宏任务队列中取出一个任务执行。每执行完一个宏任务都要回头清空微任务队列。重复这个过程。用这个框架去套题基本不会错。看这个经典例子console.log(1); setTimeout(() { console.log(2); Promise.resolve().then(() console.log(3)); }, 0); Promise.resolve().then(() console.log(4)); console.log(5);输出顺序是1、5、4、2、3。解释同步代码先执行1和5然后微任务队列里有console.log(4)所以先输出4再执行宏任务的setTimeout回调输出2回调里的微任务console.log(3)紧接着在下一轮微任务清空时输出。关键在于“每执行完一个宏任务都要清空微任务队列”所以3在2之后立即输出不会等下一个宏任务。4.2 Promise的链式调用与错误处理Promise的基础用法背得再熟也没用面试官要考的是链式调用和错误处理。最常见的坑是then里返回的值和返回值是Promise的区别Promise.resolve(1) .then(val { console.log(val); // 1 return val 1; }) .then(val { console.log(val); // 2 return Promise.resolve(val 1); }) .then(val { console.log(val); // 3 });这里的核心是then回调的返回值会被包装成Promise所以不管返回普通值还是Promise后续的then都能接收到。理解了这一点链式调用就不难了。错误处理方面最常问的是catch和then的第二个参数有什么区别。我的回答思路是catch能捕获前面所有then里的错误而then(onFulfilled, onRejected)只能捕获当前then之前不包括onFulfilled自己抛出的错误的错误。所以在实战中推荐统一用catch不要用第二个参数因为它的容错范围太窄。4.3 async/await的实质与避坑async/await是Promise的语法糖这个大家都知道但面试官想知道你是否理解它的本质。我给的公式是async函数返回的一定是Promiseawait会阻塞后续代码的执行但不会阻塞事件循环。看这种常考题async function test() { console.log(1); await Promise.resolve(); console.log(2); } test(); console.log(3);输出顺序是1、3、2。await之后的代码相当于被放进了微任务队列所以console.log(2)会在微任务阶段执行而console.log(3)是同步代码先执行。另外有个很容易踩的坑用async/await处理并发请求时如果用for循环逐个await性能会很差。正确的做法是用Promise.all// 不推荐串行请求 async function fetchUsers(ids) { const users []; for (const id of ids) { users.push(await fetchUser(id)); } return users; } // 推荐并行请求 async function fetchUsers(ids) { return Promise.all(ids.map(id fetchUser(id))); }面试时如果能主动提到这个区别会显得你不仅有基础还有实战意识。5. 数组与对象的高频APImap、reduce、扩展运算符的深挖5.1 map vs forEach不只是返回值不同很多文章说map和forEach的区别是“map有返回值forEach没有”这个说法不太准确。forEach也有返回值只是它总是返回undefined。真正的区别是map会创建一个新数组而forEach是在原数组上操作。面试官爱考的是map的“坑”const arr [1, 2, 3]; const result arr.map(item { if (item 1) { return item * 2; } // 没有return }); // result 是什么答案是[undefined, 4, 6]。很多候选人会答错因为map的回调里如果没有return对应位置就是undefined。这个题考查的是对map语义的准确理解而不是粗略的“遍历并转换”。再补一个扩展map的回调有三个参数当前值、索引、原数组如果只写一个参数就只拿当前值。但如果你写了parseInt作为回调const arr [1, 2, 3]; const result arr.map(parseInt); // result 是什么答案是[1, NaN, NaN]。因为parseInt接收了两个参数值和索引而parseInt的第二个参数是进制数索引2传给parseInt就变成了“以2为基数解析3”结果自然不对。这个题考查的是“回调函数会收到哪些参数”这个细节也是我看到过的最经典的“面试陷阱”之一。5.2 reduce的灵活应用与手写思路reduce是面试里的“全能选手”因为它可以做很多事而且一旦用到面试官的眼神就会亮起来。最经典的是用它实现数组去重、拍平、分组// 数组去重 const unique arr arr.reduce((acc, cur) { if (!acc.includes(cur)) acc.push(cur); return acc; }, []); // 拍平二维数组 const flat arr arr.reduce((acc, cur) acc.concat(cur), []); // 分组统计 const countBy arr arr.reduce((acc, cur) { acc[cur] (acc[cur] || 0) 1; return acc; }, {});如果面试官要考手写reduce可以直接用for循环实现一个基础版Array.prototype.myReduce function(callback, initialValue) { const arr this; let accumulator initialValue ! undefined ? initialValue : arr[0]; let startIndex initialValue ! undefined ? 0 : 1; for (let i startIndex; i arr.length; i) { accumulator callback(accumulator, arr[i], i, arr); } return accumulator; };注意一个关键点没传初始值时reduce会把数组第一个元素作为初始值从第二个元素开始遍历。很多人手写时漏掉这个细节导致数组只有一项时出错。这个细节面试官特别爱抠。5.3 扩展运算符的三个易错点扩展运算符...在面试里经常穿插出现主要考三个点。第一浅拷贝的坑const obj1 { a: 1, b: { c: 2 } }; const obj2 { ...obj1 }; obj2.b.c 100; console.log(obj1.b.c); // 100...只能浅拷贝一层嵌套对象仍是引用。手写深拷贝的问题就会从这里引出来。第二类数组转真数组const nodes document.querySelectorAll(div); const arr [...nodes]; // 类数组转数组这个在有arguments的场景下也常见但arguments本身不能用map、filter这些方法必须先转成数组才能用。第三合并对象时的属性覆盖顺序const defaultConfig { host: localhost, port: 3000 }; const userConfig { port: 8080 }; const config { ...defaultConfig, ...userConfig }; // config 是 { host: localhost, port: 8080 }后面的对象会覆盖前面的同名属性这个规则虽然简单但在实际合并配置时特别容易踩坑尤其是配置项很多、属性名有重叠时一不小心就把原来的配置覆盖掉了。6. 高频手写题从深拷贝到防抖节流6.1 深拷贝的完整实现与循环引用处理深拷贝是手写题里的“必修课”。初级版本是递归复制所有属性但面试官通常不会满足于此他们会追问循环引用怎么处理Symbol属性怎么处理这就是我给的完整版function deepClone(obj, map new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj); if (map.has(obj)) return map.get(obj); const clone Array.isArray(obj) ? [] : {}; map.set(obj, clone); const keys [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)]; for (const key of keys) { clone[key] deepClone(obj[key], map); } return clone; }这里有两个关键点。一是用WeakMap记录已拷贝的对象解决循环引用问题二是用Object.getOwnPropertySymbols处理Symbol类型的键。这些都加上才是一个能应对追问的“完整版”。我面试时见过太多人在循环引用这里翻车写完基础版被追问“如果对象里有个属性指向自身会怎样”然后就没下文了。你只要把WeakMap这个方案说出来面试官基本就会认定你是真的写过深拷贝而不是背下来的。6.2 防抖和节流记住适用场景防抖和节流也几乎必考而且要你讲区别。我的表达方式是防抖是“等一等再执行”适用于输入框搜索、窗口resize这种连续触发但只需要执行最后一次的场景节流是“每隔一段时间执行一次”适用于滚动加载、按钮点击这种需要控制频率的场景。实现方面// 防抖 function debounce(fn, delay) { let timer null; return function(...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; } // 节流时间戳版 function throttle(fn, interval) { let last 0; return function(...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }这里有个细节要说明为什么用fn.apply(this, args)因为事件处理函数里this指向触发事件的DOM元素如果直接调用fn()this会丢失。这个细节经常被忽略但面试官恰恰喜欢问。节流还有一种“定时器版”写法它和时间戳版的区别是时间戳版是立即执行定时器版是延迟执行。这两者的差异在面试里也会被追问到最好能说出“一个在区间开始时触发一个在区间结束时触发”。6.3 instanceof的原理与实现instanceof手写题出现的频率也很高。很多候选人知道它是用来判断原型链的但写代码时容易漏掉细节。标准实现如下function myInstanceof(obj, constructor) { if (obj null || typeof obj ! object) return false; let proto Object.getPrototypeOf(obj); while (proto) { if (proto constructor.prototype) return true; proto Object.getPrototypeOf(proto); } return false; }注意两点第一基本类型字符串、数字、布尔不是对象直接返回false第二要用Object.getPrototypeOf逐步向上查找原型链直到找到或到顶为null。这个题还有一个变体问Object instanceof Function为什么是trueFunction instanceof Object为什么也是true。这考查的是对原型链的完整理解尤其是Object.prototype和Function.prototype的互相指向关系。我建议面试前把这个关系图在心里过一遍因为一旦被追问很多人会栽在这里。7. 场景题的破解思路从字符串到三级联动7.1 字符串包含判断能说出三种方法就算优秀“判断字符串是否包含某个子串”这道题出现频率极高通常的答案是includes但面试官往往会追问“不用ES6方法怎么做”。我总结的两种替代方案const str hello world; // 方案一indexOf str.indexOf(world) ! -1; // 方案二正则 /world/.test(str); // 方案三search str.search(world) ! -1;如果面试官再深挖问“要统计子串出现的次数”那就要用正则的match或者手动遍历function countOccurrences(str, sub) { if (!sub) return 0; let count 0; let pos 0; while ((pos str.indexOf(sub, pos)) ! -1) { count; pos sub.length; } return count; }这里indexOf的第二个参数fromIndex是关键它决定了从哪个位置开始查找避免重复计数。这种从“判断”到“计数”的拓展能看出你对字符串API的熟练程度面试官很吃这一套。7.2 URL校验边界条件比正则更重要“验证URL是否有效”这道题核心考点不是正则写得多复杂而是你有没有考虑边界情况。我推荐的回答路径是先判断协议是否合法http、https再判断域名格式最后判断是否有必要的路径参数。function isValidUrl(url) { try { const parsed new URL(url); return [http:, https:].includes(parsed.protocol); } catch { return false; } }这里用URL构造函数来解析比手写正则靠谱得多。因为正则很难覆盖所有合法的URL格式而浏览器内置的URL解析器已经处理了绝大多数边界情况。如果你能主动提到“用URL构造函数而不是正则”这个加分点很难被忽略。面试官如果追问“怎么判断URL是否能访问”那就涉及网络请求了。用fetch发一个HEAD请求检查响应状态码是否在2xx或3xx范围但要注意跨域问题。这个属于网络层的知识能在JS面试里答出来会显得知识面比较广。7.3 三级联动数据结构和联动逻辑的拆解“JS三级联动”是经典场景题很多候选人遇到就慌因为不知道面试官想考什么。其实这道题考的核心点就三个数据怎么组织、事件怎么绑定、联动怎么触发。数据组织方面好的设计是const data { 北京市: [朝阳区, 海淀区, 东城区], 上海市: [浦东新区, 徐汇区, 黄浦区], 广东省: [广州市, 深圳市, 珠海市] };第一个下拉框选省份第二个下拉框根据省份渲染城市列表第三个下拉框则根据城市渲染区县列表这里可以再嵌套一级。事件绑定方面核心是用change事件监听下拉框的变化然后重新渲染下一级下拉框的选项provinceSelect.addEventListener(change, function() { const cities data[this.value] || []; citySelect.innerHTML ; cities.forEach(city { const option document.createElement(option); option.value city; option.textContent city; citySelect.appendChild(option); }); citySelect.disabled false; // 同时清空并禁用第三级 districtSelect.innerHTML ; districtSelect.disabled true; });这道题如果面试官追问“数据量很大时怎么优化”你就要提到“按需加载数据”而不是一次性把全国的数据都塞到前端。这里能引出接口设计、数据缓存、懒加载等话题属于从基础题升级到项目经验的加分路径。8. 我在面试别人时最看重的三个表达习惯这一节不聊具体的题目了聊聊我在面试别人时观察到的三个高频问题希望对正在准备面试的人有帮助。第一个问题是“只给结论不给推导”。比如问“为什么这里输出是5”候选人只说“因为var没有块级作用域”然后就沉默了。我需要的是一步步推导先分析同步代码的执行顺序再看定时器回调什么时候执行最后说明闭包捕获的是同一个变量引用。把推导过程说出来比直接说结论更能体现思维清晰度。第二个问题是“不会说‘我不知道’”。面试中遇到没见过的题很正常但很多人的第一反应是胡编或者停在那里不说话。我推荐的做法是先把题目复述一遍确认自己的理解然后说“这个场景我在项目里没直接遇到过但是按照JS的运行机制推测可能是这样的……”这种回答方式至少能展示你的分析能力即使最终答案不对也不会给面试官留下“依赖背题”的坏印象。第三个问题是“不展示代码的边界意识”。手写函数时很多人写完主逻辑就停了完全不考虑输入参数异常的情况。比如手写深拷贝不处理循环引用手写防抖不处理this丢失。如果你能在写完函数后主动加一句“这里还需要处理XX边界情况”面试官对你的评价会立刻上一个台阶。因为这说明你不是在背代码而是在思考代码的完整性。9. 这套题之外JS面试还需要补什么这一篇集中讲了JS核心机制和高频API但JS的知识面远不止这些。如果你时间充裕我建议再往这几个方向补充ES6的新特性解构、模板字符串、Symbol、Generator、模块化ES Module和CommonJS的区别、类型转换和的规则、隐式类型转换的坑、以及性能优化相关的基础内存泄漏的常见原因、垃圾回收机制的粗浅理解。这些内容大多和这一篇里的知识点有交集比如闭包会导致内存泄漏这就是一个很好的跨章节串联点。你在准备的时候不要按部就班地一个个知识点孤立地去背而是尝试把相关知识点串成网。比如作用域影响闭包闭包导致内存泄漏内存泄漏和垃圾回收相关垃圾回收又和性能优化相关。这样串起来面试时无论从哪个点切入你都能讲出完整链路。我的习惯是每次面试前都挑几个高频考点自己给自己讲一遍用手机录音然后听回放。这个方法有点笨但是效果很好——你能清楚听到自己在哪个地方卡壳哪个地方解释得不够顺。面试的本质是“说清楚”不是“想清楚”。如果你在心里想得很明白但说不出来那在面试官那里就等于没想清楚。练到能把每个知识点用两三句话给一个完全不懂JS的人讲明白那面试题基本就拦不住你了。
返回列表