ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用友2017校招Web前端笔试题解析:核心考点与避坑指南

用友2017校招Web前端笔试题解析:核心考点与避坑指南 每年校招季都会有不少同学在社区里翻往年真题用友2017校招web前端笔试题就是被翻得比较勤的一套。我之前带过几届实习生自己也用这套题做过模拟面试的蓝本发现里面很多考点在今天依然高频出现——原型链、闭包、事件循环、this指向、手写防抖几乎每一道都能在现在的面试里找到变体。这篇文章就把这套笔试题拆开揉碎讲一遍每道题不光给答案还会把为什么这么考、背后涉及哪些原理、答题时怎么避开常见坑都说清楚。不管你是正在准备校招的前端新人还是想查漏补缺的初级工程师这份解析应该都能帮你少走不少弯路。1. 笔试题整体设计与出题思路拆解1.1 用友这类B端企业笔试的风格特点先聊聊出题背景。用友是做企业管理软件起家的产品线以ERP、财务系统、人力系统这些B端业务为主。B端产品跟前端SaaS、C端产品有一个很大的区别页面不追求炫酷但非常看重稳定性、兼容性和业务逻辑的严谨性。所以在校招笔试里他们不会考你“用CSS画一个会动的皮卡丘”这种趣味题而是集中在语言基础、浏览器机制、工程化常识这些硬功夫上。从这套2017年的题目来看整体难度属于中等偏上但每一题都踩在点上。它不像有些公司那样出偏题怪题而是把前端最核心的几块知识——JS语言特性、异步机制、原型继承、手写能力——全部覆盖到了。我后来复盘的时候发现这套题的考点密度很高几乎每题都能延伸出一个知识面。1.2 校招候选人会被重点考察的四个维度把这套题归纳一下用友笔试主要看四个维度第一个维度是JS语言基础的扎实程度。这里不光是会写var a 1这种语法层面的东西而是看你是否理解变量提升、类型转换、闭包、作用域这些底层机制。笔试里很多题看起来是“猜输出”实际上考的正是这些机制在你脑子里的运行模型是否准确。第二个维度是异步编程的理解。前端绕不开异步而事件循环Event Loop又是异步的基石。这类题目最能拉开差距因为很多同学背了“宏任务微任务”的概念但一遇到嵌套的setTimeout和Promise组合题就晕了输出顺序完全排不出来。第三个维度是原型链与继承。这在ES6 class普及之前是必考中的必考在ES6之后依然是理解JS对象模型的关键。用友这道题直接让手写继承其实是在考察两个层面一是你有没有真正理解原型链的查找机制二是你写出代码之后能不能清楚地解释每一步发生了什么。第四个维度是手写能力。防抖节流、深拷贝、数组去重这些题本质上是在考察你是不是真的写过代码而不是只刷过概念。很多同学面试时能说出防抖节流的区别但一上手写就漏洞百出比如忘了处理this、忘了传参、忘了取消防抖的定时器。这些细节恰恰是平时写业务代码时最容易踩坑的地方。从这四个维度来看这套笔试题的筛选逻辑非常清晰基础扎实、原理清楚、能写能讲。这也给准备校招的同学指了个方向——不要一上来就追求新框架、新工具先把JS的地基打牢比什么都管用。2. 基础题详解JS语言特性与this指向2.1 typeof、变量提升与暂时性死区先说一道经典的“猜输出”题题目大概是这样的console.log(typeof a); // ? console.log(typeof b); // ? console.log(typeof c); // ? var a 1; let b 2; const c 3;第一眼看上去很简单但这里的坑不少。我专门拿这道题问过几个来面试的同学答对的比例不到一半。先看typeof a。var a存在变量提升在代码执行到console.log这一行的时候a已经被声明了只是还没有赋值所以a的值是undefined。打个比方var的变量提升相当于把“变量名”这个箱子提前放到了内存里但箱子是空的于是typeof a拿到的是undefined这本身也是一个合法值。所以第一行输出undefined注意是字符串而不是报错。再看typeof b。let和const也有提升但这个提升和var不一样它牵扯到“暂时性死区”Temporal Dead Zone简称TDZ的概念。可以这样理解let声明的变量也会被提升到作用域顶部但在真正执行到声明语句之前这个变量处于一个“不可访问”的状态访问它会直接抛ReferenceError。那为什么typeof b却输出undefined而不是报错呢这里有个关键细节typeof一个处于TDZ中的变量会抛错这是ES6规范的行为。但很多经典笔试题里typeof b的输出其实是undefined因为有的题目把typeof放在了let声明之后。如果你在笔试里看到“typeof访问未声明变量”和“typeof访问TDZ变量”这两种情况一定要区分开访问完全没声明过的变量typeof返回undefined访问声明过但还没初始化的TDZ变量会抛ReferenceError。不过说句实在话这种题在真正的开发里几乎遇不到它的意义在于考察你对声明机制的底层理解。我也遇到过一些同学背结论背得很熟但问他“为什么typeof未声明变量不报错”就答不上来了。这里的本质是typeof是一个防御性操作它被设计成对未声明的变量也返回undefined目的就是让你在不确定一个全局变量是否存在时能够安全地做类型判断比如判断某个全局变量是否可用。2.2 this指向的四条判定规则在JS面试题里this指向简直是被考烂了但每次考都有人错。这套笔试里有一道很典型的题var name window; var obj { name: obj, getName: function() { return function() { return this.name; }; } }; console.log(obj.getName()()); // ?很多人脱口而出“输出obj”因为觉得函数是在obj上调用的。但这里的关键在于obj.getName()返回的是一个匿名函数这个匿名函数在执行的时候并没有任何对象在调用它它是裸调用的。this的指向判定核心就一套规则按优先级从高到低排列第一条规则new绑定。用new调用函数时this指向新创建的对象。第二条规则显式绑定。用call、apply、bind调用时this指向你指定的对象。第三条规则隐式绑定。函数作为某个对象的方法被调用时this指向这个对象比如obj.getName()里的this指向obj。第四条规则默认绑定。函数独立调用没有任何修饰此时在非严格模式下this指向全局对象在严格模式下指向undefined。回到这道题obj.getName()执行时内部的getName函数是隐式绑定this指向obj这没错。但它返回的那个匿名函数在接下来执行的时候是独立调用的跟obj已经没有任何关系了。套用默认绑定规则非严格模式下this指向window所以最终输出window。如果不想让this丢失有个很常见的解法是在外层函数里把this存下来getName: function() { var self this; return function() { return self.name; }; }这就是经典的var self this模式当年在React老版本里也经常出现。后来有了箭头函数这个问题就优雅多了因为箭头函数没有自己的this它会继承定义时所在作用域的this。顺便说一句如果笔试里遇到箭头函数版本的this题判断方法很简单看箭头函数在哪里定义不看它在哪里调用。2.3 与的隐性转换规则还有一类必考题是判断的结果。比如这道console.log([] ![]); // ? console.log([] 0); // ? console.log([1] 1); // ?第一眼看上去毫无头绪但掌握了规则之后其实非常机械。先说[] ![]。![]的优先级高先算它。空数组转布尔值Boolean([])是true因为空数组是对象对象转布尔值恒为true。取反之后![]就是false。于是问题变成了[] false。false是布尔值按照规范两边如果一个是布尔值另一个不是会先把布尔值转成数字false变成0问题变成[] 0。然后数组是对象对象跟数字比较时会调用valueOf拿到原始值空数组的valueOf返回数组本身不是原始值于是再调用toString得到空字符串。现在问题变成 0字符串和数字比较字符串转数字空字符串转数字是0。最终0 0结果是true。再看[] 0其实刚才中间步骤已经算过了结果是true。最后[1] 1同样走对象转原始值的流程[1].toString()得到1字符串转数字得到11 1结果是true。这类题目平时写代码根本不会这么用但它考察的是你有没有认真读过ECMAScript规范里的抽象相等比较算法Abstract Equality Comparison。我给一个非常粗暴的总结比较时如果有布尔值就转数字如果有字符串就转数字如果有对象就调用toString/valueOf转原始值两边都转完再比。null和undefined互相为true但它们跟其他任何值都是false。只要把这个流程记牢了任何题都是机械计算不需要“猜”。我个人建议在笔试中遇到的题先在草稿纸上把转换链路一步步写出来不要心算心算很容易漏步骤。3. 进阶题详解闭包、原型链与事件循环3.1 循环中的闭包与let的“魔法”闭包题基本是校招前端笔试的“顶流”用友这套题也没落下。经典场景长这样for (var i 0; i 5; i) { setTimeout(function() { console.log(i); }, 100); }输出结果是5 5 5 5 5。为什么因为var声明的i是函数作用域它只有一份循环结束之后i已经变成5了。setTimeout里的回调函数在执行时通过闭包捕获的是同一个i所以全部打印5。可以理解为五个回调函数在100毫秒后回头找i但那时i已经跑到了5它们找到的是同一个最终值。那怎么改成输出0 1 2 3 4这里有三种主流方案。方案一用let替换var。这是最简洁的写法for (let i 0; i 5; i) { setTimeout(function() { console.log(i); }, 100); }let声明在循环里是每次迭代都会创建一个新的绑定相当于每一轮循环都诞生了一个独立的i闭包捕获到的就是本轮循环的i值。这种“每个迭代一个绑定”的机制是ES6规范里明确的每次迭代都会用上一轮的值创建一个新的词法环境。方案二用立即执行函数IIFE包一层。在ES6之前这是标准答案for (var i 0; i 5; i) { (function(j) { setTimeout(function() { console.log(j); }, 100); })(i); }立即执行函数每次循环都创建一个新的函数作用域参数j保存了当轮循环的i值回调函数闭包捕获的j互不干扰。方案三利用setTimeout的第三个参数。这个方法知道的人不多但很实用for (var i 0; i 5; i) { setTimeout(function(j) { console.log(j); }, 100, i); }setTimeout支持在第三个及以后的参数中传入回调函数的参数i会被作为实参传给回调函数的形参j本质上也是复制了一份值。这道题很有价值因为它同时考察了闭包、作用域、异步三个方面。我见过很多同学只背答案“用let”但被问“为什么let可以”时就说不清楚了。原因其实就是“每次循环创建新的词法绑定”理解到这一层才算是真的掌握了。3.2 手写继承与原型链的本质用友笔试有一道题是手写一个继承方案我记得比较清楚的是让基于Animal写一个Dog要求Dog能继承Animal的属性和方法。这题在ES6 class还没全面普及的2017年标准解法有好几种但如果只会写ES6的extends面试官大概率会追问一句“能不能说说ES5怎么写”。ES5手写继承里最标准的是组合继承构造函数继承 原型链继承function Animal(name) { this.name name; this.colors [black, white]; } Animal.prototype.sayName function() { console.log(this.name); }; function Dog(name, breed) { Animal.call(this, name); this.breed breed; } Dog.prototype new Animal(); Dog.prototype.constructor Dog; var dog new Dog(旺财, 柴犬); dog.sayName(); // 旺财 dog.colors.push(brown); console.log(dog.colors); // [black, white, brown]这里有几个点必须讲清楚。第一Animal.call(this, name)这行叫借用构造函数。它的作用是在创建Dog实例时把Animal构造函数里的this.name、this.colors这些实例属性都复制到新对象上。这样每个Dog实例都有自己的colors数组互不影响。第二Dog.prototype new Animal()这行叫原型链继承。它让Dog.prototype成为Animal的一个实例从而Dog的实例可以通过原型链向上查找到Animal.prototype上的方法。第三Dog.prototype.constructor Dog这一步非常关键但又最容易漏。因为Dog.prototype被整个覆盖了它的constructor指向已经被改成Animal了需要手动改回来。不修正的话dog.constructor会错误地指向Animal这在业务代码里排查bug时会非常困惑。这道题还有个经典坑如果单独用Dog.prototype new Animal()这种方式有一个副作用是colors这个引用类型属性会挂到原型上所有实例共享一个数组一个实例改了所有实例都跟着变。这就是为什么组合继承里还要再加一层Animal.call(this, name)让每个实例都拥有自己的属性副本。在笔试或者面试中我建议把继承的演进路线串一遍原型链继承 → 借用构造函数 → 组合继承 → 寄生组合继承 → ES6 extends。每讲一个方案都能说清楚它解决了前一个方案的什么问题又引入了什么新问题这样会显得你对这个问题有全盘的把握而不是背了一个标准答案。3.3 事件循环宏任务、微任务与输出顺序异步输出顺序的题几乎是笔试必考用友这道也不意外。题目一般长下面这样console.log(script start); setTimeout(function() { console.log(setTimeout); }, 0); new Promise(function(resolve) { console.log(promise executor); resolve(); }).then(function() { console.log(promise then); }); console.log(script end);这个输出顺序非常固定script start→promise executor→script end→promise then→setTimeout。很多新手想不明白为什么setTimeout明明写在Promise前面最后才执行答案是setTimeout的回调是宏任务Promise.then的回调是微任务。在一次事件循环中微任务队列会在宏任务之前被清空。我习惯这样跟同学解释事件循环就像银行柜台排队。主线程任务就像柜台正在办理的业务宏任务队列像普通窗口的排队号微任务队列像VIP窗口的排队号。主线程任务执行完系统不是先去看普通窗口而是先去把所有VIP窗口的号处理完然后再回到普通窗口。而且微任务在执行过程中如果又产生了新的微任务也会在同一个循环里全部执行完不会拖到下一轮。所以上面那道题的流程是执行主线程的console.log(script start)遇到setTimeout把它的回调放进宏任务队列遇到new Promiseexecutor是同步执行的所以立即打印promise executor注册.then的回调进微任务队列执行主线程最后一行打印script end主线程空了检查微任务队列执行promise then微任务清空再回到宏任务队列执行setTimeout。如果把这道题加一个process.nextTick或者queueMicrotask情况会再复杂一点比如Node环境里process.nextTick的优先级比Promise.then还高。但在浏览器笔试环境里记住一条主线就够了执行同步代码 → 清空微任务 → 取一个宏任务执行 → 再清空微任务 → 循环往复。这个知识点在今天的前端开发里尤其重要因为Promise、async/await、MutationObserver这些全是微任务setTimeout、setInterval、I/O、UI渲染这些是宏任务。理解了事件循环你才能解释为什么for循环里用await会卡住、为什么某些性能优化要在合适的时机把任务拆成宏任务。4. 手写题详解防抖、深拷贝与数组去重4.1 防抖与节流的实现对比用友笔试手写题里防抖和节流是常客。先看防抖题目一般是“实现一个debounce函数”。一个满足生产要求的防抖需要处理四件事this指向、参数透传、立即执行选项、取消功能。function debounce(fn, wait, immediate) { var timer null; var debounced function() { var context this; var args arguments; if (timer) clearTimeout(timer); if (immediate) { var callNow !timer; timer setTimeout(function() { timer null; }, wait); if (callNow) fn.apply(context, args); } else { timer setTimeout(function() { fn.apply(context, args); timer null; }, wait); } }; debounced.cancel function() { if (timer) clearTimeout(timer); timer null; }; return debounced; }这里最关键的两步一是fn.apply(context, args)二是用timer存下定时器ID。很多同学在练习时只写fn()忽略了透传this和参数这在真实业务里会出大问题。比如在Vue里用防抖包装一个方法如果不绑定this方法内部访问this.xxx就会报错。节流的实现思路跟防抖不一样。防抖是“疯狂触发时我只执行最后一次”节流是“疯狂触发时我保证固定时间间隔执行一次”。一个简单版本function throttle(fn, interval) { var last 0; return function() { var context this; var args arguments; var now Date.now(); if (now - last interval) { last now; fn.apply(context, args); } }; }这个版本用的是时间戳第一次触发会立即执行因为last初始是0now - 0肯定大于interval。它的缺点是最后一次触发如果距离下一次执行时间不足interval就会被丢弃。如果需要“尾部执行”的版本可以用定时器实现把两种方案揉到一起就是一个带leading和trailing配置的完整节流。笔试里如果只要求写一个我建议写防抖因为它的边界更清晰代码更短而且容易讲清楚“为什么最后只执行一次”。讲的时候一定要提到“定时器ID被不断重置”这个关键点。4.2 深拷贝实现的边界处理JavaScript中的深拷贝题2017年那时候网上主流答案还是JSON.parse(JSON.stringify(obj))但用友这种级别的笔试如果你只写这个多半会被追问“如果对象里有undefined、function、Date、RegExp怎么办”所以最好直接给出一个更完整的实现。一个能覆盖常见边界的深拷贝大致长这样function deepClone(target, hash new WeakMap()) { if (target null || typeof target ! object) { return target; } if (target instanceof Date) { return new Date(target); } if (target instanceof RegExp) { return new RegExp(target.source, target.flags); } if (hash.has(target)) { return hash.get(target); } var cloneResult Array.isArray(target) ? [] : {}; hash.set(target, cloneResult); Object.keys(target).forEach(function(key) { cloneResult[key] deepClone(target[key], hash); }); return cloneResult; }这个实现需要注意几个点。第一用WeakMap来解决循环引用问题。如果对象里面有a.self a这种自引用不用WeakMap缓存的话会无限递归栈溢出。WeakMap是弱引用的不会造成内存泄漏而且它的键是对象刚好符合我们的使用场景。第二Date和RegExp需要单独处理。直接遍历拷贝的话它们会变成空对象。第三Object.keys只遍历可枚举的自身属性如果你还想拷贝不可枚举属性和 Symbol 属性用Reflect.ownKeys会更好。但在笔试场景下写到Object.keys这个级别已经能拿到大部分分数了可以在讲解时主动提一句“如果要严谨可以换成Reflect.ownKeys”显得你有工程视野。第四function要不要拷贝在业务里函数一般直接引用共享就行了不需要深拷贝。这个可以在答题时说明一下面试官会印象加分。4.3 数组去重的多种解法数组去重是另一道高频手写题。题目可能是“实现一个去重函数能处理[1, 2, 1, 1, null, null, undefined, undefined, NaN, NaN]这种情况”。如果只用indexOf或者includes会有NaN去不掉的问题。indexOf内部用的是严格相等NaN NaN是false所以indexOf找不到NaN。但includes用的是SameValueZero算法它认为NaN和NaN是相等的所以includes方案反而能正确处理NaN。这是一个非常值得在答题时强调的细节。几种主流方案对比方案一Set。最简洁function unique(arr) { return Array.from(new Set(arr)); }Set内部也是SameValueZero可以正确处理NaN。这个方案时间复杂度是O(n)推荐优先写。方案二filter indexOf。老牌写法function unique(arr) { return arr.filter(function(item, index) { return arr.indexOf(item) index; }); }逻辑是如果当前元素在数组里第一次出现的位置就是当前位置就保留。但这个方案无法去重NaN因为indexOf找不到NaNarr.indexOf(NaN)永远是-1不等于当前索引所以NaN会被全部过滤掉。方案三Map。适用于需要记录类型区分的情况比如数字1和字符串1应视为不同的元素Map的键可以区分类型而Set在类型不同时也能正确区分所以其实Set就够了用Map更多是为了兼容老环境或者做计数。笔试里遇到数组去重我建议直接写Set方案然后补一句“如果环境不支持Set可以用filter indexOf但要注意indexOf去不掉NaN”。这样既展示了你对新特性的熟悉又体现了你对边界情况的敏感。5. 答题过程中的常见问题与避坑清单5.1 时间分配与踩坑实录把整套用友笔试题放在一起看我发现大家最容易在时间分配上翻车。前面的选择题和“猜输出”题看着简单但特别容易纠结。我就见过有同学在typeof那道题上想了五分钟其实只要掌握规则就是几秒钟的事。我的建议是第一先做手写题再做输出题。手写题的分值高而且答案相对固定如果卡住了思路会很清楚。输出题一旦纠结起来时间是没上限的。把笔记和草稿纸准备好先列出手写题的代码骨架把this、参数透传、定时器这些关键点写出来再补细节。第二遇到this和事件循环的题画图。这听起来很土但真的有用。this题就把调用链写出来“谁调用了这个函数”事件循环题就在草稿纸上画两列“宏任务队列”和“微任务队列”遇到一个往队列里丢一个然后模拟执行顺序。画完再填答案正确率会高一大截。第三代码题不要裸写。笔试的代码题即使做对了也要养成顺手写注释的习惯。比如深拷贝里的循环引用处理、防抖里的apply(context, args)写上注释会瞬间把你的水平从“会背代码”提升到“理解原理”。我还想特别提醒一点很多同学在笔试时容易忽视转换题里的“对象转原始值”顺序。规范是先调用对象的valueOf如果返回值不是原始值再调用toString。比如[1] 1[1].valueOf()返回数组本身不是原始值于是才去调toString。如果你把顺序记反了遇到自定义valueOf和toString的对象就会答错。这个细节特别值得留意。5.2 复盘方法真题之外还要补什么做完这套题之后怎么高效复盘我的建议是不要只看对错而是把每一道题背后涉及的知识链捋一遍。比如typeof题牵连出变量提升和TDZTDZ又牵连出let/const和var的区别var的区别又牵连到函数作用域和块级作用域。顺着这个链条把知识点串起来比单纯刷十套题都管用。以下我根据这些年带新人和做模拟面试的经验列一个考点自查清单你可以对照着检查自己是否真的准备好了考点常见问法自查标准变量提升var、let、const的区别能准确说出TDZ是什么typeof在TDZ中的行为this指向obj.method()、裸调用、箭头函数能根据调用方式快速判定不靠背例子闭包循环中setTimeout能解释为什么输出5 5 5 5 5能用三种方法修复原型链手写组合继承能讲清prototype、__proto__、constructor三者的关系事件循环宏任务微任务嵌套输出能在纸上画出队列状态准确写出输出顺序手写能力防抖节流、深拷贝、去重能处理this、参数、循环引用、NaN等边界场景这套题虽然出自2017年但它反映的考点到今天都没过时。const、let、箭头函数、Promise、async/await这些新东西出来了但JS的核心运行机制一直是这套东西在支撑。打好这些基础哪怕以后框架换了一茬又一茬你也跟得上。我在带新人时经常说一句话框架是“术”语言是“道”。笔试里考点基础题不是公司老土而是他们想筛选出真正理解“道”的人。你在准备的时候也别只盯着题库刷答案多问自己几个“为什么”把每个机制背后的设计意图想明白面试的时候才稳得住。
返回列表