拓冰建站拓冰建站
首页 / 资讯中心 / 正文

用友校招前端笔试题复盘:核心考点与解题思路全解析

各位准备校招的朋友们好久没更新笔试复盘了。最近不少学弟学妹来问用友的校招前端笔试题说网上能找到的真题资源不多大多是零散的面经。正好手头整理过一版用友2019校招web前端笔试题二的完整解析今天抽空写出来把每道题背后的考点和解题思路都摊开讲清楚。先说下这份题的特点。用友是典型的B端企业服务公司跟纯互联网公司比起来它的校招笔试题风格更偏基础、偏原理不太会问你“手写一个某某框架源码”这种偏门题而是老老实实考JavaScript核心机制、浏览器原理、网络基础这些真正影响日常开发的底层能力。这份二的题目正好集中在我认为前端笔试最精华的几个板块函数与对象、作用域与闭包、异步机制、事件循环、数组与字符串的底层方法、HTTP缓存机制、原型链继承。难度中等偏上但每道题都很有代表性特别适合用来排查自己的基础是否扎实。另外提醒一下用友这种做ERP、云服务的老牌厂商对新人考察的核心就两点一是基础牢不牢二是工程意识有没有。它不会像部分互联网大厂那样疯狂卷LeetCode算法但如果你连call、apply的区别都说不清或者对事件循环的执行顺序都分析不明白那肯定是要被筛掉的。下面进入正题。1. 函数调用与this指向高频送命题也是分水岭用友这套题的第一道重量级题目就是关于this指向的代码输出题。题目大概是这样的var name global; var obj { name: obj, getName: function() { return function() { return this.name; }; } }; console.log(obj.getName()());这道题的核心考点有两个一个是this的指向规则另一个是闭包对作用域链的影响。先说结论答案是global不是obj。1.1 为什么这里的this指向了全局很多第一次做这道题的同学会踩坑觉得getName是定义在obj内部的那它返回的函数里的this也应该指向obj。这就是对this的误解。this的指向不是由函数定义位置决定的而是由函数调用方式决定的。obj.getName()执行后返回的是一个匿名函数。当我用obj.getName()()这种方式调用时相当于先取到obj.getName()的返回值然后直接以裸调用的方式执行这个返回的函数。裸调用也就是没有调用者对象这时this就会指向全局对象。如果是浏览器环境严格模式下这里会是undefined非严格模式下就指向window。我当年做这道题时第一反应也是obj后来仔细一想问题出在把“定义的位置”和“调用的方式”混为一谈了。用友这题出得确实有水平光这一道题就能筛掉一批对this理解不透彻的人。1.2 稍微改一下结果就完全不同如果题目改成这样var name global; var obj { name: obj, getName: function() { var self this; return function() { return self.name; }; } }; console.log(obj.getName()());答案就是obj了。这里用了一个经典的var self this技巧在闭包外层先把this存下来内层函数通过作用域链访问self变量就绕开了this动态绑定的问题。我面试过不少候选人能答对第一道题的人不少但能讲清楚“为什么上面要用var self this来修复”的人就没那么多了。这也是在考察你是否真的理解了闭包对变量的保存机制。顺便说一句ES6之后我们有了箭头函数这个问题可以更优雅地解决因为箭头函数没有自己的this它会继承外层作用域的this。1.3 手写call、apply、bind才是真正的考验这套卷子里紧接着this指向的题目就是手写call、apply有的年份还会让写bind。这道题虽然代码量不大但很能体现一个人对函数原型、参数处理和边界情况的把握。我给出一个比较经典的call实现大家感受一下思路Function.prototype.myCall function(context, ...args) { // 处理边界context为空时指向全局对象 context context ?? window; // 用Symbol防止重名 const key Symbol(key); // 把当前函数挂到context上调用 context[key] this; const result context[key](...args); delete context[key]; return result; };核心思路就是把调用myCall的那个函数临时挂到传入的context对象上然后用context去调用它这样函数内部的this就自然指向了context。apply的实现几乎一样只是参数形式从展开的列表变成了数组Function.prototype.myApply function(context, args) { context context ?? window; const key Symbol(key); context[key] this; const result context[key](...args); delete context[key]; return result; };至于bind它和call的区别在于bind不会立即执行而是返回一个新函数同时支持预设参数。还要注意如果返回的新函数被当成构造函数使用this应该被忽略指向新创建的对象实例。能写到这个层次基本就拿满分了。2. 作用域与闭包变量提升、暂时性死区一起打包考用友的笔试题里作用域的题目也是必考的而且往往和变量提升、let/const的暂时性死区混在一起出。有一道题我把原题复现一下var a 1; function test() { console.log(a); if (true) { var a 2; } } test();这道题考的就是变量提升。var声明的变量会被提升到函数作用域的顶部所以函数内部的var a实际上是存在于整个test函数作用域内的只是在执行到var a 2之前它的值是undefined所以输出不是1也不是报错而是undefined。2.1 把var换成let结果就变了如果题目把var a 2换成let a 2结果会变成报错。因为let虽然不会提升初始化但它同样存在块级作用域在声明之前访问会触发暂时性死区也就是引用错误ReferenceError。这里有个很重要的区分点var是函数级作用域或全局作用域let/const是块级作用域。日常生活中我会建议新人把“变量提升”理解成两件事一是声明的提升二是初始化的提升。var两者都提升了只是初始化为了undefinedlet只提升声明不提升初始化所以访问时是死的。2.2 经典循环闭包题setTimeout输出什么这道题几乎每个前端校招笔试题里都会出现用友当然也不会放过for (var i 0; i 5; i) { setTimeout(() { console.log(i); }, 100); }输出的是5个5不是0、1、2、3、4。原因是var i是函数作用域的循环结束后i已经变成5了所有setTimeout回调共享同一个i等到100毫秒后执行时看到的自然都是5。如果想让输出变成0到4有几种常见改法。第一个是用闭包包一层for (var i 0; i 5; i) { (function(i) { setTimeout(() { console.log(i); }, 100); })(i); }把每次循环的i作为参数传入立即执行函数闭包中保存了每次循环时i的副本。第二个是直接改成letfor (let i 0; i 5; i) { setTimeout(() { console.log(i); }, 100); }这是我推荐的标准写法let每次迭代都会创建一个新的绑定简洁明了。2.3 闭包在实际项目里怎么用才不算滥用闭包这个考点笔试里除了代码输出题还经常以问答题出现比如“闭包的优缺点”、“项目中哪里用到了闭包”。我一般会给出这样一个框架来回答。优点可以私有化变量避免污染全局可以记住函数创建时的上下文实现柯里化、函数防抖节流、单例模式等。缺点也很明确如果没有及时释放引用容易造成内存泄漏尤其是引用了大型对象的时候。模块化开发中广泛运用的IIFE本质上也是闭包的应用场景之一。举个例子防抖函数就非常依赖闭包来保存定时器function debounce(fn, wait) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, wait); }; }每次调用返回的函数时都能通过闭包访问到外层作用域的timer从而实现“重置定时器”的效果。这种函数实站价值很高在搜索框实时查询、窗口resize监听这些场景下几乎是必用工具。3. 数组与字符串的底层方法考的是灵活运用不是死记API用友这套题里对数组和字符串的考察也非常重视但很少直接问你“map和forEach有什么区别”这种纯概念题而是喜欢给一个场景、一段代码让你判断输出或补全实现。3.1 那些年我们常错的数组方法返回值有一道题是这样的const arr [1, 2, 3, 4, 5]; const result1 arr.slice(1, 3); const result2 arr.splice(1, 3); console.log(result1); console.log(result2); console.log(arr);slice(1, 3)是截取索引1到3不包含索引3所以结果是[2, 3]。splice(1, 3)是删除从索引1开始的3个元素所以返回被删除的[2, 3, 4]同时原数组变成[1, 5]。这道题很多人能答对前半段但后半段会漏掉“原数组也被修改了”这个关键点。slice和splice虽然长得像但一个是纯函数不改原数组一个是会修改原数组这种对比特别能考察基本功。类似的对比还有map和forEach、concat和push、reduce和fold等。3.2 手写数组去重与扁平化的多层次解法用友经常考“数组去重”和“数组扁平化”这类实现题并且会要求写出多种解法。去重的经典解法从最原始的Set到reduce都有// 方案一Set const unique (arr) [...new Set(arr)]; // 方案二filter indexOf const unique2 (arr) arr.filter((item, index) arr.indexOf(item) index); // 方案三reduce const unique3 (arr) arr.reduce((acc, cur) { if (!acc.includes(cur)) acc.push(cur); return acc; }, []); // 方案四Map可以保留类型区分 const unique4 (arr) { const map new Map(); return arr.filter((item) !map.has(item) map.set(item, 1)); };其中Set方案最简洁但要注意它只能去重“严格相等”的值NaN和NaN会被看成同一个实际应用中没问题。如果数组里既有数字1又有字符串1不同方案的处理结果可能不一样面试时如果能主动提出来会显得思考更全面。数组扁平化也一样可以写递归版也可以写迭代版。这道题特别能体现对递归的理解// 递归实现 function flatten(arr) { return arr.reduce((acc, cur) { return acc.concat(Array.isArray(cur) ? flatten(cur) : cur); }, []); }reduce这里真的很好用每次都把当前元素处理的结果拼到累加器里如果是数组就继续递归。如果想压平多层Infinity也是一种选择const flatArr arr.flat(Infinity);3.3 手写简易版String.prototype.trim用友有一次笔试出现过“手写trim方法”这种题目。看到题别慌思路其实就是用正则把首尾空格替换掉String.prototype.myTrim function() { return this.replace(/^\s|\s$/g, ); };这个正则的^\s匹配开头连续空白\s$匹配结尾连续空白|是或者关系g标志保证全局替换。更完整的版本还可以考虑空白字符包含空格、制表符、换行符、不换行空格等用\s就已经覆盖到了。面试官如果继续追问可能会问“如果既要首尾去空格又要缩进多空格为单空格的场景怎么处理”。这时候可以replace(/\s/g, )但要注意不要影响文本内部的正常换行逻辑。这个扩展点很值得准备。4. 事件循环与异步机制一道题看清你对JS运行机制的理解异步和事件循环这块是用友校招前端笔试的重头戏。我印象里这套卷子里的异步题难度不算低直接给了一段混合了setTimeout、Promise、async/await的代码让写出输出顺序。这种题目答对的人很多但能完整讲出“为什么是这个顺序”的人就不多了。4.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(() { console.log(setTimeout); }, 0); async1(); new Promise((resolve) { console.log(promise1); resolve(); }).then(() { console.log(promise2); }); console.log(script end);最终输出顺序是script start async1 start async2 promise1 script end async1 end promise2 setTimeout我展开讲一下推导过程。同步代码先执行打印script start。遇到setTimeout把它交给Web API0毫秒后放入宏任务队列。继续执行async1()它内部第一行是同步输出async1 start。然后执行到await async2()async2是异步函数先同步执行输出async2随后await会把后续代码放到微任务队列中可以理解为暂停。接着继续同步执行new Promise里那部分代码打印promise1并调用resolve().then回调放入微任务队列。再接着同步执行script end。此时同步代码全部清空事件循环开始清理微任务队列按顺序执行微任务先执行async1后面的console.log(async1 end)再执行promise2。微任务清空后从宏任务队列取出最早的那个setTimeout打印setTimeout。整个过程很典型的体现了“先同步、再微任务、后宏任务”的规则。我在实际学习中总结出一句话记忆法当同步代码结束后事件循环会把当前微任务队里所有排队任务全部跑完然后才去宏任务队列取下一个任务。4.2 Promise.all与Promise.race的坑点除了事件循环输出题用友笔试里还爱考Promise.all和Promise.race的变体。比如给你这样一个场景有两个请求要等两个都成功后再渲染如果其中一个失败就显示错误页。这就是典型的Promise.all场景但很多人不知道Promise.all有一个经典的“全部失败才走reject”的误解实际上只要有任何一个失败它就会立刻reject。另外Promise.all传入的数组里如果包含非Promise值会直接通过Promise.resolve包装返回值的顺序会保持传入顺序而不是完成顺序这个也很容易考。有一个我比较推荐的写法当需要在Promise.all中保留每个请求的独立结果同时不希望某个请求失败影响整体时可以用这种包装const safeRequest (p) p.then( (res) ({ status: fulfilled, value: res }), (err) ({ status: rejected, reason: err }) ); Promise.all([safeRequest(req1), safeRequest(req2)]).then((results) { // 每个结果都会保留 });这其实有点Promise.allSettled的意思了ES2020已经原生支持Promise.allSettled但如果面试官想考察你对Promise底层原理的理解手写一个简易版allSettled也是一种常见变形。4.3 async/await的异常处理别只想着try...catch最后再补一个高频考点就是async/await的异常处理。用友这类厂商笔试题一般会给你一个用async写的函数里面一段await可能会抛错问你如果不做处理会发生什么。答案很简单它会返回一个rejected状态的Promise在调用处可以用.catch捕获也可以用外层try...catch包裹。但有一点很多人没注意如果这个函数被直接调用没有赋给任何变量也没有.catch那在浏览器里就会产生一个未处理的Promise rejection控制台会报一个很扎眼的错误。所以我在自己的项目里养成了一个习惯所有async函数的入口处都会套一层错误捕获要么try...catch要么在外层调用时补一个.catch兜底。否则排查线上问题的时候你会发现错误日志被吞得干干净净完全找不到源头。5. 浏览器渲染与HTTP缓存B端前端绕不开的底层知识用友是做企业级服务的它的Web产品往往承载了大量复杂报表、审批流、数据看板等业务对页面渲染性能和资源加载策略非常看重。所以它笔试里一定会出现浏览器渲染机制和HTTP缓存的题而且经常是应用题。5.1 从输入URL到页面渲染你该按什么逻辑把流程讲清楚用友曾经有一道简答题让简述“从输入URL到页面显示经历了哪些过程”。这个题目看似基础但实际能完整体现知识体系。我的回答思路会这样展开DNS解析把域名解析为IP地址过程中会经过浏览器缓存、系统缓存、路由器缓存、根DNS服务器等多级查找。建立TCP连接也就是三次握手如果是HTTPS还需要先做TLS握手协商加密参数。发送HTTP请求携带请求头、Cookie等信息。服务器处理请求并返回响应包括状态码、响应头、响应体。浏览器收到HTML后开始解析构建DOM树遇到CSS则构建CSSOM树。将DOM树和CSSOM树合成渲染树计算布局进行绘制。在执行脚本时根据defer、async等属性决定脚本加载顺序可能会阻塞渲染。页面加载完成后触发DOMContentLoaded和load事件。这道题的关键不在于把流程背得多完整而在于你能否在讲到渲染时主动引出“重排和重绘的区别”在讲到资源加载时主动引出“脚本放在头部还是尾部的差异”。考官要的不是一张字典而是你有没有结构化思考的能力。5.2 强缓存与协商缓存请求头里的乾坤另一道高频笔试题是让你对比强缓存和协商缓存并说出对应的字段。我在这里把最核心的结论列出来缓存类型核心字段缓存决策强缓存Cache-Control: max-age3600、Expires在有效期内直接使用本地缓存不发请求协商缓存ETag/If-None-MatchLast-Modified/If-Modified-Since每次都要向服务器发起请求服务器判断是否命中缓存返回304则用缓存现在推荐使用的是Cache-Control而不是Expires因为Expires依赖本地时间时间不准会出问题。而Cache-Control的max-age是相对时间明确了很多。实践中还会用到Cache-Control: no-cache和no-store的区别。no-cache的意思是“不要直接用本地缓存每次先问服务器能不能用”它会走协商缓存no-store才是真正的完全不缓存。这俩特别容易混淆笔试里经常考。5.3 为什么企业级前端项目要专门处理缓存策略用友这类公司的产品会有很明显的B端特征管理系统、报表、审批流更新频率不高但又要保证用户改动后能看到最新数据。如果单纯引入一个service worker或者打包时给文件名加hash就能解决大部分问题。我见过很多前端团队在部署时使用的策略HTML文件设置为no-cache保证每次都能拿到最新的入口文件JS、CSS等静态资源文件加上内容hash然后设置Cache-Control: max-age31536000也就是一年强缓存。因为文件内容变了文件名hash就会变浏览器会当作新资源请求文件名没变说明内容没变直接用缓存即可。这是目前业内最通用的一种静态资源缓存策略也是前端校招里经常出现的论述题答案。6. 浅拷贝与深拷贝原型链和对象引用的双重考验用友的笔试里面浅拷贝和深拷贝这道题出现的频率相当高而且往往会和原型链的题目连在一起考。我见到过一道综合题给一个嵌套对象要求实现深拷贝并说明为什么用JSON.parse(JSON.stringify(obj))会有局限性。6.1 JSON方法为什么不是万能的先用最常见的方式const clone JSON.parse(JSON.stringify(original));这个一行代码深拷贝确实方便但有四个经典限制函数、undefined、Symbol这些值会被直接丢弃或变成nullDate对象会被转成字符串而不是保留为Date实例RegExp对象会被转成空对象循环引用会直接抛错。对应到实际业务场景比如后端返回的数据里带着时间戳如果你用JSON.parse(JSON.stringify())去深拷贝再对这个字段调用.getTime()那就会直接报错。这一点在企业级开发里特别常见尤其是报表筛选条件、表单回显这些环节。6.2 手写深拷贝应该覆盖哪些知识点如果让我给一份面试能写出的递归深拷贝代码我的思路是这样function deepClone(obj, hash new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj); if (hash.has(obj)) return hash.get(obj); const result Array.isArray(obj) ? [] : {}; hash.set(obj, result); Object.keys(obj).forEach((key) { result[key] deepClone(obj[key], hash); }); return result; }这里有几个值得说的点。用WeakMap来缓存已经拷贝过的对象是为了解决循环引用问题用Date和RegExp的特判是为了解决JSON方法的局限用Object.keys而不是for...in是为了避免枚举到原型链上的属性。当然这版代码还够不上完美比如没有处理Map、Set、Symbol属性等但在笔试限时场景下能把上面几点写全面已经是优秀水准了。如果在笔试时间充裕的情况下主动提一句“如果要支持Map和Set我会再扩展两个分支”就完美了。6.3 深拷贝之外的思考才是加分项有一个细节很容易被忽略笔试里问深拷贝其实往往不只是考递归写法还在考你是否理解“引用类型在赋值时只拷贝指针”这个问题。如果这道题以代码输出形式出现比如const a { name: a }; const b a; b.name b; console.log(a.name);答案几乎所有人都会但能解释清楚“为什么改变b会改变a而基本类型不会”的人才算真正理解了JS的内存模型。我一般会用一句话讲清楚基本类型存的是值引用类型存的是指向堆内存的地址复制引用类型时复制的是地址不是对象本体。有了这层认知深浅拷贝的题目就是送分题。7. 用友笔试的节奏、策略和常踩的坑讲完具体考点我再从应试策略角度说说用友这类校招笔试题应该怎么安排时间。整体卷面我印象里是选择题30%左右、填空题10%、简答题20%、手写代码题40%。手写代码题的占比非常高所以如果你光会选答案、不会在编辑器里快速写出可运行代码那会比较吃亏。7.1 时间分配的实战建议我建议按这个比例来分配时间选择题和填空快速过不要纠结超过1分钟简答题控制在15分钟以内每道题按“结论先行再补原理最后给例子”的结构来写手写代码题留出至少50分钟以上的时间每道题先写思路注释再写代码最后跑一个简单例子验证逻辑。很多人做题容易犯一个错误在一道选择题上死磕很久说自己“就是想知道答案”。但笔试是限时的为了一个不确定的选项放弃后面一整道大题的代码分很不划算。如果实在不确定我会先在题号旁边标记一个?选一个直觉上最合理的等所有题做完再回头复查。7.2 代码题如何避免“思路对但是分低”代码题最可惜的情况是思路完全正确但挂了几个边角条件丢分。我总结三个提升代码题得分的习惯。第一函数入口先处理边界条件。比如手写深拷贝时第一个if (obj null || typeof obj ! object)能不能想到直接决定了答案的上限。第二核心语句写注释。不需要逐行解释而是在关键难点旁边用一行批注说明“这里用WeakMap避免循环引用”让阅卷人快速get到你的思考过程。第三自己提出一个简单用例验证。写上类似console.log(deepClone({a:{b:1}}))这样的测试调用可以直观展示代码可运行也能让自己发现语法小错误。7.3 答题之外的隐藏加分项笔试的时候卷面上偶尔会有开放性问题比如“列出你常用的性能优化手段”。这种题目没有标准答案但特别能拉开差距。我常用的回答框架是从网络层面讲减少请求数、CDN加速、利用HTTP缓存从代码层面讲路由懒加载、组件按需加载、图片懒加载、长列表虚拟滚动从渲染层面讲减少重排重绘、使用transform替代top/left动画、高频事件用防抖节流。在答这种开放题的时候不用面面俱到但每一条都要能说一句“为什么有效”。比如长列表虚拟滚动它的原理是只渲染可视区域内的列表项而不是把几千条数据全部渲染进DOM这样能显著减少DOM节点数量和内存占用。这样一写阅卷人就会觉得你不仅知道名词而且理解原理。8. 用友笔试常考考点总结与拓展方向最后我再来一张速查表把这套题涉及的核心考点、对应原理、典型场景串一下。这份表格是我自己复习时整理的现在分享出来你们可以直接拿去用。考点板块核心知识点典型题目/场景this指向调用方式决定this箭头函数继承外层this对象方法返回函数、call/apply/bind手写作用域与闭包变量提升、暂时性死区、闭包应用setTimeout输出、防抖节流实现数组/字符串数组方法是否修改原数组、去重、扁平化slice vs splice、trim手写异步机制事件循环、微任务宏任务顺序、Promiseasync/await混合输出题浏览器与缓存渲染流程、强缓存与协商缓存输入URL到页面显示、缓存字段对比对象拷贝引用类型、深拷贝/浅拷贝、JSON局限深拷贝手写、循环引用处理框架基础Vue响应式原理、React setState同步异步组件生命周期、虚拟DOM如果你在校招笔试前准备时间有限我建议优先复习前三行和后三行。这是用友笔试题里出题密度最高的区域也是前端开发每天都会用到的底层能力。把这些考点吃透了不只能应付用友市面上大部分B端企业的前端笔试也都能覆盖个七七八八。最后再分享一个小技巧做笔试题之前先花30秒快速浏览一遍全部题目把会做的题目标记出来。先做那几个确定的题目把基础分稳稳拿下再回头啃难题。校招笔试不是竞赛抢答关键是“会做的全做对不会做的能拿步骤分”。我当年就是用这个策略在有限时间里完成了整套卷子最后顺利进面。希望这份复盘对你们有帮助。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门