
1. 从循环陷阱说起闭包与作用域的收集机制翻了一圈之前的笔记我发现自己反复写js笔记这两个字但真正值得沉淀的往往不是某个API怎么用而是在调试器里蹲到凌晨才想明白的那些底层机制。这篇js笔记2我决定把最近几个高频踩坑点整理出来它们都围绕同一个主题看起来人畜无害的语法背地里藏着 JavaScript 引擎的一套规则。先说闭包。很多人背过定义函数能够记住定义时的作用域即使它在别处执行。但真正理解闭包最好从一个经典到发腻的题目切入——循环里的var。for (var i 0; i 5; i) { setTimeout(() { console.log(i); // 5 5 5 5 5 }, 100); }这段代码几乎出现在每一篇闭包教程里。它为什么不是 0 1 2 3 4因为var声明的i属于整个函数作用域循环结束之后i的值已经是 5。等setTimeout的回调真正执行时——那已经是至少 100 毫秒之后——它沿着作用域链找到的i是那个被累加到 5 的同一个变量。1.1 作用域链是怎么串起来的我一开始对作用域链这个词总是似懂非懂直到想通一件事JavaScript 在函数定义的时候就把当前能访问到的变量环境打包成了一个链。内层函数能访问外层变量靠的不是记忆而是这个链还挂着那个环境。再看那个经典解法let版本for (let i 0; i 5; i) { setTimeout(() { console.log(i); // 0 1 2 3 4 }, 100); }let每次循环都会创建一个新的词法环境换句话说每一轮迭代里的i都是独立的副本。回调函数捕获的是自己那一轮的i互不干扰。如果面试官不依不饶问没有 let 的时代怎么写那就是立即执行函数表达式IIFE的舞台for (var i 0; i 5; i) { (function (j) { setTimeout(() { console.log(j); }, 100); })(i); }把i作为参数传进一个立即执行的函数参数j在那一轮就被固定住了。这背后还是同一套逻辑每个函数都有自己的词法环境。1.2 闭包与内存到底谁该被回收理解了链就不得不面对闭包的代价。当一个内层函数长期存活它携带的外层变量就永远不会被垃圾回收。典型的坑长这样function setup() { const heavyData new Array(100000).fill(Math.random()); window.addEventListener(resize, () { console.log(heavyData.length); }); }resize回调绑定在window上它引用了setup里的heavyData。只要页面不关、监听器不解除那个大数组就一直占着内存。这不一定是 bug但如果setup会被反复调用每次都会新增一个监听器、又多占一份内存问题就严重了。我自己的习惯是闭包捕获的数据越精简越好。能只捕获一个对象属性就不要捕获整个对象能手动解绑的事件就在合适的生命周期里移除。社区里常说的内存泄漏排查先从事件监听器开始多半也是这个原因。2. this 指向四种绑定规则与回调场景的丢 this事故如果说闭包让人头疼this大概能让人原地爆炸。我见过不少同事在调试this丢失时抓狂的样子也曾经狠狠抓狂过。this的规则其实可以压缩成四句话绑定方式规则例子默认绑定函数独立调用时this指向全局严格模式下为undefinedfn()隐式绑定通过对象调用this指向该对象obj.fn()显式绑定call/apply/bind强制指定fn.call(obj)new 绑定构造调用this指向新对象new Fn()规则背下来不难难的是组合场景。2.1 回调函数里为什么丢 this最常见的丢 this场景是下面这种const user { name: A同学, greet: function () { console.log(Hello, ${this.name}); }, }; setTimeout(user.greet, 1000); // Hello, undefined问题出在哪user.greet只把函数本身传给了setTimeout。当定时器回调触发时它是一个独立调用不再有任何对象调用它于是走了默认绑定非严格模式下this指向全局对象而全局对象上大概率没有name属性。这里我可以顺便吐槽一下任何脱离执行方式去分析this的尝试都是徒劳。this是调用点决定的不是定义位置决定的。一行setTimeout(user.greet)看似和user.greet长得一样但调用点完全变了。2.2 箭头函数一种高情商的解决方案箭头函数最大的特点不是简短的语法而是它没有自己的this。它沿用的是定义时外层作用域的this。const user { name: B同学, greet: function () { setTimeout(() { console.log(Hello, ${this.name}); // Hello, B同学 }, 100); }, };箭头函数定义在greet内部所以它捕获的是greet执行时的this——也就是user对象。这就是词法this跟作用域链一样箭头函数的this在定义时就被决定了。一个容易忽略的点箭头函数不能用作构造函数也不能对它用new因为它没有[[Construct]]内部方法。它也没有自己的arguments对象要用参数就只能用剩余参数语法。2.3 显式绑定与绑定丢失bind和call/apply的区别值得专门写一笔。call和apply是立即调用bind返回一个绑定了this的新函数。而且bind返回的函数如果再被call、apply或再次bind第一个参数会被忽略const obj1 { value: 1 }; const obj2 { value: 2 }; function show() { console.log(this.value); } const bound show.bind(obj1); bound.call(obj2); // 1而不是 2原因在于bind返回的新函数已经锁死了this后续的显式绑定不能覆盖。这种一次绑定终身有效的特性在事件处理、定时器回调里尤其好用。3. 事件循环setTimeout(0)不是立刻执行setTimeout的延迟参数到底意味着什么我直到被一个问题连续坑了两次才算真正想通。那是在一个模拟项目X里我在setTimeout(0)里读取一个刚被修改的 DOM 宽度结果读到的是旧值。原因其实就一句话setTimeout的回调不是立即执行它只是把回调放进任务队列等当前执行栈清空、并且过了至少指定延迟之后才会被取出执行。3.1 执行栈、任务队列与渲染时机JavaScript 是单线程的它的运行模型可以简化成三步同步代码在执行栈里按顺序跑。遇到异步任务定时器、网络请求、DOM 事件交给相应环境处理回调会被扔进任务队列。执行栈空了事件循环从队列里取一个任务执行然后再检查下一个。在我刚才说的例子中修改 DOM 是同步操作而读取发生在setTimeout回调里。虽然延迟是 0但执行栈里的同步代码——包括那次 DOM 修改——先执行完了事件循环才会去取回调。问题还不止于此浏览器通常会在执行完一批任务后才进行渲染所以即使回调里读取 DOM也可能赶在渲染之前拿到的是内存里最新的值而不是视觉上已经画出来的值。一个非常容易混淆的点是任务类型。宏任务包括setTimeout、setInterval、I/O 等而Promise.then、MutationObserver属于微任务。微任务会在当前执行栈清空后、下一个宏任务之前一口气全部执行。console.log(1); setTimeout(() { console.log(2); }, 0); Promise.resolve().then(() { console.log(3); }); console.log(4); // 输出顺序1 4 3 2为什么3比2先出来因为当执行栈清空后事件循环先检查微任务队列把Promise.then里的3跑完才去宏任务队列里取出定时器的2。理解了这一层很多奇奇怪怪的执行顺序问题都会豁然开朗。3.2 把异步执行顺序变成心理脚本我现在写代码有个习惯遇到异步逻辑先在脑子里跑一遍事件循环的剧本把每个函数的入队时机画出来。尤其是async/await与微任务的组合一旦嵌套多了光靠直觉很容易翻车。举个我后来反复用的例子async function test() { console.log(a); await Promise.resolve(); console.log(b); } test(); console.log(c); // a c bawait后面的代码被安排到微任务队列里了所以b在c之后打印。这种微任务优先于宏任务的规则不仅影响你理解执行顺序还会影响你写状态管理、轮询和调度器的逻辑。另一个容易被忽视的坑是requestAnimationFrame它既不是宏任务也不是微任务它在浏览器渲染之前的机会窗口执行适合做动画和需要与渲染同步的 DOM 操作。如果拿setTimeout做动画不仅可能掉帧还可能因为任务排队积压导致间隔不稳定。4. 原型链与继承instanceof为什么偶尔不靠谱原型链在 JavaScript 里像一张流动的族谱。每个对象都有一个隐藏的[[Prototype]]普通浏览器里用__proto__访问而Object.getPrototypeOf()是标准方式。对象实例通过原型链把自己的属性查找委托给构造函数的prototype对象。所以obj.someMethod()能执行是因为引擎在obj上找不到someMethod时继续沿着__proto__往上找。4.1instanceof的判断本质与两个坑instanceof判断的是构造函数的prototype对象是否出现在实例的原型链上。function Animal() {} const dog new Animal(); console.log(dog instanceof Animal); // true看起来没问题但有两个著名场景会让它失灵。第一个是跨作用域。比如浏览器里有多个 iframe每个 iframe 都有自己的一套全局对象和构造函数。A 页面创建的Array拿到 B 页面用instanceof Array判断可能是false因为 B 页面的Array.prototype不在这个数组对象的原型链上。防御方案是用Array.isArray()。第二个是我最近才彻底绕明白的改写了prototype之后旧实例会认不出新原型。function Person() {} const p new Person(); Person.prototype {}; console.log(p instanceof Person); // falsep的原型链上还挂着原来的Person.prototype而Person.prototype已经换成了新对象。这个行为说得通但确实容易让人误判。实际开发中Symbol.hasInstance还能自定义instanceof的行为这时候静态判断就更加不能用直觉代替了。4.2class本质与私有字段ES6 的class是语法糖底层仍然是构造函数和原型链的组合。定义一个类方法自动挂在原型上而字段初始化放在构造逻辑里。class Counter { #count 0; increment() { this.#count 1; } get count() { return this.#count; } }#count是真正的私有字段用闭包或者下划线都没法等价替代。私有字段的访问由引擎强制约束这在做底层框架时特别可靠。继承方面extends背后也有一套规则子类的prototype指向父类的prototype实例同时Object.setPrototypeOf让子类本身继承父类。这就是为什么既可以用Child instanceof Parent判断子类实例也可以用Child.__proto__ Parent判断类之间的继承关系。说实话class让我从手写寄生组合式继承的阴影里彻底走了出来。5. 高频陷阱清单每次看都有新发现最后列一份我踩过多次、至今不敢说自己完全免疫的 JavaScript 陷阱清单。这里不打算写百科只挑那些真实项目中反反复复出现的。5.1 浅拷贝与数组方法的变异陷阱Array.prototype.sort会原地修改数组const arr [3, 1, 2]; const sorted arr.sort(); console.log(arr); // [1, 2, 3]arr 被改了想保留原数组就先用[...arr].sort()或arr.slice().sort()。类似的reverse也是原地操作。我见过不止一次因为顺手写了个list.sort()把一个本该只读的状态数组给改了最终导致 UI 顺序错乱。JSON.parse(JSON.stringify(obj))做深拷贝也很常见但它有几个致命短板会丢弃undefined、函数和Symbol不支持循环引用日期对象会被序列化成字符串。如果对象里有这些特殊结构这个方案就是灾难。常规替代方案是结构化克隆算法浏览器有structuredClone或专门的深拷贝库。5.2parseInt的第二参数、Number()的隐蔽行为[1, 2, 3].map(parseInt)的结果是[1, NaN, NaN]这个坑已经经典到快过时了。原因是parseInt接收两个参数map会把索引作为第二参数传给它于是parseInt(2, 1)就变成了非法解析。Number(null)返回 0而Number(undefined)返回NaN。parseInt(null)和parseInt(undefined)都返回NaN。返回 0。这些边界细节虽然琐碎但遇到表单校验、后端字段清洗时会直接影响逻辑正确性。5.3的类型转换以及严格相等为什么更省心0 是true0 0也是truefalse false却是false。这些规则能背下来但每次都要思考一遍的成本太高了。我现在的默认习惯是全程只在明确需要判断null或undefined时用宽松比较的变体技巧比如value null。反过来说NaN NaN是false判断NaN要用Number.isNaN()。这个我也写错过——第一次判断NaN时顺手写了结果死活进不了分支最后一步步调试才发现是自己被直觉坑了。5.4 可选链与空值合并的优先级a ?? b只在a是null或undefined时返回b。它和||的关键区别是0、、false在??眼里是正常值不会被矫正成b。在处理配置项默认值、金额清零这类场景里这个差异非常关键。可选链?.也不能乱用——它只在访问链上某一环为null/undefined时短路。如果为了图省事处处加上?.反而会掩盖某些应该暴露出来的逻辑错误。6. 结语记笔记真正的价值在于复述这篇 js笔记2 写到这里我想说的已经不是某个具体的知识点而是整理笔记的方法。很多人把笔记做成了文档搬运复制粘贴一堆 API 说明过两周再打开完全不想看第二次。我的体会是真正能沉淀下来的笔记每一条都长这样当时是在什么场景遇到这个问题的、我怎么排查的、最后怎么解决的、如果再遇到我会如何规避。像this绑定、事件循环的时序、原型链的判断这些知识在教程里都写得清清楚楚但不到代码出错的那一刻大脑很难把它当成自己的东西。我自己重复踩坑之后才明白所谓理解就是能在脑子里重新演算一遍执行过程。所以这篇笔记我不追求覆盖面广而是把每个问题都尽量说透后面遇到类似场景就能直接搬出来用。如果这篇文章对你有用我建议你别只收藏试着用自己的话把某一个小节复述一遍。能讲明白才是真会了。