春招进行到三月中旬,我的web前端简历已经投出去十几份,多数石沉大海。那天下午收到小满的笔试邀请邮件,标题写着"第二批春招Web前端岗",那一刻先松了一口气——能进入第二批笔试,至少说明简历筛选过了。当时我其实还不太清楚第二批意味着什么,以为是补录,后来才发现它只是春招的批次安排,考核范围和第一批同属一个题库池,只是抽取的题目不同。这场时长90分钟的笔试,几乎把我自认为扎实的web前端开发能力从上到下扒了一遍。今天把完整复盘写出来,既是为了给正在准备web前端开发春招的朋友提供一个具体样本,也是给自己留一份技术总结。
1. 考前准备:我踩了一个很多人都会踩的复习顺序坑
1.1 笔试邀请邮件里最容易忽略的规则
笔试前一天晚上收到邮件时,我其实已经连续投了两周简历,每天刷面经刷到凌晨,整个人处于一种"好像什么都会、又好像什么都不会"的混沌状态。邮件里的关键信息很明确:考试时间90分钟,需要开摄像头,浏览器推荐Chrome,不能切屏超过三次,否则系统会标记作弊。我当时只盯着考试时间看了,把"不能切屏"这条规则忽略了。
开考后我才意识到这条规则有多要命。笔试的时候题目里有一道关于CSS Grid的题,我记得不太清楚,第一反应就是"切出去查一下"。但一想到切屏三次就会被标记,整个人瞬间老实了,只能靠自己的记忆硬写。这里提醒后来人一句:凡是要求开摄像头的在线笔试,基本都带切屏监测,别指望考试时临时查资料。与其把希望寄托在搜答案上,不如考前把基础知识点过一遍。
1.2 我把80%时间浪费在"看"上,而不是"写"上
几乎所有准备过前端笔试的人都会犯同一个错:觉得自己会了,但一上手写就卡壳。我考前那晚的复习安排是,80%时间在看八股文,比如TCP握手几次、HTTP状态码有哪些、Vue的响应式原理是什么,剩下20%时间用来默写代码。
这个比例放在现在回头看,完全是反的。那场笔试的实际题目告诉我:选择题里确实有八股,但占比不高,而且考得相当偏;反倒是手写代码题分值最大、最拉分。你要是能把防抖节流、深拷贝、Promise.all、数组转树这几类手写题写到闭眼都能默出来的程度,笔试基本就稳了一半。而"看"出来的知识,在考场上很容易被紧张情绪冲散。
2. 试卷结构的大致画像:90分钟要回答哪些东西
2.1 题型分布和分值的直观感受
打开试卷之后,我的第一反应是"题量不算大":一共四大题,包含若干小题,总分100分。题目类型分布大概是下面这个感觉:
| 题型 | 题量 | 估分占比 | 建议时间 |
|---|---|---|---|
| 单选题 | 8题 | 约20分 | 15分钟 |
| 多选题 | 4题 | 约16分 | 10分钟 |
| 手写编程题 | 3题 | 约36分 | 35分钟 |
| 简答题 | 2题 | 约28分 | 20分钟 |
这里我用的词是"大概",因为小满的笔试页面并不会在开头就告诉你每道题的具体分值,你只能从题目的难易程度猜。单选题和多选题占了大概36分,剩下的64分基本压在手写题和简答题上。这个分值比例给我的直接信号是:这家公司招前端不太想招"背题家",更想招"能写代码的人"。
2.2 我定下的答题顺序,以及后来为什么被打乱
我的原定顺序是从前往后做:先选择题,再多选题,再手写题,最后简答题。为什么会定这个顺序?因为选择题和判断题通常可以快速拿分,先把能拿的分装进兜里,心里比较有底。而且选择题里可能会有一些考点能"提醒"后面的手写题怎么答,比如选择题考了事件循环,可能后面就会考一道Promise相关的手写题。
但这个秩序在执行到中途就崩了。原因很简单:我在多选题上卡得比想象中久。有几道多选题设置得非常刁钻,每个选项看起来都对,但正确答案偏偏是"ABD"而不是"ABCD"。我在那几道题上磨了将近20分钟,导致后面手写题的时间被挤掉不少。后来交卷那一刻我复盘了一下:如果当时果断跳过拿不准的多选题,把时间优先给手写题和简答题,分数可能会高出5到8分。
3. 选择题复盘:四道很容易翻车的题,也是最常见的考点
3.1 事件循环输出顺序:async/await的微任务排队规则
单选题里有一道让我印象深刻的代码输出题,题目大致是这样:
async function async1() { console.log('async1 start'); await async2(); console.log('async1 end'); } async function async2() { console.log('async2'); } console.log('script start'); setTimeout(() => { console.log('setTimeout'); }, 0); async1(); new Promise((resolve) => { console.log('promise1'); resolve(); }).then(() => { console.log('promise2'); }); console.log('script end');这道题的输出顺序是:script start、async1 start、async2、promise1、script end、async1 end、promise2、setTimeout。关键在于await async2()后面的代码并不会立刻执行,而是被包装成一个微任务,排在当前宏任务之后。而且微任务队列遵循先进先出,async1 end比promise2更早进入队列,所以先输出。
我为什么觉得这题容易翻车?因为平时在浏览器控制台里跑的和在答卷上写的,心理状态完全不一样。如果对"微任务排队顺序"的理解只停留在"promise.then比setTimeout先执行"这个粗糙层面,看到await就会懵。建议准备笔试的朋友把await之后的那段代码理解成"then回调的语法糖",这样推演输出顺序时会更顺手。
3.2 严格模式下this的指向:一个被低估的高频坑
多选题里有一道关于this指向的问题,选项设计得很有迷惑性:
var name = 'global'; const obj = { name: 'obj', fn: function() { console.log(this.name); }, arrow: () => { console.log(this.name); } }; const f = obj.fn; f(); obj.fn(); obj.arrow();题目问的是:在非严格模式下,以上三个调用的输出分别是什么。这个问题本身不难,答案是global、obj、global。但问题设置了一个陷阱,它要求你判断"如果代码加了'use strict',结果会变成什么"。在严格模式下,f()里的this是undefined,所以访问this.name会直接抛TypeError,而不是输出"global"。
我当时在这道题上犹豫了很久。因为很多前端学习资料在讲this时,默认都在非严格模式下展开,很少有人强调严格模式带来的差异。这个点提醒我:面试官考this,往往不是单纯考指向,而是考"边界条件",也就是你在实际项目中可能遇到的异常情况。
3.3 flex item里的min-width: auto,一个非常隐蔽的布局坑
另一道选择题直接给了一段CSS:
<div class="container"> <div class="item">这是一段很长的文本内容,它会不会把容器撑破?</div> <div class="item">item2</div> </div>.container { display: flex; width: 300px; } .item { flex: 1 1 50%; }题目问:两个.item的实际布局表现是什么?答案的关键在于flex item默认的min-width是auto,也就是说,无论你设置flex: 1 1 50%,item都不会缩小到小于它内容的最窄宽度。如果第一个item里面有一段很长的连续英文或数字,它会把整个flex容器撑破,第二个item被挤到几乎看不见。
解决办法是给.item加min-width: 0。这个知识点如果你没在真实项目里遇到过,光靠背八股很难想起来。很多组件库的grid布局出现横向溢出,排查到最后都是这个原因。这道题之所以被我记下来,是因为它属于"看起来简单但杀伤力极大"的题。
3.4 协商缓存里ETag和Last-Modified同时存在,听谁的
单选题里有一道跟HTTP缓存相关的题:当响应头里同时返回ETag和Last-Modified,浏览器重新请求时,请求头里会带什么?
正确做法是浏览器会带If-None-Match,其值来自上一次响应里的ETag。服务器收到后优先比较If-None-Match,这个字段匹配不上就直接返回200和新的资源,只有匹配上才会去看If-Modified-Since。也就是说,ETag的优先级高于Last-Modified。
这题的坑在于,很多人只记得浏览器会发送If-Modified-Since,却忽略了ETag的优先级。我在项目里排查过两次静态资源更新不生效的问题,最后都是因为CDN和Nginx的缓存策略里只配了Last-Modified,导致文件内容变了但时间戳没变,浏览器一直拿旧资源。如果在笔试里遇到这类题,别只选"带If-Modified-Since",要把If-None-Match也考虑进去。
4. 手写编程题逐题复盘:三道题,三种不同的体验
4.1 第一道手写题:带并发上限的异步请求调度器
题目描述大概是:有一个数组,里面每一项都是一个返回Promise的函数,现在要求写一个run(list, limit)方法,让这些异步任务最多同时执行limit个,全部执行完后返回每个任务的结果数组。
我当时的思路是维护一个"正在执行"的任务池,数量不超过limit。每当一个任务完成,就立刻从任务列表里取下一个任务补上。实现如下:
async function run(tasks, limit) { const results = new Array(tasks.length); let index = 0; const workers = []; const execute = async () => { while (index < tasks.length) { const current = index++; try { results[current] = await tasks[current](); } catch (e) { results[current] = undefined; } } }; const count = Math.min(limit, tasks.length); for (let i = 0; i < count; i++) { workers.push(execute()); } await Promise.all(workers); return results; }这里有个细节很容易写错:如果用for循环去主动管理任务池,容易在"任务完成回调"和"补充新任务"之间出现混乱。换成"每个worker一直从共享的index里取任务"这种写法,逻辑会清晰很多,也不容易出现并发下的竞态问题。
我交完卷后回忆,觉得还有更好的写法,比如用Promise.race动态监听任务池中最早结束的任务。但那需要额外维护一个数组,在笔试的紧张环境下,反而不如上面这个Promise.all加worker的方案稳妥。如果你的目标是拿分而不是炫技,这种写法足够。
4.2 第二道手写题:扁平数组转树结构
第二道题给了一个数组,每个元素长这样:{ id, parentId, name },要求转换成嵌套的树结构。这个题型在前端笔试里出现频率极高,我见到的版本至少有三种:一种是纯递归,时间复杂度偏高;一种是利用Map先存节点再二次遍历;第三种就是拆成"先建映射,再拼父子关系"。
我采用的是Map加两次遍历的做法:
function arrayToTree(items) { const map = new Map(); const roots = []; items.forEach((item) => { map.set(item.id, { ...item, children: [] }); }); items.forEach((item) => { const node = map.get(item.id); if (item.parentId !== null && map.has(item.parentId)) { map.get(item.parentId).children.push(node); } else { roots.push(node); } }); return roots; }之所以用两次遍历,是因为题目里没有保证父节点一定在子节点前面出现。如果只做一次遍历,遇到一个子节点时它的父节点可能还没放进Map,那就只能挂在临时数组里,逻辑会更复杂。用Map先把每个节点的引用存下来,再统一挂靠,就不会有顺序依赖,时间复杂度是O(n),比递归的O(n^2)要稳。
这道题真正容易丢分的地方是"边界条件":如果parentId指向一个不存在的节点怎么办?如果数组为空怎么办?我在答题时把roots数组兜住了空数组的情况,但parentId指向不存在的节点时,我只是简单地把这个节点当成根节点处理了。严格来说可能跟出题人预期不一致,但这种边界处理至少证明你考虑过异常场景,比什么都不写强。
4.3 第三道手写题:深拷贝,循环引用是最隐蔽的坑
第三道题是手写深拷贝。这道题我本以为最简单,结果反而让我最紧张,原因是出题人在描述里加了一句"要处理循环引用"。
常规深拷贝写法无非就是递归遍历对象,typeof判断类型,但一旦涉及循环引用,比如obj.self = obj,不加缓存的话就会无限递归,把调用栈撑爆。我当时写的是:
function deepClone(value, cache = new Map()) { if (value === null || typeof value !== 'object') { return value; } if (cache.has(value)) { return cache.get(value); } if (value instanceof Date) { return new Date(value); } if (value instanceof RegExp) { return new RegExp(value.source, value.flags); } if (Array.isArray(value)) { const arrCopy = []; cache.set(value, arrCopy); value.forEach((item, index) => { arrCopy[index] = deepClone(item, cache); }); return arrCopy; } const objCopy = {}; cache.set(value, objCopy); Object.keys(value).forEach((key) => { objCopy[key] = deepClone(value[key], cache); }); return objCopy; }这里最核心的cache参数,就是用Map把"原始对象"和"拷贝对象"的对应关系存下来。当递归遇到同一个对象时,直接从Map里取出之前创建的拷贝对象,从而打破循环。我当时在Array.isArray的分支里忘了先cache.set,后来检查时发现的,赶紧补上。如果你在考场上也遇到这题,一定要记住:只要走了typeof value === 'object'分支,就要在创建拷贝对象后立刻cache.set,否则后面递归到循环引用时会出事。
深拷贝还有个扩展点是函数和Symbol。函数通常直接复用引用,不拷贝;Symbol作为key时Object.keys拿不到,需要换成Reflect.ownKeys。我答题时没有写完这个扩展,但简答题里我提到了一句"完整实现需要考虑Reflect.ownKeys和Symbol",不知道能不能多拿一点印象分。
5. 简答题:性能优化题怎么答才能让面试官觉得你真有实战经验
5.1 我给出的分层答案:从指标、工具到手段
简答题有两道,一道是"如果线上页面加载很慢,你会怎么排查和优化",另一道是"如何理解前端工程化"。第二道我答得比较中规中矩,说一下模块化、组件化、构建工具、CI/CD这些,不再展开。第一道才是真正拉分的题,我当时的答案分了三层。
第一层,先定位问题指标。我会说,打开DevTools的Performance面板录制一次页面加载过程,用Lighthouse跑一个总分,重点看FCP、LCP、INP、CLS这几项指标。如果LCP超过2.5秒,基本能判断瓶颈在资源加载;如果CLS比较大,说明页面视觉稳定性有问题。
第二层,按资源链路排查。先看HTML是否被缓存、是否有多余的重定向;再看JS和CSS体积,有没有做代码分割;再看图片是不是WebP或者AVIF格式,有没有用懒加载;最后看服务器有没有开Gzip或Brotli压缩。如果项目用的是Webpack,就检查有没有开启SplitChunksPlugin把第三方库和业务代码拆开。
第三层,给出优化措施和验证结果。比如"将首屏非必需的组件改为动态import,LCP从2.8秒降到1.9秒"这种表达。这一层特别重要,因为它让面试官看到你有量化的概念,而不是只会背概念。
5.2 为什么"先量化再做优化"在笔试里更吃香
很多人在答性能优化题时会直接写一堆手段,比如CDN、缓存、懒加载、SSR,听起来都对,但缺乏抓重点的思路。我后来复盘时觉得,笔试里的性能优化题,考官真正想看的不是你能罗列多少方法,而是你拿到一个模糊问题时,会不会先缩小范围。
"先量化"这个思路放在答案的开头,会给阅卷人一种"这个人真的有线上排查经验"的印象。我答那题时用的句式是:先跑Lighthouse看指标,再进Performance面板定位长任务,其次按网络加载阶段逐个排查,最后给出措施和前后对比。这样的结构天然就是一篇"小项目复盘",比单纯堆概念要立体得多。如果你准备面试,强烈建议自己找几个真实项目跑一遍性能分析,哪怕只是把一张大图的体积降下来,都能成为很好的回答素材。
6. 交卷后的24小时:复盘优先级比焦虑更重要
6.1 我预估最可能丢分的三个位置
交卷那一刻,我第一感觉是"完了,多选题肯定错了不少",但冷静下来后,我用十分钟快速做了一个自我评估。最可能丢分的三个位置,我按风险从高到低排了一下。
第一是简答题的第二道,关于工程化的理解,我写得太泛,没有结合实际项目经验。第二是深拷贝那道题,虽然核心逻辑对了,但在Symbol和非枚举属性上处理得不够完整。第三是多选题里的CSS Grid题,考了一个比较少见的grid-template-areas命名方式,我填的答案比较犹豫。
我当时还列了一个简单的预估得分表:
| 题型 | 预估得分区间 | 主要失分点 |
|---|---|---|
| 选择题 | 24~32分 | 多选选项纠结,有漏选风险 |
| 手写题 | 26~32分 | 深拷贝边界处理不完整 |
| 简答题 | 22~26分 | 工程化题太泛,不够落地 |
把得分预期写下来之后,我反而没那么焦虑了。因为我能清楚看到自己扣分的大方向,这也直接指导了接下来两周的复习重点。
6.2 接下来两周的复习方向调整
笔试后的第三天,我调整了复习计划,把我之前"看"的部分全部砍掉,改成"默写"模式。每天早上起来先花半小时默写一份手写题,包括防抖节流、深拷贝、Promise.all、数组去重、数组转树、发布订阅、防重复请求等。默完再对答案,错了就重写一遍。
另一个明显的调整是多选题的练习。以前我刷题总喜欢只选一个最有把握的选项,但笔试里多选题是漏选给部分分还是零分,不同公司规则不一样,小满这套题是漏选也给一部分分,所以"多选"策略反而应该先选绝对有把握的,再决定要不要冒险。我后来练题时专门做了几套多选题组的模拟,专门练"判断哪些选项我真的能确定"。
这两周里我还干了一件事,就是把之前项目里的一个组件库的滚动性能问题重新排查了一遍,用Performance面板记录了优化前后的对比数据。这个案例后来在面试环节帮了我大忙,因为面试官问性能优化时,我直接拿这个真实案例说了十分钟。
最后再分享一个小经验
如果让我回到那场笔试前,我最想告诉自己的一句话是:别把所有希望寄托在"临场发挥"上。在线笔试的题目难度其实往往低于你的心理预期,但就是在基础题上最容易翻车。尤其要注意那些平时看代码觉得"没什么难的"的题,比如事件循环、this指向、flex的最小宽度,它们才是真正筛人的题。
那次笔试最后没有给我offer,但我在复盘笔记里写了一段话:一次笔试能够逼你重新梳理前端知识体系,本身就是一笔很划算的买卖。后来我靠那两周整理的手写模板和性能优化案例,拿到了另一家公司的offer。现在把这些内容写出来,也是希望准备web前端开发春招的朋友少走一点弯路。祝你们都能拿到想要的offer。