2015年腾讯春招的这套web前端开发练习卷,现在回头看,几乎就是当年前端面试的“知识地图”。我当初刷这套题的时候,还在用jQuery写页面,对MVVM只有模糊的概念,对原型链更是似懂非懂。没想到几年后再翻出来看,发现里面考的很多东西,放到今天依然是面试高频题,只是问法换了一层皮。
这篇文章我想借着这套练习卷,把前端面试笔试背后的考察逻辑拆开聊一聊。不仅把题目类型和参考答案梳理清楚,更想聊聊为什么面试官当年要这么考,以及这些知识点在今天的前端面试里还在怎么变形出现。无论你是准备校招的应届生,还是准备跳槽的初级前端,这套题都值得认真过一遍——不是为了背答案,而是为了搞懂前端面试到底在筛选什么能力。
1. 先聊这套题:2015年前端面试到底在考什么
1.1 2015年的前端技术栈,和现在完全是两个世界
2015年这个时间点很特殊。ES6规范刚在年中定稿,但浏览器兼容还远没跟上,生产环境里写的大多还是ES5。jQuery还是绝对的主流,AngularJS 1.x正在国内技术社区收割热度,React刚发布不久,很多人还在观望,Vue才刚刚起步。移动端H5开始爆发,但大家还在用手写rem和媒体查询来做适配,CSS预处理器是加分项而不是必备项。
在这种技术背景下,笔试面试的考点高度集中在JavaScript语言本身,而不是框架。你会发现这套练习卷几乎没有考任何框架API,考的全是底层能力:变量类型、作用域、闭包、原型、DOM操作、事件机制、浏览器兼容。为什么?因为当年技术选型百花齐放,招人最怕的是只会用某个框架、但不懂浏览器和语言底层的人。今天React/Vue二分天下,面试虽然会深挖框架原理,但底层语言能力的考察权重依然很高,只是从直接出题变成了“在场景中考察”。
1.2 这套卷子背后的人才筛选逻辑
腾讯当年的校招前端笔试题,整体筛选逻辑可以概括成一个三层漏斗。
第一层是筛掉基本功不扎实的人。题型以选择题和填空题为主,覆盖面非常广,包括数据类型判断、字符串方法、数组方法、运算符优先级、作用域、事件冒泡等。这一层考察的是“有没有系统学过JavaScript”,而不是“会不会写页面”。很多用jQuery写页面写得很熟练的人,在这个环节就挂了,因为压根不关注语言层面的细节。
第二层是筛掉只会背API、不理解机制的人。典型的就是闭包、this指向、原型链这类题。这些题光背概念没用,必须在实际场景里能推断出运行结果。这层考察的是“有没有理解JavaScript的核心运行机制”。
第三层是筛掉动手能力差的人。编程题一般有数组去重、事件封装、简易计算器这类,题目不难,但要求写出健壮、可用的代码,考察编程习惯——边界处理、变量命名、代码结构。很多人一看题目会觉得简单,但真动手写,各种问题就冒出来了。
这三层筛选逻辑,放到今天依然成立。所以这套题的价值不在于题目本身有多难,而在于它清晰展示了一套“从语言基础到运行机制再到动手能力”的考察链路。
2. 核心题型拆解:JavaScript基础与闭包
2.1 面试官为什么会对闭包情有独钟
闭包是2015年校招笔试题里出现频率极高的话题,因为它一题能同时考察三个能力:变量作用域是否清晰、函数表达式是否熟练、是否理解函数创建与执行的环境关系。
闭包的定义其实很简单:函数内部引用了外部函数的变量,并且这个内部函数在外部函数执行完毕后仍然可被访问。更直白地说,函数记住了自己出生时的环境。
当年有一道很经典的题,问下面的代码输出什么:
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 100); }正确答案是输出五个5。原因在于var声明的i是函数作用域,循环结束后i已经变成5,五个定时器回调执行时访问的都是同一个i。这题考的不只是异步,更是作用域共享的本质。
加分回答是用三种方式修复:
// 方式一:IIFE 传参 for (var i = 0; i < 5; i++) { (function(j) { setTimeout(function() { console.log(j); }, 100); })(i); } // 方式二:bind 绑定 for (var i = 0; i < 5; i++) { setTimeout(console.log.bind(console, i), 100); } // 方式三:let 块级作用域 for (let i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 100); }这里有个特别容易踩坑的点:let方式在2015年的环境里兼容性不行,IE全挂,所以当时面试官期待的主流答案是IIFE。今天再答这题,let已经是首选了,因为ES6的块级作用域让这个老坑从设计层面消失了。但从面试答题角度,能把三种方案都讲出来,并说明各自的兼容性和适用场景,这才是满分回答。
2.2 闭包的实际用途:不只是面试考点
闭包被问得多,也因为它在真实业务里确实很有用。当年我在项目里用闭包最多的地方是封装私有变量。比如做个计数器,不希望外部直接改内部状态:
function createCounter() { var count = 0; return { increment: function() { count++; return count; }, decrement: function() { count--; return count; }, getCount: function() { return count; } }; } var counter = createCounter(); counter.increment(); // 1 counter.increment(); // 2 console.log(counter.getCount()); // 2 console.log(counter.count); // undefined这其实就是模块模式的原型。后来我理解ES Module和CommonJS,发现它们的核心思想跟这个是一样的,只不过把闭包封装提升到了文件级别。理解了闭包,再去看打包工具编译出来的代码,很多东西就通了。
面试时如果被问到“闭包有什么实际应用”,不要只答“读变量、保存变量”,最好能现场手写出这样一个封装私有状态的例子,再提到防抖节流函数也是用闭包保存状态,这样会显得你是真的会写代码,不是背概念。
注意:闭包会保留外部函数的活动对象,导致这些变量无法被垃圾回收。如果用完闭包后不再需要,最好把引用置为
null。在IE时代还要小心循环引用造成的内存泄漏,现代浏览器基本没有这个问题,但长生命周期页面里大量使用闭包,该释放的引用还是要主动处理。
2.3 this指向问题:一题能辨别“真懂”和“背答案”
this指向是2015年笔试最爱出的另一类题。原因是它没法靠背,必须理解调用方式对this的影响。
有个典型题目是判断下面的输出:
var name = 'window'; var obj = { name: 'obj', fn: function() { console.log(this.name); } }; obj.fn(); // obj,方法调用 var f = obj.fn; f(); // window,普通函数调用 obj.fn.call({name: 'call'}); // call,call改变this var f2 = new obj.fn(); // undefined,构造函数里的this指向新对象答好这道题,关键是把this的绑定规则梳理清楚。我在当时的笔记里记了四句话:
- 作为对象方法调用,this指向该对象;
- 作为普通函数调用,this指向全局对象(浏览器里是window);
- 用call、apply、bind调用,this指向指定的对象;
- 用new调用,this指向新建的实例对象。
这套规则今天依然适用,只是多了一个箭头函数的特殊情况——箭头函数本身没有this,它沿用了外部词法作用域的this。所以现代前端面试里,这道题会升级成箭头函数版本,问你在对象方法里用箭头函数和普通函数的区别,本质上考的还是同一套规则。
我最推荐的答题方式,是把规则说完之后补一个场景题:事件回调里怎么保存this?这是当年的高频变形题。
// 错误示范:回调里this指向按钮元素 button.addEventListener('click', function() { var self = this; // 常见做法:先存this setTimeout(function() { self.disabled = true; }, 1000); }); // 箭头函数方案 button.addEventListener('click', function() { setTimeout(() => { this.disabled = true; }, 1000); });能把这个场景题讲明白,比干背十条规则都有说服力。
3. 编程实操:数组去重、事件委托与ajax封装
3.1 数组去重的五个版本,你能写到第几个
几乎每个前端求职者在2015年都遇到过这道题:“写一个数组去重的方法。”它考的不只是能不能实现,还有你对JavaScript数据结构和API的掌握深度。
我当时在做这道题时,一口气对比了五种实现方式,每种都有不同的性能特征和兼容性要求:
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| 双重循环 | 逐个比较,重复则跳过 | 兼容性最好,任何环境可用 | O(n^2)复杂度,性能差 |
| indexOf | 遍历时检查是否已存在 | 代码简洁 | 同样O(n^2),不支持IE8- |
| 排序后相邻去重 | 先排序再比对相邻项 | 可分步完成 | 改变了原数组顺序 |
| 对象键值对 | 利用对象键唯一性 | O(n)性能好 | 无法区分数字1和字符串'1' |
| filter+indexOf | 函数式写法 | 代码优雅 | 性能同上,且只保留第一个 |
最值得深入讲的是对象键值对方案,因为当年很多人在这里翻车。你能区分1和'1'吗?用obj[item]做键,会把数字1和字符串'1'当成同一个键。解决办法是键名加上类型信息:
function unique(arr) { var obj = {}; var result = []; for (var i = 0; i < arr.length; i++) { var key = typeof arr[i] + '_' + arr[i]; if (!obj[key]) { obj[key] = true; result.push(arr[i]); } } return result; }这样1会被转成number_1,'1'会被转成string_1,两者互不干扰。这个思路后来在ES6里可以用Set一行搞定:
var unique = arr => [...new Set(arr)];但老实说,如果你面试时直接写这行,面试官要么觉得你懂ES6,要么觉得你没经历过底层问题。最好两种都能写,先说思路再说ES6方案,顺便聊聊为什么Set能去重——它内部用了类似SameValueZero的比较算法。
经验之谈:数组去重这道题,真正拉开差距的不是能不能写出方案,而是能不能把方案的边界条件和历史演变讲清楚。面试官通过这题看的是你对语言本身的了解程度,而不是你会不会背一个函数。
3.2 事件委托:从手写实现到性能优势
2015年的笔试和面试里,事件委托也是高频题。它考的是DOM事件机制的核心理解:事件冒泡。
事件委托的原理很直白:不把事件监听器逐个加到子元素上,而是加到父元素上,利用事件冒泡机制,在父元素上统一处理。这样有两个明显好处:一是动态添加的子元素不需要重新绑定事件,二是减少监听器数量,节省内存。
当年的一道典型题是:给一个ul下面动态生成的li绑定点击事件。很多人第一反应是循环绑定,但如果li是异步加载出来的,循环绑定的时机就不对。事件委托轻松解决:
var list = document.getElementById('list'); list.addEventListener('click', function(e) { var target = e.target; if (target.tagName.toLowerCase() === 'li') { console.log(target.textContent); } });这里有一个必须注意的细节:如果用e.target判断,点击li内部的span或a标签时,target会落在子元素上。所以健壮的写法是用closest或者循环上溯找最近的li:
list.addEventListener('click', function(e) { var li = e.target.closest('li'); if (li) { console.log(li.textContent); } });closest这个API在2015年还不太普及(IE不支持),当时更通用的做法是:
function getParentByTagName(el, tag) { while (el && el.parentNode) { el = el.parentNode; if (el.tagName && el.tagName.toLowerCase() === tag) { return el; } } return null; }这题如果你能顺着往下讲:为什么要用事件委托、它解决了什么问题、如何处理target不是目标元素的情况、怎么兼容老浏览器,基本就是高分行为了。今天React的合成事件系统,底层原理也是事件委托——把所有事件挂在根容器上统一管理。把这个关联说出来,面试官会觉得你有知识迁移能力。
3.3 手写一个ajax封装:兼容性处理是重头戏
2015年前端面试几乎必考手写ajax,因为当时fetch还没有普及,axios也刚出来不久,大家日常开发用的都是jQuery的$.ajax。面试考手写ajax,是在考察你知不知道jQuery底层做了什么。
基础版本要能写出这样的代码:
function ajax(options) { var xhr = new XMLHttpRequest(); var method = (options.type || 'GET').toUpperCase(); var url = options.url; var async = options.async !== false; var data = options.data || null; if (method === 'GET' && data) { url += (url.indexOf('?') === -1 ? '?' : '&') + serialize(data); data = null; } xhr.open(method, url, async); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) { options.success && options.success(parseData(xhr.responseText)); } else { options.error && options.error(xhr.status, xhr.statusText); } } }; if (method === 'POST') { xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.send(serialize(data)); } else { xhr.send(); } } function serialize(data) { var result = []; for (var key in data) { if (data.hasOwnProperty(key)) { result.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key])); } } return result.join('&'); } function parseData(text) { try { return JSON.parse(text); } catch (e) { return text; } }这个封装里,最有考察价值的细节有这么几个。
第一是encodeURIComponent,对参数值做编码是很多人会漏掉的。如果参数里带中文或特殊字符,不编码会直接导致请求出错。
第二是状态判断,xhr.status的校验不能只判断=== 200,因为浏览器对304缓存的策略在各浏览器间并不完全一致,老IE尤其容易出现怪异行为。把304放进来考虑,是一个经验细节。
第三是readyState === 4,这个只是表示请求完成,不代表成功。真正的成功状态要看HTTP状态码。这两个概念初学者极容易混淆。
第四是GET和POST的参数处理方式不同,GET拼URL,POST放body,而且POST要手动设置Content-Type。这个也是当年常见的丢分点。
我在实际笔试时,通常还会把onreadystatechange换成onload事件再做一版,因为onload只需要判断status,逻辑更清晰。但在2015年,onload的兼容性不如onreadystatechange好,所以笔试里稳妥答案还是前者。
4. 那些年必考、这些年依然没变的硬核知识
4.1 原型链与继承:理解JavaScript面向对象的钥匙
2015年的题里,原型链相关题目几乎是必出的。比如给一个对象,让你顺着原型链找它的构造函数和顶层原型,或者写几种继承方式并比较优劣。
我当时整理笔记时,把原型链的核心画成了一张概念图:实例的__proto__指向构造函数的prototype,函数的prototype又有一个constructor指回函数自己,最终所有对象沿着原型链一路向上找到Object.prototype,再往上就是null。这张图必须烂熟于心,因为几乎所有原型链题目都能靠它推出来。
关键是理解new一个对象时发生了什么。这是一个非常经典的面试手写题:
function create(Constructor, ...args) { // 1. 创建空对象,原型指向构造函数的prototype var obj = Object.create(Constructor.prototype); // 2. 把构造函数里的this指向这个新对象并执行 var result = Constructor.apply(obj, args); // 3. 如果构造函数返回了对象,则返回它;否则返回新对象 return (typeof result === 'object' && result !== null) ? result : obj; }原型的实际价值在哪里?最典型的应用是方法共享。把方法挂到prototype上,所有实例共享同一个方法引用,而不是每个实例都拷贝一份。这在内存使用上优势非常明显。JS里很多内置方法就是这么设计的,比如Array.prototype.map、String.prototype.trim,所有数组和字符串实例都能调用,但内存里只有一份实现。
继承方面,当年最流行的是组合继承和寄生组合继承。我笔试时最喜欢用的是寄生组合继承,因为它在实现继承的同时避免了调用两次父构造函数的问题。后来ES6的class语法本质上就是寄生组合继承的语法糖。面试时如果能把class继承和原型继承的对应关系讲清楚,会显得你理解得很透。
提醒:理解原型链不需要死记硬背,最有效的方式是用
console.dir打印一个实例对象,展开__proto__一层层看,看它从哪里来、能访问到哪些属性。自己动手看几次,比背十遍图都管用。
4.2 浏览器渲染原理:从输入URL到页面显示
这套题里还有一类占比不小的题目,是浏览器相关的。2015年常考的是:输入一个URL之后发生了什么?这个题到今天依然是面霸级的开放式问题。
我当时回答这个问题的标准框架是:
- DNS解析域名,拿到IP地址;
- 建立TCP连接,完成三次握手;
- 发送HTTP请求,服务器返回HTML;
- 浏览器解析HTML,构建DOM树;
- 同时解析CSS,构建CSSOM;遇到相关资源会触发浏览器对新资源的请求(CSS会阻塞渲染,JS会阻塞DOM解析,所以有些脚本标签会放在body末尾,或者使用defer);
- 合并DOM树和CSSOM,生成渲染树;
- 布局:计算每个节点的几何位置;
- 绘制:把节点绘制到屏幕上。
这个框架今天依然可以用,但可以加深一层:把后面几步和性能优化结合。比如布局和绘制对性能的影响,如果频繁修改DOM,会导致强制同步布局,这是页面卡顿的常见原因。现代前端面试问浏览器渲染,更倾向于考“为什么React需要虚拟DOM”以及“如何避免布局抖动”,本质还是这套基础框架的延伸。
4.3 移动端适配与性能优化:2015年的热点方向
2015年移动端H5爆发,所以腾讯的校招题里也出现了移动端适配相关的内容。主要考点集中在viewport设置、rem适配方案、图片优化、首屏加载速度这几个方面。
viewport这块,当时的标准答案是:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">rem适配的核心是动态设置根字号。当时的经典实现是:以750px设计稿为基准,把根字号设置为视口宽度的十分之一,然后按设计稿标注除以100得出rem值。我还记得当时的写法:
(function(doc, win) { var docEl = doc.documentElement; var resizeEvt = 'orientationchange' in window ? 'orientationchange' : 'resize'; var recalc = function() { var clientWidth = docEl.clientWidth; if (!clientWidth) return; docEl.style.fontSize = clientWidth / 10 + 'px'; }; recalc(); win.addEventListener(resizeEvt, recalc, false); })(document, window);这个方案现在看是粗糙的,但在2015年,它确实解决了大屏小屏字体自适应的问题。后来出现了更精细的vw方案,以及flexible方案的逐步退场,这个演进过程本身就是面试的好谈资。
性能优化方面,2015年常考的点有:合并压缩请求、CSS雪碧图、图片懒加载、CDN加速、减少DOM操作、事件委托。这些都是当年页面优化的常规操作。现在再问性能优化,答案会变成Tree Shaking、代码分割、HTTP缓存策略、资源预加载、Web Worker这些,但底层思想是一脉相承的——减少传输、减少计算、减少阻塞。面试时能把新旧优化手段的对应关系梳理出来,会让你的回答有纵深感。
5. 常见问题与避坑经历
5.1 笔试现场最容易踩的坑
从我自己做题和批改经验来看,2015年这类前端笔试题的失分点,其实非常有规律。提前知道这些坑,刷题效率会高很多。
第一个坑是运算符优先级。比如console.log(1 + '2' + 3)和console.log(1 + +'2' + 3)的输出完全不同,前者是字符串拼接,后者因为有单目运算符+把字符串转成了数字。这类题考的是表达式的评估顺序,很多人基础不牢就在这里丢分。
第二个坑是隐式类型转换。[] == ![]这个表达式在JavaScript里结果是true,第一次看到的人基本都懵。原因是![]先被转成了false,然后==两边都转成数字比较,[]转数字是0,false转数字也是0,所以相等。这类题看着奇葩,但它确实反映你对JavaScript类型转换体系的把握程度。
第三个坑是与浏览器兼容相关的题目。比如IE下获取事件对象要用window.event,标准浏览器用事件回调里的参数;addEventListener在IE8及以下不支持,要用attachEvent;event.target在老IE里是event.srcElement。这类题在今天已经不太考了,因为IE已经不是主流,但2015年的卷子里基本都会有一两道。
第四个坑是闭包与循环的组合题。前面已经说过了,就是那个输出五个5的setTimeout题。这个坑太经典了,很多人笔试栽在这里,面试也栽在这里。
第五个坑是数组和字符串方法的混淆。比如["1", "2", "3"].map(parseInt)的输出,竟然是[1, NaN, NaN]。因为parseInt接收两个参数,map传入的三个参数值被parseInt硬生生取走了两个,第二项下标被当作进制数解析了。这种题就是考察你对API参数细节的理解是否深入。
5.2 面试答题技巧:从“能做对”到“能讲好”
做对一套笔试题只是第一步,能把做题思路清晰地讲出来,才是校招面试翻盘的关键。我后来参与过一些面试评审,发现很多候选人代码写得没问题,但讲题的时候逻辑混乱,白白丢失了印象分。
我的建议是,准备笔试答案时就要按“三步法”来梳理思路:
第一步,先说结论是什么,运行结果或者设计思路是什么。不要上来就分析过程,先给出一个锚点,让面试官知道你目标明确。
第二步,说为什么是这个结论。这里要拆解原理,比如涉及作用域就说作用域链,涉及事件就说事件流向。简洁有力,不要绕弯子。
第三步,说可以怎么改。给出优化方案或替代方案,顺便提一下不同方案的优缺点和适用场景。
以this指向的题为例:先说这题的输出是obj、window、call、undefined;然后解释四种调用方式分别触发了哪种this绑定规则;最后补充在实际开发中如何通过箭头函数或bind来规避this指向问题。这样一套回答下来,既显示了知识的广度,又显示了应用的深度。
另外特别想提醒的一点是,写代码的时候即便时间紧张,也要保持变量命名清晰、代码缩进规范。很多面试官会因为代码风格给加分或减分。校招笔试的编程题本来就简单,代码规范程度反而成了区分度来源。我在刷这套题的时候,有一个习惯:每道编程题写完之后,自己以“面试官视角”再读一遍,看有没有多余的变量、有没有可读性不高的地方。这个习惯对我后来在项目里写代码也有很大帮助。
5.3 多年后回头看:这套题带给了我什么
说实话,这套2015年的练习题里,有几道题在今天已经失去了“时效性”。比如IE兼容相关的内容,现在基本没有产品需要兼容IE8了;比如$.ajax的手写封装,现在大家直接用fetch或者axios。但核心的那部分——闭包、this、原型链、事件机制、浏览器渲染——今天依然是前端面试的重中之重,只是题目包装方式变了,环境从浏览器扩展到了Node.js、小程序,甚至服务端。
我个人的体会是,这套题真正有价值的地方不在于题目本身,而在于它逼着你把JavaScript语言的核心机制从头到尾梳理了一遍。当年我花了一个多星期逐题分析、逐题写总结,做完之后再看框架源码,很多原本看不懂的地方突然就通了。后来我面试别人的时候也喜欢用类似的原题去摸候选人的功底,因为语言基础扎实的人,学任何框架都只是时间问题;语言基础不扎实的人,写再多业务代码也只是在堆页面而已。
如果你现在准备前端面试,我的建议是把这套经典题找出来做一遍,即使你用的是React/Vue,即使你已经会用各种构建工具——做题的时候不要查资料,坚持把每一道选择题的解释写下来,把每一道编程题至少想出三种解法。做完之后你会发现,那些在框架层反复出现的设计思路,底层逻辑全都来源于这些基础机制。
最后再分享一个小技巧:刷题的时候不要只看正确答案,一定要把错误选项为什么错也搞清楚。我当年准备这套题时最受益的一个习惯,就是把每个错误选项都单独写一段说明,讲清楚它错在哪里、正确的思路应该是什么。这个习惯让我在面试中遇到变体题时,能快速识别出出题人设置的陷阱,而不是只机械地死记答案。希望这个方法也能帮到你。