2023年小满秋招Web前端岗第二批笔试,我是在一个周六上午参加的。当时人在家里,用的是在线笔试平台,摄像头开着,整个考试过程不能切屏,时间一到自动交卷。说实话,我一开始是把它当成一场普通笔试来准备的,刷了不少面经,结果拿到卷子才发现,光靠背题根本扛不住——这套卷子更看重的,是你有没有真正理解前端的底层逻辑。
考完之后我把整套题从头到尾回忆了一遍,发现大部分题目都不超纲,但陷阱埋得很深。这篇文章就把我记得的考点、解题思路、踩过的坑,以及备考方向完整拆一遍。无论你是准备下一批笔试,还是打算把基础补扎实,都值得花几分钟看完。
1. 整体考情复盘:这套卷子到底在考什么
1.1 题型分布与时间分配
整套卷子大致分成四大块,总时长120分钟,题量不算大,但信息密度很高。我记得试卷结构大概是这样的:
| 题型 | 分值占比 | 覆盖内容 |
|---|---|---|
| 选择题(单选+多选) | 约30% | HTML、CSS、JavaScript基础、浏览器原理、网络协议 |
| 填空题 | 约10% | 作用域、原型链、事件循环的输出结果 |
| 简答题 | 约20% | CSS布局方案、跨域方案、性能优化思路 |
| 编程题 | 约40% | 手写函数、算法题、逻辑题 |
我的建议是开考后先快速扫一遍全卷,别急着做题。选择题和填空题会就会、不会就不会,卡住没意义;编程题分值最高,但需要完整思路,适合放在大脑最清醒的前半段。我个人的时间分配是:选择和填空40分钟,简答20分钟,编程题40分钟,最后20分钟检查。这套节奏比较保守,能保证每道题都有时间碰一遍。
1.2 难度分层:送分题、基础题、拉分题
从实际体验来看,这套卷子明显分为三个层次:
- 送分题:标签语义、CSS属性作用、HTTP状态码含义、Promise基础用法,这类题基本是看一眼就该出答案的。
- 基础题:闭包输出、事件循环顺序、三栏布局实现,这类题要求你真正写过代码,不能只看过概念。
- 拉分题:手写深拷贝、手写Promise.all、实现带并发限制的调度器,这些题没有标准答案,完全靠平时的工程积累和代码功底。
第二批的拉分题比第一批更灵活。我当时看到有一道简答题甚至允许你写出多种方案,但要说明各自的优缺点。这种题根本没有"背"的可能性,如果平时只是照着教程敲代码、没有自己思考过方案的取舍,到这里就会明显露怯。
2. 基础考点盘点:CSS、HTML与网络基础
2.1 三栏布局:圣杯、Flex、Grid怎么选
选择题和简答题都出现了三栏布局,题干是"实现左右各200px、中间自适应的三栏布局,要求中间部分优先加载"。这题看着经典,但很多人会忽略"中间部分优先加载"这个条件。如果你直接把左侧div写在中间div前面,那中间部分就不是第一个加载的DOM节点,这题至少扣一半分。
我当时写的是flex方案,代码是这样:
<div class="container"> <div class="main">中间内容</div> <div class="left">左侧</div> <div class="right">右侧</div> </div>.container { display: flex; } .main { flex: 1; order: 1; } .left { width: 200px; order: 0; } .right { width: 200px; order: 2; }这里的关键是,HTML结构里把中间内容放在最前面,但通过order属性调整视觉顺序。这样中间部分确实是先加载的,同时页面显示上还是左、中、右。可能有人会觉得order是万能的,但实际上order只改变视觉顺序,不改变DOM的解析顺序,这一点在面试官追问时一定要能说出来。
我又补充了grid方案:
.container { display: grid; grid-template-columns: 200px 1fr 200px; } .main { grid-column: 2; } .left { grid-column: 1; } .right { grid-column: 3; }grid写法更简洁,但在老浏览器兼容性上不如flex。如果题目没有说明对浏览器兼容有硬性要求,我一般会优先推荐flex,因为生产环境中大部分中后台项目都要求兼容到Chrome 60以上,grid在当时还不够保险。这道题真正考查的不是你会不会写fiex布局,而是你脑子里有没有"多方案横向对比"的意识。
2.2 选择器优先级和BFC
选择题里有一道是"以下选择器优先级从高到低排列",选项类似#app .name、.wrap .name、div p.name、div p。如果你只背口诀"important > 内联 > id > class > 标签",这道题很容易掉坑。
正确理解是选择器优先级按三元组逐位比较:id选择器个数、class/属性/伪类选择器个数、标签/伪元素选择器个数,从高到低逐位比较。#app .name对应(1,1,0),.wrap .name对应(0,2,0),比较时先看id数,所以(1,1,0)更高。后面接着考了!important和行内样式的比较,这个在笔试里就是送分题,但前提是你别把!important和display:none的优先级混淆。
BFC简答题是这套卷子里我印象比较深的一道,题干是"什么是BFC?如何触发?解决了什么问题?"。BFC全称Block Formatting Context,块级格式化上下文,我习惯把它理解成一个独立的渲染区域,区域内外的元素不互相干扰。触发条件我写了overflow不为visible、float不为none、position为absolute或fixed、display为inline-block,以及flex/grid容器的子项。解决场景经常考的是三个:防止margin塌陷、清除浮动、避免元素被浮动元素覆盖。如果能写出"两栏布局中让右侧盒子形成BFC,可以避免被左侧浮动元素遮挡"这个实际案例,比只罗列定义高一个档次。
2.3 跨域方案与缓存机制简答
简答题里有跨域和缓存,这两个都是高频考点。跨域那题问的是"如何解决跨域",我写了三种方案:CORS、JSONP、反向代理。重点写了CORS,因为它是服务端配置的,需要在响应头里带上Access-Control-Allow-Origin,并且要区分简单请求和预检请求。JSONP只能支持GET请求,而且依赖动态创建script标签,适合老项目,但工程上现在已经很少用了。
缓存那题问的是"强缓存和协商缓存的区别"。强缓存是浏览器直接用本地缓存,不发请求,对应的响应头是Cache-Control的max-age和Expires;协商缓存是浏览器带着条件请求头去问服务器,服务器返回304就走缓存,对应的响应头是Last-Modified/If-Modified-Since和ETag/If-None-Match。我当时补了一句"ETag的优先级高于Last-Modified",因为Last-Modified只能精确到秒,同一秒内文件变化判断不到,这种细节很容易成为加分项。
3. JavaScript运行机制:闭包、原型链、事件循环
3.1 闭包输出题:var、let与"5个5"
填空题考了一道闭包经典题,代码我给个近似的版本:
for (var i = 0; i < 5; i++) { setTimeout(() => { console.log(i) }, 1000) }答案是连续输出5个5。很多人背过这个结论,但不理解为什么。真正原因是var声明的i是函数作用域,循环结束后i已经是5,所有setTimeout回调在1秒后执行时,访问到的都是同一个i,也就是5。如果你只背了答案,题目一变就废了。
但如果把var改成let:
for (let i = 0; i < 5; i++) { setTimeout(() => { console.log(i) }, 1000) }输出就是0、1、2、3、4。因为let在每次迭代时会创建新的词法环境,每个回调捕获的是当次迭代的i副本。
后面还加了一问:"不使用let,如何用var加闭包实现正确输出"。我写的是IIFE版本:
for (var i = 0; i < 5; i++) { (function(j) { setTimeout(() => { console.log(j) }, 1000) })(i) }这道题真正想测的是你对作用域和变量生命周期的基础是否扎实。凡是靠背答案的同学,一旦题目改成循环次数是动态的,或者两个for循环嵌套,就很容易暴露。
3.2 原型链与手写new
选择题里有一段代码:
function Foo() {} Foo.prototype.a = 1 const foo = new Foo() console.log(foo.a) console.log(foo.__proto__.a) console.log(Foo.prototype.constructor === Foo)答案分别是1、1、true。理解只有一句话:实例通过__proto__访问到构造函数的prototype,prototype默认有一个constructor属性指回构造函数本身。
但真正拉分的是后面让你手写new。我写的是:
function myNew(Constructor, ...args) { const obj = Object.create(Constructor.prototype) const result = Constructor.apply(obj, args) return result !== null && (typeof result === 'object' || typeof result === 'function') ? result : obj }两个关键点必须体现出来:一是用Object.create(Constructor.prototype)让新对象的原型链正确;二是如果构造函数显式返回了一个对象,new操作符会返回那个对象,而不是我们自己创建的obj。如果构造函数返回的是原始类型,则忽略并返回obj。这个边界情况很多人写不出来,实际项目里也很少遇到,但笔试就是靠这种区分度筛人。
3.3 宏任务与微任务的执行顺序
简答题给了一段混合Promise和setTimeout的代码,要求写出输出顺序。我回忆一下大致类似这样:
console.log('start') setTimeout(() => { console.log('timeout1') Promise.resolve().then(() => { console.log('promise in timeout') }) }, 0) new Promise((resolve) => { console.log('promise1') resolve() }).then(() => { console.log('then1') }) console.log('end')我在考场上的推理过程是:先执行所有同步代码,所以start、promise1、end依次输出;然后清空微任务队列,then1输出;接着执行宏任务setTimeout回调,输出timeout1,回调内部又注册了一个微任务,所以在输出timeout1之后还要补一个promise in timeout。最终顺序是start、promise1、end、then1、timeout1、promise in timeout。
这里特别容易错的一点是,很多人把Promise构造函数里的代码当成异步。它其实是同步执行的,只有then回调才是微任务。这个理解如果错了,后面所有输出顺序的题都做不对。
还有个扩展方向是Node环境下的差异。不同版本Node的事件循环机制不一样,Node 11之后微任务执行时机和浏览器趋于一致,但老版本可能存在差异。笔试里一般不用展开太多,但写一句"以上结论基于浏览器环境"会更严谨,展示出你考虑过运行环境。
4. 手写代码题:深拷贝、防抖节流、Promise.all
4.1 深拷贝:别只写JSON方法
编程题第一道是"实现一个深拷贝函数,要求能够拷贝数组、普通对象以及处理循环引用"。拿到这题,我看到很多人第一反应是写JSON.parse(JSON.stringify(obj)),然后就被扣分了。原因有两个:一是JSON方法会忽略值为undefined的字段、函数、Symbol,二是无法处理循环引用,对象互相引用时会直接抛错。题目里明确写了"处理循环引用",所以JSON方案从一开始就出局了。
我写的是递归版本,用WeakMap记录已经拷贝过的对象:
function deepClone(target, map = 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) } if (map.has(target)) { return map.get(target) } const clone = Array.isArray(target) ? [] : {} map.set(target, clone) for (let key in target) { if (Object.prototype.hasOwnProperty.call(target, key)) { clone[key] = deepClone(target[key], map) } } return clone }这个版本能覆盖绝大多数场景。解释一下为什么用WeakMap:WeakMap的键是弱引用,意味着当原对象不再被引用时,它可以被垃圾回收;如果用普通Map,拷贝完成后整个Map一直握着原对象的引用,会导致内存泄露。能写出WeakMap并解释清楚这一点,面试官基本能判断你写过真实项目,而不是背的。
还有一个容易忽略的点是for...in会遍历到原型链上可枚举的属性,所以必须加hasOwnProperty判断。否则拷贝出来的对象会带上原型上的扩展属性,这在实际工程中会引发bug。
4.2 防抖与节流:this是最大坑
编程题第二道要求"实现一个防抖函数和一个节流函数,并说明两者的区别及使用场景"。我的防抖实现:
function debounce(fn, delay = 300) { let timer = null return function(...args) { clearTimeout(timer) timer = setTimeout(() => { fn.apply(this, args) }, delay) } }这里最大的坑是this指向。如果setTimeout回调里用普通function,那么执行时this会指向window(严格模式下是undefined),而我们需要的是返回函数被调用时的this。所以回调必须用箭头函数,让this从外层词法作用域继承,再通过fn.apply(this, args)把正确的this传给原函数。这个细节我身边不少同事都踩过,笔试时写不上来非常可惜。
节流的实现:
function throttle(fn, interval = 300) { let last = 0 return function(...args) { const now = Date.now() if (now - last >= interval) { last = now fn.apply(this, args) } } }场景差异我说得很直白:防抖适合输入框搜索,用户连续输入时只发最后一次请求;节流适合滚动事件监听,保证固定时间内至少执行一次,避免计算太频繁。如果能补充"防抖会把一个高频操作合并成一次,节流则是限制高频操作的执行频率",这道题就答全了。
4.3 手写Promise.all的核心要点
最后一道编程题是实现Promise.myAll。这个题我写过太多次了,但考试时还是提醒自己不要漏边界情况。我的实现:
Promise.myAll = function(promises) { return new Promise((resolve, reject) => { const result = [] let count = 0 if (promises.length === 0) { resolve(result) return } promises.forEach((p, index) => { Promise.resolve(p).then((value) => { result[index] = value count++ if (count === promises.length) { resolve(result) } }).catch(reject) }) }) }必须强调三个点:
- 用
Promise.resolve(p)包裹一遍,这样就算传入的是普通值,也能正常挂在then链上。 - 存储结果时按索引存,不按完成顺序存。比如第一个Promise最后才完成,它的值也必须放在
result[0],这样才能保证输出顺序和入参顺序一致。 - 用计数器
count来判断是否全部完成,而不能用result.length === promises.length。因为数组是稀疏赋值,先给result[2]赋值后,result.length就变成3了,会提前触发resolve,逻辑就错了。
第三点是我觉得全网很多教程都没仔细讲的地方,但笔试里一旦写错,代码跑出来的结果会非常诡异。
5. 算法与逻辑题:思路比背模板更重要
5.1 数组扁平化与去重排序
逻辑题里有一道将任意深度数组扁平化并去重排序的题,类似输入[1, [2, [3, 3, [4, 5]]], 6],要求输出[1, 2, 3, 4, 5, 6]。
扁平化我写了reduce递归:
const flat = (arr) => arr.reduce( (acc, cur) => acc.concat(Array.isArray(cur) ? flat(cur) : cur), [] )去重排序用Set和sort,但这里有个大坑:sort默认按字符串的字典序排,[1, 10, 2]直接sort()会变成[1, 10, 2],因为"10"在字典序里排在"2"前面。所以必须写成sort((a, b) => a - b)。这个坑在笔试里出现频率特别高,我写的时候特意留了个心眼。
5.2 二叉树的还原遍历
还有一道二叉树相关的题,给出前序遍历和中序遍历,要求还原二叉树并输出后序遍历。我记得前序大概是[3, 9, 20, 15, 7],中序是[9, 3, 15, 20, 7]。
思路是固定的:前序第一个元素一定是根节点,去中序里找到根节点的位置,左边是左子树,右边是右子树,递归处理。为了降低复杂度,可以先用Map把中序数组每个值的索引存起来,这样每次查位置就是O(1),而不是O(n)。
这类题的唯一建议是多练。递归的边界条件很容易出错,光看解析全会,一写就废。LeetCode上"从前序与中序遍历序列构造二叉树"这道题,吃透它比临时背十道模板都管用。如果你能把边界条件用注释标出来,比如"左子树为空"和"右子树为空"各自返回什么,面试官会觉得你很专业。
6. 失分点复盘与避坑经验
6.1 时间分配与检查策略
这次笔试我最大的失误是在填空题上花太久。当时遇到一道关于Set和Map的题,我有点拿不准,愣是想了10分钟,结果后面的编程题时间被砍掉了一大截。考完复盘,正确的策略应该是:判断题超过2分钟还没有明确思路,先标记跳过,把后面分值高的题写完再来抠。一道填空撑死2分,一道编程题15分起步,为了2分牺牲15分,这个账怎么算都不划算。
检查阶段也很重要。我通常先看编程题有没有明显语法错误、能不能跑通示例,再看填空和选择题有没有写了但选项没填对的情况。有一次我发现自己把B选成A,纯粹是因为鼠标点快了,这种低级失误完全可以通过检查避免。
6.2 易错知识点速查表
我把这套卷子反复回忆后,整理出一个易错知识点速查表,备考时过一遍很管用:
| 考点 | 常见错误 | 正确理解 |
|---|---|---|
| 选择器优先级 | 只记id/class/标签 | 按(id, class, 标签)三元组逐位比较 |
| var与let | 以为只是作用域不同 | 本质是每次迭代是否创建新的词法环境 |
| Promise执行时机 | 以为整个都是异步 | 构造器内代码同步执行,then才进微任务 |
| 深拷贝 | 直接用JSON | JSON丢失undefined/函数/Symbol,不能处理循环引用 |
| 防抖this | 回调里用普通function | 必须用箭头函数继承this或显式绑定this |
| Promise.all | 用result.length判断完成 | 稀疏数组会让length提前达标,必须用计数器 |
| sort排序 | 默认字典序 | 不传比较函数时按字符串排序,数字需传(a,b)=>a-b |
| BFC触发方式 | 只记得overflow和float | position/display/flex容器子项等也常见 |
| order属性 | 以为改变DOM解析顺序 | 只改变视觉顺序,DOM加载顺序仍由HTML决定 |
| 二叉树还原 | 记不住边界 | 前序定根,中序分割左右,递归构建 |
这张表我建议准备笔试的朋友打印出来,考前一天过一遍,能避掉很多非技术性失分。
6.3 考试环境与答题细节
在线笔试的纪律性一定要重视。我当时考试平台要求打开摄像头,浏览器有切屏检测,切屏超过一定次数会被系统判违规。所以开考前要保证网络稳定、关闭无关浏览器标签页、手机静音放到一边。输入法务必切成英文半角,否则代码里的标点很容易出问题,一旦提交运行全是语法错误。
编程题的答题区一般有固定模板,别上来就删模板,先看题目要求你导出什么、函数参数名是什么,尽量在模板基础上改。写完代码一定要点一次运行,确认能通过示例用例再交卷。我不止一次发现,代码本地没问题,粘到答题区就出现缩进混乱或引号被改成全角的情况,提交前不运行一遍很容易挂。
7. 考后复盘与备战方向
7.1 从笔试反推学习优先级
经历这次笔试,我最大的感受是:前端笔试早就不是"你会用API"就能过关的了,现在出的题几乎都是围绕"你理解它为什么这样设计吗""你写过边界情况吗"来展开的。如果你还在准备后续批次或补录面试,建议把精力按这个优先级放:
JavaScript核心机制(闭包、原型链、事件循环、异步)大于手写代码能力(深拷贝、防抖节流、Promise系列)大于CSS布局与浏览器渲染原理,最后才是框架源码。框架全家桶会问,但笔试阶段真的更看重基础功底。Vue或React的响应式原理、diff算法可以过一遍,但不用背源码,能讲清核心思想就够了。
7.2 复习资料推荐
说实话我不太喜欢列一堆书单,最后看不完反而焦虑。我实际用下来最有效的就三个:MDN的JavaScript指南,用来把基础概念查清楚;LeetCode上与二叉树构造、数组扁平化相关的题目,用来练手写能力;再就是自己之前做过的项目代码,把里面用到的防抖、节流、深拷贝、跨域配置拿出来重新理解一遍,比背任何面经都有用。
7.3 心态与节奏
最后说句实在话,笔试没过不代表你水平不行,有时候就是差那么一点熟练度和临场经验。我前面因为一道填空题浪费时间,导致编程题差点没写完,那种被动感太难受了。后面再考其他公司,我学乖了:先扫全部题目,按分值排序,把编程题放在优先位置,整套做下来反而更从容。这是我的真实体会,希望你下次坐在笔试电脑前的时候,能避开我踩过的这些坑。