ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaScript学习笔记整理法:从课程到排错手册

JavaScript学习笔记整理法:从课程到排错手册

这套课程我前后刷了三遍。第一遍开着倍速当背景音,第二遍老老实实跟着敲代码,第三遍是准备面试前拿出来重新过了一遍。最后真正让我把 JavaScript 学会的,不是视频里那些听得我连连点头的知识点,而是两样东西:持续地写小例子,以及一本自己一个字一个字攒出来的 JS 笔记。这里说的课程,是那位讲课时总喜欢把幻灯片做成粉色调、声音很有辨识度的前端 UP 主的 JavaScript 基础教程。不管你是跟着谁的课学,今天的重点都不是课程本身,而是我如何把一套几十集的 JavaScript 教程,沉淀成了一本能随时翻、能排错、能在面试前突击的笔记。这份笔记不只在电脑里躺着,它后来真的帮我在实际项目里少踩了好几个坑。

1. 这套JavaScript教程我刷了三遍,最后留下了什么

1.1 从“听懂了”到“会写了”,中间隔着一条沟

刚开始学 JS 的时候,我的状态应该和大多数人一样:老师敲一行,我跟着敲一行,页面上的效果出来了,心里很开心,觉得自己已经会了。直到某天我想关掉视频,自己独立写一个“点击按钮切换图片轮播”的小 Demo,才发现我连最基本的取元素、绑定事件、切换 class 这段逻辑都要想很久,写出来还有一堆报错。那一刻我才意识到,跟着视频写代码时,真正的逻辑判断都是老师做的,我只是在当打字员。眼睛和手都参与了,大脑却没有。

后来我了解到,这叫“流畅度错觉”:当你看着别人一步步解决问题时,你会误以为那种顺畅感是自己的能力。破解方法只有一个——脱离视频,自己纯手写一遍。为了逼自己完成这个过程,我决定做一本 JS 笔记,规则很简单:不看视频、不看文档,用自己的话把每个知识点写下来,写不出来就说明没真正理解。这个过程非常痛苦,但效果立竿见影。第一遍写笔记时,很多知识点我只能写出个大概,比如“闭包就是函数套函数”,但完全说不清它到底有什么用。这些缺口,就是需要重看教程的地方。

1.2 笔记不是抄目录,而是写给三个月后的自己

一开始我犯过一个错:把笔记做成了课程目录的摘抄,也就是把视频里的小标题抄下来,再配上几句老师说的话。这种笔记除了让我看起来很努力之外,没有任何用处。因为三个月后我翻回去看,根本不知道那些标题底下到底想表达什么。后来我换了一种思路——把笔记当作“写给三个月后的自己看的信”。我不是在记录老师讲了什么,而是在记录“我下次遇到这个问题时应该怎么办”。

具体来说,我的每一个知识点都包含三块内容:结论、原因、踩过的坑。举一个例子,“== 和 === 的区别”这个知识点,如果只是抄定义,那没有任何意义。我的笔记里写的是:日常开发一律用 ===,因为 == 会做隐式类型转换,容易造成“看起来相等”的 bug;什么时候会用到 ==?几乎没有,除非你在做框架底层的兼容判断。笔记里还补了一个我亲身踩过的坑:用 if (a == null) 判断 a 是否为空,这里 a 能同时匹配 null 和 undefined,很多人不清楚这一点,容易误判。这种“结论 + 原因 + 坑”的结构,让复习效率高了很多。

1.3 一本能打的知识笔记,要有自己的目录结构

经过反复调整,我的 JS 笔记最终分成了六个部分,分别是:JavaScript 地基(数据类型、作用域、闭包)、函数与面向对象、DOM 与 BOM 操作、事件机制、ES6+ 与异步编程、排错手册。前五部分和大多数教程的章节顺序差不多,但第六部分“排错手册”是我自己加上的,也是后来价值最高的部分。每当我踩到一个新的报错坑,就会在手册里记一页:报错信息是什么、出错的代码什么样、我当时的猜测是什么、最终怎么解决的。这份排错手册后来成了我面试前最常翻的东西,因为大多数面试题问的其实就是你在实操中踩过的坑。

