ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript面试核心考点精讲:变量提升、闭包、事件循环与手写题

JavaScript面试核心考点精讲:变量提升、闭包、事件循环与手写题 1. 变量提升与暂时性死区var、let、const 到底怎么考1.1 var 的变量提升到底提的是什么前端面试问到 JS十次里有八次会从变量提升开场。我当年第一次被问“var 和 let 的区别”时只背了句“var 有变量提升let 没有”结果面试官追问了一句“那函数声明有变量提升吗函数表达式呢”我就卡住了。所以这一节咱们把变量提升这件事彻底讲透。先看最经典的代码console.log(a); // undefined var a 10;很多新手以为这是“报错”实际输出是undefined。原因在于JS 引擎在执行代码之前会先做一次完整的“收集”过程把所有var声明的变量和function声明的函数登记到当前执行上下文的变量环境里并且把var声明的变量初始化为undefined。这个过程就是变量提升。注意这里的两个关键点提升的是声明不是赋值赋值仍然留在原来的位置。var声明的变量会被初始化为undefined所以提前访问不报错但拿不到值。如果换成 letconsole.log(a); // ReferenceError: Cannot access a before initialization let a 10;let 也会被“提升”但它不会初始化成undefined而是进入一个叫“暂时性死区”Temporal Dead Zone简称 TDZ的状态。在 TDZ 内访问变量直接抛 ReferenceError。所以严格来说let 也存在提升只是行为表现和 var 完全不同。面试官想听的往往就是这一层你不知道 let 其实也被提升了说明你对执行上下文的理解还停留在背结论阶段。再看函数声明和函数表达式的区别foo(); // 函数声明 bar(); // TypeError: bar is not a function function foo() { console.log(函数声明); } var bar function () { console.log(函数表达式); };函数声明整体提升所以调用没问题函数表达式本质是var bar xxx只提升了 var 声明bar当时是undefined调用自然报错。这些细节面试官特别爱拆开问答的时候别急把“声明提升”和“初始化时机”分开说思路会清晰很多。1.2 let/const 的暂时性死区与经典循环输出题暂时性死区最直观的触发场景有两个块级作用域内的提前访问以及 typeof 的使用。typeof x; // ReferenceError而不是 undefined let x;注意这里有个反直觉的坑typeof在没有声明过的变量上会返回undefined但一旦进入 TDZtypeof也会抛错。这题我见过不少候选人答错因为他们只记了“typeof 一个不存在的变量不会报错”这条结论没意识到 TDZ 的优先级更高。for 循环里的经典输出题几乎每次面试都会出现for (var i 0; i 5; i) { setTimeout(() { console.log(i); }, 0); } // 输出 5 5 5 5 5换成 let 后输出 0 1 2 3 4。面试官会追问“为什么”这里有两个层面的理解第一层var 版本var 声明的是全局变量循环结束后 i 已经变成 5五个定时器的回调函数读取的都是同一个 i。第二层let 版本let 声明的是块级变量for 循环的每次迭代会创建一个新的词法环境闭包捕捉的是当次迭代的 i所以每个回调拿到的 i 值不同。这其实涉及闭包的知识放到下一节展开。const 的特殊性也要单独提const 声明时必须赋值且不能重新赋值。但“不能重新赋值”不等于“不可变”——对象内部的属性仍然可以改。const obj { name: 张三 }; obj.name 李四; // 允许 obj {}; // TypeError: Assignment to constant variable这个点面试官经常用来引出深拷贝、不可变数据这些进阶话题。我建议你在准备时顺手把Object.freeze和 const 的区别也梳理清楚Object.freeze是冻结对象让属性不可修改但它是浅冻结嵌套对象仍然能改。2. 闭包与 this 指向面试官最爱的两个陷阱2.1 闭包的本质与内存问题如果说变量提升是开场菜闭包就是前端面试的主菜。面试官可能会让你“说说什么是闭包”也可能直接甩一道“用闭包实现计数器”的手写题。这道题本身不难但很多人讲不明白闭包产生的机制。闭包的本质就一句话函数 它创建时所在的词法环境。当内部函数引用了外部函数的变量且内部函数被返回或传递到外部时外部函数的执行上下文虽然已经“销毁”但它的变量对象仍然被内部函数引用着不会从内存中消失这就形成了闭包。一个规范的计数器实现function createCounter() { let count 0; return function () { count; return count; }; } const counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2面试常见追问是“闭包有什么应用场景”。这个问题要想答得漂亮得结合真实业务只背“保存变量”太单薄了。常见的场景有防抖与节流通过闭包保存定时器 id 和上次执行时间。封装私有变量在模块模式IIFE 闭包里实现外部无法直接访问的内部状态。函数柯里化通过闭包记住逐步传入的参数。如果面试官继续追问“闭包会导致内存泄漏吗”这里要小心这不是一个简单的“会”或“不会”。闭包本身不必然导致内存泄漏但如果你长期持有一个大对象的引用而这个引用本可以释放那它确实会造成内存占用过高。经典错误是在全局环境中保存了一个闭包闭包里引用了一个永远不会再用的 DOM 节点或大数据对象。function handleClick() { const bigData new Array(10000000).fill(1); document.getElementById(btn).onclick function () { console.log(clicked); }; }这里的bigData明明没被回调函数用到但因为闭包保留了整个词法环境bigData仍然被隐式引用着。排查手段是打开 Chrome DevTools 的 Memory 面板录制堆快照搜索不期望存在的对象。实际代码里可以在回调函数内部手动bigData null或者用 WeakMap 管理这类不需要强引用的数据。2.2 this 指向的四种绑定规则与箭头函数陷阱this 指向问题几乎是一道必考题而且经常以“输出题”的形式出现。我见到的最典型的错误答案是这个候选人背了“谁调用就指向谁”的口诀但遇到let obj { fn: fn }这种赋值引用场景或者const test obj.fn之后再调用就完全乱了。规范的做法是记四种绑定规则按优先级从低到高默认绑定独立调用函数时非严格模式下 this 指向 window/globalThis严格模式下是 undefined。隐式绑定函数作为对象的方法调用时this 指向该对象。显式绑定call、apply、bind 手动指定 this。new 绑定通过 new 调用构造函数时this 指向新创建的对象。优先级关系new 绑定 显式绑定 隐式绑定 默认绑定。最容易丢分的是隐式绑定丢失的场景const obj { name: obj, fn() { console.log(this.name); }, }; const test obj.fn; test(); // undefined 或报错this 指向 window/undefined把方法单独取出来调用隐式绑定就丢了回到了默认绑定。setTimeout 传回调时也一样setTimeout(obj.fn, 100); // this 指向 window/undefined修复方法是用箭头函数或者 bindsetTimeout(() obj.fn(), 100); // 或 setTimeout(obj.fn.bind(obj), 100);这里自然过渡到箭头函数。箭头函数没有自己的 this它的 this 由外层作用域决定而且一经确定不可改变——所以 call/apply/bind 对箭头函数无效。这个特性让箭头函数在处理回调场景时非常省心但也带来了一个面试高频题箭头函数能不能当构造函数答案是不能。因为它没有自己的 this也没有 prototype 属性new 一个箭头函数会直接报错。3. 原型链与继承JS 面向对象的核心考区3.1 原型链的查找机制原型链是 JS 里最抽象、最劝退的一类知识点但也是面试官检验候选人“有没有系统学过 JS”的分水岭。很多人能说出“对象通过__proto__向上找”但说不清楚prototype和__proto__到底是什么关系。首先要区分三个容易混的概念prototype函数才有的属性指向一个对象这个对象会作为该函数构造出来的实例的原型。__proto__每个对象都有的属性除了Object.create(null)创建的对象指向它自己的原型。constructor原型对象上的一个属性指回构造函数本身。当访问一个对象的属性时JS 引擎的查找顺序是先查自身属性查不到就顺着__proto__往上找一直找到Object.prototype再往上就是null查找结束。function Person(name) { this.name name; } Person.prototype.sayHi function () { console.log(Hi, this.name); }; const p new Person(张三); p.sayHi(); // Hi, 张三为什么 p 能调用到 sayHi因为p.__proto__就是Person.prototype所以沿着原型链找到了 sayHi。而p.toString能调用是因为继续向上找到了Object.prototype.toString。面试官此时喜欢问原型链的应用场景最典型的就是属性共享和方法共享。你在构造函数的 prototype 上定义的方法所有实例共享同一个函数引用不会每个实例都复制一份这在内存上非常高效。这也是为什么“方法定义在 prototype 上”是常见的写法约定。还会追问的一个点是instanceof的工作原理。instanceof会沿着左侧对象的原型链查找看右侧函数的 prototype 是否出现在这条链上。所以p instanceof Person; // true p instanceof Object; // true这两个都是 true因为 Person.prototype 和 Object.prototype 都在 p 的原型链上。这也顺带解释了为什么所有对象都能调用 Object 的方法——它们都能在原型链末端找到 Object.prototype。3.2 五种继承方式对比与手写继承的实现方式是 JS 面试的经典保留项目从 ES5 到 ES6 演化出了一系列方案。我给候选人面试时通常期望听到的不是“我会用 class extends”而是能讲清楚各种继承方式的优缺点以及为什么最终落到寄生组合继承。先快速过一下五种继承方式继承方式核心代码思路主要缺点原型链继承Child.prototype new Parent()父类引用类型属性会被所有实例共享且无法向父类构造函数传参构造函数继承在 Child 中执行Parent.call(this)无法继承 Parent.prototype 上的方法组合继承原型链 构造函数Child 中 call 父类Child.prototype 指向 new Parent()父类构造函数被调用了两次子类原型上有冗余属性寄生组合继承Child.prototype Object.create(Parent.prototype)并修正 constructor最优解ES6 class 的底层原理ES6 class extendsclass Child extends Parent super本质上是寄生组合继承的语法糖这里重点看寄生组合继承的标准实现面试手写题如果考到直接写这个版本function inheritPrototype(Child, Parent) { const prototype Object.create(Parent.prototype); prototype.constructor Child; Child.prototype prototype; } function Parent(name) { this.name name; } Parent.prototype.sayName function () { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); this.age age; } inheritPrototype(Child, Parent); Child.prototype.sayAge function () { console.log(this.age); };有人会问为什么不用Child.prototype new Parent()这种更简单的写法原因是new Parent()会执行父类构造函数在子类原型上留下一份冗余的实例属性而Object.create(Parent.prototype)只是让子类原型继承父类原型的方法不执行父类构造函数干净又高效。ES6 的 class 底层逻辑就是这个面试官如果追问“class 和 ES5 继承有什么区别”可以从两个层面答一是语法层面class 更接近传统面向对象语言的写法二是底层行为class 继承必须调用 super而且 class 类内的代码自动处于严格模式类声明不会提升new 之前必须定义类。4. 事件循环与 Promise异步编程的高频考区4.1 宏任务与微任务的执行顺序异步编程是前端面试的重头戏JavaScript 是单线程语言但通过事件循环机制实现了“看起来像多线程”的能力。面试官常用的考法是给你一段混合 setTimeout、Promise、async/await 的代码让你写出输出顺序。这种题看着复杂其实只要掌握事件循环的调度规则就能稳拿分。核心规则可以拆成四步每轮事件循环先从宏任务队列里取出一个宏任务执行首次执行整段脚本算作第一个宏任务。宏任务执行过程中遇到微任务则放入微任务队列遇到新的宏任务则放入宏任务队列。当前宏任务执行完清空整个微任务队列微任务中产生的微任务也会在这一轮全部执行完。微任务清空后进行渲染更新时机是每帧渲染前再取下一个宏任务。先上经典例题console.log(script start); setTimeout(() { console.log(setTimeout 1); }, 0); Promise.resolve() .then(() { console.log(promise 1); }) .then(() { console.log(promise 2); }); setTimeout(() { console.log(setTimeout 2); }, 0); console.log(script end);输出顺序是script start → script end → promise 1 → promise 2 → setTimeout 1 → setTimeout 2。原理不复杂script start 和 script end 是同步代码先执行两个 setTimeout 是宏任务进入宏任务队列Promise 的 then 是微任务进入微任务队列。同步代码执行完后清空微任务队列所以 promise 1 和 promise 2 先于两个 setTimeout 输出。两个 setTimeout 之间按进入队列的顺序执行所以 setTimeout 1 在前。关于 Promise 和 setTimeout 谁先执行的问题从上面例子来看是 Promise 先但这只在“同步代码后的第一轮微任务清空”的情况下成立。如果你在 setTimeout 里再创建 Promise那就要看定时器何时回调。这种嵌套场景特别容易考建议练习时自己多组合几段代码。微任务里还有一个细节process.nextTickNode 环境优先级高于 Promise 的 then。浏览器环境没有 nextTick只有queueMicrotask。如果你在简历里写了 Node 后端经验面试官可能会顺带问 Node 的事件循环有哪几个阶段比如 timers、poll、check 这几个阶段的大致分工建议准备 Node 方向的同学也复习一遍。4.2 Promise 的常见 API 与手写Promise 相关的手写题在面试中出现的频率极高常见的包括手写 Promise.all、手写 Promise.race、手写一个简易版 Promise以及 async/await 的原理说明。先看一组易混 API 的对比API行为适用场景Promise.all全部成功才成功返回结果数组一个失败就立即失败多个请求之间无依赖但需要同时等待Promise.allSettled等所有 Promise 都结束返回每个成功/失败的结果不关心失败只关心全部状态Promise.race第一个结束的 Promise 决定结果无论成功失败请求超时控制Promise.any第一个成功的 Promise 决定结果全部失败才失败多接口资源取最快成功的笔试常错题是 Promise.all 的“快速失败”行为。注意它是“立即失败”但其他 Promise 并不会被取消它们仍然在后台继续执行只是你不会再收到它们的结果。这个点需要跟面试官明确说出来会显得你理解更深。手写 Promise.all 是高频手写题一个标准实现如下function promiseAll(promises) { return new Promise((resolve, reject) { const results []; let count 0; promises.forEach((promise, index) { Promise.resolve(promise).then( (value) { results[index] value; count; if (count promises.length) { resolve(results); } }, (reason) { reject(reason); } ); }); }); }这里有两个关键细节一是用Promise.resolve(promise)包装兼容传入非 Promise 的普通值二是结果数组按下标存储保证结果顺序与入参顺序一致而不是完成顺序。这两点都是面试官会追问的“考点”。async/await 的本质也要说清楚它是 Generator Promise 的语法糖。await 后面的表达式会被包装成 Promiseawait 的下一行代码相当于 then 回调。所以 async 函数内部任何异常都会被包装成 reject 的 Promise你可以在外部用 try/catch 或 .catch 捕获。5. 数组与字符串高频方法笔试与手写的重灾区5.1 数组方法全家桶哪些会改变原数组哪些不会数组方法这块面试官很少直接问你“map 和 forEach 有什么区别”而是通过输出题、手写题来考察。比如给出[1,2,3].map(parseInt)这种经典题问输出结果。这题考的是 map 回调函数的参数map 会传入当前值当前索引数组本身而 parseInt 的第二个参数是 radix所以 parseInt(1, 0)、parseInt(2, 1)、parseInt(3, 2) 的结果分别是 1、NaN、NaN。所以先把“会修改原数组”和“不会修改原数组”的方法按阵营理清分类方法说明修改原数组push、pop、shift、unshift、splice、reverse、sortsplice 用于增删改sort 默认按字符串编码排序不修改原数组map、filter、reduce、forEach、find、findIndex、some、every、includes、flat、slice返回新数组或布尔值/元素forEach 和 map 的区别要能说清楚forEach 只是为了遍历没有返回值或返回 undefinedmap 会把每次回调的返回值收集成一个新数组。所以需要“把原数组转换成一个新数组”的时候就选 map不需要结果时用 forEach。reduce 是面试中的进阶考点它可以在一次遍历中完成很多逻辑。最常见的用法是求和const sum [1, 2, 3, 4].reduce((acc, cur) acc cur, 0); // 10还有“按属性分组”这个高频场景const grouped people.reduce((acc, person) { const key person.city; if (!acc[key]) { acc[key] []; } acc[key].push(person); return acc; }, {});这个方法如果你不熟悉可以先在本地写几遍。reduce 能做很多事情包括扁平化数组、管道函数组合、统计出现次数等。面试官如果问“数组扁平化有几种写法”你可能用到 flat、reduce concat、JSON 字符串替换或者递归实现。这道题考察的其实是“对一个 API 的熟练度和边界情况考虑”。字符串方法在热点词里也出现了“js replace 方法”“js 字符串长度”这类词说明这是新手面试容易踩坑的地方。replace 最容易错的地方是“默认只替换第一个匹配”必须用正则加全局标志 g 才能替换全部hello world hello.replace(hello, hi); // hi world hello hello world hello.replace(/hello/g, hi); // hi world hi字符串长度这块也有个常见误解hello.length是 5 没争议但.length也是 2因为 emoji 是双字节字符。遇到 UTF-16 代理对想准确统计用户看到的字符数量得用Array.from(str).length或者展开运算符[...str].length。这些小细节很容易被深挖。5.2 手写题中的高频考点防抖、节流与深拷贝手写题是前端面试的“压轴题”面试官想看的不是你能不能默写代码而是你思考问题的过程。这里挑三个最高频的面试手写题详细讲。第一个是防抖debounce和节流throttle。这两个概念的核心区别是防抖是“在一段连续触发后只执行最后一次”节流是“在一段时间内只执行一次”。防抖适合搜索框输入节流适合滚动事件。防抖实现function debounce(fn, delay 300) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }这里有个关键细节是fn.apply(this, args)它保证回调里的 this 指向调用者而不是 window。很多候选人写手写题时忽略 this 和参数的处理面试官一眼就能看出来你是背的模板。节流实现时间戳版本function throttle(fn, interval 300) { let lastTime 0; return function (...args) { const now Date.now(); if (now - lastTime interval) { lastTime now; fn.apply(this, args); } }; }如果需要“第一次立即执行、停止后还能再执行一次”这种边缘行为就要用定时器版本或者时间戳 定时器组合。面试时能主动说出这两种方式的差异很加分。第二个是深拷贝。手写深拷贝通常的要求是能处理对象、数组、循环引用、函数、Date、RegExp 这些常见类型。一个比较标准的递归版本function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } if (map.has(target)) { return map.get(target); } const clone Array.isArray(target) ? [] : {}; map.set(target, clone); Reflect.ownKeys(target).forEach((key) { clone[key] deepClone(target[key], map); }); return clone; }用 WeakMap 是为了解决循环引用问题const obj {}; obj.self obj;如果不用 map 记录已克隆对象会无限递归直接爆栈。这个细节是手写深拷贝的经典考点。面试里常见的浅拷贝方式也要能说出来展开运算符{...obj}、Object.assign、数组的slice和concat。它们都只是拷贝了第一层属性嵌套对象仍然是引用。还有一个容易忽略的JSON.parse(JSON.stringify(obj))能实现深拷贝但会丢失函数、undefined、Symbol 属性并且 Date 会变成字符串、RegExp 会变成空对象所以有特殊类型的对象不能用它。第三个高频题是数组去重。解法有很多从最早的双循环、indexOf到 Set、filter Map再到要考虑对象去重、NaN 去重层层递进。最简单的 Set 版如下const unique [...new Set([1, 2, 2, 3, NaN, NaN])]; // [1, 2, 3, NaN]这里有个冷知识Set 对 NaN 的判断是“认为 NaN 等于 NaN”所以 NaN 去重可以成功。但[NaN].indexOf(NaN)返回 -1因为 indexOf 用的是严格相等而 NaN ! NaN。这种细节很能体现候选人对 JS 语义的掌握程度面试官一旦问到你就能顺手举出来。字符串的“判断是否包含”、“扩展运算符合并数组”也是在热搜词里反复出现的 JS 基础操作我顺便提一下答案字符串判断包含首选includes其次是indexOf扩展运算符合并数组就是const merged [...arr1, ...arr2]相比 concat 更直观也经常用于浅拷贝数组。6. 面试答题的组织思路与避坑经验6.1 面试官视角什么样的回答能拿高分我以面试官视角分享一下真实感受大部分候选人不是在“答错”而是“不会组织答案”。比如问闭包候选人直接说“函数套函数”这种回答等于没答。真正高分回答是有一个清晰的框架讲本质闭包是函数与其词法环境的组合讲机制内部函数引用了外部函数的变量外部执行上下文销毁后变量仍被引用讲应用防抖、节流、私有变量、柯里化讲注意点内存占用问题、排查方法。这个框架同样适用于原型链、事件循环等所有知识点。先用一句话给结论再展开机制接着举例最后说坑。面试官其实很怕候选人“背了结论但不会展开”所以你的回答要尽量做到“由浅入深、有代码、有场景”。另外一个加分项是主动交代边界条件和限制。比如讲深拷贝时主动说“JSON.parse 方案会丢函数和 undefined所以这类实现需要递归”讲 Promise.all 时主动说“一个失败立即 reject但其他 Promise 不会被取消”。主动暴露“我知道这个方案的局限”恰恰说明你真的用过而不是只背了文档。6.2 常见的踩坑点和复习建议准备 JS 面试时除了背八股文我强烈建议你动手手写一遍并且用浏览器跑一遍输出题。有几个踩坑点值得重点练习parseInt和map组合的坑[1, 2, 3].map(parseInt)的结果是[1, NaN, NaN]原因之前说过是 radix 参数被索引覆盖。想避免的话写map((item) parseInt(item))或者直接用Number。sort默认按字符串排序[10, 9, 100].sort()得到[10, 100, 9]因为比较的是字符串编码。要传比较函数(a, b) a - b才能按数字大小排。和的隐式类型转换null undefined是 true但null undefined是 false。0 false也是 true。实际开发中永远用严格相等不会做隐式转换。map、filter并不会跳过稀疏数组的坑forEach会跳过空位但map会保留空位并用回调处理非空位。比如[1, , 3].map(x x * 2)的结果是[2, , 6]中间那个空位仍然存在。复习建议上我会推荐一个顺序先过基础语法题变量提升、类型转换、作用域再过闭包和 this这两块是“理解型知识”不会就是真不会接着过原型链和继承这块偏“抽象记忆”需要画图辅助理解最后是异步和手写题这块需要大量刷题来形成肌肉记忆。把每个知识点都整理成一个“结论 机制 例子 坑”的小卡片面试前翻一遍比临时抱佛脚背大段文档高效得多。6.3 一个用来自检的综合输出题最后给大家留一道综合练习题它几乎覆盖了前面讲到的所有核心考点async function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); async1(); new Promise((resolve) { console.log(promise executor); resolve(); }).then(() { console.log(promise then); }); console.log(script end);我在面试中见过这道题的变体出现很多次它同时考了同步代码执行顺序、async/await 的暂停与恢复、宏任务与微任务的调度。输出结果是script start → async1 start → async2 → promise executor → script end → async1 end → promise then → setTimeout。这里最容易错的是 async1 end 和 promise then 的顺序。记住 await 会交出线程控制权async1 end 是作为微任务排在 await 之后的。从“同步代码结束后先清空微任务队列”的角度看async1 end 和 promise then 都属于微任务谁先进入队列谁先执行所以 async1 end 在前。大家可以在本地跑一下亲眼看效果比背答案有用得多。前端面试的 JS 八股文是一个整体这几个核心专题弄明白后衍生出来的手写题和场景题基本都能应付。下一篇我会接着整理 Promise 进阶、事件循环在 Node 端的差异、以及更多场景面试题大家先把这一篇吃透再说。
返回列表