当前位置: 首页 > news >正文

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

2018年那会儿,用友校招的web前端笔试题在网上流传度挺高的,尤其是这套题的第三题,几乎成了当年不少前端求职者刷题列表里的“老朋友”。和互联网大厂偏重算法、源码的套路不同,用友作为深耕To B企业管理软件的老牌厂商,它的笔试题带着很明显的工程化倾向——不和你绕弯子,直接考察你在真实业务里会不会用JavaScript。这篇就以第三题为例,把当时这道题背后涉及的JS核心机制、答题思路、以及后来面试官追问的问题一次性拆透。

1. 2018年用友校招前端笔试的整体画像与第三题定位

先说下背景。用友2018校招笔试的大致结构是:单选、多选、填空、简答加两道编程题,题量不算小,限时90分钟。前面客观题覆盖了计算机网络基础(TCP三次握手、HTTP状态码)、CSS盒模型与BFC、ES6新特性,这些属于送分题,筛的是基本功。从简答题开始,难度往上走了一个台阶,典型的有“解释一下闭包及其应用场景”“浏览器从输入URL到页面渲染发生了什么”这类能拉开差距的问题。而第三道编程题,则出现在编程题的第一题位置。

我当年拿到这道题的时候,第一反应是“题目怎么这么简单”,第二反应是“不对,这里面肯定有坑”。这道题表面上是让候选人写一个构造函数,并实现几个原型链上的方法,但内里却串联了this指向、执行上下文、事件循环、异步编程模式这些前端核心的底层机制。用友的出题风格和当时主流互联网公司的风格很不一样:它不考你刷了多少道LeetCode,而是考你在写业务代码、封装公共组件时,到底有没有把JS的运行机制吃透。

结合后来和用友的前端工程师交流,以及网上流传的考后回忆帖,第三题的题目原型大概是这样的:

实现一个EventEmitter(事件发射器)类,要求支持onoffonceemit四个方法。事件类型为字符串类型,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

笔试时间紧张,我第一反应是先实现onemit,把基本盘拿到手,代码和上面展示的MiniEmitter差不多。这一步本身没有问题,问题出在一个细节:emit里我是用fn.apply(this, args)还是fn(...args)

这两个写法在当前场景下效果一样,但面试官如果要追问“回调里的this指向哪里”,答案就分水岭了。第一种写法,this指向调用emit的那个实例;第二种写法,回调里的thisundefined(严格模式下)。很多候选人在这里想都不想就写了箭头函数,结果回调里拿不到实例上挂载的状态。用友这类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这个方法的边界情况最多,现场笔试能全部处理好的候选人比例其实很低。三个典型的边界场景是:

  1. 在回调A里off了回调B(B尚未执行),此时B不应该被触发。
  2. 在回调A里off了回调A自己,此时后续的回调不应该受影响。
  3. 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功底扎实,能快速上手任何一套技术栈”。

对于准备这类校招笔试题的候选人,我的建议集中在三个方向上:

第一,笔试题的回答要体现“分步演进”,不要一上来就追求完美。阅卷人看的是你的思考过程,而不是最终答案。先写一个满足基本功能的版本,再逐步补充onceoff的边界处理、异步触发的扩展,这种写法让阅卷人一眼就能看出你不仅会写,还知道为什么要这么写。

第二,要主动阅读Node.jsevents模块的源码。它是这个问题的最佳范本,从onceWrapperemit的快照复制,再到off的边界处理,所有值得学的细节都在里面。读一遍源码,等于站在Node.js核心团队的肩上写答案。

第三,要准备“如果我是面试官,我会怎么追问”。EventEmitter的追问路径非常清晰:once是怎么实现自动移除的?移除过程中会不会影响正在触发的循环?emit是同步还是异步?如果要支持异步怎么写?每一个问题都是上一题的答案里埋下的引子。我当时在准备这道题时,把这些追问全部写成了自问自答的笔记,面试时果然被问到了两个,直接原样输出。

5.1 由这道题引申的EventEmitter实践清单

现在回头想,这道笔试题其实是我入门“事件驱动编程”的启蒙课。在后来的开发中,EventEmitter的思想被我用在了很多场景里:前端埋点SDK的消息通道、低代码平台的组件联动、Web Worker之间的消息中转。每写一次,我都会想起当年在笔试卷子上写出的那个简略版,和后来在生产代码里逐步完善的版本之间的差距。这个差距就是经验和思考的差距,也是从“会写代码”到“写好代码”之间需要补的那段路。

如果你现在正准备一道道刷前端笔试题,我的建议是不要满足于“把题目做出来”,而是对每一道题多问一句“如果放到真实业务里,会遇到什么边界情况”。用友这道题之所以值得反复咀嚼,就是因为它看似简单,却有足够深的延展空间,覆盖了从“类与继承”到“事件循环”再到“工程健壮性”的完整知识链,是一道性价比极高的练习题。

http://www.cnnetsun.cn/news/4295157.html

相关文章:

  • 2016校招前端笔试题复盘:JavaScript基础与浏览器原理是核心
  • 百度核心网络研发校招笔试题解析:TCP/IP、epoll与网络底层考点
  • OpenCut:如何5分钟跑通这款免费开源视频编辑器?新手完整指南
  • 网易校招云计算网络开发笔试题:VPC/SDN/VXLAN核心考点全解析
  • Windows系统文件Windows.Internal.Shell.XamlInputViewHost.dll丢失找不到问题解决
  • 小批量梯度下降法:原理、优势与工程实践
  • 包管理工具(cnpm,yarn)
  • 前端面试必问:DNS解析原理与实战排查全指南
  • 一文讲透|盘点2026年遥遥领先的的AI论文网站
  • 接入AI 模型实现聊天流式输出
  • PowerToys FancyZones 实战指南:从初始配置到多显示器布局的完整流程
  • 前端面试必考:JavaScript闭包原理与手撕代码详解
  • 基于SpringBoot的在线招聘系统系统设计与实现源码+文档+讲解视频
  • 如何快速搭建ops-nn开发环境:Docker、CANNLab与本地部署3种方式完整实战
  • 交易类项目-flink
  • 如何快速找到高质量公开数据集:awesome-public-datasets 完整使用指南
  • 蓝桥杯单片机频率计设计:测频法与测周法融合及自动量程切换实战
  • 第四范式前端笔试复盘:从JS到算法,原理型选手的筛选
  • markitdown:两条命令把 PPT 转成 AI 能直接读的 Markdown
  • 大扭矩电机驱动IC怎么选?以RMC2082为例讲透选型逻辑
  • 提示工程完整指南:4个核心技巧10分钟写出稳定提示词
  • OpenCode选型指南:本地终端AI编程助手能否替代商业订阅
  • 基于复合词与动态超图的音乐生成:解决长序列结构化创作难题
  • 阅文前端笔试题拆解:从CSS布局到异步编程的实战指南
  • Hoppscotch 实时通信测试指南:3 步完成 WebSocket 与 SSE 接口联调
  • 前端笔试怎么备考?以格力真题为例拆解考点与答题策略
  • 嵌入式C++开发实战:从环境搭建到智能小车系统构建
  • 如何快速打造你的智能饮食规划助手:一份面向新手的完整指南
  • 深度拆解网易雷火游戏研发笔试:从C++到综合架构设计全解析
  • MinerU:3 条命令把 PDF 和 Office 文档解析成 Markdown/JSON