阿里巴巴2017秋招前端笔试题精讲:从闭包到算法的高频考点解析
2026/9/5 17:40:55 网站建设 项目流程

我做前端这些年,每次有人让我推荐一套面试题,我都会第一个想到阿里巴巴2017秋招前端笔试题。这套题不算难,但胜在“稳”——它把JavaScript这门语言里最容易出错的角落几乎都扫了一遍。很多今天看起来花里胡哨的面试题,其实都是当年这套题衍生出来的变种。它适合三类人:正在准备校招的应届生、想转行前端的新人、以及在业务里泡了几年想回头补基础的同学。

网上流传的“阿里2017秋招前端笔试”题目版本很多,完整还原每一道原题并不现实,我也没必要把每个选择题的选项抄一遍。我更想做的事情,是把这套题的出题逻辑、高频考点和答题套路拆开讲清楚,再挑几道最有代表性的题目,从题目本身一路讲到面试官想听到的答案。这样你拿到的不是一份标准答案,而是一种解题的思维方式。

1. 这套题为什么值得反复做

1.1 2017年秋招前端面试的行业背景

2017年是前端行业一个非常特殊的节点。Vue 2.x刚发布一年多,React 16也在那一年推出,前端工程化开始从“会用webpack”变成“要懂webpack”。但有意思的是,阿里巴巴那年的笔试并没有考太多框架题,而是集中考原生JavaScript、浏览器原理和算法。这背后的逻辑其实很朴素:笔试阶段刷掉的是基础不扎实的人,框架可以入职后再学,但语言和浏览器底层能力很难短期补起来。

那年投递前端岗位的应届生,普遍手里都会握着一两份Vue或React的项目经验。面试官不是不看项目,而是项目很难在笔试里被验证。你写在简历上的“熟练掌握Vue”,笔试没法直接测出来,但一个闭包输出题、一个数组去重题,就能把你的水平看得明明白白。所以这套题的定位从一开始就很明确:它是过滤器,不是加分项。

1.2 整套题的模块分布与底层逻辑

按流传的版本和后续面经来看,这套题大致可以分成四块:JavaScript语言核心、浏览器与网络、DOM与事件、算法与编程。选择题部分喜欢考this指向、闭包、作用域、原型链、继承、数组方法、类型转换这些细颗粒度的知识点;简答题部分会要求手写事件委托、防抖节流、深拷贝、数组去重这类高频工具函数;编程题部分则集中在排序、递归、二分查找、数组扁平化这些基础算法。

这个结构放在今天看依然不过时。你可以对照一下现在的面经,除了多出微前端、SSR、性能优化这些工程化话题,核心语言的考点几乎平移。换句话说,这套题考的不是“你会不会用某个框架”,而是“你脱离框架之后还剩多少底子”。这一点,恰恰是很多做了两三年业务但没系统补过基础的前端同学最心虚的地方。

1.3 放在2026年回看,它的难度是什么水平

如果单看代码量,这套题放到2026年只能算中等偏下。现在随便一个前端岗位的笔试都可能让你手写Promise.all、手写深拷贝、手写防抖节流加一个场景题。但难度不高不代表没有价值,反而因为题目足够基础,它更能暴露人的知识盲区。

我见过不少同学刷题喜欢刷难题、偏题、新题,觉得老题没意思。但实际面试里,考倒人的往往不是难题,而是最基础的概念被换个角度问出来。比如“数组去重有几种写法,各自的复杂度是多少”,这种题看起来简单,能把原理讲透的人并不多。这套2017年的题就像一面镜子,你做一遍就能照出自己对JavaScript底层理解到底到不到家。

2. JavaScript语言核心题精讲

2.1 数组去重:从双重循环到Set的演进

数组去重是前端笔试出现频率最高的一题,没有之一。这题的好处在于它几乎没有上限——新手能写for循环嵌套,高手能讲出Set的底层实现、NaN的比较规则、对象数组的key选择、时间复杂度和空间复杂度的取舍。面试官非常喜欢用它来分层:能写出来的人至少会基础语法,能写多种方案的人说明有积累,能把方案演进和复杂度讲明白的人基本可以直接进入下一轮。

先看最基础的双重循环版本:

function unique(arr) { var result = []; for (var i = 0; i < arr.length; i++) { var item = arr[i]; if (result.indexOf(item) === -1) { result.push(item); } } return result; }

