资讯动态

JS三大拦路虎:闭包、原型链与事件循环面试深度解析

发布时间:2026/9/10 7:26:08 来源:尧图企业网站定制
看过太多人在字节一面栽跟头回头复盘时发现问来问去就那么几个点闭包、原型链、事件循环。说它们是三大拦路虎一点不夸张。这三块既是 JS 语言的核心机制也是面试官判断候选人有没有真正理解 JavaScript的最快路径。这篇我按字节一面最常见的提问角度把闭包、原型、事件循环从底层原理到面试变形题全部捋一遍。不是单纯背答案而是把为什么讲透顺便把我自己面试别人时踩过的典型误区也翻出来帮你避开那些让面试官瞬间失去兴趣的回答。1. 闭包面试最爱问但八成人的理解是错的1.1 闭包到底是什么不是函数套函数这么简单很多人一被问闭包第一反应就是一个函数里面返回另一个函数。这话对吗对了一半但完全没说到本质。闭包的本质是当一个函数能够记住并访问它定义时的词法作用域Lexical Scope即使这个函数在定义作用域之外执行这个组合就叫闭包。这里的关键词是词法作用域。JS 的作用域在代码编写阶段就确定了不是调用的时候才决定的。函数内部引用的变量在函数定义的那一刻就通过作用域链锁定了它出生的地方。闭包就是把这个出生的地方一直带在身边。我举一个最典型的例子function outer() { let count 0; return function inner() { count; return count; }; } const fn outer(); console.log(fn()); // 1 console.log(fn()); // 2 console.log(fn()); // 3outer执行完后count这个局部变量按理说应该被垃圾回收了。但因为inner函数还在被fn引用着而inner的作用域链里还挂着count所在的变量对象所以count不会被回收它会一直活在内存里。这就是闭包的核心它把变量的生命周期延长了。面试的时候我建议你主动说出这个点闭包本质上是通过作用域链保留了对父级作用域变量的引用使得变量不随函数执行结束而销毁。这句话一出来面试官基本就知道你不是背的。1.2 闭包的三大经典应用场景光会背定义不够面试官一定会问闭包有什么实际应用。这里我整理三个最常被问到的场景每个都要能当场写出来。场景一数据私有化ES6 之前没有真正的私有变量闭包是实现私有的主流方式function createCounter() { let count 0; return { increment: function() { count; return count; }, decrement: function() { count--; return count; }, getCount: function() { return count; } }; } const counter createCounter(); counter.increment(); counter.increment(); console.log(counter.getCount()); // 2 // 外部无法直接访问 count只能通过暴露的方法操作 console.log(counter.count); // undefined这个模式至今仍在无数项目里使用。出去面试时可以说闭包可以实现类似私有变量的效果因为外部作用域无法直接访问闭包内部的变量只能通过暴露的接口操作。场景二循环中保存变量这个几乎是必考尤其是var时代的经典问题// 经典错误写法 for (var i 0; i 3; i) { setTimeout(function() { console.log(i); // 输出 3 3 3 }, 1000); } // 闭包修正 for (var i 0; i 3; i) { (function(index) { setTimeout(function() { console.log(index); // 输出 0 1 2 }, 1000); })(i); } // 用 let 也能解决原理是每次循环创建新的块级作用域 for (let i 0; i 3; i) { setTimeout(function() { console.log(i); // 输出 0 1 2 }, 1000); }这里要能解释清楚为什么第一种会输出 3 3 3setTimeout的回调函数在宏任务中执行此时循环早已结束i已经变成了 3。而var声明的变量是函数级作用域三个回调引用的都是同一个i。闭包的修法是通过 IIFE 每次传入当时的i形成一个新的作用域每个回调绑定的都是自己那次循环的副本。场景三函数柯里化function multiply(a) { return function(b) { return a * b; }; } const multiplyBy2 multiply(2); console.log(multiplyBy2(5)); // 10 console.log(multiplyBy2(8)); // 16柯里化就是闭包的典型应用把多参数函数变成单参数链式调用每层函数都通过闭包把之前的参数记录下来。1.3 闭包面试必问的坑循环、内存泄漏与防抖节流面试官问到闭包大概率还会追加这几个问题坑一循环 闭包 事件绑定的组合题阿里和字节都喜欢把闭包、事件绑定、作用域串起来考// 假设有 3 个按钮想点击第几个就输出第几个 var buttons document.querySelectorAll(button); // 错误写法无论点哪个都输出 3 for (var i 0; i buttons.length; i) { buttons[i].onclick function() { console.log(i); }; } // 修正闭包保存索引 for (var i 0; i buttons.length; i) { (function(index) { buttons[index].onclick function() { console.log(index); }; })(i); }坑二内存泄漏问题闭包确实可能造成内存泄漏但不是闭包本身的错而是不正确的使用方式。比如function someFunction() { const largeData new Array(1000000).fill(0); return function() { console.log(hello); }; }如果返回的函数不再需要largeData但它仍挂在闭包里这块大内存就一直被占用。在 Chrome 等现代引擎的优化下这种情况有时会优化掉但依赖引擎行为是危险的。正确的做法是大型数据不需要时把引用置空或者避免在闭包里持有大对象。坑三防抖节流的实现防抖和节流本质都是闭包在保存状态// 防抖触发后等待 delay 毫秒再执行期间再次触发则重新计时 function debounce(fn, delay) { let timer null; return function(...args) { if (timer) 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); } }; }timer和last就是闭包捕获的变量每次调用返回的函数都能看到最新的值。能把闭包和防抖节流联系起来说面试官会很满意。2. 原型与原型链一图胜千言但要会画会讲2.1 原型概念的来龙去脉为什么 JS 要搞出个原型ES6 之前 JS 没有class关键字实现继承全靠原型机制。但很多刚学 JS 的人觉得原型很难懂是因为课本总是一上来就抛概念不讲为什么需要原型。想象一下你要造一批结构相似的对象每个对象都有name和sayHello方法。如果每个对象都复制一份sayHello内存浪费就太严重了。原型的设计思路是把公共的属性和方法放在一个共享的对象即原型上其他对象通过链接去共享它。这个链接就是隐式原型__proto__而每一层共享的对象通过链接串起来就形成了原型链。一句大白话原型就是一个共享的仓库对象需要什么公共能力就去仓库里拿仓库里没有就去仓库的仓库里找。2.2 三条链的完整关系prototype、proto、constructor 纠缠不清这里必须把三个核心属性捋清楚任何含糊都会在面试中被抓住prototype函数才有的属性指向该函数的原型对象。__proto__每个对象包括函数都有的属性指向创建它的构造函数的prototype。constructor原型对象上的属性指回构造函数。看这段代码function Person(name) { this.name name; } Person.prototype.sayHello function() { console.log(Hello, I am this.name); }; const p1 new Person(Alice); console.log(p1.__proto__ Person.prototype); // true console.log(Person.prototype.constructor Person); // true console.log(Person.prototype.__proto__ Object.prototype); // true console.log(Object.prototype.__proto__); // nullp1没有sayHello方法但访问p1.sayHello()时JS 先在p1自己身上找找不到就顺着p1.__proto__找到Person.prototype发现sayHello在这里于是调用。原型链的终点是Object.prototype.__proto__值为null。查找过程若一路到null都没找到就返回undefined。为了帮忙记忆我在面试中一般是这么描述的每个函数都有 prototype 属性指向一个原型对象每个实例对象都有proto属性指向构造它的函数的 prototype。这个proto一层层串上去形成原型链查属性的过程就是沿着这条链逐级查找直到找到或走到 null 为止。2.3 new 操作符的五步之内做了什么new的原理也是高频考点而且它和原型是连在一起的function myNew(constructor, ...args) { // 1. 创建一个新对象 const obj {}; // 2. 将新对象的 __proto__ 指向构造函数的 prototype Object.setPrototypeOf(obj, constructor.prototype); // 3. 让构造函数内部的 this 指向新对象并执行构造函数 const result constructor.apply(obj, args); // 4. 如果构造函数返回的是对象则返回该对象否则返回新建的 obj return result instanceof Object ? result : obj; } // 使用 const p2 myNew(Person, Bob); p2.sayHello(); // Hello, I am Bobnew把this绑定到新对象上并且为新对象设置了__proto__从此新对象就能顺着原型链找到构造函数原型上的方法。手写new这个问题字节也常考花十分钟把它背得滚瓜烂熟非常值。2.4 原型链继承与 class 继承的区别ES6 的class本质是语法糖底层还是原型链。但面试官会问两者区别// ES6 写法 class Animal { constructor(name) { this.name name; } eat() { console.log(this.name is eating); } } class Dog extends Animal { constructor(name) { super(name); this.type dog; } bark() { console.log(this.name is barking); } } const dog new Dog(Wangcai); dog.eat(); // Wangcai is eating dog.bark(); // Wangcai is barkingDog的实例通过原型链可以访问Animal.prototype上的eat方法所以顺着原型链就实现了继承。super的作用是让父类构造函数在子类this上执行一遍完成父类实例属性的初始化。记住一个关键点class 的 extends 语法糖背后仍然是 原型链 连接dog.proto.proto Animal.prototype 依然成立。注意字节面试官可能还会追问原型链上hasOwnProperty和in的行为区别。hasOwnProperty只检查实例自身属性in会沿原型链查找包括继承的属性。这个细节不少三年经验的人也答不完整。2.5 高频变形题instanceof 的原理、手写继承instanceof的原理就是沿着原型链查找function myInstanceof(left, right) { let proto Object.getPrototypeOf(left); const prototype right.prototype; while (true) { if (proto null) return false; if (proto prototype) return true; proto Object.getPrototypeOf(proto); } } console.log(myInstanceof(dog, Dog)); // true console.log(myInstanceof(dog, Animal)); // true console.log(myInstanceof(dog, Object)); // true console.log(myInstanceof(dog, Array)); // false手写继承也是经典题。最稳妥的方式是组合继承同时覆盖原型链和构造函数属性function Parent(name) { this.name name; this.hobbies [reading]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); // 继承实例属性 this.age age; } Child.prototype Object.create(Parent.prototype); // 继承原型方法 Child.prototype.constructor Child; // 修正 constructor 指向 Child.prototype.sayAge function() { console.log(this.age); };用Object.create(Parent.prototype)而不是Child.prototype Parent.prototype是为了避免子类改原型时污染父类原型。3. 事件循环从进程线程到宏微任务的完整链路3.1 为什么 JS 是单线程的不是缺陷是设计首先得明白一个底层前提JS 是单线程的。为什么因为 JS 最初诞生在浏览器里它的主要职责是操作 DOM、响应交互。如果 JS 是多线程的两个线程同时操作同一个 DOM浏览器根本不知道该听谁的一致性没法保证。单线程带来一个必然结果如果一个任务卡住了后面的任务全部排队等待页面就会无响应。为了解决单线程不能阻塞的问题事件循环Event Loop这套机制就诞生了。可以类比餐厅里只有一个服务员一个客人菜没上完服务员不能一直等他而是先把其他桌的需求处理完等空闲了再回来服务这位客人。3.2 执行栈、任务队列、微任务与宏任务的完整拆解事件循环的执行过程可以分解成四步执行当前执行栈中的所有同步代码碰到异步 API如setTimeout、Promise、事件回调时交给浏览器宿主环境处理。宿主环境把回调放到对应队列宏任务回调放进宏任务队列微任务回调放进微任务队列。同步代码全部执行完毕后先清空微任务队列直到微任务队列为空。再从宏任务队列取出一个宏任务执行执行完后再次清空微任务队列如此循环往复。宏任务有哪些setTimeoutsetIntervalsetImmediateNode 环境I/O 操作UI 渲染MessageChannel微任务有哪些Promise.then/Promise.catch/Promise.finallyprocess.nextTickNode 环境优先级更高MutationObserverqueueMicrotask两者的关系用一句话概括每执行一个宏任务都会先去清空微任务队列微任务执行过程中产生的新微任务也会在当前清空过程中一并执行完毕。3.3 老生常谈的经典输出题一步步推演来一道字节一面出现率极高的题看你能不能一次推对console.log(start); setTimeout(() { console.log(timeout1); Promise.resolve().then(() { console.log(promise1); }); }, 0); Promise.resolve().then(() { console.log(promise2); }); console.log(end);推演步骤同步代码先输出start然后end。宏任务队列setTimeout回调入队。微任务队列promise2入队。同步代码执行完清空微任务输出promise2。取出宏任务timeout1执行输出timeout1发现内部又产生了一个微任务promise1。当前宏任务执行完立即清空微任务输出promise1。最终输出顺序start-end-promise2-timeout1-promise1。注意微任务是在当前宏任务结束后、下一个宏任务开始前清空的。哪怕timeout1是后加入的它内部的微任务也会在它自身宏任务结束后立即执行这解释了为什么promise1排在promise2之后但仍在其他宏任务之前。再上一个更复杂的版本检验你是否真的掌握console.log(1); setTimeout(() { console.log(2); Promise.resolve().then(() { console.log(3); }); }, 0); new Promise((resolve) { console.log(4); resolve(5); }).then((value) { console.log(value); }); setTimeout(() { console.log(6); }, 0); console.log(7);推演结果同步输出 1创建 Promise 执行器立即执行输出 4输出 7。微任务队列then中的 5。宏任务队列第一个setTimeout2、第二个setTimeout6。清空微任务输出 5。执行第一个宏任务输出 2内部产生微任务 3执行结束立即清空输出 3。执行第二个宏任务输出 6。最终1 - 4 - 7 - 5 - 2 - 3 - 6。这里考核重点是new Promise执行器内的代码是同步的then回调才是微任务。如果能立刻反应过来这句话这类题就稳了。3.4 async/await 事件循环题await 后面的代码什么时候执行async/await是 Promise 的语法糖但它在事件循环里的行为有个非常容易被忽略的细节。await后面的代码并不像有些人理解的那样立刻继续执行而是相当于放进了一个.then()回调里。见题async function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } console.log(script start); (async function() { await async1(); console.log(async1 awaited done); })(); new Promise((resolve) { console.log(promise1); resolve(); }).then(() { console.log(promise2); }); console.log(script end);同步阶段输出script start。调用 IIFE内部await async1()执行async1输出async1 start。执行async2输出async2async2执行完返回一个 resolved 的 Promise。await async1()让出线程async1 end被放入微任务。继续执行外层同步代码new Promise执行器立即执行输出promise1.then入微任务输出script end。清空微任务先输出async1 end再输出promise2最后输出async1 awaited done。完整输出script start - async1 start - async2 - script end - async1 end - promise2 - async1 awaited done关键点await之后的代码等同于微任务谁先进入微任务队列谁先执行。这里async1 end比promise2先入队所以先输出。3.5 浏览器和 Node 事件循环的差异一面加分项字节一面偶尔会问浏览器和 Node 的事件循环有什么不同最主要差异在宏任务的优先级处理上。浏览器的宏任务队列可以粗略看成先进先出但 Node 的宏任务队列分为多个阶段timerssetTimeout/setIntervalpending callbacks系统级回调idle/prepare内部使用pollI/O 回调checksetImmediateclose callbacks关闭回调process.nextTick比较特殊它不属于微任务队列但在每个阶段之间都会执行优先级比微任务还高。浏览器的微任务在每个宏任务结束后都会清空Node 10 及以后版本的行为也趋近于浏览器每个阶段结束后、进入下一阶段前会清空微任务和nextTick。提示如果面试官追问Node 和浏览器事件循环输出顺序哪里可能不同可以从setImmediate和setTimeout的相对顺序、process.nextTick的优先级差异两个角度展开。4. 三大拦路虎的串讲与实战演练4.1 组合题闭包 原型 事件循环一起考字节一面非常喜欢出组合题。拿一道典型的function Foo() { this.name foo; this.tasks []; for (let i 0; i 3; i) { this.tasks.push(function() { console.log(this.name i); }); } } Foo.prototype.getName function() { console.log(this.name); }; const foo new Foo(); setTimeout(function() { foo.getName(); }, 0); foo.tasks.forEach(function(task) { task(); });这道题同时考察了闭包let的块级作用域、new 与原型链foo.getName的来源、事件循环setTimeout的执行时机。执行顺序分析同步代码for循环创建三个任务函数因为let块级作用域每个i都被独立保存。setTimeout回调进入宏任务队列。循环执行task()函数独立调用时this指向全局严格模式下是 undefined因此this.name为 undefined。但由于let i形成了闭包i分别是 0、1、2。输出undefined0、undefined1、undefined2。宏任务执行foo.getName()通过原型链找到getName方法调用时this指向foo输出foo。最终顺序undefined0 - undefined1 - undefined2 - foo。如果面试时能准确分析出为什么this.name是 undefined、为什么getName能找到、为什么setTimeout最后执行这道题就过了一大半。4.2 一道真正难度的经典大题全链路输出预测把三个知识点彻底融合试试这道function Animal(name) { this.name name; } Animal.prototype.speak function() { console.log(this.name makes a sound.); }; function Dog(name) { Animal.call(this, name); } Dog.prototype Object.create(Animal.prototype); Dog.prototype.constructor Dog; Dog.prototype.speak function() { console.log(this.name barks.); }; const dog new Dog(Rex); Promise.resolve() .then(() { console.log(microtask 1); dog.speak(); }) .then(() { console.log(microtask 2); setTimeout(() { console.log(inside timeout); }, 0); }); setTimeout(() { console.log(timeout); dog.speak(); }, 0);推演同步代码建立原型链。遇到微任务.then入队、宏任务setTimeout入队。清空微任务输出microtask 1调用dog.speak()Dog 原型上有speak输出Rex barks.。继续执行微任务microtask 2输出后又把inside timeout推入宏任务队列。宏任务队列现有两个回调先入队的timeout、后入队的inside timeout。依次执行输出timeout、Rex barks.最后输出inside timeout。最终输出microtask 1 - Rex barks. - microtask 2 - timeout - Rex barks. - inside timeout。这个例子特别能说明微任务全部清空后才取宏任务的顺序原则。很多人在第二轮的inside timeout上出错是因为没意识到宏任务队列里已经有排队的任务新加入的要排在后面。4.3 我作为面试官最想听到的回答路径字节一面问这些知识点时面试官其实不是在考你会不会背诵而是在观察你的思维路径。我面试候选人时最想听到的答案是这种结构先给结论这个概念是什么一句话说清。再给原理它为什么这么设计底层的机制是什么。然后给代码现场写出最小可运行示例。最后给应用/坑实际项目中哪里会用到哪里容易出错。以闭包为例我最理想的回答是闭包是函数与其词法作用域的组合。JS 的函数在定义时就确定了作用域链即使函数在外部执行也能访问定义时的变量。它最常见的应用是数据私有化和函数柯里化。要注意的是闭包不会自动释放被引用的变量如果不小心让不被使用的对象长期留在闭包里就可能造成内存泄漏。这套结构能明显拉开和普通候选人的差距。5. 刷题之外的准备策略从会做题到真正理解5.1 复习路径规划按优先级排序的三层知识体系如果距离面试只剩一两周建议按这个优先级来第一层必会保命层闭包的定义、三大应用场景、循环陷阱的修复原型链的完整通路、手写new、手写instanceof事件循环的宏微任务顺序、经典输出题的推演第二层加分拉开差距层组合继承与Object.create的原型链细节async/await事件循环变形题防抖节流的闭包实现第三层进阶防深度追问浏览器与 Node 事件循环差异微任务执行过程中产生新微任务时的顺序原型链上方法遮蔽shadowing的具体行为每层之间最好配合代码去验证不要只看文章。自己写.html文件跑一遍输出比看十篇博客都管用。5.2 高频面试题的自我训练题集把下面的题目当成模拟面试题每道都要求自己用结论 原理 代码 应用/坑四段式回答一遍什么是闭包它是如何形成的闭包常见的应用场景有哪些举个例子。闭包会内存泄漏吗怎么避免什么是原型链属性查找的过程是怎样的new操作符做了什么手写实现。instanceof的原理是什么手写实现。如何实现继承组合继承的缺点是什么什么是宏任务和微任务分别有哪些setTimeout、Promise、async/await混用时的执行顺序是什么浏览器和 Node 的事件循环有什么区别这十道题如果都能顺畅答完字节一面核心知识关基本不会有太大障碍。5.3 我踩过的坑和一点真诚的建议我在早期准备面试时有一个很大的误区盯着各种偏题怪题刷但闭包、原型、事件循环的底层逻辑没吃透。结果是刷的题都见过但稍微换个问法就慌了。后来才意识到字节这类公司面试的风格特别偏爱变形题和场景题题目本身可能你见过但背后的原理没变。同样一个知识点面试官可以问闭包什么是闭包概念闭包这个循环为什么输出错误排错闭包用闭包实现一个只执行一次的函数。应用如果只会背概念第一关就过不去如果理解了原理三道题都能覆盖。另外强烈建议在面试前自己动手把代码都敲一遍。因为在面试现场如果你卡壳了面试官通常会让你写出来试试。手写代码是最真实的水平反映平时不练到现场必然现出原形。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价