2. 整理笔记时,JavaScript地基部分我翻了又翻

2.1 数据类型判断:typeof 和 instanceof 各自管哪一段

数据类型这块,教程里讲得很快,但真正写代码时坑特别多。我笔记里专门用一张表整理了三种判断方式的适用范围,这张表后来面试时也被我用上了。

判断方式适用情况典型返回值注意点
typeof基本类型(string、number、boolean、undefined、symbol、bigint)"string"、"number"、"boolean"、"undefined"、"object"、"function"typeof null 返回 "object",这是历史遗留 bug
instanceof引用类型(对象、数组、函数等)true / false判断的是原型链上存不存在某个构造函数;跨 iframe 场景会失效
Object.prototype.toString.call()万物皆可判断"[object Array]"、"[object Object]"、"[object Date]"最稳定的“终极大招”,日常写工具函数时最推荐

这里有一个特别容易混淆的点:typeof function 返回的是 "function",但这并不代表 function 是一种独立的数据类型。函数本质上是对象的一种特殊形态,在 typeof 看来它有自己的标记而已。所以面试里如果有人问你“JS 有哪些数据类型”,你的回答里不能把“function”算作数据类型。另外还有一个高频坑是 NaN,typeof NaN 返回 "number",但 NaN 并不等于自身。判断 NaN 时,isNaN() 会把参数先转成数字再判断,比如 isNaN("abc") 返回 true;而 Number.isNaN() 不会做转换,只判断传入值本身是不是 NaN,所以更可靠。这些细节我全部记在了笔记的地基部分,因为它们是后边所有代码判断的前提。

2.2 作用域链与闭包:先画图再做题

作用域和闭包是 JS 笔记里最绕的一页,我最初以为背下定义就行,后来发现不行。闭包的标准定义是“函数嵌套函数,内层函数被返回到外部使用时,外层函数的作用域不释放”,这个定义每个字我都认识,但完全不知道它想表达什么。直到我自己画了一次作用域链图,才算真正想明白。

我用一个生活化的类比帮助自己理解:外层函数好比一个自习室,里面放着一些书(变量);内层函数就是坐在自习室里的学生。正常情况下,自习室关门后学生就必须离开,书也带不走。但闭包做的事情是:学生离开时偷偷带走了自习室的钥匙,之后任何时候他都可以通过这把钥匙回来拿书。在代码里,这把“钥匙”就是被返回到外部的内层函数,那些“书”就是外层函数的局部变量。因为内层函数在外面还能被调用,所以 JS 引擎就不会回收外层函数的作用域,这就是闭包的本质。

闭包的实际用途很广:防抖和节流函数、模块化开发里的私有变量、循环里保存每一次的状态,全都依赖闭包。笔记里我会要求自己每次写闭包代码时,先画一张作用域链的图:画出全局作用域、外层函数作用域、内层函数作用域之间的嵌套关系,标出变量查找的路径。画图的过程帮我建立了空间感,背概念时的一片模糊,在画图之后清晰了很多。

2.3 变量提升与暂时性死区:控制台为什么“报错”又“不报错”

这块地基内容之所以让我反复翻,是因为它在控制台里的表现非常反直觉。比如下面这段代码:

console.log(a) // undefined var a = 10

不报错,却打印出 undefined。原因是 var 声明的变量会被“提升”到作用域顶部,但赋值操作还留在原地。所以第一行执行时 a 已经存在,但还没有赋值,默认就是 undefined。但如果把 var 换成 let:

console.log(a) // ReferenceError: Cannot access 'a' before initialization let a = 10

这次直接报错了。原因是 let 声明的变量也存在“提升”,但它被放进了“暂时性死区”,从作用域顶部到声明语句执行之前的这段区域,任何对变量的访问都会报错。为什么不干脆不提升呢?这是我笔记里写给自己的问题。答案是:var 的“宽松提升”带来了太多隐蔽 bug,比如忘记初始化变量时,undefined 和真正的值错误混在一起,很难排查。let 和 const 通过暂时性死区强制代码“先声明后使用”,把错误暴露在更早的阶段,宁可报错也不让你带着错误跑。

