每年校招季,都会有不少同学在社区里翻往年真题,用友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",字符串转数字得到1,1 == 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 Promise,executor是同步执行的,所以立即打印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初始是0,now - 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找不到NaN,arr.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题牵连出变量提升和TDZ,TDZ又牵连出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的核心运行机制一直是这套东西在支撑。打好这些基础,哪怕以后框架换了一茬又一茬,你也跟得上。
我在带新人时经常说一句话:框架是“术”,语言是“道”。笔试里考点基础题,不是公司老土,而是他们想筛选出真正理解“道”的人。你在准备的时候,也别只盯着题库刷答案,多问自己几个“为什么”,把每个机制背后的设计意图想明白,面试的时候才稳得住。