JavaScript核心知识串讲:闭包、事件循环与手写EventEmitter实战

发布时间:2026/10/13 15:53:09
JavaScript核心知识串讲:闭包、事件循环与手写EventEmitter实战 今天正好是这个系列的第 6 天。前五天我们把变量、作用域、闭包、原型、异步这些 JavaScript 知识点一个个拆开讲过但不少同学反馈说每个概念单独看都懂做题也还行一遇到真实代码就不知道怎么把它们串起来。所以第六天我没有安排任何新的大块知识点而是做了三件事——把 JavaScript 核心串联成一条能自洽的运行逻辑把前端必须掌握的网络基础补上最后用一道非常经典的手写题 EventEmitter 来做落地检验。这篇文章就是这次串讲的完整记录。如果你正处于“学了很多但连不起来”的阶段或者面试前想快速把 JS 主线捋一遍第六天这些内容可以直接拿走照着练。尤其是手写 EventEmitter 那一部分我给了从简到繁的两个版本能跑、能讲、能应对追问。1. 第六天的串联到底串的是什么1.1 为什么学了这么多知识点写代码还是卡壳我见过太多人陷入同一种学习困境闭包知道是什么意思原型链能说出那串查找规则事件循环也能背出宏任务微任务的顺序可真到写一个稍复杂的功能比如做一个带缓存功能的请求模块或者写一个事件总线立刻就开始乱套。根本原因不是知识量不够而是知识点在脑子里是孤岛。孤岛和孤岛之间没有道路你提取信息的时候只能一个个回忆根本没有办法在做题的那一刻快速组合它们。第六天的核心任务就是给这些孤岛修路。JavaScript 看起来知识点很多但真正撑起整个运行机制的主干只有三条作用域链、原型链、事件循环。剩下的变量提升、闭包、this 指向、异步回调其实都是这三条主干的衍生品。今天我要做的就是先把这三条主干打通再把分支挂上去。1.2 先给零散知识点画一张逻辑地图不用画复杂的图用文字就能说清楚。任何一段 JavaScript 代码从被浏览器拿到到真正执行完走的是同一条路线引擎把源码拆成一个个词法单元再做语法解析生成抽象语法树。这个阶段如果语法有错代码根本跑不起来。进入执行阶段。每次进入一个函数就会创建一个执行上下文压进调用栈。栈顶的上下文是当前正在执行的代码。上下文里会绑定 this 指向、确定变量环境同时为每一层函数保留它定义时所在的外部作用域引用这就是作用域链的实体。如果代码里访问对象属性会触发属性查找。对象自身没有这个属性就沿着原型链往上找。如果代码里有定时器、Promise、事件回调这些异步逻辑不会立刻执行而是进入任务队列由事件循环在合适的时机取出来执行。闭包、原型链、事件循环三大机制在这条主线上各占一段。闭包决定了函数能访问哪些变量原型链决定了对象能不能找到某个属性事件循环决定了某段代码究竟什么时候真正运行。想清楚这条主线很多看似割裂的题目其实是一道题。1.3 闭包、原型、事件循环在主线上的位置差异我习惯用角色来区分三者。闭包是作用域机制的一种保留现象函数在定义时就把外部环境记住了哪怕外层函数已经返回这个函数依然能访问外层变量。它不是魔法就是作用域链的必然结果。原型链则是对象层面的查找规则。JavaScript 里几乎都是对象你访问一个属性它先看自己有没有没有就沿着原型链往上找找到 Object.prototype 还没有就返回 undefined。继承的本质不是复制而是查找。事件循环是时间调度器。同步代码在调用栈里立即执行微任务在同步代码结束后优先清空宏任务下一轮再执行。这个顺序不是拍脑袋定的而是浏览器的渲染模型决定的理解了它setTimeout 延迟不准这一类问题就不会再困惑。这三者平时各管各的但代码一旦复杂起来就会交织在一起。比如一个闭包函数里访问对象属性同时这个函数还被放到 Promise 里异步执行你预测输出结果时必须同时用到三套知识少一套都会算错。1.4 用一道组合题检验串联效果下面这段代码输出顺序是什么不要看答案先自己在心里走一遍。function createCounter() { let count 0; return { increment() { count 1; return count; }, getCount() { return count; } }; } const counter createCounter(); console.log(A:, counter.increment()); Promise.resolve().then(() console.log(C:, counter.getCount())); setTimeout(() console.log(B:, counter.getCount()), 0); console.log(D: end);正确答案是 A: 1、D: end、C: 1、B: 1。拆开看就很清楚A 和 D 是同步代码按顺序立即执行Promise 的回调是微任务同步代码全部结束后先执行所以 C 在 B 前面setTimeout 的回调属于宏任务排在微任务之后。至于 count 为什么都能拿到值这就是闭包在起作用count 并没有消失它随着返回的那个对象一起留在了内存里。能自己把这段代码讲明白说明作用域链、闭包、事件循环已经有了初步的串联意识。第六天练习的正是这种组合判断能力而不是再学一个偏门 API。2. 网络基础前端心里要有一张完整的访问链路图2.1 从输入 URL 到页面渲染中间每一步都可能出问题网络基础这一块很多前端觉得跟自己关系不大实际恰恰相反。联调出问题时你的价值体现在能快速判断问题出在哪一层是 DNS 解析挂了是 TCP 连接被重置是服务端返回了 5xx还是本地缓存挡住了新代码。如果脑子里没有完整的链路就只能反复刷新页面干瞪眼。完整的访问链路其实就这几步输入 URL 之后浏览器先做 DNS 解析把域名换算成服务器的 IP 地址。浏览器和服务器建立 TCP 连接这里有三握手环节。如果是 HTTPS还要做 TLS 握手协商加密参数。浏览器发送 HTTP 请求携带 URL、请求方法、请求头、可能的请求体。服务器处理请求返回 HTTP 响应包含状态码、响应头、响应体。浏览器拿到 HTML 之后开始解析构建 DOM 树和 CSSOM 树执行 JavaScript渲染页面。我见过有人在本地起了项目接口报超时第一反应是去改前端代码。查了半天才发现是本地代理配置的转发地址写错了请求根本没有发到后端服务。这种情况如果你熟悉链路一眼就能锁定是第 4 步之前的问题而不是第 6 步的渲染问题。学网络基础学的不是协议背书而是排查问题时的手指方向。2.2 HTTP 缓存为什么你改了代码页面却纹丝不动前端联调最常碰到的假性 Bug就是代码改了页面还是老样子。十次里有八次是 HTTP 缓存搞的鬼。HTTP 缓存分成强缓存和协商缓存两类理解它们的关键就是看两个阶段。强缓存阶段浏览器直接读本地缓存根本不发请求。控制它的是响应头 Cache-Control比如 Cache-Control: max-age3600 代表一小时内直接用缓存。如果一个接口在开发环境被设置了很长的 max-age你就只能硬刷新或者手机关缓存再看效果。协商缓存则是浏览器会在请求里带上上一次响应给的标记比如 If-None-Match 带上 ETag 的哈希值或者 If-Modified-Since 带上 Last-Modified 的时间。服务器一看内容没变就返回 304 Not Modified响应体为空浏览器继续用本地缓存。它们之间的关系我用表格整理过记起来很方便缓存类型关键响应头是否发请求命中效果强缓存Cache-Control、Expires不发请求直接读取内存或磁盘缓存协商缓存ETag、Last-Modified发请求命中后返回 304用本地缓存第六天学到这个程度就够了重点是建立“页面没更新先查缓存”的直觉。等你真的在线上调试过几次自然会把缓存相关的响应头熟记于心。2.3 跨域到底跨的是什么同源策略和 CORS 的真相跨域这个词前端每天都会碰到但很多人理解是模糊的。跨域的本质是浏览器实施的同源策略。同源的定义是协议、域名、端口三者全部一致只要有一个不同浏览器就会拦截跨域请求的响应这个拦截发生在浏览器那一层服务器和服务器之间从不担心跨域。常见的解决方式是 CORS。服务端在响应头里加 Access-Control-Allow-Origin告诉浏览器允许哪个源访问。简单请求只需要加一个允许源复杂请求比如带自定义头或者 PUT 之类的方法会先触发一次 OPTIONS 预检请求预检通过之后才会发出真实请求。开发阶段更推荐用本地代理来绕过跨域逻辑很简单让前端页面访问同源的地址然后由开发服务器把请求转发给后端目标地址从浏览器视角看完全同源。项目里配置一个 proxy 字段指向后端服务基本就是做这一件事。理解了这个场景你在配置代理时就不会觉得它是玄学反而会很清楚自己在解决哪一层的问题。3. 手写 EventEmitter从需求出发而不是从 API 出发3.1 EventEmitter 本质上是一张事件名对应回调数组的映射表老规矩做手写题之前先想清楚这工具到底在解决什么问题。EventEmitter 是典型的事件发布订阅模型和直接在代码里写死调用方向不同发布订阅把通知方和接收方解耦了。发事件的人不需要知道谁会响应订阅事件的人也不需要知道事件从哪来两边只通过事件名取得联系。用一个生活化的例子。你加了一个快递群快递员把取件信息发到群里谁有时间谁就下楼去取。快递员不需要挨个打电话你也不需要盯紧了快递员的手机大家围绕“取件通知”这个事件名进行协作。EventEmitter 做的就是同一件事on 相当于进群emit 相当于发消息off 相当于退群once 则像是“这条消息我只想接一次”。用数据结构的语言说它本质上是一张对象表。表的键是事件名值是一个数组数组里存的是这个事件对应的回调函数。做个最简单的抽象EventEmitter 对象 数组 回调函数到这里方向就完全清晰了。3.2 第一版实现二十行代码搭起最简骨架先来实现最基础的四件套on、emit、off、once。代码不长但每一行都有它存在的意义。class SimpleEmitter { constructor() { this.events {}; } on(type, listener) { if (!this.events[type]) { this.events[type] []; } this.events[type].push(listener); return this; } emit(type, ...args) { const listeners this.events[type]; if (!listeners || listeners.length 0) { return false; } [...listeners].forEach((listener) { listener.apply(this, args); }); return true; } off(type, listener) { const listeners this.events[type]; if (!listeners) { return this; } this.events[type] listeners.filter( (item) item ! listener ); return this; } once(type, listener) { const wrapper (...args) { this.off(type, wrapper); listener.apply(this, args); }; this.on(type, wrapper); return this; } }第一版里有几个细节值得多说两句。emit 里我刻意写了[...listeners]而不是直接遍历 listeners原因后面会专门展开先用一个词概括快照。off 里用 filter 生成新数组替换掉原数组本质上是不可变更新的思路能避免不少脏数据问题。once 则是套了一层 wrapperwrapper 每次执行前先把自己解绑再调用真正的 listener。每段方法后面返回 this是为了支持链式调用例如emitter.on(a, fn1).on(b, fn2)。这个习惯从库设计层面就值得养成因为每次返回 this 的成本几乎为零但是调用体验提升很明显。3.3 第二版实现补齐生产环境真正在意的边界第一版能跑但距离生产可用还差几个关键细节。真实项目中EventEmitter 要面对的是各种预料之外的输入。第二版我把它升级成更接近标准库的样子构造函数里用 Object.create(null) 创建事件表避免事件名直接撞上原型属性比如监听名为 toString 或 constructor 的事件时不会出乱子。对 error 事件做特殊处理没有一个监听器响应 error 时直接抛出异常这是很多场景下防止静默失败的底线设计。off 时要兼容 once 包装过的回调用原始引用找不到时还要检查包装函数上的 original 标记。增加最大监听器数量的提醒逻辑防止同一个事件上被堆了成百上千个回调直接影响性能。class SafeEmitter { constructor(maxListeners 10) { this.events Object.create(null); this.maxListeners maxListeners; } on(type, listener) { if (!this.events[type]) { this.events[type] []; } if (this.events[type].length this.maxListeners) { console.warn( Warning: possible memory leak. ${type} has too many listeners. ); } this.events[type].push(listener); return this; } emit(type, ...args) { const listeners this.events[type]; if (!listeners || listeners.length 0) { if (type error) { const err args[0] instanceof Error ? args[0] : new Error(String(args[0] ?? Unhandled error event)); throw err; } return false; } [...listeners].forEach((listener) listener.apply(this, args)); return true; } off(type, listener) { const listeners this.events[type]; if (!listeners) { return this; } this.events[type] listeners.filter( (item) item ! listener item.original ! listener ); return this; } once(type, listener) { const wrapper (...args) { this.off(type, wrapper); listener.apply(this, args); }; wrapper.original listener; this.on(type, wrapper); return this; } }写到这里手写题已经超出了背诵答案的层次。你在写的过程中会自然训练到对对象引用、闭包、this 指向、数组操作的敏感度。这正好呼应了第一部分的主题一个工具内部其实也是一次 JavaScript 核心知识的综合运用。4. 手写 EventEmitter 最容易翻车的三个细节4.1 边执行边解绑为什么必须拷贝一份监听器数组手写 EventEmitter 最大的坑藏在 emit 的遍历里。很多人的第一反应是直接遍历 this.events[type]边遍历边调用回调看起来很自然。但你想一个场景回调内部执行了emitter.off(type, fn)当前监听器从数组中被移除。如果正在遍历原数组数组长度变了下一个监听器可能被跳过甚至可能出现只执行一半的问题。解决方式就是在遍历前先拷贝一份快照然后遍历快照。快照里保留了发起 emit 这一刻所有注册的监听器后续的增删改只影响原数组不影响这次已经开始的广播过程。这也是我前面写[...listeners]的原因。这个思路在真实业务里也常见比如你要遍历一个列表同时列表可能被回调修改。最稳妥的做法就是先切片复制再遍历。面试官顺着这个坑往下追问往往还会问“那某个监听器在回调里又把自身 off 了还剩几个会执行”答案是其余监听器照常执行因为快照已经定了。理解这一个点能把发布订阅模型对一致性的处理讲出深度。4.2 once 和 off 的配合包装函数的身份识别问题once 看起来简单真正写的时候有一个很隐晦的坑就是 off 时候的回调引用匹配。假设你写const handler () {}; emitter.once(log, handler); emitter.off(log, handler);直觉上你会认为注册的是 handler所以 off 也应该移除 handler。但如果实现里只存了一个包装函数 wrapperoff 拿 handler 去数组里找会发现数组里根本没有 handler因为实际存的是 wrapper于是 off 无效。这个问题我在第一版实现里其实已经暗中处理了就是给 wrapper 挂了 original 属性off 时用item.original ! listener判断一次。这类问题本质上还是对象引用的身份问题。记住一个原则包装函数和原始函数是两个不同的函数对象只要你包装过removal 时就必须要能从包装函数反向找到原始函数。不只是 EventEmitter任何装饰器模式都会遇到同样的取舍。4.3 边界场景速查表手写题能不能过全看这里把常见的边界情况整理成一张表写的时候对照检查基本能覆盖面试官八成以上的追问方向。场景期望行为实现注意点emit 过程中移除当前监听器其余监听器仍执行遍历前拷贝快照once 之后再 off 原始函数应能解除监听wrapper 挂 original 标记off 不存在的函数静默返回不报错filter 后原数组不变则忽略error 事件无人监听抛出异常emit 时检查 event error同一函数重复注册多次注册则多次执行不去重除非业务有要求监听器数量过多给出警告遍历时统计数量并 console.warn每一行其实都是一道小题。能把这些小题讲明白比单纯背出整段代码更有说服力。面试官常说的“你写一个 EventEmitter”考察的不是你会不会复制 API而是你对对象、函数、数组和边界情况有没有系统性的敏感认识。5. 学完三块内容怎么把它们用回真实项目里5.1 把 EventEmitter 改造成轻量事件总线应用很简单但又很实用。页面里如果有两个组件需要通信比如一个搜索框要通知列表组件刷新数据直接用原生事件也好用框架的状态管理也好但在不引用任何库的纯 JS 模块里EventEmitter 就很适合快速搭一个小事件总线。const bus new SafeEmitter(); bus.on(search:submit, (keyword) { fetch(/api/search?q${encodeURIComponent(keyword)}) .then((res) res.json()) .then((data) bus.emit(search:done, data)); }); bus.once(search:done, (data) { renderSearchResult(data); });能看到什么对象复用、闭包在回调里保留上下文、异步请求和事件循环穿插。这一个小例子就同时踩中了第一部分和第二部分的全部知识点。手写 EventEmitter 不只是为了面试它本身就是一种可复用代码结构。项目里多写几次你对发布订阅模型的感觉会非常扎实。5.2 网络基础和 JS 执行模型的配合网络请求不是凭空发生的。一次 fetch 调用本身就是一个异步操作它脱离当前调用栈进入浏览器的网络请求流程拿到响应之后再根据 Promise 的注册位置进入微任务队列。这串逻辑恰好把第六天的两半内容接上了。以后再做接口调优你会自动想这件事如果并发请求太多该怎么限制连接数如果接口被缓存该怎么设置请求头如果跨域被拦截问题出在预检请求还是响应头。这些都是网络视角的排查思路。能把这些思路和 Promise、事件循环的执行顺序对应上你的调试能力会明显上一个台阶。5.3 第六天之后复习节奏怎么安排第六天适合停下来做一次输出。我的建议是把今天的代码题用自己的话写在笔记里别只贴代码把你对闭包、原型、事件循环之间关系的描述写出来。第二步把 EventEmitter 的两种版本各重写一遍不看答案写完再和上面的代码做对比找出自己哪里少了边界处理。第三步给自己出一道组合题比如写一个带缓存和超时控制的请求封装里面自然会用到闭包、Promise、HTTP 缓存相关概念。这些年我带新人最大的体会是与其死记硬背不如自己设计一道能串联多个知识点的题目。手写 EventEmitter 就是这种思路的典型产物它看起来是一个小工具实际上是一块很好的试金石。第六天练完这个下一步建议去挑战手写 Promise你会发现套路是相通的一旦形成体系后面新知识吸收的速度会快很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询