笔记里还记了一个面试高频题:for 循环里的 var 和 let 有什么区别。原因就在这里——var 是函数作用域,循环结束之后 i 还是那个 i,所有异步回调共享同一个变量;let 是块级作用域,每轮循环都创建了独立的变量绑定。理解了变量提升和暂时性死区,这道题就不需要背答案了。

3. 从“看得懂”到“写得出”,函数和面向对象是分水岭

3.1 函数声明与函数表达式,两个写法差在哪

教程视频中经常出现两种函数写法,我学的时候完全没注意它们有什么区别,直到某次我在函数声明之前就调用了一个函数表达式,浏览器直接报“xxx is not a function”,才意识到问题比我想象中严重。函数声明和函数表达式的核心区别在于提升机制:

// 函数声明:整体提升,可以在声明位置之前调用 sayHi() function sayHi() { console.log('hello') } // 函数表达式:只有变量提升,声明位置之前调用会报错 sayBye() // TypeError: sayBye is not a function const sayBye = function () { console.log('bye') }

原因很简单:var(或 const / let)声明的变量会被提升,但右侧的匿名函数不会被提前创建,所以在赋值完成之前,sayBye 这个变量还是 undefined(const 则是处于暂时性死区)。这段笔记我补充了一条实用经验:项目里函数声明式写起来更直观,适合工具函数;函数表达式更适合做回调、事件处理函数,因为它可以随时赋给变量,还能传参控制。如果涉及递归或需要解绑事件,就必须用函数表达式并给函数命名,而不是纯匿名函数。

箭头函数也是常客。它没有自己的 this,也不能用作构造函数,不能使用 arguments。也就是说,箭头函数身上发生的“函数行为”比普通函数少很多,它是纯表达式逻辑,适合做小型回调。我在笔记里的结论是:不是所有地方都适合用箭头函数,尤其对象的方法里用了箭头函数,this 就不再指向这个对象,这个后面排错章节还会提到。

3.2 this 的指向:不在定义处,而在调用处

this 是 JavaScript 新手阶段最容易懵的概念之一,因为它和大多数语言里“this 指向当前对象”的直觉不一样。JS 里 this 的值取决于函数被调用的那一刻,而不是函数被定义的地方。我笔记里整理了四条最常见的调用场景:

调用场景this 指向典型例子
普通函数调用window(严格模式下是 undefined)直接调用一个函数
对象方法调用调用方法的那个对象obj.showName()
构造函数调用通过 new 创建的实例对象new Person()
箭头函数外层作用域的 this,自己没有 thissetTimeout 里的回调函函数

记忆方法很简单:别管函数定义在哪个对象里,只看“调用时点号前边是谁”。obj.showName() 的 this 是 obj,arr.push() 的 this 是 arr。一旦脱离了对象去调用,比如 const fn = obj.showName; fn(),this 就丢了,变成了普通函数调用。当初我在笔记里给自己反复强调这一点,后来真的帮我解决了排错手册里的一类高频 bug。在实际项目中,想要固定 this,可以用箭头函数(利用外层 this)或者 Function.prototype.bind 来硬绑定,这一点也是闭包和 this 结合最紧密的地方。

3.3 原型链与 class:面向对象笔记里最绕的一页

面向对象这一章,我花的时间最多。因为 ES6 的 class 看起来很简单,但背后的原型链机制如果没搞懂,遇到复杂继承还是会翻车。new 一个构造函数时,JS 引擎做了四件事:创建一个新对象;把新对象的原型指向构造函数的 prototype 属性;把构造函数内部的 this 指向新对象并执行构造函数体;如果构造函数显式返回了一个对象,则返回那个对象,否则返回新创建的对象。

