JS进阶三硬核:闭包、原型链与事件循环全面解析
发布时间:2026/10/13 17:22:18 锦皓数字建站

今天是自学路线的第13天正好也是进阶专题的第三篇。前12天我从零开始依次过完了HTML/CSS布局、JavaScript基础语法、DOM事件处理进阶前两篇则分别啃了作用域与函数式编程。Day 13 进阶3给自己定的主题是三块公认的硬骨头闭包、原型链、事件循环。说实话这三块在各类面试题里出现率极高但在日常写页面时又容易被一带而过属于典型的“不考不知道一考全露馅”。这篇笔记把这三大块彻底讲透配了可直接运行的代码和练习适合刚学完JS基础准备进阶的读者也适合想系统复习一遍的开发者。1. Day 13 进阶3的学习定位为什么它是整个入门阶段的关键转折进阶这个系列不是随便定的它是把JavaScript从“会用”推向“理解”的必经之路。Day 13 进阶3之所以重要是因为闭包、原型链、事件循环这三块内容恰好覆盖了JS语言最底层的三条主线函数与作用域的关系、对象与继承的实现机制、异步代码的执行调度。只要这三条线通了后续看框架源码、写组件封装、排查线上问题都会顺手很多。1.1 前12天的路线复盘进阶是搭在基础上的先把我前面12天的安排完整摊开这样你能理解为什么进阶3敢直接上这些硬核内容。我的学习路线不是按教科书章节排的而是按“先用起来再理解原理”的顺序来的。天数主要内容掌握程度Day 1-3HTML基础标签、CSS选择器、盒模型、Flex布局能独立还原静态页面Day 4-6CSS Grid、响应式布局、动画过渡能写出自适应页面Day 7-9JS变量、数据类型、运算符、流程控制、函数基础能写简单交互逻辑Day 10DOM操作、事件监听、表单处理能完成常见页面交互Day 11进阶1作用域、变量提升、暂时性死区能解释代码执行顺序Day 12进阶2高阶函数、柯里化、函数式思想能写出复用性更高的函数Day 13进阶3闭包、原型链、事件循环能解释框架底层机制Day 11 讲作用域的时候我发现很多代码问题是“变量莫名其妙被改了”或者“循环里拿不到想要的索引”这类问题最终都指向闭包。Day 12 讲高阶函数时柯里化和函数工厂本质上也在利用闭包保存状态。所以闭包不是凭空冒出来的概念它是前面作用域知识的自然延伸。原型链则承接了“对象”这个主线。前7天我只知道用对象字面量存数据遇到需要多个相似对象时只会复制粘贴。进阶3里会把“构造函数、原型、实例”这三者的关系彻底讲清楚让对象创建和继承从“复制”变成“委托”。事件循环是Day 13里最偏“运行时”的一块。前面写异步代码时只知道用setTimeout或者事件回调但为什么setTimeout的延迟时间不准、为什么Promise排在setTimeout前面执行这些都需要看事件循环的调度规则。1.2 进阶3三个主题的内在联系语法骨架与运行骨架很多人把这三大块当独立知识点记其实它们之间有非常强的联动关系。先看闭包和原型链闭包是函数与作用域的组合原型链是对象与对象之间的链接。JS里的函数本身就是对象所以一个函数既可以通过闭包保存变量也可以作为构造函数创建实例还能被挂到原型上被其他对象共享。这两者在写类、写模块、做封装时经常同时出现。再看原型链与事件循环浏览器里的DOM事件机制依赖对象体系。比如事件对象、事件目标它们都是通过原型链继承来的。而事件回调是异步执行的回调里的this指向、参数捕获都和闭包、原型链直接相关。我给Day 13定了一个总目标用闭包保护状态用原型链组织对象关系用事件循环理解异步触发时机。三个知识点合在一起才是JS这门语言的完整运行图景。2. 闭包先解决一个真实问题再背定义闭包的定义网上有一堆但我建议你别急着背“函数返回函数”“内部函数引用外部变量”这类结论。先看它到底解决什么问题定义自然就懂了。2.1 从一段最朴素的计数器说起假设你要做一个计数器每点一次按钮数字加一。初级写法通常是这样let count 0; function increment() { count; return count; } console.log(increment()); // 1 console.log(increment()); // 2这个写法能跑但count是全局变量。项目一旦复杂任何地方都能修改count你根本不知道它是在哪一步被改坏的。更专业的做法是把count藏起来只暴露increment函数function createCounter() { let count 0; return function increment() { count; return count; }; } const counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2这里createCounter执行完后局部变量count按理说应该被回收。但实际上increment函数还在使用count所以JS引擎会把这个变量保留在increment函数的“背包装备”里。这个组合就叫闭包一个函数加上它创建时所在的作用域。关键点在于increment不仅能访问count而且每次调用它访问的都是同一个count。如果你再创建一个counter2它会拥有独立的count。这种“一个函数一份私有状态”的能力正是闭包最大的价值。2.2 闭包的生活类比每个函数都背着一个小背包我常用一个类比来帮助理解闭包把函数想象成一个员工函数声明时所在的上下文是一个工位。工位上的抽屉局部变量在员工离开后会清空但如果这个员工手里握着一把抽屉钥匙并且把这个钥匙封装进了自己的技能里那不管他走到哪都能打开那个抽屉。闭包就是这把“钥匙”。它让一个函数在离开创建环境之后依然能访问创建环境里的变量。这个类比还能解释一个常见的注意点闭包保存的不是变量的“快照”而是变量的“引用”。换句话说如果闭包引用的变量值后来变了闭包里拿到的也是新值不是旧值。这个特性在循环里最容易被坑到。2.3 闭包的三个高价值应用场景封装、柯里化、循环捕获闭包最常见的应用场景有三个每一个都值得手写一遍。第一个是数据私有化。借助闭包你可以实现类似其他语言中“private”的效果。上面计数器的例子就是最简单的私有状态。更进一步可以把多个方法包进同一个闭包形成一个模块function createUser(name) { let _name name; return { getName: function () { return _name; }, setName: function (newName) { _name newName; } }; } const user createUser(小明); console.log(user.getName()); // 小明 user.setName(小红); console.log(user.getName()); // 小红外部无法直接访问_name只能通过getName和setName操作这就保证了数据的安全边界。第二个是柯里化。柯里化指把多参数函数转换成一系列单参数函数。它本质上也是在用闭包保存参数function multiply(a) { return function (b) { return a * b; }; } const double multiply(2); console.log(double(5)); // 10 console.log(double(8)); // 16double函数一直“记得”a等于2这全靠闭包在背后支撑。第三个是循环中的变量捕获。这是实际开发里踩得最多的坑for (var i 0; i 3; i) { setTimeout(function () { console.log(i); }, 100); }这段代码的输出是3、3、3而不是0、1、2。原因是setTimeout的回调函数在100毫秒后执行那时循环早已结束i的最终值是3所有回调共享同一个i。用闭包可以修复for (var i 0; i 3; i) { (function (num) { setTimeout(function () { console.log(num); }, 100); })(i); }每次循环执行IIFE时把当前的i作为参数num传入闭包保存的是每个回合的num副本。这样输出就是0、1、2。当然现在用let也能直接解决但理解闭包捕获机制依然是排查这类问题的基本功。2.4 闭包的坑与注意点不是所有闭包都要用闭包好用但代价也要说清楚。第一闭包会让变量长期驻留内存。因为外部函数执行完后内部函数还持有外部变量的引用垃圾回收器不会回收这块内存。滥用闭包尤其在循环里大量创建闭包会导致内存占用上升。我在写代码时会先问自己这里真的需要保存状态吗如果只需要一次性的逻辑就没必要用闭包。第二闭包保存的是引用而不是值。如果闭包指向一个对象对象属性被修改闭包读到的也是修改后的结果。这不是bug而是预期行为但新手容易在对象配置、事件监听时理解偏差。第三事件监听器里使用闭包要小心。如果给DOM元素绑定了闭包回调但移除元素前没有解绑回调会一直持有元素和相关变量的引用造成“游离DOM”内存泄漏。页面长列表频繁增删节点时尤其明显。我个人的做法是闭包优先用于模块封装和参数预置避免在循环体内做不必要的闭包嵌套。需要动态取索引时优先用let声明循环变量既清爽又安全。3. 原型链对象继承不是复制而是委托再来看第二个硬骨头原型链。很多初学者把JS的继承理解成“复制父类的方法到子类”这个方向从头就偏了。JS的对象继承机制不是复制而是委托。3.1 “继承”的真相找不到属性就去问原型抛开书上的术语用日常逻辑解释。当你访问一个对象的属性时JS引擎先在这个对象自身找。如果找不到它会顺着对象的原型对象接着找。原型对象也可能有自己的原型这样一层层往上走直到某层的原型为null。这条链路就是原型链。举个例子const person { name: 小明, greet: function () { console.log(你好我是 this.name); } }; const student Object.create(person); student.name 小红; student.greet(); // 你好我是小红student自身有name属性但greet方法在student身上找不到于是顺着原型链去person对象上找找到了greet就调用。这里student没有复制greet它只是“委托”person来响应greet这个请求。这就是委托式继承的核心。3.2 构造函数、原型、实例三者的关系实际项目里不会老用Object.create更多是用构造函数加new关键字创建对象。这时候会涉及三个角色构造函数、原型对象、实例对象。function Person(name) { this.name name; } Person.prototype.greet function () { console.log(你好我是 this.name); }; const p1 new Person(小明); const p2 new Person(小红); p1.greet(); // 你好我是小明 p2.greet(); // 你好我是小红 console.log(p1.greet p2.greet); // true这里有几个关键结论Person.prototype是原型对象它身上定义的greet方法会被所有实例共享。p1和p2自身没有greet方法但它们通过原型链都能访问到Person.prototype.greet所以两个实例调用的是同一个函数。实例有一个内部指针指向构造函数的prototype对象这个指针在浏览器控制台里一般显示为[[Prototype]]在代码里可以通过Object.getPrototypeOf()来获取。构造函数本身有一个prototype属性而实例没有这个属性只有指向原型的内部指针。用表格可以把这三者的关系列得更直观角色如何访问原型相关内容构造函数通过fn.prototype访问它创建实例时使用的原型对象原型对象通过protoObj.constructor指回构造函数实例对象通过Object.getPrototypeOf(instance)访问它的原型3.3 手写继承的几种方式对比组合继承和Class的区别理解了原型与实例的关系后就能看懂各种继承写法了。这里我不啰嗦每一种的历史细节直接对比几种主流方式的优缺点。第一种是经典的原型链继承function Animal(name) { this.name name; } Animal.prototype.eat function () { console.log(this.name 在吃东西); }; function Dog(name) { this.name name; } Dog.prototype new Animal(); // 让Dog的原型指向Animal实例 Dog.prototype.constructor Dog; const dog new Dog(旺财); dog.eat(); // 旺财在吃东西这种方式的问题是所有Dog实例共享同一个Animal实例里的引用类型属性。如果Animal的实例属性里有数组修改一个Dog实例的数组其他Dog实例也会看到变化。第二种是组合继承即构造函数继承加原型链共享function Animal(name) { this.name name; this.foods []; } Animal.prototype.eat function () { console.log(this.name 在吃 this.foods.join(,)); }; function Dog(name) { Animal.call(this, name); // 每个实例都有一份自己的引用类型属性 } Dog.prototype Object.create(Animal.prototype); Dog.prototype.constructor Dog; const dog1 new Dog(旺财); dog1.foods.push(骨头); const dog2 new Dog(来福); console.log(dog2.foods); // []互不影响组合继承是ES6之前最常用的可靠方案解决了引用类型属性共享问题同时保留了方法共享的能力。第三种是ES6的class语法class Animal { constructor(name) { this.name name; this.foods []; } eat() { console.log(this.name 在吃 this.foods.join(,)); } } class Dog extends Animal { constructor(name) { super(name); } } const dog1 new Dog(旺财); dog1.foods.push(骨头); const dog2 new Dog(来福); console.log(dog2.foods); // []class语法是组合继承的语法糖写法更清晰但底层机制依然是原型链委托而不是真正的类复制。理解原型链再看class才能明白extends关键字和super的调用背后到底发生了什么。3.4 项目里的原型链技巧判断、枚举、扩展除了写继承原型链在代码排查中也很常用。判断某个属性是实例自身的还是原型上的用hasOwnPropertyfunction Person(name) { this.name name; } Person.prototype.greet function () {}; const p new Person(小明); console.log(p.hasOwnProperty(name)); // true console.log(p.hasOwnProperty(greet)); // false判断一个对象是谁的实例用instanceofconsole.log(p instanceof Person); // true console.log(p instanceof Object); // trueinstanceof的原理其实就是顺着原型链找构造函数的prototype对象。如果找到就返回true找不到返回false。所以深入理解原型链后instanceof的结果一眼就能看出来。还有一个重要注意点不要轻易给内置对象的原型添加方法。比如给Array.prototype加一个myUnique方法在小型Demo里很爽但会污染全局环境和其他库冲突的风险很高。如果非要在项目里做数组去重这类操作优先写成独立工具函数或者用class继承内置类型再扩展而不是直接修改原型。4. 事件循环JS异步执行的调度真相闭包和原型链处理的是“写代码”的层面事件循环处理的是“代码跑起来”的层面。我学到这里的时候才真正明白为什么setTimeout不可靠为什么Promise回调不是立刻执行。4.1 单线程的JS如何异步先看整体调度逻辑JS运行时是单线程的这意味着同一时刻只能执行一段代码。但浏览器里有很多事不能干等比如请求接口、点击事件、定时器这些不能阻塞主线程。于是运行时引入了事件循环机制。可以把整个执行过程抽象成三个角色调用栈、任务队列、事件循环。调用栈是当前正在执行的任务队列。函数调用时进栈函数返回时出栈。任务队列里放的是等待执行的回调任务。事件循环是一个永不停止的循环它的职责就是不断检查调用栈是否为空如果为空就从任务队列里取一个任务放进调用栈执行。当代码执行到setTimeout或者发起网络请求时这类操作不会原地等待而是交给浏览器的其他模块去计时或通信。计时结束或请求返回后对应的回调会被放进任务队列。主线程的调用栈一空事件循环就把回调取出来执行。这样解释就能理解为什么计时器不准setTimeout只是把回调“排进队列”实际执行时间取决于队列前面还有多少任务、每个任务执行了多久。4.2 宏任务与微任务执行顺序的完整规则任务队列里其实还分两类宏任务和微任务。这个区分非常关键。宏任务包括同步代码整体、setTimeout、setInterval、I/O操作、UI渲染。微任务包括Promise的then/catch/finally回调、queueMicrotask、MutationObserver。执行规则是每执行完一个宏任务事件循环会清空整个微任务队列然后再执行下一个宏任务。注意是清空整个微任务队列而不是只执行一个。看一个经典示例console.log(1); setTimeout(function () { console.log(2); }, 0); Promise.resolve().then(function () { console.log(3); }); console.log(4);输出结果是1、4、3、2而不是1、2、3、4。推演过程是这样的同步代码依次执行输出1和4。setTimeout把回调作为宏任务排进队列。Promise的then把回调作为微任务排进队列。同步代码全部执行完调用栈清空。事件循环先处理微任务队列输出3。微任务队列清空后才处理宏任务队列输出2。这个规则在实际开发中非常重要。比如你用Promise封装了一个接口请求后续的then回调会在当前宏任务结束前优先执行而不是等到所有宏任务都跑完。再看一个微任务产生微任务的例子Promise.resolve().then(function () { console.log(A); Promise.resolve().then(function () { console.log(B); }); }).then(function () { console.log(C); });输出顺序是A、B、C。第一个then执行时又触发了新的微任务因为当前还在“清空微任务队列”阶段所以新产生的微任务也会在本轮内被执行。这也是agent task源的一个核心特性深入调试异步逻辑时会经常用到。4.3 Promise与async/await更顺手的异步写法Promise是微任务的主要来源async/await则是Promise的语法糖。这两个必须放在一起理解。先看Promise链条function fetchData(ms) { return new Promise(function (resolve) { setTimeout(function () { resolve(数据已返回); }, ms); }); } fetchData(1000) .then(function (msg) { console.log(第一次获取, msg); return fetchData(500); }) .then(function (msg) { console.log(第二次获取, msg); }) .catch(function (err) { console.error(err); });这里then里返回一个新Promise后续的then就会等这个Promise完成后才执行形成了串行异步流程。同样的逻辑用async/await写更直观async function main() { try { const msg1 await fetchData(1000); console.log(第一次获取, msg1); const msg2 await fetchData(500); console.log(第二次获取, msg2); } catch (err) { console.error(err); } } main();await就是“暂停当前异步函数等待Promise完成”但它并没有阻塞主线程。这个暂停只是把async函数后续代码包装成微任务继续执行。所以async/await写起来像同步代码本质依然是异步的。我做并发请求时有一个习惯没有依赖关系的请求不要傻傻地一个个await而是用Promise.all并行。只有前后依赖时才用串行await。这样能明显减少总等待时间。async function loadParallel() { const [userInfo, listData] await Promise.all([ fetchData(800), fetchData(1200) ]); console.log(userInfo, listData); }4.4 长任务与渲染卡顿的实战建议理解了事件循环还要理解它对页面渲染的影响。浏览器渲染新帧是有节奏的比如60Hz屏幕大约每16.7毫秒渲染一帧。如果主线程连续执行一个超长任务比如一次处理上万条数据渲染就会被卡住页面出现肉眼可见的掉帧和卡顿。常见的解决方案是把长任务拆分成多个短任务。比如用setTimeout片段处理大数组function processChunk(items, chunkSize, handler) { let index 0; function next() { if (index items.length) { return; } const end Math.min(index chunkSize, items.length); for (; index end; index) { handler(items[index]); } setTimeout(next, 0); // 把下一块任务排入宏任务队列 } next(); }每次处理完一小块数据就主动让出主线程让浏览器有机会渲染。虽然整体耗时可能变长但页面不会卡死。另一个思路是用requestAnimationFrame和任务分片或者用Web Worker把计算挪到独立线程。不管用哪种前提都是理解事件循环的调度时机。5. 进阶3的落地练习三个能直接跑起来的小实验光看不练等于白看。Day 13 进阶3我给自己布置了三个练习每个都综合了当天的知识。你照着跑一遍理解深度完全不一样。5.1 实验1用闭包加原型链封装一个带私有状态的任务队列这个练习的目标是做一个只能在队列尾部添加任务、从头部取出任务的小工具。队列数据不能被外部直接修改。function createTaskQueue() { let tasks []; // 闭包保护的私有状态 const queue { add(task) { tasks.push(task); return queue; }, take() { return tasks.shift(); }, size() { return tasks.length; } }; return queue; } const queue createTaskQueue(); queue.add(写文章).add(写代码).add(调试); console.log(queue.size()); // 3 console.log(queue.take()); // 写文章 console.log(queue.size()); // 2 console.log(queue.tasks); // undefined外部拿不到真实数组这里闭包负责隐藏tasks数组对象方法负责暴露操作接口。如果你试了直接访问queue.tasks会发现结果是undefined因为tasks根本不在queue对象上。在此基础上你可以给queue对象挂一个自定义方法再通过原型链共享给多个队列实例。比如用工厂函数配合Object.create或者直接用class重写。我自己会再写一版class对比两遍之后对“类就是构造函数的语法糖”这句话的体会特别深。5.2 实验2手写一个简单的并发控制器进阶到最后事件循环的知识要能用起来。这个练习实现一个并发控制器限制同一时间最多执行两个异步任务。function createConcurrentRunner(limit) { const queue []; let activeCount 0; function next() { if (activeCount limit || queue.length 0) { return; } const task queue.shift(); activeCount; task().finally(function () { activeCount--; next(); }); } return { add(task) { queue.push(task); next(); return this; } }; } function createAsyncTask(name, delay) { return function () { return new Promise(function (resolve) { setTimeout(function () { console.log(完成, name); resolve(); }, delay); }); }; } const runner createConcurrentRunner(2); runner .add(createAsyncTask(任务A, 1000)) .add(createAsyncTask(任务B, 500)) .add(createAsyncTask(任务C, 300)) .add(createAsyncTask(任务D, 400));运行输出大致是完成任务B 完成任务C 完成任务A 完成任务D推演过程A和B先启动B在500ms后完成空出一个槽位C进入C在300ms后完成D进入A在1000ms后完成。这个练习既用到了闭包保护队列也用到了Promise的finally和微任务调度机制是Day 13很合适的综合题。5.3 实验3预测事件循环输出顺序最后一个练习不需要写代码而是读代码把执行顺序一步步写下来。给你一段无删减的代码console.log(start); setTimeout(() { console.log(timeout1); Promise.resolve().then(() console.log(promise-in-timeout)); }, 0); Promise.resolve().then(() { console.log(promise1); }).then(() { console.log(promise2); }); console.log(end);先自己推一遍再放到浏览器控制台跑。预期输出是start end promise1 promise2 timeout1 promise-in-timeout推演关键点同步代码输出start、end微任务队列里有promise1和promise2宏任务队列里有timeout1。微任务队列先清空输出promise1、promise2。然后宏任务timeout1开始执行它内部又产生了一个新的微任务promise-in-timeout。注意这个微任务是在timeout1宏任务执行过程中产生的并不会影响后续宏任务的顺序因为当前宏任务还没结束内部的微任务会在当前宏任务结束后立即清空所以promise-in-timeout先输出之后才轮到下一个宏任务。6. 常见问题与避坑实录学完三大主题实操中必然踩坑。我把这段时间遇到的典型问题整理成速查表方便你以后排查。问题现象可能原因解决方法循环里setTimeout输出全是最后一项闭包捕获了循环变量的最终值用let声明循环变量或用IIFE传入当前索引修改一个实例的数组其他实例也变了引用类型属性被原型链共享在构造函数里用this.xxx []初始化不要挂到原型上用instanceof判断子类实例返回false子类原型没有被正确设置或prototype被覆盖检查是否用了Object.create(SuperClass.prototype)Promise的then有时不执行忘了调用resolve或rejectPromise状态永远pending在异步操作完成回调里显式调用resolve大量DOM操作导致页面卡顿主线程被长任务阻塞渲染掉帧拆分任务、减少同步大循环、必要时用requestAnimationFrame接口并发时总耗时等于所有请求之和串行await了没有依赖关系的请求改用Promise.all并行请求排查思路我一般分三步。第一步在关键位置打印日志或者用断点确认数据流和调用栈。第二步缩小范围把可疑代码抽成最小可复现的Demo在控制台单独测。第三步用浏览器性能面板看主线程的任务切片识别长任务和密集的微任务。闭包相关的bug最隐蔽因为它不报错只是行为不符合预期。我实际排查过一次计数器一直跳两位数的现象最后发现是某个地方把createCounter返回的函数存了两份两个闭包各自维护了独立状态互相覆盖导致混乱。这种问题靠读逻辑很难发现直接在控制台执行并展开函数的[[Scopes]]属性能看到闭包里到底保存了哪些变量。原型链相关的bug则集中在“方法不生效”和“属性莫名共享”。方法不生效先检查prototype是否被整体覆盖了属性共享则检查是否在原型上定义了引用类型字段。事件循环相关的bug最容易让人抓狂的是执行顺序不可控。遇到这种情况别猜把宏任务和微任务分别列出来按“同步代码 → 微任务清空 → 宏任务 → 微任务清空”的时序推一遍十次能中九次。Day 13 进阶3学完我最大的感受是这三块内容组合起来整个JS的“运行时画面”终于完整了。以前写代码是靠记忆拼凑语法现在写代码是知道每一段代码在引擎里如何被调度、如何被检索、如何被回收。这种状态上的转变比记住多少API都重要。对于正在学进阶系列的人我的建议很直接不要停留在看文章把上面的三个练习逐一跑起来再自己改参数观察输出变化。跑一遍代码比读十遍概念都管用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。