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移除当前正在执行的项时,数组的索引会前移,导致数组里的下一个回调被跳过。这在笔试里是极其隐蔽的扣分点,因为正常顺序写once+on再触发一次,结果是正确的,看起来一切正常,只有在回调互相穿插时才会暴露。
解决办法有两个方向。一是emit里遍历副本,二是off里通过“标记”而不是“删除”来跳过无效回调。我当时笔试用的就是第二种方向,因为那时还没意识到快照遍历的重要性,但恰好绕开了数组索引前移的问题。不过标记法也有代价,被标记的回调会一直占用数组空间,直到事件被整体清理,所以在生产环境里,我建议在emit遍历副本,在off时直接splice移除,两件事各司其职,内存和时间都兼顾。
3.3 第三版:off在事件触发过程中的正确行为
off这个方法的边界情况最多,现场笔试能全部处理好的候选人比例其实很低。三个典型的边界场景是:
- 在回调A里
off了回调B(B尚未执行),此时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之间的消息中转。每写一次,我都会想起当年在笔试卷子上写出的那个简略版,和后来在生产代码里逐步完善的版本之间的差距。这个差距就是经验和思考的差距,也是从“会写代码”到“写好代码”之间需要补的那段路。
如果你现在正准备一道道刷前端笔试题,我的建议是不要满足于“把题目做出来”,而是对每一道题多问一句“如果放到真实业务里,会遇到什么边界情况”。用友这道题之所以值得反复咀嚼,就是因为它看似简单,却有足够深的延展空间,覆盖了从“类与继承”到“事件循环”再到“工程健壮性”的完整知识链,是一道性价比极高的练习题。