原型链的查找规则是“顺着原型链向上找”。对象访问属性时,先看自己有没有;没有就往构造函数的 prototype 上去找;还没有就继续往 prototype 的原型上找,直到 Object.prototype。这套机制解释了为什么所有对象都能调用 toString 和 hasOwnProperty——它们定义在 Object.prototype 上。class 是 ES6 提供的语法糖,它的本质仍然是构造函数加原型。用一句话说:class 是给原型机制穿上的一件衣服,穿起来好看,但脱了衣服里子还是原型。

笔记里我对比了 ES5 和 ES6 的继承写法,发现 ES6 的 extends 加 super 其实是在帮我们处理“把父类构造逻辑先跑一遍,再把子类构造逻辑接上”这件事。我自己动手手写了一遍继承,从借用构造函数继承到原型链继承再到组合继承,把每一步为什么会有问题整理清楚之后,再去理解 class,就变得顺理成章。这部分内容虽然没有直接体现在日常业务里,但 Debug 的时候经常用到,比如看到某个方法来自原型链,我就知道 bug 大概率出在 prototype 被覆盖,而不是方法本身写错了。

4. DOM、BOM 与事件:笔记记满了“我以为”和“实际上”

4.1 取元素、改内容,从“能用就行”到“知道为什么”

DOM 操作是让页面“动起来”的关键,但刚学的时候我的代码全是“能跑就行”,选择器换来换去,完全不清楚各自差异。后来我把常见的选择器和操作方式整理成了一张对照表,这张表成了我写交互代码时的速查手册:

选择器方式返回结果性能适用场景
getElementById单个元素最快取特定 id 元素
getElementsByClassNameHTMLCollection(动态集合)较快取同一 class 的元素组
getElementsByTagNameHTMLCollection(动态集合)较快取同一标签的元素组
querySelector / querySelectorAll单个元素 / NodeList(静态快照)较慢但最灵活需要复杂 CSS 选择器时

我特别想强调的是 querySelectorAll 返回的 NodeList 是一个伪数组,不能直接调用数组方法。很多人写代码时想对它做 forEach,发现报错,就是因为没有先转成真实数组。我的习惯是 Array.from(document.querySelectorAll('.item')),转完之后 map、filter 随便用。另外,innerHTML 和 textContent 的区别也要注意:innerHTML 会把字符串里的 HTML 标签解析成真实节点,有注入风险;textContent 只改文本,安全得多。如果只是改文字,就用 textContent;如果确实要插入一段包含标签的结构,也必须确保内容是可信的,不要直接把用户输入塞进 innerHTML。

4.2 事件冒泡与事件委托:一个表格讲清楚

事件机制这章,我从“知道有冒泡”到“能主动用事件委托”,经历了一个不小的跨越。事件在浏览器里会经过三个阶段:捕获阶段、目标阶段、冒泡阶段。默认情况下,addEventListener 的第三个参数为 false,意思是只在冒泡阶段处理事件。冒泡意味着点击一个子元素,事件会一路向上传给父元素、祖父元素,直到 document。

事件委托就是利用冒泡机制,把子元素的事件监听器注册到父元素上,然后通过判断实际触发目标 e.target 来处理。比如一个动态列表:

const ul = document.querySelector('#list') ul.addEventListener('click', (e) => { if (e.target.tagName === 'LI') { console.log(e.target.textContent) } })

这样的好处有两个:一是减少了事件监听器的数量,特别是在长列表里性能优势明显;二是新增的子元素不需要重新绑定事件,天然就能被父级监听到。这里要分清楚 e.target 和 e.currentTarget:e.target 是实际触发事件的元素,e.currentTarget 是当前正在执行事件监听器的元素(也就是 ul)。在事件委托里,这两个指向不同,弄混了代码就会出现莫名其妙的 bug。我的笔记里专门为这一对概念画了对比表,每次写委托事件之前都会扫一眼。

4.3 防抖与节流:笔记里除了代码,还有两张“时间轴图”

防抖和节流是我在实际项目里用到最多的两个工具函数,它们都是为了限制函数被频繁触发。课程里讲得比较快,但面试和工作中特别常见。我用通俗的话做了区分:防抖是“你疯狂触发,我等你消停了才执行”,好比电梯关门后如果又有人来按,门又重新打开,只有在一段安静时间内没人按,电梯才真正关门走人;节流是“每隔固定时间我就执行一次,不管你触发多少次”,好比景点售票窗口每 10 分钟放一批人进去,游客再怎么挤,窗口的开闸频率是固定的。