这个方案逻辑清晰,但是时间复杂度是O(n²),一旦数组变大性能会很难看。稍微进阶一点,可以用filter加indexOf简化:

function unique(arr) { return arr.filter(function (item, index) { return arr.indexOf(item) === index; }); }

这个写法很优雅,但它有一个隐藏前提:indexOf只能找到第一个匹配的位置。如果数组里有两个相同的对象,它们的内容一样但引用不同,indexOf匹配的是引用,所以这个写法对基本类型没问题,对引用类型就失灵了。

再往上升级就是Set方案:

function unique(arr) { return Array.from(new Set(arr)); } // 或者 const unique = (arr) => [...new Set(arr)];

Set内部使用的是SameValueZero算法,它对NaN的处理比indexOf严谨得多,NaN在Set里被视为同一个值。这里有个高频追问:为什么indexOf去重会把NaN漏掉?因为indexOf内部使用严格相等比较,而NaN === NaN的返回结果是false。这个细节一提出来,面试官对你的印象分就会不一样。

还有一个容易被忽略的坑:用对象key做去重时,数字1和字符串'1'会被当成同一个key,因为对象的key会被隐式转成字符串。如果你遇到的是对象数组去重,更稳妥的做法是用Map,key可以选对象里的某个唯一字段:

function uniqueByKey(arr, key) { const map = new Map(); return arr.filter(item => { if (map.has(item[key])) return false; map.set(item[key], true); return true; }); }

我建议你在准备这类题的时候,不要只背最后一行Set的写法,而是把所有演进过程都过一遍。面试官问“还有别的方案吗”的时候,你能从双重循环一直聊到Map,这就不是在背答案,而是在展示你对这个问题的完整理解。

2.2 闭包输出题:for循环与setTimeout的相爱相杀

这套笔试题里有一道几乎必定出现的经典题,变体很多,但内核完全一致:

for (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 1000); }

问:最终输出什么?

答案是 5 5 5 5 5。如果面试官把延迟时间改成0,结果依然是 5 5 5 5 5,因为setTimeout的回调是在当前同步代码执行完之后才进入任务队列的。var声明的i不存在块级作用域,循环结束之后全局的i已经变成了5,所有回调拿到的都是同一个i。

这题常见的改法有三种。第一种是把var改成let:

for (let i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 1000); }

let会为每一轮循环创建一个独立的块级作用域,回调拿到的i是当前轮的值。第二种是IIFE包裹:

for (var i = 0; i < 5; i++) { (function (j) { setTimeout(function () { console.log(j); }, j * 1000); })(i); }

把每一轮的i作为参数传进立即执行函数,回调闭包捕获的是这个函数作用域里的j。第三种是用bind固定参数:

for (var i = 0; i < 5; i++) { setTimeout(console.log.bind(console, i), i * 1000); }

binds会创建一个新的函数,把i作为预设参数代入,同样能解决问题。

这题的问法变化很多,有时候会改成点击列表项弹窗显示序号,有时候会改成Promise循环输出,但本质都在考同一个概念:闭包捕获的是变量本身,而不是变量的值。你把这个机制讲清楚了,不管它怎么变形都能接住。

2.3 手写new与instanceof,理解原型链的入口

原型链是JavaScript笔试里的老朋友,阿里的题也不例外。我印象里有一类题是这样的:手写一个new操作符,或者让你解释new一个构造函数时内部做了什么。这题考察的不是背诵能力,而是你有没有真正理解构造函数、原型对象和实例三者之间的关系。

new在执行的时候大概做了四件事:创建了一个新对象;把这个新对象的原型链指向构造函数的prototype;把构造函数的this绑定到新对象上并执行;如果构造函数返回了一个对象类型的结果,就返回那个结果,否则返回新对象。

手写版本:

function _new(Constructor, ...args) { const obj = Object.create(Constructor.prototype); const result = Constructor.apply(obj, args); return (result !== null && typeof result === 'object') ? result : obj; }

注意最后一步很多人会漏掉:如果构造函数显式return了一个对象,new的返回值是这个对象,而不是我们创建的obj。如果return的是基本类型,则忽略。这个细节我在面试别人时经常追问,十个人里有四五个会卡住。

既然聊到原型链,instanceof也常被拉出来考:

function _instanceof(left, right) { let proto = Object.getPrototypeOf(left); const prototype = right.prototype; while (proto) { if (proto === prototype) return true; proto = Object.getPrototypeOf(proto); } return false; }

核心逻辑就是沿着原型链往上走,直到找到和right.prototype相等的对象。如果走到了头还没找到,就返回false。这里有一个很容易忽略的边界:Object.getPrototypeOf(null)会报错,所以left为null时得提前处理。能把这种边界条件主动说出来,比写对函数本身更加分。

3. 浏览器、网络与事件机制考点

3.1 事件委托:1000个li背后的设计取舍

场景题在阿里的笔试题里也占了不少比重。最典型的一道是:页面上有一个ul,里面有1000个li,点击某个li要把它的索引打印出来,你的方案是什么?如果你直接给每个li绑定click事件,面试官会紧接着问:这1000个事件监听器会不会有性能问题?动态添加的li怎么办?

正确的思路是事件委托。利用事件冒泡机制,把监听器绑在ul上,点击任意li时,事件都会冒泡到ul,然后通过e.target判断实际点击的是不是li:

document.getElementById('list').addEventListener('click', function (e) { const target = e.target; if (target.tagName.toLowerCase() === 'li') { console.log([].indexOf.call(target.parentNode.children, target)); } });

更现代一点的写法可以用closest:

document.getElementById('list').addEventListener('click', function (e) { const li = e.target.closest('li'); if (li) { console.log([].indexOf.call(li.parentNode.children, li)); } });

事件委托的核心优势有三个:内存占用从1000个监听器变成1个;动态新增的li不需要重新绑定;逻辑集中在一起,维护起来更方便。面试官大概率会追问target和currentTarget的区别,你直接回答:target是触发事件的元素,currentTarget是绑定监听器的元素,在事件冒泡过程中两者可能不一样。

我建议你把这个场景题背熟,因为它考察的不是某一条API,而是你对浏览器事件机制整体有没有概念。从冒泡、捕获讲到事件委托,再讲到阻止冒泡的场景,这条问题链能串出不少知识点。

3.2 浏览器缓存:强缓存与协商缓存的完整链路

浏览器缓存也是前端笔试里的常驻嘉宾。这类题一般会问:强缓存和协商缓存的区别?响应头里的Cache-Control和Expires有什么区别?ETag和Last-Modified谁优先?要回答好,得先理清一次完整请求的链路。

浏览器第一次请求资源时,服务器在响应头里带上缓存策略。浏览器再次请求同一个资源时,先检查本地缓存是否命中强缓存,如果命中了强缓存,直接用本地副本,不发请求到服务器。如果强缓存过期了,就带着协商缓存相关的请求头去服务器验证,服务器返回304时继续用本地缓存,返回200时则用新资源并更新缓存。

这两个阶段的字段优先级很好记:

场景优先级具体字段
强缓存Cache-Control优先于ExpiresCache-Control: max-age=3600 / Expires: Wed, 21 Oct 2026 07:28:00 GMT
协商缓存ETag优先于Last-ModifiedETag / If-None-Match,Last-Modified / If-Modified-Since

Cache-Control使用相对时间,Expires使用绝对时间。绝对时间受客户端本地时间影响,如果用户改了系统时间,缓存就乱套了,所以现在实际项目中Cache-Control更常用。ETag是资源内容的哈希值,Last-Modified是文件最后修改时间,精确度上ETag更可靠,因为文件在一秒内多次修改时Last-Modified可能检测不到变化。

这道题值得你花时间把它变成一套完整的话术:从强缓存讲到协商缓存,再从缓存流程讲到刷新操作对缓存的影响。按一下F5、Ctrl+F5、地址栏回车,三者的缓存策略都不一样。这些细节能讲透,说明你是真的在日常开发里踩过缓存坑的人。

3.3 URL参数解析与前端安全:面试官偏爱的表单类题

还有一类手写题,题目很短:实现一个函数,把URL里的query string解析成对象。看起来简单,但能写出完整可用的版本并不容易。

function parseQuery(url) { const params = {}; const queryString = url.split('?')[1] || ''; if (!queryString) return params; queryString.split('&').forEach(pair => { if (!pair) return; const [key, value] = pair.split('='); if (key) { params[decodeURIComponent(key)] = decodeURIComponent(value || ''); } }); return params; }

