“js学习二”这个标题,乍一看有点像随手记的笔记名,但它其实是很多前端学习者必经的一个分水岭。第一次接触 JavaScript 的时候,大家往往停留在变量、循环、DOM 操作这些基础语法上,能写页面、能调接口,就觉得自己“会了”。真正开始啃进阶内容后才发现,回调、闭包、this 指向、事件循环,每一个概念都能把人绕晕。这篇博文就围绕“js学习二”这个节点,聊聊我实际学习、踩坑、再复盘的过程,把第二阶段最该掌握的核心知识点和实操经验整理出来。不管你是刚学完语法基础、准备进阶的新手,还是写了一段业务代码但总觉得底层逻辑模糊的开发者,这篇文章都能帮你把零散的知识串成体系。
1. 内容整体设计与思路拆解
1.1 第二阶段为什么从“函数”和“异步”切入
JavaScript 这门语言最特别的地方在于:函数不只是定义行为,它本身也是一种值,可以传递、可以返回、可以嵌套,甚至可以作为另一个函数的参数。这种设计让 JS 有了极高的灵活性,但同时也带来理解上的陡坡。第一阶段学习时,我们写的函数大多数是扁平的、一次性的:定义一个函数,调用它,拿到结果。到了第二阶段,函数的角色变了——它是构建逻辑单元的核心工具,比如回调函数、数组高阶方法、定时器、事件监听,背后全是函数在“流动”。
与之紧密相关的就是异步。浏览器里几乎所有耗时的操作都是异步的:网络请求、定时器、用户交互、文件读取。JS 是单线程语言,却靠着事件循环和回调机制做到了“不阻塞”。很多初学者在第二阶段最容易困惑的问题就是:“为什么代码明明按顺序写,执行结果却不是按顺序来的?”这个问题不搞清楚,后面写接口请求、轮询、并发控制都会一头雾水。
所以我在设计这一阶段的学习内容时,把“函数进阶”和“异步编程”作为两条主线。不是因为它们高深,而是因为它们卡住了太多人,是后续学习模块化、框架原理、性能优化绕不开的底层基石。
1.2 学习路径的取舍原则
第二阶段的知识点很多,如果全铺开学,很容易陷入“什么都学、什么都没学会”的困境。我的取舍原则很简单:优先学那些“每天都在用、但天天有人出错”的内容。比如 this 指向,业务代码里到处都是,但十个前端面试者里至少有一半说不出为什么事件回调里 this 变了;比如 reduce,面试常考、业务常用,但很多人只会用 map 和 filter;再比如事件循环,看起来偏底层,却直接决定你在 setTimeout 和 Promise 混用时能不能预判输出顺序。
我当时给自己定的目标是:能够独立分析一段“异步 + 闭包 + this”混合的代码,准确说出每一步的执行顺序和 this 指向,就算过关。事实证明,这个目标虽然听起来不难,但真正能在纸上推演正确的人并不多。这一阶段的学习,比的不是记忆量,而是能否把概念“串”起来。
1.3 笔记内容的组织逻辑
“js学习二”这个名字虽然简单,但它实际上暗示了一份进阶笔记的组织方式:不是按语法清单逐条罗列,而是按“问题驱动”来组织。我在整理笔记时从来不按“函数、对象、数组、Promise”这种目录来写,而是反过来问自己:每次卡壳时,真正的问题是什么?
比如“为什么闭包能记住外部变量”,背后的真正问题是“作用域链和变量生命周期”;“为什么所有 forEach 里 this 是 undefined”,背后的真正问题是“this 绑定规则”;“为什么 Promise 比 setTimeout 先执行”,背后的真正问题是“宏任务与微任务的执行顺序”。按问题来组织内容,好处有二:一是学的时候目的性强,二是复习的时候能直接定位到自己的薄弱点。这篇博文的结构,我也是按这个思路来安排的。
2. 核心细节解析与实操要点
2.1 作用域与闭包:被低估的底层逻辑
闭包这个概念,几乎每本 JS 书都会讲,但能真正讲清楚的很少。我第一次学习闭包时的感受是:知道“函数内部可以访问外部变量”这个现象,但不知道它有什么用。后来在写事件绑定和定时器时才开始意识到,闭包真正的作用是“让变量在函数执行结束后依然活着”。
举个例子:
function createCounter() { let count = 0; return function () { count++; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2这里的 count 按常理来说,在 createCounter 执行完后就该被回收了。但因为内部函数还引用着它,所以它被保留在内存中,并且只有内部函数能访问。这种“私有变量”的效果,在日常业务里非常有用,比如节流、防抖、缓存、单例模式,全依赖这个机制。
但闭包也是一把双刃剑。它保留变量的同时也意味着内存不会立即释放。如果闭包被全局持有,而且内部引用了大对象,那这块内存就会一直占着,直到页面关闭。很多性能问题排查到最后,都是闭包使用不当造成的。实操上建议:闭包只在需要“保留状态”的时候用,用完的引用记得置 null,尤其是事件监听器里创建的闭包,一定要在组件销毁时做解绑,否则轻则内存泄漏,重则导致 DOM 节点无法被回收,页面越来越卡。
2.2 this 指向的四种判断法则与箭头函数特例
this 是 JS 进阶路上最大的“劝退点”之一。它跟作用域不同:作用域看的是函数在哪里定义,this 看的是函数在哪里、以及如何被调用。同一个函数,不同调用方式下 this 完全不同。我总结出了四步判断法,屡试不爽:
- 如果函数是普通调用(函数名加括号直接执行),严格模式下 this 是 undefined,非严格模式下指向全局对象。
- 如果函数是某个对象的方法(obj.method()),this 指向该对象。
- 如果使用了 call、apply、bind,this 指向显式传入的第一个参数。
- 如果函数是用 new 调用的,this 指向新创建的对象。
箭头函数是唯一的例外:它没有自己的 this,所谓 this 就是它的外层作用域的 this。这个特性让箭头函数特别适合做事件回调和定时器,因为不需要额外绑定就能继承外层 this。
我早期踩过最典型的坑是:在事件监听器里用普通函数,结果 this 指向了触发事件的 DOM 元素,而我想访问组件实例里的数据,结果拿到的全是 undefined。后来我总结了一条规则:回调函数里需要访问外层 this 时,优先用箭头函数;需要动态指向调用对象时,用普通函数。这条规则简单,但能避免 90% 的错误。
2.3 数组高阶方法:从“遍历”到“声明式处理”
第二阶段很值得花时间去掌握的,是数组的 map、filter、reduce。很多初学者抱怨“高阶方法记不住”,我理解这种感受,因为直接从 for 循环跳到 reduce,确实跨度太大。但高阶方法的威力在于:它让我们从“怎么循环”转向“要得到什么结果”。
- map:把数组每一项映射成新的值,返回新数组。
- filter:筛选符合条件项,返回新数组。
- reduce:把数组归约为任意值,比如求和、去重、分组。
实际业务里最常见的组合是“先过滤,再映射,最后归约”。比如从订单列表里统计已支付订单的总金额:
const total = orders .filter((order) => order.status === 'paid') .map((order) => order.amount) .reduce((sum, amount) => sum + amount, 0);这段代码用 for 循环写当然也可以,但高阶写法的优势是逻辑分层清楚,每一步只做一件事。我建议的学习方法是:先刻意用 map 替代 for 循环,再给每次操作添加更多条件,最后把 reduce 当成“万能归约器”来练习。刚开始可能不习惯,但一旦上手,就再也回不去写 for 循环的日子了。
3. 实操过程与核心环节实现
3.1 Promise:从回调地狱到链式调用的关键一跃
很多初学者在学到 Promise 之前,先体验了回调地狱:一个接口返回的数据要作为另一个接口的参数,第二个接口的结果又要传给第三个。代码一层一层嵌套,缩进越来越深,读到后面根本分不清谁是谁。
Promise 的核心作用不是让代码变少,而是把“异步操作的结果”变成一种可传递的、可组合的状态。一个 Promise 对象只有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦确定就不再改变。这个“不可逆”的特性,让我们可以安全地链式处理结果。
实际操作中,我建议用三段式写法来封装请求:
function fetchUserInfo(userId) { return new Promise((resolve, reject) => { setTimeout(() => { if (userId > 0) { resolve({ id: userId, name: '某开发者' }); } else { reject(new Error('非法用户ID')); } }, 300); }); } fetchUserInfo(1) .then((user) => { console.log('用户信息:', user); return fetchUserInfo(user.id + 1); }) .then((nextUser) => { console.log('下一个用户:', nextUser); }) .catch((err) => { console.error('请求失败:', err.message); });这里的细节在于:第一个 then 里 return 的新 Promise,会作为下一个 then 的输入。如果中间任何一个环节抛错,控制权直接跳到 catch。理解了这个“错误传递链”,后面学习 async/await 就顺理成章了。
我在刚学 Promise 时最容易犯的错是:在 then 里写了业务逻辑,却忘记 return 一个新的 Promise,导致下一步拿到的不是期望的数据,而是 undefined。这一种问题很难发现,因为代码不会报错,只是数据变成了 undefined。排查时建议在每一步 then 的出入口打印一次数据,快速定位断点。
3.2 async/await:让异步代码变得像同步一样
async/await 本质上是 Promise 的语法糖,但它的价值在于可读性。当我第一次用 async/await 重写上面那段接口请求时,最大的感受是:终于不用在 then 之间来回跳跃了。代码变得像从上往下读的同步逻辑,这对心智负担的降低非常明显。
async function loadUsers() { try { const user1 = await fetchUserInfo(1); const user2 = await fetchUserInfo(user1.id + 1); const user3 = await fetchUserInfo(user2.id + 1); return [user1, user2, user3]; } catch (err) { console.error('加载用户失败:', err.message); throw err; } }注意,await 只能在 async 函数内部使用,这是最常见的报错原因之一。而且被 await 包裹的异步操作如果失败,会以 throw 的形式抛出错误,所以必须用 try/catch 捕获,否则错误会直接冒泡到外层,变成一段难看的未捕获 Promise 异常。
另一个重要细节是“并发请求的提速”。如果多个请求之间没有依赖关系,不要用 await 依次等待,因为那会白白增加总耗时。正确的做法是用 Promise.all 同时发起,然后统一等待结果。
async function loadDashboardData() { const [userInfo, orderList, messageList] = await Promise.all([ fetchUserInfo(1), fetchOrderList(), fetchMessageList(), ]); return { userInfo, orderList, messageList }; }我在实际业务里就见过这种问题:明明三个接口互不依赖,硬是写了三个 await,结果接口响应时间从 200 毫秒变成了 600 毫秒。这不是异步编程的问题,是用错了异步的姿势。
3.3 事件循环与执行顺序:看懂 setTimeout 和 Promise 的排队规则
事件循环是 JS 异步机制的核心调度器。它之所以让初学者困惑,是因为它跟我们习惯的“代码从上到下执行”直觉相悖。它背后的规则其实很清晰:同步代码先执行;执行中遇到的异步任务,根据类型被放入不同的队列;等同步代码执行完,循环开始切换任务。
这里要区分两个关键词:宏任务和微任务。setTimeout、setInterval、I/O 操作属于宏任务;Promise 的 then、catch、finally,以及 async 函数内部的 await 之后的内容,属于微任务。每一轮事件循环,先执行一个宏任务,然后清空整个微任务队列,再进入下一个宏任务。
预测这段代码的输出顺序,就是检验是否理解事件循环的经典题目:
console.log('script start'); setTimeout(() => { console.log('setTimeout'); }, 0); Promise.resolve() .then(() => { console.log('promise1'); }) .then(() => { console.log('promise2'); }); console.log('script end');正确答案是:script start、script end、promise1、promise2、setTimeout。
原因很简单:同步代码先执行,所以两个 console 先输出;Promise 的 then 属于微任务,在同步代码结束后立即排队执行;setTimeout 的 0 毫秒并不代表“立即”,而是“至少等待当前宏任务结束并让出循环”,所以排在了微任务后面。我建议大家可以自己多写几段混合 setTimeout、Promise、async/await 的小代码,预测输出再运行验证,这个学习方式比看十篇教程都管用。
4. 常见问题与排查技巧实录
4.1 闭包造成的内存泄漏:问题不在闭包,在用完没舍得松手
我印象很深的一次问题排查,是页面切到下一个页签后内存占用持续上涨,怎么找都找不到原因。后来逐个检查组件的销毁逻辑,发现一个定时器里用闭包引用了整个组件实例,而组件销毁时只是移除了 DOM,并没有清除定时器。定时器每秒钟执行一次,闭包里的组件实例就被“钉”在内存里,页面切得越频繁,内存泄漏越严重。
这类问题的排查思路:先复现,再定位。打开开发者工具的内存面板,录制堆快照,观察哪些对象占据了大量空间,然后沿着引用链回溯,往往就能找到长时间未释放的事件监听器或定时器。要在写代码时就留下解绑的入口,比如计时器 ID、监听器函数引用,统一在销毁方法里清理。
4.2 回调里 this 变成 undefined:别急着改代码,先判断哪里丢失了绑定
随着 ES6 模块的普及,JavaScript 文件默认严格模式,普通函数回调里的 this 很容易变成 undefined。例如 forEach 回调、事件监听器回调、定时器回调都会出现 this 丢失的情况。遇到这个问题,我的排查路径一般是:先打印当前函数的调用方式,判断是不是普通调用;再决定是改成箭头函数、用 bind 绑定,还是在外围缓存一个 self 变量。
这里要特别提醒:改动 this 指向时,优先考虑箭头函数,因为它语义清晰;使用 bind 时要小心多次绑定的情况——bind 不会覆盖 this 的“永久绑定”,第一次 bind 后再次 bind 是无效的。很多人踩过这个坑,以为可以二次绑定改变指向,实际完全没作用。
4.3 await 后的代码不按预期顺序执行:检查你是否“忘记 await”
async/await 最大的隐藏问题,是“写了 await 但没写全”。比如:
async function logout() { await fetchLogout(); redirectToLoginPage(); }这段代码本身没问题,但如果 fetchLogout 这个函数没有返回 Promise,而是内部自己异步处理,那 await 等于没有等待任何东西,redirectToLoginPage 可能立刻执行,页面跳转发生在请求真正完成之前。这种问题在封装层尤其隐蔽:别人封装的函数看起来是请求,实际返回的是 undefined。
排查技巧:在 await 表达式上右键查看类型,或者直接在 await 前断点,观察被 await 的值是不是 Promise 实例。如果拿到的不是 Promise,就可以肯定封装层出了问题。这类问题不报错、不崩溃,只会在特定网络环境下偶尔出错,很容易被当成“偶发 bug”,查半天才发现是异步时序问题。
最后分享一个小技巧
我个人在实际学习 javascript 第二阶段时,最大的体会是:不要急着追求跑通框架,先把原生能力吃透。前端框架日新月异,每一层都在拼命把底层细节封装得更好,但回调、作用域、this、事件循环这些东西,是框架绕不过去的。无论你用的工具多高级,页面最终跑的依然是一段单线程的 JavaScript 脚本。遇到报错时,不要只看错误行号,往前翻几行,想想这里有没有闭包、有没有 this 绑定、有没有异步时序问题。把分析问题的思维换成“JS 引擎视角”,很多疑难杂症突然就变得有迹可循了。