// 防抖:最后一次生效 function debounce(fn, delay = 300) { let timer return function (...args) { clearTimeout(timer) timer = setTimeout(() => fn.apply(this, args), delay) } } // 节流:固定间隔生效 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) } } }

笔记里我不仅贴了这段代码,还在旁边画了时间轴图:横轴是时间,竖条代表用户触发动作,阴影段代表实际执行函数的时间。画完图之后我就记住了:搜索输入框的联想建议用防抖,因为用户敲字过程中我们不想每次按键都发请求;页面滚动监听、窗口大小变化这种持续触发的事件用节流,保证固定频率的执行。防抖和节流内部都用到了闭包,正好把前面学的闭包知识串起来了。

4.4 事件绑定的三种方式,以及“怎么绑就怎么解”

事件绑定有几种方式,写法不同,行为也不同。直接在 HTML 上写 onclick 属性是最原始的方式,混在结构里不利于维护,我基本不用。用 element.onclick = fn 这种方式,DOM 元素上的 onclick 属性只能挂一个函数,后挂的会覆盖前面的。而 addEventListener 可以给同一元素、同一事件挂多个监听函数,互不覆盖,这是它的优势,也是陷阱所在——如果不小心重复挂载同一个函数,点击一次会执行多次。

解绑时要特别注意:removeEventListener 必须传入和添加时完全相同的函数引用。如果你添加时写的是匿名函数,哪怕长得一模一样,在内存里也是两个不同的函数,removeEventListener 根本找不到要删的目标,事件依然存在。这是排错手册里一个非常经典的问题,后面我会专门讲一次完整的排查过程。笔记里我的建议是:凡是需要解绑的事件,从一开始就不要用匿名函数,直接命名一个具名函数再传进去。

5. ES6+ 与异步编程:笔记里最难啃的几页

5.1 解构赋值、模板字符串、展开运算符,写起来爽,坑也不少

ES6+ 的内容让写代码的体验好了很多,尤其是解构赋值、模板字符串和展开运算符。以前拼接字符串用加号,一次写一长串还容易漏符号,模板字符串 ${} 直接内嵌变量,一目了然。解构赋值最常见的用法是从对象或数组里快速取出需要的字段:

const user = { name: '张三', age: 20 } const { name, age } = user const arr = [1, 2, 3] const [first, second] = arr

展开运算符 ... 则常用于复制数组、合并对象。但我笔记里反复标注过:... 复制对象时是浅拷贝,只会复制一层。如果对象属性里嵌套着另一个对象,展开之后里层对象依然是同一个引用。这一点直接导致后面的深浅拷贝内容,也是面试时的一个高频扩展问题。解构赋值还有一个隐藏的坑就是会把 this 一起带丢,比如从对象里解构出一个方法再单独调用,this 已经不再指向原对象。这个坑我排错时遇到了,记到了排错手册里,后面会展开说。

5.2 Promise:从回调地狱到链式调用,代码的可读性转折点

在没有 Promise 之前,多层异步操作要靠一层层回调嵌套来实现,代码越写越往右缩进,俗称“回调地狱”。它不只是难看,更重要的是错误处理很难做,内层抛出的异常很容易被吞掉。Promise 解决的问题就是让异步流程变得扁平化、可读、可统一处理错误。

Promise 有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦从 pending 变为 fulfilled 或 rejected,就不会再变。then 方法用于处理成功后拿到值,catch 处理失败信息。链式调用时,then 里面返回的值会被包成一个新的 Promise,所以可以继续往下 .then,形成一条链。面试里经常问 Promise.all 和 Promise.race 的区别:all 等待所有 Promise 都成功,返回一个数组;race 返回第一个 settled 的 Promise 结果,不管成功还是失败。