这里有几个细节值得注意。第一,如果值里包含中文或特殊字符,URL会做百分号编码,所以要用decodeURIComponent解码。第二,某些参数可能没有等号,比如?foo,这时候value是undefined,要给兜底值。第三,同一个key可能出现多次,比如?tag=a&tag=b,具体是覆盖还是转数组要根据业务决定。

这道题还有一层隐藏考点是前端安全。如果解码之后的字符串直接用于DOM操作,很容易引入XSS风险,这里的最佳实践是永远不要用未经验证的用户输入拼接HTML。笔试不会让你真的写出攻击代码,但面试官问一句“你解析出来的参数如果包含script标签怎么办”,你要能接住。

4. 算法与编程能力题

4.1 数组扁平化与递归思想

编程题里有一道很典型的数组扁平化,给的输入可能是[1, [2, [3, [4]]]],要求输出[1, 2, 3, 4]。这题考的是递归思想,也是很多同学第一次接触“自己调用自己”时的噩梦。

递归版本:

function flatten(arr) { return arr.reduce((acc, cur) => { if (Array.isArray(cur)) { return acc.concat(flatten(cur)); } return acc.concat(cur); }, []); }

这个写法的思路是:遍历数组的每一项,如果是数组就递归展开,否则直接放入结果。用reduce的好处是一行代码就能完成累加逻辑。如果不想用reduce,也可以用while配合some和展开运算符:

function flatten(arr) { while (arr.some(item => Array.isArray(item))) { arr = [].concat(...arr); } return arr; }

这种方案每循环一次就展开一层,直到数组里不再存在数组元素。它只能处理一层一层的展开,但胜在逻辑直观,不熟悉递归的人也能理解。

还有一道衍生题是控制扁平化的深度,只展开到指定层数。面试官看到你能写基本版之后,大概率会追加这一问。这时候递归函数需要多接收一个depth参数,每进入一层就减一,深度为0时直接原样返回。这类题练完,你会发现自己对“递归的终止条件”理解得更深了,因为数组扁平化的终止条件就是“当前元素不是数组”。

4.2 二分查找:边界条件决定生死

二分查找也是阿里笔试编程题的常客。题目一般很直接:给定一个有序数组和一个目标值,返回目标值的下标,不存在则返回-1。看起来简单,但边界条件写错是常态,尤其是当数组元素为偶数个时,mid的取整方向和左右指针的移动逻辑会直接影响循环能否正常结束。

function binarySearch(arr, target) { let left = 0; let right = arr.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (arr[mid] === target) return mid; if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }

这段代码使用的是左闭右闭区间,所以循环条件是left <= right,mid被排除后,left更新为mid + 1,right更新为mid - 1。如果你用左闭右开区间,循环条件就变成left < right,边界处理也会不一样。面试的时候建议先明确自己用的是哪种区间写法,再动笔,否则容易在边界判断上自相矛盾。

这道题还有一个进阶版本:找出目标值在有序数组中的第一个位置或最后一个位置。比如[1, 2, 2, 2, 3]里找2的第一个下标和最后一个下标。此时找到目标值不能直接返回,还要继续把区间往左侧或右侧收缩。能把这个扩展讲清楚,说明你是真的理解了二分查找的循环不变量,而不是背了个模板。

4.3 全排列:从回溯理解搜索树的剪枝

阿里的编程题偶尔会上一些典型的回溯算法题,全排列就是代表之一。题目是:给定一个不包含重复数字的数组,返回它的所有排列。

function permute(nums) { const result = []; const used = new Array(nums.length).fill(false); const path = []; function dfs() { if (path.length === nums.length) { result.push([...path]); return; } for (let i = 0; i < nums.length; i++) { if (used[i]) continue; used[i] = true; path.push(nums[i]); dfs(); path.pop(); used[i] = false; } } dfs(); return result; }

这段代码的核心是“选择、递归、撤销选择”的回溯三步走。used数组用来标记哪些元素已经被选过,path记录当前排列,当path长度等于nums长度时,就得到了一组完整排列。这里有一个细节:result.push([...path])不能写成result.push(path),因为path后续会被pop修改,直接push引用得到的会全是空数组。

全排列的时间复杂度是O(n!),面试官可能会追问“能不能优化”,此时如果你能提到重复元素的剪枝方案——先排序,再跳过和前一个相等且前一个还没被使用的元素——就又多了一个亮点。这类题不一定要全部背下来,但至少得知道回溯的基本框架,因为笔试里很多排列、组合、子集问题都是同一个模板换皮。

