九月初投完简历,我在第三天晚上收到小满的笔试链接。2023年度小满秋招Web前端岗第一批笔试,全卷一共27道题,时间120分钟,在线编辑器不是常见的ACM格式,而是填空、简答、手写代码混在一起的实战风格。考完复盘时我最大的感受是:这批卷子没有堆砌偏题怪题,但如果你只靠背面试题来应付,多半会在手写题和框架原理题上卡住。它的筛选逻辑很直接——web前端开发里的基本功,你到底是“知道”,还是“真会”。这篇文章就当作一次完整复盘,把当时的题目拆开讲清楚,也把影响我最后结果的几个踩坑点写出来,希望给准备前端秋招的人一些具体参考。
1. 这批笔试给我的第一印象:题不多,但每道题都踩在能力边界上
1.1 题量、时长与题型分布
先还原一下卷面。整体27题,选择题占了12道,单选和多选混排;填空题5道;简答题3道;手写代码题5道;最后还有一道开放设计题。时间给的是120分钟,但如果你是第一次打开这套题,实际可支配时间大概只有90分钟,因为前两部分的选择题里藏着不少需要逐项推理的题目,稍不注意就会陷进去。
我印象比较深的是,这套题的选择题不是“看一眼就知道答案”的那种,而是每个选项都像在故意诱导你选错。比如一道事件循环的题目里,setTimeout、Promise.resolve()、async/await、requestAnimationFrame全混在一起,要求选出执行顺序。这种题如果只是背过“微任务先于宏任务”,没考虑过await后面的代码被包装成微任务排队的具体行为,很容易错。
从难度分布看,基础题大概占50%,中等题30%,偏工程经验的20%。前30分钟我做完了选择题,但正确率没有把握。后60分钟全部砸在手写代码和简答题上,最后10分钟快速补设计题框架。时间其实非常紧,如果你在选择题某一道上纠结超过3分钟,后面大概率写不完。
1.2 和常见刷题笔试的差异
市面上很多公司笔试都在用平台题库,题型以单选、多选、判断和纯算法题为主,考察方式非常标准。但这套题有一个明显差异:它把“项目里真实会遇到的问题”直接搬到了卷子上。
举个例子,选择题里有一道是关于<script>标签的加载顺序,async和defer的区别。常规题库考到这儿就结束了,但小满这道题还追加了一个场景:如果脚本在</body>前加载,页面里有一段依赖该脚本的DOM初始化逻辑,问你怎么选。这就是典型的工程题,不是“知道API”就能答,而是要知道浏览器在解析HTML时的阻塞行为,以及defer保证执行顺序的底层逻辑。
这种出题风格说明一件事:他们想招的不是刷题机器,而是平时就对代码运行机制有好奇心、愿意往深一层看的人。从这个角度看,这套题更像是“笔试筛选 + 简历验证”的组合,你写在简历上的技能,笔试里会换着花样检验。
2. 选择题里的暗礁:五个最值得复盘的知识点
2.1 事件循环:微任务队列的执行顺序不是一句话能说清的
有一道单选题原题大概是这样,输出什么:
console.log('start'); setTimeout(() => { console.log('timeout'); }, 0); Promise.resolve().then(() => { console.log('promise'); }); async function test() { console.log('async start'); await 1; console.log('async end'); } test(); console.log('end');答案很多人知道:start、async start、end、promise、async end、timeout。但这里有个容易忽略的点——await 1后面的代码不会立即执行,而是会把async end包装成一个微任务放到微任务队列里。同时,Promise.resolve().then(...)已经先一步把promise这条微任务排了进去。
所以微任务队列的顺序是:promise先入队,async end后入队。如果题目改成await Promise.resolve(),执行顺序依然不变。关键在于理解await会把后面的代码then化,而不是同步执行。
这类题目真正想考的是你如何描述“宏任务、微任务、渲染时机”之间的关系。答题时最好画出队列结构,我一般用“先执行完当前同步代码,再清空所有微任务,最后才取一个宏任务”来作主线,再补充requestAnimationFrame和requestIdleCallback的出现时间点,这样就算题目换形式也能应对。
2.2 HTTP缓存:Cache-Control和Expires同时出现时,谁说了算
另一道选择题问:响应头同时有Cache-Control: max-age=600和Expires: Wed, 21 Oct 2025 07:28:00 GMT,浏览器会以哪个为准?正确答案是Cache-Control。但题目真正想挖的坑在下一问:如果Cache-Control是no-cache,却又带了Last-Modified和ETag,那么缓存的验证流程是什么?
很多人把no-cache理解成“不使用缓存”,这是完全错误的。no-cache的意思是“可以使用缓存,但必须回源服务器做新鲜度验证”。如果服务器返回304,浏览器继续用本地副本;如果返回200,则更新缓存。同理,no-store才是真正禁止写入缓存。
我在这道题上纠结了很久,因为真题里把Cache-Control、ETag、Last-Modified、Expires四项全部列了出来,问“强缓存命中还是协商缓存命中”以及“请求是否发到了服务器”。这类题已经把HTTP缓存生命周期完整考了一遍,建议准备笔试前把缓存优先级、强缓存与协商缓存的区分、以及Vary头的作用都梳理清楚。
2.3 CSS层叠上下文:z-index为什么没有让元素置顶
有一道CSS题很典型:
<div class="parent"> <div class="child"></div> </div> <div class="sibling"></div>parent设置了transform: translateX(0),child设置了position: absolute; z-index: 999,sibling是普通文档流元素。问谁在上层。答案是parent整体创建了一个层叠上下文,child的z-index只是在parent内部进行比较,并没有让parent整体提升到和sibling相比更高的层级。
这道题想考的是“层叠上下文”的创建条件:position+z-index、transform、opacity小于1、filter、will-change、contain等都会创建新的层叠上下文。一旦父级有了层叠上下文,子元素的z-index就再也不能跨出父级去和其他兄弟节点比较了。
准备这类题时,建议亲手写一个demo验证,光靠背结论很容易被题目变化绕进去。后面简答题里也考了“如何触发BFC以及BFC的实际应用”,和这道题属于同一类底层布局知识,必须能说出“为什么”和“怎么用”,不能只背定义。
2.4 原型链与instanceof:跨iframe判断数组为什么失效
选择题里有一道特别经典的坑:在父页面中通过document.querySelector('iframe').contentWindow拿到子页面的数组对象,然后用父页面的[] instanceof Array去判断,结果返回false。原因是不同全局对象拥有不同的Array.prototype,instanceof沿着原型链查找时找不到父页面的Array.prototype。
正确做法是使用Array.isArray(value),它会跨全局对象判断。类似的还有Object.prototype.toString.call(value)返回[object Array],也能判断。
这道题看起来简单,但它连带考了Symbol.hasInstance、Object.create(null)为什么不能调用instanceof等细节。我复盘时发现,自己之前对instanceof的理解停留在“检查构造函数的prototype是否出现在对象原型链上”这个层面,忽略了“原型是可以被修改的”和“跨全局环境”这两个边界条件。如果你在笔试里看到instanceof,一定要多想一层:它真的可靠吗?
2.5 CORS预检请求触发的条件:请求头里的“小动作”可能让Request直接失败
还有一道网络题。题目给了一个前端请求,设置了自定义请求头X-Token,同时使用Content-Type: application/json,问这个请求是否会触发CORS预检。正确答案是会,因为自定义头会导致Access-Control-Allow-Headers校验,而且application/json不属于“简单请求”的Content-Type白名单。
很多人能答出“非简单请求会触发OPTIONS预检”,但题目继续追问:如果预检请求返回的Access-Control-Max-Age已经过期,第二次正式请求前是否会重新发送OPTIONS?答案是会。这个细节在真实项目中很常见,尤其是上传文件、携带Token登录等场景。
我当时的解答思路分两层:先解释“简单请求”的三个条件,方法必须是GET/POST/HEAD,不能有自定义请求头,Content-Type必须限于application/x-www-form-urlencoded、multipart/form-data或text/plain;然后说明预检请求的存在是为了保护服务端,避免不安全请求直接触发副作用。把这个逻辑讲清楚,比死记硬背选项有用得多。
3. 手写代码题复盘:考的是“能跑”还是“能扛”?
3.1 带并发限制的Promise调度器:边界的处理比主流程更重要
第一道手写题是:给定一组返回Promise的函数,要求并发执行时最多同时执行limit个,并且能收集所有结果。这道题看起来很常规,但批卷时最容易拉开差距的不是主流程,而是边界条件:传入的空数组、limit大于任务数、某个任务reject了要不要中断后续调度。
我给出的实现思路是这样的:
async function runWithConcurrency(tasks, limit) { const results = new Array(tasks.length); let index = 0; async function worker() { while (index < tasks.length) { const i = index; index += 1; results[i] = await tasks[i](); } } const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker); await Promise.all(workers); return results; }这里用共享的index来分配任务,保证任务不会重复执行。重点在于index += 1必须放在await之前,否则多个worker并发时会抢到同一个任务。还有一个隐藏问题:如果某个任务reject,await会抛出异常导致当前worker退出,但其他worker还在跑。如果要求整体失败时快速失败,可以用Promise.all的reject语义;如果要求不中断其他任务,就要用Promise.allSettled。我在代码里明确做了注释,说明我在什么情况下选哪种。
3.2 按权重随机取值:从Math.random到前缀和的思路演进
第二道题要求实现一个函数:给定一组带weight属性的对象,按权重概率随机返回其中一个。这道题属于“看着简单,写出来容易错”的类型。
最直观的写法是不断生成随机数,然后遍历权重区间:
function pickWeighted(list) { const total = list.reduce((sum, item) => sum + item.weight, 0); let random = Math.random() * total; for (const item of list) { random -= item.weight; if (random < 0) { return item; } } }这段代码能跑,但有一个质量上的问题:如果list很大,每次随机选择都需要线性遍历,时间复杂度是O(n)。我在笔试时写的是循环遍历版本,然后附了一段优化思路:如果选择操作会被频繁调用,可以把权重数组改成前缀和,再用二分查找把时间复杂度降到O(logn)。面试官多半不会强制你写二分,但你主动写出这个优化,会证明你考虑到了实际性能。
边界条件也不能忽略:权重为0的项不应该被选中;权重数组可能非常大,累加和可能超出安全整数范围等。这些我在注释里都提了。
3.3 深拷贝遇到循环引用:WeakMap的正确打开方式
手写深拷贝几乎是Web前端开发笔试必考题,但小满这道题特意加了循环引用。题目提示是这样的:const obj = {}; obj.self = obj;,如果直接递归拷贝会爆栈,要求实现一个能正确处理循环引用的深拷贝。
我的答案:
function deepClone(value, visited = new WeakMap()) { if (value === null || typeof value !== 'object') { return value; } if (visited.has(value)) { return visited.get(value); } const clone = Array.isArray(value) ? [] : {}; visited.set(value, clone); for (const key of Reflect.ownKeys(value)) { clone[key] = deepClone(value[key], visited); } return clone; }关键在于visited用WeakMap而不是普通Map,因为WeakMap的键是弱引用,不会阻止原对象被垃圾回收。复制时用Reflect.ownKeys可以覆盖字符串键和Symbol键。对于Date、RegExp、Map、Set这些特殊类型,上面的代码还不够完善,我在最后补了一段类型判断的处理逻辑,说明如果要求完整版,还需要针对内置对象单独初始化。
这种题的评分点通常不只看你是否写出来,还看你对“为什么要用WeakMap”的解释。我正好把“弱引用”和“内存泄漏”的关系答了出来,这应该是加分项。
3.4 两道经典算法题的速答思路
除了上述题目,还有一道二叉树题和一道动态规划题。二叉树题是“寻找二叉树的最近公共祖先”,动态规划题是“编辑距离”。这两道都是LeetCode中频题,但笔试时间紧,我建议不要在完整实现上花太多时间,优先写出核心逻辑和复杂度分析。
二叉树的最近公共祖先有一个很清爽的递归写法:
function lowestCommonAncestor(root, p, q) { if (!root || root === p || root === q) return root; const left = lowestCommonAncestor(root.left, p, q); const right = lowestCommonAncestor(root.right, p, q); if (left && right) return root; return left || right; }编辑距离的DP写法比较长,但状态转移就两行:如果字符相等,dp[i][j] = dp[i-1][j-1];否则取插入、删除、替换的最小值再加1。我不会建议你三天速成动态规划,但至少要把背包问题、最长公共子序列、编辑距离这几类经典题目的思路背熟,笔试碰到的概率极高。
4. 框架与工程化题:会调用API和会解释原理,是两种人
4.1 组件通信题:只答props和emit是不够的
简答题里有这么一道:Vue 3项目里,一个深层嵌套的组件需要触发最外层组件的事件,你有哪些方案?这题如果只写“props + emit”,只能算及格。因为props逐层传递会带来“prop drilling”问题,中间组件被迫声明很多自己根本用不到的属性,一旦层级超过三层,代码可读性和维护性都会下降。
我的回答分了三层:第一层是props/emit,适合父子直接通信,但要保持单向数据流;第二层是provide/inject,适合祖先和后代的跨级通信,尤其适合传递“函数”或“响应式数据”;第三层是Pinia或Vuex这类集中式状态管理,适合多个页面/组件共享的数据和事件。
我额外补充了一个容易被忽略的点:如果使用事件总线(比如mitt),组件销毁时一定要注销事件监听,否则会造成内存泄漏。这个点正好接上之前手写题里的WeakMap,像是在提醒你,框架知识不能脱离原生JS基础,否则很多隐藏问题根本看不见。
4.2 路由懒加载背后的模块系统逻辑:动态import和魔法注释
另一道工程化题目是:为什么vue-router推荐用动态import做路由懒加载?它和Webpack打包有什么关系?
这题我在简历里写过“熟悉Webpack配置”,所以必须答得更细致。我在代码区写了这种写法:
const routes = [ { path: '/user', name: 'User', component: () => import(/* webpackChunkName: "user" */ '@/views/User.vue') } ];然后解释:动态import会被Webpack解析成独立的chunk,页面访问到对应路由时才异步加载JS。webpackChunkName注释不是摆设,它决定了chunk文件名,能帮助你在构建产物里快速定位问题模块。再把prefetch和preload的区别讲清楚:prefetch是在浏览器空闲时预取将来可能用到的资源,preload则是在当前导航时立刻加载关键资源。
这种题表面考路由懒加载,实际考的是“模块打包”和“浏览器加载策略”。如果你只答“让首屏更快”,没解释清原理,阅卷人会觉得你只是用过API,没有深入理解。
4.3 页面性能优化的综合题:一切优化都要建立在可量化目标上
最后一道开放设计题很经典:一个移动端H5页面首屏加载需要4秒,要求你给出优化方案。题目没有给出具体业务场景,只说页面包含图片、视频、第三方统计脚本和一个大的React组件包。
我答的时候按“关键路径”拆成四块:
- 网络层:开启HTTP/2、启用gzip或Brotli压缩、静态资源上CDN。
- 资源层:图片用WebP并做懒加载;视频用
preload="none"或覆盖在首屏外;第三方脚本放到异步加载,或延迟到requestIdleCallback。 - 代码层:路由懒加载、组件按需引入、去掉无用依赖,用
webpack-bundle-analyzer检查包体。 - 渲染层:使用骨架屏减少白屏焦虑;关键CSS内联;避免同步脚本阻塞DOM解析。
但我也在回答里强调,优化前先定义指标,用Lighthouse或Performance面板做基线,改完一项测一项,不要凭感觉优化。这种“先量化再优化”的思路,我认为是小满这道题真正想看的。它不是想听你背出一堆优化点,而是想确认你真的了解优化流程的闭环。
5. 笔试现场最容易失分的三个环节:环境、读题、时间分配
5.1 在线编辑器里的“本地能跑”不等于“提交能过”
我在做手写题时遇到一个很现实的问题:在线编辑器不提供console面板,部分浏览器环境下WeakMap、Reflect.ownKeys这些API虽然能识别,却无法直接看到运行结果,只能靠静态检查。
也就是说,你写出来的代码如果语法错误、括号不匹配,可能直到最后都没有提示。那次我因为少写了一个右括号,在“深拷贝”代码里多花了5分钟检查,直接压缩了最后一道设计题的时间。
所以我的建议是:笔试前先去目标公司的在线平台做一套模拟题,熟悉编辑器的自动保存、代码高亮和回车缩进行为。如果是纯远程的腾讯会议监考,还要提前测好网络。笔试最亏的不是不会写,而是会写却没时间提交。
5.2 读题陷阱:题目里的“不”字比答案更重要
第二场经验是读题要圈关键词。有一道编程题要求“不用Array.prototype.flat实现数组扁平化”,但我当时的思路是先把flat的使用方式写在注释里,顺便说明如果题目没有限制可以直接用,结果浪费了一部分时间。
还有一道节流题要求“首次立即执行,最后一次也执行”。如果按普通节流直接实现,第一次点击会被延迟到wait毫秒后才执行,不符合题意。这种题考的不是节流本身,而是你能否严格按输入输出要求编码。
我复盘时总结了一个方法:手写题动笔前,先在草稿区用两行字写清楚三个东西——输入是什么、输出是什么、边界条件是什么。不要急着把代码往编辑器里塞,想清楚再写,反而快。
5.3 时间分配策略:先拿稳分,再啃硬骨头
当时我的时间分配大概是:选择题40分钟,填空题10分钟,手写题50分钟,简答+设计题20分钟。这个分配导致我在手写题上稍微超了一点,简答题只能写核心要点,没有时间展开例子。
现在回头看,我会把选择题压缩到30分钟,遇到不确定的先用排除法标记,不要恋战;手写题如果某道卡住超过8分钟,先跳过去写后面的,等最后5分钟再回来补基础版本。因为阅卷是按点给分,你交一个能跑的主流程,永远比交一个半成品强。
还有一个小技巧:设计题虽然没有唯一答案,但你只要结构清晰,列出问题、方案、优先级、验证方式,就已经能拿大部分分。不要追求把所有优化点都写完,重点是让阅卷人看到你有系统的分析思路。
6. 如果你想冲击下一批前端笔试,提前这样做
6.1 把考点从“见过”变成“能讲出来”
这次笔试让我彻底意识到,用“眼熟”来准备前端笔试是最大的坑。事件循环、原型链、缓存、BFC这些概念,面试题刷多了都会有印象,但一旦题目加了工程场景,印象就不够用了。
我备考后期的做法是每天选两个知识点,用“费曼式复述”讲给自己听:一是什么,二是为什么会有,三是在项目中怎么用,四是最容易踩的坑是什么。比如“闭包”不是只背定义,而是要说清楚闭包带来的内存占用问题,以及为什么在循环里用var会出问题、用let能解决。这种输出式学习,比反复看面经有效得多。
6.2 用“查漏—补缺—输出”的循环代替盲目刷题
准备笔试时,最容易陷入的状态是每天刷50道选择题,但刷完不总结,下次遇到变体还是错。我建议按知识模块来刷,比如今天只练“异步编程 + 事件循环”,明天只练“HTTP缓存 + 浏览器渲染”,后天只练“CSS布局 + 层叠上下文”。
每完成一个模块,就把错题整理成一个“为什么错”的文档,写清楚当时的错误认知和正确认知。我这次里“no-cache不能理解为禁用缓存”就是错误认知的典型例子。用这个循环过三轮,比广撒网式刷题更扎实。
6.3 写手写题之前,先自己想测试用例
最后一件事,也是我这次笔试最大的收获:手写题不是“给个函数名+实现”就完了,还要自己构造测试用例来验证边界。比如写深拷贝,你必须自己试null、数组、循环引用的情况;写并发调度器,你必须试空数组、limit=1、limit大于任务数的情况。
很多线上笔试平台没有自动判题,但是阅卷人会看你代码里的注释和分析。我在Promise调度器那道题里,主动写了“这里不需要判断index越界,因为while条件会在下一次进入前判断”,这种分析能让阅卷人看出你不只是背了代码,而是真的理解了控制流的走向。
如果你还有时间,建议多去写一些带“限制条件”的题目。比如不用new实现Object.create、不用class实现一个简单的响应式系统、不用async/await实现pify。这些题不一定考,但练过之后,你对JS语言机制的理解会明显上一个台阶。
按我个人的体会,2023年度小满秋招Web前端岗第一批笔试的难度不在“知识广度”,而在“知识深度和表达准确度”。它没有偏题怪题,却把每个知识点都问到了“你会不会在真实项目里用”的层面。如果你正在准备下一批笔试,建议把重点从“背题量”转移到“讲原理、写边界、做测试用例”这三件事上。笔试只是第一关,但它暴露出来的问题,越早发现越划算。