我笔记里给 Promise 部分做了不少标注。比如 .then 里面如果抛出了异常,这个异常会传递到后面的 .catch 被捕获。再比如 Promise.all 里如果有一个 Promise reject 了,整个 Promise.all 都会 reject,并且不会等其他的 Promise 完成。这些行为看起来简单,但写并发请求、做并行数据加载时特别容易踩坑。

5.3 async/await 配 try...catch:异步代码的“同步写法”

async/await 是 Promise 的语法糖,让异步代码以几乎同步的顺序呈现,阅读体验好了很多。但我在刚开始学的时候误以为它是“阻断型”的,把异步变成了同步。实际上,async 函数执行时,遇到 await 会暂停向下执行,让出控制权给事件循环,等 Promise 结果回来以后再继续向下执行。await 下面的代码,本质上相当于被放进了 .then 里,区别只是写法更接近同步代码。

async function loadData() { try { const user = await getUser() const posts = await getPosts(user.id) console.log(posts) } catch (error) { console.error(error) } }

这段代码最大的好处是错误处理变成了熟悉的 try...catch,不用一路 .catch。笔记里我给自己写了一个提醒:await 只能用在 async 函数里,普通函数里写 await 直接报语法错误;如果要在 for 循环里连续 await 多个任务,默认是串行执行的,速度慢时要考虑用 Promise.all 来并行。还有一个坑是 async 函数只要不写 await,它也会立刻返回一个 Promise,调用处如果不做处理,返回值拿不到真实数据。这些细节在实际开发中很容易绊倒人。

5.4 深拷贝与浅拷贝:面试题里的“送分题”,也是隐藏得最深的坑

拷贝这个话题,课程里只是简单提了一下,但面试和实际项目里出现频率极高。浅拷贝只复制一层,深拷贝则要把嵌套的所有层级全部复制出来,生成一份独立的数据。展开运算符和 Object.assign 都属于浅拷贝。假设对象里有一个嵌套对象,浅拷贝后新对象和旧对象的嵌套子对象仍然指向同一份内存,修改其中一边,另一边也会跟着变。

JSON.parse(JSON.stringify(obj)) 是最常见的“伪深拷贝”,但它的坑很多:函数、undefined、Symbol 作为属性值时会直接被丢弃;Date 会被转成字符串;对象的循环引用会直接抛错。我笔记里写了一个手写深拷贝的递归版本,核心要点是:先判断是不是数组,再区分是不是普通对象,递归处理每一个属性值;同时用 WeakMap 记录已经拷过的对象,解决循环引用问题。手写这个函数的过程,帮我理顺了“数据类型判断”“递归”“引用类型”几个重要概念,这也是我在笔记里把它归到“综合实战”部分的原因。

6. 把笔记改造成“排错手册”之后,复习效率确实高了很多

6.1 解构赋值把 this 带丢了:方法一调就报错

这个 bug 是我实际开发里遇到的第一个“看起来很怪”的报错。当时我从一个对象里解构出某个方法,然后想直接调用:

const user = { name: '阿强', showName() { console.log(this.name) } } const { showName } = user showName() // TypeError: Cannot read properties of undefined (reading 'name')

报错的根本原因是我在 3.2 里反复强调的那条规则: this 的值由调用方式决定。showName 被解构出来后,是作为一个普通函数被调用的,前面没有任何对象,this 就不再指向 user,所以 this.name 自然就 undefined。排查过程其实不复杂:先在报错行打印 this,确认它不是 user;反推调用方式,发现解构赋值改变了 this 的指向。解决方式有三种:不要解构,用 user.showName() 调用;用箭头函数定义方法(但它会失去动态 this 的能力);或者调用时用 bind 硬绑定 this。这个案例记进排错手册后,我对 this 规则的理解就再也没忘过。

6.2 闭包加循环变量:var 留下的经典问题

上一个坑解决后不久,我又遇到了闭包加 var 的经典问题,也是教程里反复讲、但写代码时还是会犯的错。需求很简单:给三个按钮分别绑定点击事件,点击第几个按钮就提示“第几个”。

for (var i = 0; i < 3; i++) { btn[i].onclick = function () { console.log(i) } }

