前端校招笔试题深度拆解:闭包、事件循环与异步调度器
当年投用友校招web前端岗一共大概三轮笔试这套是第三套。相比前面两套全是“基础语法快问快答”第三套卷子明显更偏向工程场景题干里藏着不少用友业务后台的影子比如表格数据批量操作、后台报表加载、老浏览器兼容甚至有一道题直接让写一个异步调度器理由是批量导入文件时并发请求太大会压垮接口。如果你正在准备校招想找一套贴近B端真实工程实践的前端笔试题做自测这套题很值得认真过一遍。这篇文章会把我印象里最有代表性的几道题拆开复盘不只说答案更把当时的思考过程、翻车原因、以及后来在工作中的实际应用都串起来。1. 作用域与闭包一道选择题引发的思维纠偏1.1 原题还原这段代码到底输出什么卷子的第一道选择题就有点“阴”。题目大概长这样var a 1; function foo() { console.log(a); var a 2; console.log(a); } foo();选项给了1, 2、undefined, 2、2, 2、ReferenceError四个。很多人一看函数内部有var a 2下意识觉得第一个console.log(a)访问的肯定是全局的 1于是直接选了1, 2。当年笔试这一题挂在var变量提升上的同学非常多我身边好几个都中招了。1.2 变量提升背后的编译逻辑var声明会被提升到当前作用域顶部但赋值不会跟着提。上面这个函数在真正执行之前内部其实已经被引擎“看”成了这样function foo() { var a; console.log(a); a 2; console.log(a); }这时候再分析就清楚了第一个console.log(a)访问的是函数作用域里的a它此刻还没有赋值所以输出undefined。第二个打印才是 2。全局变量的a 1从头到尾没被函数访问到因为函数作用域里已经有了同名变量局部变量遮蔽了全局变量。变量提升不是 JavaScript 的隐藏 bug而是编译阶段的一种处理方式。真正理解它能解释很多看似诡异的现象比如函数声明和变量声明的优先级、为什么在条件语句里声明var依然会污染外层作用域等等。真题里考得不算深但你要是能把编译阶段的变量环境讲清楚面试官大概率会继续追问“那let为什么没有提升”这就是知识体系的延伸了。1.3 闭包循环陷阱的变体这道题后面还跟了一个闭包变体问下面代码输出什么for (var i 0; i 3; i) { setTimeout(function() { console.log(i); }, 0); }标准答案是3 3 3。因为var i是全局函数作用域的同一个变量等定时器里的函数执行时循环早已结束i已经变成 3。我当时在考场上的处理是直接用let替换var再把i传入闭包或者在 setTimeout 外面包一层 IIFE这几种都能得到0 1 2。后来在实际工作中类似的坑也常出现在循环绑定事件上。比如后台表格给多行绑定点击事件取行号时发现总是最后一行的 index这就是闭包共享变量的典型场景。所以这题不只是笔试题也是工程里会真实遇到的雷。如果能顺手解释 IIFE 为什么能保存变量副本说明你对闭包的理解已经超出了背答案的层面。2. 事件循环async/await 混合 Promise 的输出顺序2.1 原题让你手写出执行结果这套卷子第二道大题是让手写执行结果代码大概是async function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } console.log(script start); setTimeout(function() { console.log(setTimeout); }, 0); async1(); new Promise(function(resolve) { console.log(promise1); resolve(); }).then(function() { console.log(promise2); }); console.log(script end);我当年写的答案是script start, async1 start, async2, promise1, script end, async1 end, promise2, setTimeout。当时这是正确的顺序。但有同学写成async1 end在promise2之后或者让setTimeout更靠前说明没有把微任务队列的顺序吃透。2.2 宏任务与微任务的分层机制浏览器执行 JavaScript 是单线程的但任务分两类宏任务script整体代码、setTimeout、setInterval、UI render、I/O 等。微任务Promise.then、MutationObserver以及 Node 里的process.nextTick。每次执行完一个宏任务引擎会把手头所有微任务排空才会开始下一个宏任务。await在功能上可以理解成把后面代码放进一个微任务里。所以分析这道题的关键是同步代码先执行完再处理微任务。async1()里的async1 start是同步输出的await async2()这里async2也是同步执行并返回一个立即 resolve 的 Promise但await会让后面的async1 end进入微任务。紧接着创建的 Promise 里promise1是同步输出的它的.then又产生一个微任务。所以所有同步输出结束后微任务队列里依次是async1 end和promise2最后才是宏任务里的setTimeout。2.3 为什么有时候顺序会“变”2019 年前后各大浏览器以及 Node.js 对await的处理还在逐步对齐规范。早期 V8 引擎对await的优化会导致async1 end有可能在promise2之后输出某些浏览器确实存在这个差异。当时笔试现场没有浏览器可以验证只能靠规范推理。如果面试官针对这道题追问你可以说清楚不同版本的差异并提一下新规范里await会把后续代码包装成微任务两次最终行为趋于一致。现在的标准行为通常就是上面那种输出顺序。我后来在带新人时经常用这道题考察大家对异步的理解因为它能把宏任务、微任务和同步执行之间的关系一次性摸清。如果你在这个机制上还靠记忆输出顺序建议关掉 DevTools手写一遍调用过程比看十篇文章都管用。3. 手写代码实现一个带并发限制的异步调度器3.1 题目要求题干背景记得很清楚用友后台的批量导入功能如果同时上传很多文件几十个请求同时打到接口上很容易把服务端压垮。请你实现一个异步调度器限制同时执行的 Promise 数量为 n所有任务按序执行。函数签名可以自己设计。我当时给的是这样一个类class Scheduler { constructor(limit) { this.limit limit; this.activeCount 0; this.taskQueue []; } add(task) { return new Promise((resolve, reject) { this.taskQueue.push({ task, resolve, reject }); this.run(); }); } run() { while (this.activeCount this.limit this.taskQueue.length) { const { task, resolve, reject } this.taskQueue.shift(); this.activeCount; Promise.resolve(task()) .then(resolve, reject) .finally(() { this.activeCount--; this.run(); }); } } }add返回一个 Promise这样外面能拿到每个任务的结果。run负责在 activeCount 未满时持续出队任务。当某个任务完成时finally里递减计数再递归调用run继续捞下一个任务。这个设计很直接也比较好扩展。3.2 几种常见写法的对比我当时还写过另一版每次递归从数组末尾取任务把等待状态当成回调嵌套。这样能跑但问题在于不好返回每个任务的 Promise 结果而且错误处理分散。比如某个任务 reject 了我的旧版本会把整个调度器都带崩不符合并发隔离的要求。所以在最终提交的版本里我才把每个任务的 resolve/reject 放到外层 Promise 中保证单个任务失败不会影响其他任务。实现时有一个容易翻车的点add里要立刻调用run但run中又可能触发新的add调用。如果任务里再同步地 add 新任务就可能产生循环调用。稳妥的做法是让run通过Promise.resolve().then(() this.run())进入微任务或者利用finally回调天然异步的特性来避免同步递归。上面版本用.finally触发一般不会出现同步嵌套过深的问题。3.3 验证与扩展我习惯写一段小测试验证调度器行为const delay (ms, name) new Promise(resolve { setTimeout(() { console.log(name); resolve(); }, ms); }); const scheduler new Scheduler(2); scheduler.add(() delay(1000, 任务A)); scheduler.add(() delay(500, 任务B)); scheduler.add(() delay(300, 任务C)); scheduler.add(() delay(200, 任务D));当 limit 为 2 时初始 A、B 同时开始B 先完成然后 C 开始A 完成后 D 开始。最终输出顺序大概是 B、C、A、D。这类题在手写过程中还常见要求支持超时、取消、重试。我后来给前端上传组件加文件并发控制时就是在这个基础上扩展了 abort 信号限制同时上传文件数为 3并给单个任务加超时效果不错。笔试时如果时间够可以顺带提一下扩展思路哪怕不写全面试官也会觉得你有工程意识。4. 框架题Vue 2 的响应式原理与表格数据更新4.1 原题为什么数组通过索引赋值不生效卷子里有一道题很贴近用友后台的真实场景给了一段 Vue 2 代码data 里有一个数组list通过this.list[0] { name: 新数据 }修改后页面不更新。问原因并请给出至少两种解决办法。注意这里聊的 Vue 2 响应式原理是 2019 年前后的主流实现。Vue 3 已经换成了 Proxy不在本题范围内。4.2 Object.defineProperty 的边界Vue 2 在初始化 data 时会通过Object.defineProperty给对象的每个属性加 getter/setter并在 setter 中触发依赖更新。但数组的索引赋值并不是通过 setter 触发的Vue 出于性能考虑只拦截了数组的 7 个变更方法push、pop、shift、unshift、splice、sort、reverse。直接list[0] xxx引擎根本没有走这些方法所以视图不会响应。另外给对象新增一个不存在的属性同样不会触发更新因为属性在初始化时没有 getter/setter。这就是为什么 Vue 提供Vue.set(obj, key, value)或this.$set(...)。$set的作用是在新属性上重新定义响应式属性再手动触发依赖通知。这里有个小坑如果对象是用Object.freeze冻结过的$set也会失效因为defineProperty无法修改非扩展对象的属性。4.3 在 B 端后台最常见的翻车场景用友这类系统里表格数据经常来自接口返回的树结构或列表。业务同学往往想快速改掉某一行数据的某个字段比如点个按钮后把row.status改成已审核this.tableData[index].status 已审核;大多数情况下能生效因为status字段在接口数据返回时已经存在Vue 初始化时给它建立了响应式劫持。但如果接口一开始没有返回status字段你直接给 row 新增status页面就不会刷新。遇到这种问题推荐做法是使用this.$set(row, status, 已审核)。如果整行数据都是新结构可以直接用splice替换整个tableData[index]。理解了响应式原理后这类 bug 排查起来就很有方向而不是一遍遍刷新浏览器碰运气。4.4 顺带的简答题Vue 组件通信方式这套卷子还考了一道简答列举 Vue 2 中父子组件通信方案。无非是 props 向下、$emit向上、$parent/$children、$refs、eventBus、Vuex、provide/inject 等。笔试题不是看你背出多少而是看你在什么场景选什么。比如用友的后台往往有多个页面共享登录用户信息这种跨页面状态直接用 Vuex 更合适如果只是弹窗和父页面传参props $emit就够了。遇到深层级的表单校验联动provide/inject能少写一大串透传 props但需要留意数据可追踪性变差。写 Vue 题时最忌讳只甩名词。把原理讲透、结合场景说明方案比堆概念得分高很多。我当年拿到这道题就开始列场景面试官后来追问“为什么不用 eventBus”我答出它的事件监听难排查、容易造成隐式耦合对方就基本满意了。5. 浏览器缓存与性能优化从问答题到白屏治理5.1 真题描述这道题出现在卷子中部不是直接让默写而是做判断题。题目给出了三条说法第一条是强缓存阶段浏览器不会向服务器发送请求第二条是协商缓存返回 304 时不会下载新资源第三条是Cache-Control和Expires同时存在时以Expires为准。前两条判断“对”不难真正容易掉坑的是第三条正确答案是Cache-Control优先级更高。Expires是一个绝对时间受本地时钟影响Cache-Control的max-age是相对秒数更准确所以谁更可靠谁优先。5.2 强缓存与协商缓存完整梳理强缓存常用Cache-Control的max-age和Expires。浏览器判断缓存新鲜就直接用本地副本不发请求。协商缓存则会在请求头带上If-Modified-Since或If-None-Match服务端根据Last-Modified或ETag判断资源是否变化没变返回 304有变化返回 200 和完整资源。可以这样对比类型是否发请求状态码关键字段优先级强缓存否200 (from cache)Cache-Control, ExpiresCache-Control 优先协商缓存是但请求头带条件304ETag, Last-ModifiedETag 优先要注意返回 304 时响应头里会带上新的缓存头告诉浏览器后续还能缓存多久。如果ETag和Last-Modified都出现优先使用ETag因为它能精确到字节级变化而Last-Modified精度到秒可能漏掉一秒内的多次修改。5.3 用友后台系统怎么配置缓存才合理后台系统的首页和菜单权限往往是动态接口不能强缓存。但静态资源如 js/css/img是构建产物适合文件名带 hash 后设置max-age31536000和immutable这样浏览器永远不向服务器发资源请求只有构建 hash 变了才回源拿到新文件。如果是 Webpack 构建的项目还可以配splitChunks把依赖拆成公共 chunk不要动不动让整个 bundle 失效。我当时在这道题后面补了一段真实场景用友 U8 这类系统如果打包出一个超大 js 文件建议优先做代码分割。白屏时间往往不是网络慢而是主 bundle 太大导致解析执行时间过长。用 performance 面板扫一眼长任务再改路由懒加载和组件按需加载收益非常明显。5.4 性能优化题还能怎么考除了缓存校招常考的还有首屏性能、资源加载优先级、懒加载、preload/prefetch、requestIdleCallback等。用友笔试卷子里考过一道场景题后台报表页加载很慢请列出优化方案。我会从网络链路、渲染链路和代码层面分层回答减小入口文件体积、压缩图片、开启 gzip、接口数据量大时做分页和虚拟滚动、图表组件异步加载、给关键接口加 loading 但不要阻塞页面渲染。这种题没有标准答案关键是要有系统性的排查意识。6. 一道让我翻车的布局题三栏布局的 N 种实现6.1 原题中间自适应左右各固定 200px要求写出至少三种实现并说明在 IE 下的兼容情况。第一反应肯定是 flex但当年答题时我犯了个低级错误只写了 flex 和浮动第三种想了半天才写出 position 定位。/* 方案一flex */ .container { display: flex; } .left, .right { width: 200px; flex-shrink: 0; } .main { flex: 1; } /* 方案二position */ .container { position: relative; } .left, .right { position: absolute; top: 0; width: 200px; } .left { left: 0; } .right { right: 0; } .main { margin: 0 200px; }这两种实现非常直观但 flex 方案要求父容器display: flex左右两栏需要flex-shrink: 0防止被压缩position 方案要记得给父容器设置position: relative否则左右栏会相对更外层定位。浮动方案也值得写但要注意清除浮动否则父容器高度会塌陷.container::after { content: ; display: block; clear: both; } .left { float: left; width: 200px; } .right { float: right; width: 200px; } .main { margin: 0 200px; }这三种方案分别代表了弹性布局、脱离文档流和传统文档流三大类思路答题时能把它们的关系讲清楚比单纯堆代码更能拿分。6.2 圣杯与双飞翼老面试题里的工程智慧如果笔试时间充足可以继续写圣杯布局和双飞翼布局。圣杯布局是让三块区域都浮动利用padding与margin把两侧挤出中间区域主要为了在 DOM 顺序上让主要内容main排在前面有利于首屏渲染。双飞翼通过嵌套一个内层容器使用margin实现同样效果思路比圣杯更简单一点。这类布局在 B 端后台的经典页面是左侧菜单 中间内容 右侧详情很多老系统确实用浮动写过。现在用 flex 后代码量少很多但理解“内容优先渲染”的思想不只是为了应付笔试在做长列表页时依然有参考价值。Grid 也可以实现但 2019 年时在 IE 环境基本不可用答题时适合放在“现代浏览器方案”里提。6.3 为什么我当时会翻车说到底还是练得太少。三栏布局是 CSS 基本功但很多人包括我自己平时都用 flex 一把梭没想过在普通布局里如何优雅处理高度塌陷和 DOM 顺序。笔试里要求写多种方案本质是在考察你有没有真正在兼容性要求高的环境下布局过。用友存在大量老浏览器用户这种题出现在校招里并不奇怪。后来再做这类题我会先想三个维度文档流、脱离文档流、弹性布局。浮动属于文档流但需要清浮动position 直接脱离文档流flex 是最现代的方式。所有变体都不会跳出这几个维度。面试官听到这个总结往往比多背几种布局代码更认可。