尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

2018用友前端笔试题拆解:手写EventEmitter背后的JS核心机制

2018用友前端笔试题拆解:手写EventEmitter背后的JS核心机制 2018年那会儿用友校招的web前端笔试题在网上流传度挺高的尤其是这套题的第三题几乎成了当年不少前端求职者刷题列表里的“老朋友”。和互联网大厂偏重算法、源码的套路不同用友作为深耕To B企业管理软件的老牌厂商它的笔试题带着很明显的工程化倾向——不和你绕弯子直接考察你在真实业务里会不会用JavaScript。这篇就以第三题为例把当时这道题背后涉及的JS核心机制、答题思路、以及后来面试官追问的问题一次性拆透。1. 2018年用友校招前端笔试的整体画像与第三题定位先说下背景。用友2018校招笔试的大致结构是单选、多选、填空、简答加两道编程题题量不算小限时90分钟。前面客观题覆盖了计算机网络基础TCP三次握手、HTTP状态码、CSS盒模型与BFC、ES6新特性这些属于送分题筛的是基本功。从简答题开始难度往上走了一个台阶典型的有“解释一下闭包及其应用场景”“浏览器从输入URL到页面渲染发生了什么”这类能拉开差距的问题。而第三道编程题则出现在编程题的第一题位置。我当年拿到这道题的时候第一反应是“题目怎么这么简单”第二反应是“不对这里面肯定有坑”。这道题表面上是让候选人写一个构造函数并实现几个原型链上的方法但内里却串联了this指向、执行上下文、事件循环、异步编程模式这些前端核心的底层机制。用友的出题风格和当时主流互联网公司的风格很不一样它不考你刷了多少道LeetCode而是考你在写业务代码、封装公共组件时到底有没有把JS的运行机制吃透。结合后来和用友的前端工程师交流以及网上流传的考后回忆帖第三题的题目原型大概是这样的实现一个EventEmitter事件发射器类要求支持on、off、once、emit四个方法。事件类型为字符串类型emit触发时携带的参数数量不固定。附加要求once注册的回调只能触发一次触发完毕后需要自动移除off需要支持移除指定回调并且在移除过程中不影响正在触发的事件队列。这个题目放在今天来看依然是前端岗位笔试/面试中的高频题型。它考察的核心不是你能不能背出EventEmitter的代码而是你对“回调函数队列管理”“闭包对变量的持有”“this指向丢失与恢复”“异步执行顺序”这些底层概念的熟练度。用友选择这道题还有一个隐含意图它的很多前端业务都跑在自研的低代码平台和报表引擎上事件机制是这些系统的底层骨架。候选人如果能在笔试中写出一份健壮的EventEmitter实现说明他具备在生产环境里处理复杂交互状态的基础能力。2. 事件机制的核心原理为什么说EventEmitter是前端业务的“地基”在拆解这道题的具体写法之前有必要先把事件机制的底层逻辑讲清楚。EventEmitter并不是前端独有的概念Node.js里有内置的events模块浏览器端的很多框架Vue的$on/$emit、jQuery的on/trigger也都在不同层面实现了类似的能力。它解决的本质问题是“多个模块之间如何解耦地通信”。我经常用一个例子来解释这件事假设你的页面上有一个“保存”按钮点击之后需要同时触发数据提交、埋点上报、表单校验三个逻辑。如果这些逻辑直接写死在按钮的onclick处理函数里那么每次新增一个需求都要去改那一段代码时间一长这个函数会膨胀得不可维护。而事件发射器做的事情是把“点击”这个动作抽象成一个信号——各个模块自己决定要不要监听这个信号互不依赖。这就像公司里前台广播“3楼开会了”需要参会的人自己过去而不是前台挨个打电话通知。用代码来说事件机制的最小模型就是三个东西一张“事件名-回调函数数组”的映射表一个on用来向某个事件名下挂载回调一个emit用来遍历并执行某个事件名下的所有回调class MiniEmitter { constructor() { this._events Object.create(null); } on(type, fn) { if (!this._events[type]) this._events[type] []; this._events[type].push(fn); } emit(type, ...args) { if (this._events[type]) { this._events[type].forEach(fn fn.apply(this, args)); } } }这段代码是EventEmitter的骨架子笔试中第一步要能快速写出来。但写出来只是及格线真正的区分度在后头once怎么实现off移除回调时会不会对正在进行的emit造成影响如果emit在触发过程中又on了同一个事件会发生什么这些都是阅卷人要看的细节。我在准备这道题的时候把events模块的Node.js源码找出来对照读了一遍发现真实的实现里有一个不起眼但极其关键的设计在触发事件时回调函数数组会被复制一份再遍历而不是直接遍历原数组。这个细节的原因是回调在触发过程中很可能动态地增删监听器如果直接遍历原数组数组的长度和内容在遍历过程中发生变化会出现三种典型问题漏触发、重复触发、死循环。用友这道题虽然没有明确要求处理这种情况但代码里如果能体现“快照遍历”的思路相当于主动展示了边界意识这在阅卷时是很加分的。3. 从笔试答案到生产级实现全过程拆解与坑点复盘这道题我在笔试时写的是简化版结果有一个隐藏用例没有跑通最后虽然进了面试但笔试分数不理想。后来我又重新梳理了一遍把整个实现过程拆成了四步每一步都踩过真实的坑。下面按照“渐进式增强”的思路一步一步说清楚。3.1 第一版只满足最基础的on和emit笔试时间紧张我第一反应是先实现on和emit把基本盘拿到手代码和上面展示的MiniEmitter差不多。这一步本身没有问题问题出在一个细节emit里我是用fn.apply(this, args)还是fn(...args)这两个写法在当前场景下效果一样但面试官如果要追问“回调里的this指向哪里”答案就分水岭了。第一种写法this指向调用emit的那个实例第二种写法回调里的this是undefined严格模式下。很多候选人在这里想都不想就写了箭头函数结果回调里拿不到实例上挂载的状态。用友这类To B系统里事件回调经常需要访问所属实例的上下文比如“保存事件触发后把当前表单的id写入实例的一个属性里”这时候回调函数拿不到正确的this整个链路就断了。所以第一版实现我建议在emit里显式绑定this为当前实例这也是Node.js原生实现的默认行为。3.2 第二版为once实现“一次性”语义once的常规思路是在on一个包装函数包装函数里先调用原始函数然后调off把自己移除。once(type, fn) { const wrapper (...args) { fn.apply(this, args); this.off(type, wrapper); }; this.on(type, wrapper); return this; }这个版本看起来顺理成章但它有一个隐患如果once注册的回调在emit过程中被触发而emit采用的又是“直接遍历原数组”的方式那么off移除当前正在执行的项时数组的索引会前移导致数组里的下一个回调被跳过。这在笔试里是极其隐蔽的扣分点因为正常顺序写onceon再触发一次结果是正确的看起来一切正常只有在回调互相穿插时才会暴露。解决办法有两个方向。一是emit里遍历副本二是off里通过“标记”而不是“删除”来跳过无效回调。我当时笔试用的就是第二种方向因为那时还没意识到快照遍历的重要性但恰好绕开了数组索引前移的问题。不过标记法也有代价被标记的回调会一直占用数组空间直到事件被整体清理所以在生产环境里我建议在emit遍历副本在off时直接splice移除两件事各司其职内存和时间都兼顾。3.3 第三版off在事件触发过程中的正确行为off这个方法的边界情况最多现场笔试能全部处理好的候选人比例其实很低。三个典型的边界场景是在回调A里off了回调BB尚未执行此时B不应该被触发。在回调A里off了回调A自己此时后续的回调不应该受影响。off一个不存在的回调程序不能报错要静默返回。这些场景看起来简单但在“直接遍历原数组”的实现下第一个场景会导致B被跳过因为数组索引前移第二个场景会导致A后面那个回调被跳过同样是索引前移。所以像第3.2节说的复制数组遍历是唯一稳妥的选择也是我最终在生产代码里采用的方式。还有一个小点off应该返回this以支持链式调用Node.js原生实现是这样做的笔试题如果明确“要求支持链式调用”那你写return this就是送分项。3.4 第四版生产级EventEmitter的完整代码把上面这些思考汇总起来一份可以在生产环境里使用的EventEmitter实现是这样的class EventEmitter { constructor() { this._events Object.create(null); } on(type, fn) { if (!this._events[type]) this._events[type] []; this._events[type].push(fn); return this; } once(type, fn) { const wrapper (...args) { fn.apply(this, args); this.off(type, wrapper); }; wrapper.origin fn; this.on(type, wrapper); return this; } off(type, fn) { const callbacks this._events[type]; if (!callbacks) return this; if (!fn) { delete this._events[type]; return this; } const index callbacks.findIndex(cb cb fn || cb.origin fn); if (index ! -1) callbacks.splice(index, 1); return this; } emit(type, ...args) { const callbacks this._events[type]; if (!callbacks) return false; // 快照遍历防止回调队列在触发过程中变化导致索引错乱 callbacks.slice().forEach(fn { fn.apply(this, args); }); return true; } removeAllListeners(type) { if (type) { delete this._events[type]; } else { this._events Object.create(null); } return this; } }这段代码里有两个细节值得单独说明。第一once包装出来的wrapper函数上挂了一个origin属性指向原始函数这样一来off的时候无论外部传进来的是原始函数还是内部包装函数都能正确匹配到目标。这不是我想出来的原创技巧Node.js的 events 模块里onceWrapper就是这么设计的我是在被off匹配问题折腾了一次之后翻源码才意识到的。第二callbacks.slice()这一行是整个实现的灵魂。它确保emit遍历的是触发瞬间的快照回调在执行过程中对_events做的任何增删操作都不会影响本次遍历。代价是每次emit都产生一次浅拷贝在极端高频触发场景比如每帧触发的事件会有轻微性能损耗但换来的是逻辑的确定性。业务系统里的事件触发频率远没到需要优化这层的程度安全第一。3.5 关于this指向的几个笔试常问变体EventEmitter这道题还有一个常见的变体on注册的不再是一个普通函数而是一个对象的方法比如emitter.on(save, store.save)。这时候store.save里的this指向已经丢失了emit触发时this变成了EventEmitter实例结果就是调用失败或属性访问出错。这个问题在笔试里通常不会直接给出现象而是让你判断“以下代码的输出是什么”。我在考前整理了一个清单回调写法emit触发时 this 指向能访问 emitter 实例吗emitter.on(save, store.save)EventEmitter实例不能想访问store需要靠闭包emitter.on(save, () store.save())词法作用域store不能直接访问emitter.on(save, store.save.bind(store))store不能直接访问emitter.on(save, (...args) store.save(...args))store不能直接访问这四种写法里只有第三种和第四种能保证store.save内部拿到正确的store上下文。但第四种写法在处理不定长参数时更灵活因为emit触发时可以带任意数量的参数bind方式虽然也可以但需要在bind之后再处理参数写法上不如闭包转发直观。我在封装前端埋点SDK的时候大量用到了第四种模式外部模块只需要sdk.on(track, (...args) tracker.send(...args))发送逻辑永远拿到的都是正确上下文不需要关心this的问题。4. 笔试题隐藏的第二层从EventEmitter延伸到JavaScript异步机制用友这套笔试题的第三个考察层次藏在emit触发时机上。题目对emit的描述是“触发事件”但并没有明确说“同步触发”还是“异步触发”。当时有一部分候选人包括我在面试时被追问了这个问题如果要让emit变成异步的也就是回调不立即执行而是在当前宏任务之后执行应该怎么改这个问题瞬间把题目的难度从“会写类”拉升到了“懂事件循环”。答案的核心在于理解宏任务和微任务的差别。如果emit把回调包装成setTimeout执行那么回调会进入宏任务队列多个事件的回调执行顺序取决于定时器的到期时间和注册顺序。如果emit把回调包装成Promise.resolve().then那么回调会进入微任务队列会在当前宏任务结束、下一个宏任务开始之前执行。我画了一个简单的测试用例来验证const emitter new EventEmitter(); emitter.on(test, () console.log(回调1)); emitter.on(test, () console.log(回调2)); setTimeout(() console.log(定时器)); emitter.emit(test); console.log(同步代码);同步触发的输出顺序回调1→回调2→同步代码→定时器。如果把emit改成setTimeout触发输出顺序同步代码→定时器→回调1→回调2回调在宏任务里按注册顺序执行。如果用Promise.resolve().then触发输出顺序同步代码→回调1→回调2→定时器微任务优先于宏任务。用友这类To B系统里有大量需要异步处理的事件场景比如表单校验通过后提交、报表导出完成后通知等。笔试里如果能主动把“异步 emit 的两种实现差异”补充进答案阅卷人一眼就能看出你写过真实业务而不是只会背题。这也是我当时面试时被问到后能现场答出来的关键因为我在准备阶段特别把事件循环的Node.js代码跑了一遍记下了每种情况的输出顺序。4.1 用友笔试为什么把事件机制和异步绑在一起后来我才意识到这道题的第三问和异步机制绑定并不是刻意拔高难度而是用友自身的业务场景决定的。用友的前端界面里最常见的交互不是“点击按钮弹个框”而是在一个复杂的组织架构树中选中节点、触发权限加载、再联动渲染详情面板这种多步骤、多状态流转的操作。这类操作天然适合事件驱动每一步都是一个事件监听者自己决定要做什么。而事件与事件的衔接几乎必然涉及异步——请求要等后端返回动画要等渲染完成数据要等处理结束。所以如果你在EventEmitter这道题里展示了对异步处理的理解相当于在用友面试官面前展示了“我能直接上手做企业级前端”的信号弹。这不是一道单纯考记忆的题而是一道考工程经验的题。5. 回看这道题用友前端面试背后的用人标准与备战建议从这道2018年的笔试题里可以很清晰地反推出用友前端团队的用人标准。它在笔试题里强调事件机制、原型链、this、异步没有考查具体的框架API背后是有原因的用友前端的技术栈覆盖了老系统里的jQuery、中间过渡期的AngularJS以及现在的Vue和React多套技术栈并存意味着它对候选人的要求不是“熟悉某一种框架”而是“底层JS功底扎实能快速上手任何一套技术栈”。对于准备这类校招笔试题的候选人我的建议集中在三个方向上第一笔试题的回答要体现“分步演进”不要一上来就追求完美。阅卷人看的是你的思考过程而不是最终答案。先写一个满足基本功能的版本再逐步补充once、off的边界处理、异步触发的扩展这种写法让阅卷人一眼就能看出你不仅会写还知道为什么要这么写。第二要主动阅读Node.jsevents模块的源码。它是这个问题的最佳范本从onceWrapper到emit的快照复制再到off的边界处理所有值得学的细节都在里面。读一遍源码等于站在Node.js核心团队的肩上写答案。第三要准备“如果我是面试官我会怎么追问”。EventEmitter的追问路径非常清晰once是怎么实现自动移除的移除过程中会不会影响正在触发的循环emit是同步还是异步如果要支持异步怎么写每一个问题都是上一题的答案里埋下的引子。我当时在准备这道题时把这些追问全部写成了自问自答的笔记面试时果然被问到了两个直接原样输出。5.1 由这道题引申的EventEmitter实践清单现在回头想这道笔试题其实是我入门“事件驱动编程”的启蒙课。在后来的开发中EventEmitter的思想被我用在了很多场景里前端埋点SDK的消息通道、低代码平台的组件联动、Web Worker之间的消息中转。每写一次我都会想起当年在笔试卷子上写出的那个简略版和后来在生产代码里逐步完善的版本之间的差距。这个差距就是经验和思考的差距也是从“会写代码”到“写好代码”之间需要补的那段路。如果你现在正准备一道道刷前端笔试题我的建议是不要满足于“把题目做出来”而是对每一道题多问一句“如果放到真实业务里会遇到什么边界情况”。用友这道题之所以值得反复咀嚼就是因为它看似简单却有足够深的延展空间覆盖了从“类与继承”到“事件循环”再到“工程健壮性”的完整知识链是一道性价比极高的练习题。
返回列表