结果无论如何点击,打印出来的都是 3。原因是 var 声明的 i 属于函数作用域,变量 i 在整个循环里只有一个,循环结束后它的值已经变成 3,所有按钮的 click 回调共享的是同一个 i。等到点击事件被触发时,循环早就跑完了,i 自然就是 3。解决方式是使用 let,每一轮循环都会创建独立的变量绑定,闭包访问到的就是当轮循环的 i,而不是共享的同一个变量。如果非要使用 var,就得通过立即执行函数加参数传递来制造闭包副本。排错顺序是:先打印 i 确认值,再打印闭包捕获的环境,最后定位到循环变量的绑定问题上。这个案例让我真正理解了块级作用域和闭包变量的关系。

6.3 事件解绑失败:removeEventListener 为什么删不掉

有一个功能需求是:点击按钮后,给另一个元素注册一个 mousemove 监听,再次点击后解除监听。我写的代码大概是这样的:

btn.addEventListener('click', function () { if (isListening) { box.removeEventListener('mousemove', function (e) { console.log(e.offsetX) }) isListening = false } else { box.addEventListener('mousemove', function (e) { console.log(e.offsetX) }) isListening = true } })

问题很典型:监听永远解不掉,而且每次点击后 mousemove 会同时打印多行。排查时我先在 removeEventListener 前后打印了 box 的事件监听器数量,发现注册之后数量一直增加。再仔细看代码,原因很直接:removeEventListener 里面传的匿名函数和 addEventListener 里的匿名函数不是同一个引用,浏览器根本找不到要移除的目标。解决方法是把处理函数提取出来命名:

function onMouseMove(e) { console.log(e.offsetX) } btn.addEventListener('click', function () { if (isListening) { box.removeEventListener('mousemove', onMouseMove) isListening = false } else { box.addEventListener('mousemove', onMouseMove) isListening = true } })

这个坑让我养成了“事件绑定函数必须具名”的习惯,也让我对 JS 函数引用的概念有了更具体的感知。排错手册里我还特意补了一句话:匿名函数在 addEventListener 里看着很美,但需要解绑时就是给自己埋雷。

6.4 异步请求的竞态:样式和数据的错乱

最后一个排错案例来自异步请求的时序问题。场景很简单:搜索框里输入关键词,每输入一次就发一次请求,结果却是先输入的内容被后返回的旧数据覆盖了。最常见的表现是,快速输入“前端”后,页面先显示了为“前”字请求到的结果,然后又跳回了旧结果。本质上这不是服务端的问题,而是客户端没有处理“请求的先后返回顺序不确定”这个事实。

排查的完整链路是:我先在请求回调里打印了发送时间和接收时间,发现一个请求明明先发出去,却比后发的请求晚返回,页面被旧数据刷新了。解决办法就是加入一个请求序号,每次请求发出前让序号加一,只有最新序号的响应才允许更新页面;或者用 AbortController 取消掉之前未完成的请求。我最后选择了 AbortController,因为浏览器本身支持取消请求,不光是逻辑上忽略旧响应,还能节省网络资源。这个案例的根因已经不在“语法”层面,而是对“异步时序”的理解,所以也被我记在了排错手册的最后一页。


写到这儿,这本 JS 笔记已经从我最初以为的“课程目录摘抄”变成了我自己的排错手册和面试弹药库。如果让我给正在学 JavaScript 的朋友一句建议,那就是别急着收集别人的笔记,一定要写一份自己的,哪怕开头是流水账、字很丑、逻辑也乱。因为整理笔记的过程,本身就是你和自己的理解互相较劲的过程,别人替代不了。

最后说一个我自己坚持了很久的小习惯:每解决一个报错,我会先在笔记里新建一页,把报错信息、出错的代码片段和当时的思考过程原样贴上去,再写下结论。这样三个月后再看到时,我不仅能想起来“怎么改”,还能想起来“当时为什么卡住”。这份 JS 笔记后续我还会往里补充网络请求、性能优化和相关设计模式的内容,它对我来说不只是一份资料,更像是学前端这条路上自己写下的答卷。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进