今天是自学路线的第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-3 | HTML基础标签、CSS选择器、盒模型、Flex布局 | 能独立还原静态页面 |
| Day 4-6 | CSS Grid、响应式布局、动画过渡 | 能写出自适应页面 |
| Day 7-9 | JS变量、数据类型、运算符、流程控制、函数基础 | 能写简单交互逻辑 |
| Day 10 | DOM操作、事件监听、表单处理 | 能完成常见页面交互 |
| 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 项目里的原型链技巧:判断、枚举、扩展
除了写继承,原型链在代码排查中也很常用。
判断某个属性是实例自身的还是原型上的,用hasOwnProperty:
function 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判断一个对象是谁的实例,用instanceof:
console.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或reject,Promise状态永远pending | 在异步操作完成回调里显式调用resolve |
| 大量DOM操作导致页面卡顿 | 主线程被长任务阻塞,渲染掉帧 | 拆分任务、减少同步大循环、必要时用requestAnimationFrame |
| 接口并发时总耗时等于所有请求之和 | 串行await了没有依赖关系的请求 | 改用Promise.all并行请求 |
排查思路我一般分三步。第一步,在关键位置打印日志或者用断点,确认数据流和调用栈。第二步,缩小范围,把可疑代码抽成最小可复现的Demo,在控制台单独测。第三步,用浏览器性能面板看主线程的任务切片,识别长任务和密集的微任务。
闭包相关的bug最隐蔽,因为它不报错,只是行为不符合预期。我实际排查过一次计数器一直跳两位数的现象,最后发现是某个地方把createCounter返回的函数存了两份,两个闭包各自维护了独立状态,互相覆盖导致混乱。这种问题靠读逻辑很难发现,直接在控制台执行并展开函数的[[Scopes]]属性,能看到闭包里到底保存了哪些变量。
原型链相关的bug则集中在“方法不生效”和“属性莫名共享”。方法不生效先检查prototype是否被整体覆盖了;属性共享则检查是否在原型上定义了引用类型字段。
事件循环相关的bug最容易让人抓狂的是执行顺序不可控。遇到这种情况,别猜,把宏任务和微任务分别列出来,按“同步代码 → 微任务清空 → 宏任务 → 微任务清空”的时序推一遍,十次能中九次。
Day 13 进阶3学完,我最大的感受是这三块内容组合起来,整个JS的“运行时画面”终于完整了。以前写代码是靠记忆拼凑语法,现在写代码是知道每一段代码在引擎里如何被调度、如何被检索、如何被回收。这种状态上的转变,比记住多少API都重要。对于正在学进阶系列的人,我的建议很直接:不要停留在看文章,把上面的三个练习逐一跑起来,再自己改参数观察输出变化。跑一遍代码,比读十遍概念都管用。