5. 笔试现场的战术与踩坑实录

5.1 六个高频失分点,每一个都是血泪教训

我刷过不少前端笔试,也帮人看过不少笔试答案,总结下来,最吃亏的往往不是不会做,而是会做却拿不到分。这六个失分点几乎每次都有人踩。

第一,只写方案不讲思路。笔试系统里很多简答题不是只看代码的,你可以在代码旁边用注释说明自己的思路。比如数组去重,你写了Set一行方案,再补一句“Set内部使用SameValueZero,可以处理NaN”,这道题的得分立马不一样。

第二,边界条件全部回避。笔试题里输入可能为空数组、null、undefined,代码一上来就操作了length属性,直接崩掉。老老实实先判空,既安全又能展示严谨性。

第三,变量命名混乱。写出来的代码全是a、b、c、d,面试官还要去猜你是什么意思。用arr、target、result这种语义化命名,代码可读性高很多。

第四,不测试示例数据。代码写完不自己走一遍,边界用例如空数组、单个元素、全是相同元素、含有NaN,全都交给系统去发现。你多花两分钟自查,就能避免低级错误。

第五,在死磕一道题上浪费太多时间。编程题往往有好几道,遇到卡壳超过20分钟就果断跳过,先把能拿的分拿到手。笔试的得分率往往比单题的完美度更重要。

第六,忽视代码风格。缩进混乱、缺分号、中英文混用,这些不会直接判错,但会大大影响阅读体验,遇到人工阅卷时很吃亏。

5.2 面试官到底想从手写题里看到什么

很多同学以为手写题就是考察“代码能不能跑”,这是误解。面试官想看的其实是三件事:你的思路是否清晰、你的基础知识是否扎实、你的代码是否具备工程可读性。代码能不能在本地运行反而没那么重要,因为笔试环境里很少真的执行代码,更多是人工阅卷。

所以答题时别一上来就埋头写代码,先在脑子里过一遍思路,然后按这个顺序组织答案:先说思路,再估一个复杂度,再写代码,写完用示例数据走一遍,最后主动补充边界情况和可能的优化方向。这套答题节奏很多人不习惯,但确实是最能体现水平的表达方式。

“思路先行”还有一个好处:即使最后代码写bug了,面试官能看到你的思考过程,也能给你一个基础分。反过来,代码写得对但完全说不出为什么这么写,面试官反而会怀疑你是背下来的。笔试是面试的敲门砖,但面试官更希望招到一个能沟通、能拆解问题的同事,而不是一个答题机器。

5.3 把一套题变成一张知识网:我的备战方法

做这套题的时候,我建议你准备一张A4纸,把每一道题涉及的知识点写下来。数组去重不只是“去重”,它关联到Set、Map、对象的隐式类型转换、严格相等、NaN比较规则、时间复杂度和空间复杂度。闭包题关联到作用域、执行上下文、事件循环、异步任务队列。写完之后你会发现,一套题涉及的几十个知识点,其实可以浓缩成五六个知识簇。

我的习惯是分三轮来做。第一轮不限时,能做多少做多少,做完之后对答案,把自己没掌握的知识点标记出来。第二轮限时一小时,模拟真实笔试状态,重点训练时间分配。第三轮不再逐题做,而是把自己整理的A4纸拿出来,对着每个知识点展开复述,能讲清楚就算过关,讲不清楚就回到对应的题目重新理解。

另外提醒一句:网上能找到的历年笔试题质量参差不齐,尤其是流传很多年的题目,经常被转载得面目全非,甚至混入一些与题目完全无关的内容。做题时要学会筛选,优先看那些有明确解析、有代码、有讨论的版本,别在残缺和错误的题目上浪费太多时间。真题的价值在于帮自己找盲区,而不是追求“押中原题”。

最后再分享一个我自己备考这套题的小习惯。当年我拿到这套题之后,没有直接做题,而是先把题目里出现的所有名词写在纸上:闭包、原型链、事件委托、缓存、二分查找……然后每天挑一个名词,用自己的话讲一遍,讲不出来的就是缺口。做完一套题,重要的不是记住了多少标准答案,而是你发现自己还有多少个说不清楚的概念。把那串名单上的空洞填满,这套题才算真正做完。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询