谁还记得2023年秋招那阵子,大家一边刷八股一边焦虑“HC到底还有没有”的状态。我这篇想聊聊当时参加的小红书前端岗第三批笔试,题目不算偏,但考察角度挺有代表性——它不问你背得多熟,而是看你在真实业务场景里怎么用这些基础能力。如果你正准备投前端岗,或者想了解大厂笔试到底筛什么,这篇应该能给你一个比较完整的参照。
先说一下背景。小红书的前端笔试走的是牛客网在线笔试系统,第三批大概是九月中下旬,题型分三块:单选、多选、两道编程题。考试时间一共90分钟,其中有20道选择题,每题大概2分,编程题占大头。整体难度在所有互联网公司里算中上,比字节稍微温和一点,但比普通中厂要扎实不少,尤其是编程题,两道题都有明显的业务场景影子。
1. 整体考点分布与笔试策略拆解
1.1 选择题考点统计与出题逻辑
我整理了一下当时记下来的考点,给后来人做个参考:
| 考点方向 | 具体内容 | 出题方式 |
|---|---|---|
| JavaScript基础 | 事件循环、闭包、this指向、原型链 | 代码输出题 |
| CSS布局 | flex布局、BFC、层叠上下文 | 概念判断 |
| 浏览器原理 | 缓存、渲染流程、跨域 | 单选/多选 |
| React/Vue | setState机制、diff算法、响应式原理 | 代码分析 |
| 网络基础 | HTTP缓存、TCP握手 | 概念题 |
| 工程化 | webpack打包过程、sourcemap | 基础概念 |
这里有个很有意思的现象,选择题基本没有那种纯背诵的题目,比如“http状态码404代表什么”这种送分题基本不存在,它喜欢把知识点藏在一小段代码里让你推导输出。比如事件循环那道题,给了一段setTimeout、Promise、async函数混合的代码,让你写出输出顺序。这种题网上八股一搜一大把,但真正做起来还是容易栽,因为考点一旦嵌套在async/await里,很多人对微任务队列的执行时机就会搞混。
CSS部分考的也不浅。有一道关于flex布局的题,给了一个容器设置了flex-wrap: wrap,子项分别设置了flex-basis和flex-grow,问在不同宽度下的换行表现。看似简单,实际上把flex-basis、flex-shrink、flex-grow三者的关系全考进去了。还有一道BFC的题,问的是怎么触发BFC以及BFC能解决什么问题,选项里既有float、overflow这些常规手段,也有position: fixed这种容易让人犹豫的选项。
浏览器和网络部分是另一块比重比较大的内容。HTTP缓存那一题,给了两次请求,第一次响应头里有Cache-Control: max-age=600,第二次请求时服务端把ETag改了,问会走什么缓存流程。这里要是不理解强缓存和协商缓存的判定顺序,很容易选错。React部分倒是没考太偏,一道setState是同步还是异步的题目,外加一道diff算法中key的作用,都属于基础中的基础,但越基础越容易说错,尤其setState那道题,很多人只背了“React18以后自动批处理”,但没理解在原生事件和异步任务里到底怎么表现。
1.2 编程题的题型风格与考察方向
小红书这两道编程题不是典型的力扣风格,而是在力扣的基础上套了一层业务场景。第一道大概意思是:有一个异步请求函数,要求实现一个带并发数限制的调度器,保证同一时间最多只有N个请求在执行,某个请求结束后自动补下一个。这题本质上考察的是Promise、异步队列、并发控制这三个核心能力,属于前端工程师日常开发中真的会遇到的场景,比如批量上传文件、并发请求接口限制等等。
第二道题是数组处理相关,给一个二维数组,要求按指定规则进行扁平化处理,同时还要去重和排序。表面上看是个工具类题目,难度不大,但它对时间复杂度和边界情况的考察很看重,普通解法能跑通部分用例,但要拿满分需要注意细节,比如大数情况下用Set去重和用对象去重的区别,扁平化时用递归和用迭代的性能差异,这些细节会直接决定能不能通过全部测试用例。
从出题逻辑来看,小红书的笔试更看重候选人的工程思维,而非纯粹刷题能力。第一题是典型的“工作中会用到的工具函数实现”,第二题则是在考查代码严谨性。所以备考时,不能只盯着力扣热门题,手写Promise、手写防抖节流、手写并发控制、数组工具函数这一类的题一定要重视。
2. 核心考点深度解析与易错点复盘
2.1 事件循环与Promise执行顺序问题
先拿事件循环这道题说吧。类似这样的代码,几乎是前端笔试必出:
console.log(1); setTimeout(function() { console.log(2); Promise.resolve().then(function() { console.log(3); }); }, 0); new Promise(function(resolve) { console.log(4); resolve(); }).then(function() { console.log(5); }); async function test() { console.log(6); await 7; console.log(8); } test(); console.log(9);正确答案是:1、4、6、9、5、8、2、3。
这道题看似简单,但很多人会栽在第8个输出上。关键在于await 7这一行,await后面的表达式如果不是Promise,会立即执行,但await之后的代码会作为一个微任务被调度。所以console.log(8)不会立即执行,而是被推进微任务队列。很多人会把8放在5之前输出,就是因为没搞懂await在这里的具体行为。
我当时的分析思路是:
- 同步代码依次执行:1、4,然后Promise的executor里面resolve之后,then回调被推入微任务队列,但还没执行。
- test()调用时,同步输出6,await 7因为7不是Promise,所以将其转换为Promise.resolve(7)并等待,后面的8被推入微任务队列。
- 继续执行同步代码输出9。
- 主线程同步代码执行完毕,开始清空微任务队列:先执行Promise的then回调,输出5;再执行await之后的代码,输出8。
- 最后执行宏任务setTimeout回调,输出2,紧接着回调里的Promise.resolve().then被推入微任务队列并立即执行,输出3。
注意:在浏览器和Node.js较新版本中,微任务队列执行时机都是“当前宏任务结束、下一个宏任务开始前”。只要记住这个原则,事件循环题基本不会错。
2.2 flex布局与BFC的隐藏考点
flex那道选择题,题干大概是这样的:一个容器宽度为600px,里面有三个子元素,基础宽度分别是200px、150px、100px,设置了flex-wrap: wrap,问当容器宽度缩到500px和400px时,子元素分别怎么排列。
这道题真正想考的其实不仅仅是flex-wrap,而是flex-shrink的默认行为。因为默认flex-shrink为1,所以当宽度不足时,子元素会按比例缩小,而不是直接换行。只有当缩小到最小内容宽度仍然无法放下时,才会触发换行。
很多备考的同学容易忽略一个点:flex-shrink的基数是“flex-basis设置的值”而不是“内容实际宽度”。比如flex-basis: 200px的子元素,在容器宽度不足时,它是从200px开始缩的。如果能算出这个逻辑,选项就很容易排除。
BFC那道题,正确答案是:overflow: hidden、float: left(或right)、display: inline-block、position: absolute(或fixed)都能触发BFC。不过严格来说,position: fixed在CSS2.1里没有明确说能触发BFC,但在实际渲染里它和absolute一样会创建新的格式化上下文。这里要看命题人的意图,如果选项里同时有absolute和fixed,一般会选absolute,因为这是规范里明确提到的。遇到这种题不要慌,优先选根正苗红的选项。
2.3 HTTP缓存流程的完整推导
网络部分的缓存题,几乎是所有前端笔试的标配。考的是这样一条链路:
- 第一次请求资源,响应头:Cache-Control: max-age=600,ETag: "abc123"。
- 10分钟后(600秒内),再次请求该资源,浏览器判断缓存未过期,直接使用本地缓存,不发请求到服务器,这是强缓存命中。
- 600秒后,再次请求,浏览器发现缓存过期,携带If-None-Match: "abc123"请求服务器。
- 服务器比对ETag,如果资源没有变化,返回304 Not Modified,浏览器继续使用本地缓存;如果资源有变化,返回200和新资源,浏览器更新本地缓存。
这是标准流程,但笔试容易错的地方在第二步。很多人以为强缓存命中也发请求,只是不下载body。不是这样的,强缓存命中时,终端根本不发起网络请求,直接从本地读缓存。而协商缓存命中时,会发起一个请求,服务器返回304,此时传输的只有头部没有body,耗时通常更短。
这道题如果变形,可以问Cache-Control和Expires同时存在时以谁为准,这时候答案是Cache-Control优先级更高。如果问no-cache和no-store的区别,记住no-cache不是不缓存,而是每次都要向服务器验证,no-store才是不缓存。
2.4 setState同步还是异步,取决于什么
React那道题,具体是问下面这段代码中,handleClick执行完后,count最终会变成多少:
function Counter() { const [count, setCount] = useState(0); function handleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1); } return <button onClick={handleClick}>{count}</button>; }答案是1,不是3。虽然setState调了三次,但因为count在本次渲染中没有更新,所以三次都是基于同一个旧值,最终合并成一次更新。这个题目属于React 18之前的基础题,React 18的自动批处理在Promise、setTimeout等异步场景下也会合并更新了,但函数组件里连续调用setCount这种场景,结果依然是1。
另一道diff算法题目,问key的作用是什么。标准答案是:key用于识别节点是否发生变化,帮助React在diff时复用DOM节点,从而减少渲染开销。错误选项往往说“key能提高diff算法的时间复杂度”,这是不准确的,key不改变diff算法的时间复杂度,它是用来优化节点匹配效率的。
3. 编程题完整思路与逐步实现
3.1 带并发数限制的异步调度器
这道题是笔试里的重点,值得完整写一下实现思路。题干大意是实现一个Scheduler类,它能往里面添加异步任务,同时最多执行N个,任务执行完自动取下一个任务执行。
先看一个典型的调用示例:
const scheduler = new Scheduler(2); const task1 = () => new Promise(resolve => setTimeout(() => { console.log('task1'); resolve(); }, 1000)); const task2 = () => new Promise(resolve => setTimeout(() => { console.log('task2'); resolve(); }, 500)); const task3 = () => new Promise(resolve => setTimeout(() => { console.log('task3'); resolve(); }, 300)); const task4 = () => new Promise(resolve => setTimeout(() => { console.log('task4'); resolve(); }, 400)); scheduler.add(task1); scheduler.add(task2); scheduler.add(task3); scheduler.add(task4);当并发数限制为2,期望的执行顺序是:最开始task1和task2同时执行,task2先完成(500ms),task2结束后自动执行task3(300ms),task3先于task1完成,task3结束后执行task4。所以输出顺序是task2、task3、task1、task4(如果task1耗时1000ms,task4耗时400ms,那task4也会比task1早完成,最终是task2、task3、task4、task1)。
这道题的经典解法是利用Promise和递归。核心思路是:
- 用一个计数器记录当前正在执行的任务数。
- 用一个队列存储等待执行的任务。
- 每次add任务时,先入队,然后调用一个run方法尝试执行。
- run方法里,如果当前执行数没有达到上限,就出队一个任务,执行数加一,任务完成后执行数减一,再递归调用run。
完整实现:
class Scheduler { constructor(limit) { this.limit = limit; this.queue = []; this.activeCount = 0; } add(task) { return new Promise(resolve => { this.queue.push({ task, resolve }); this.run(); }); } run() { if (this.activeCount >= this.limit || this.queue.length === 0) { return; } const { task, resolve } = this.queue.shift(); this.activeCount++; Promise.resolve(task()).then(() => { this.activeCount--; resolve(); this.run(); }); } }这里有个关键设计:add方法返回一个Promise,并在任务真正执行完成后才resolve。这个在笔试时可能不会直接要求,但为了代码严谨,建议加上。否则调用方没法知道任务什么时候完成。
另一个细节是,run方法里的Promise.resolve(task())要这么写,因为task可能返回的不是Promise,或者可能抛异常。用Promise.resolve包一层可以保证拿到一个Promise对象,异常也能被catch。当然更严谨的写法还要加上catch,避免任务出错导致后面的run无法执行。
这个实现虽然简单,但有几个容易犯的错:
- 在add里直接执行task,忽略了并发上限判断。
- 任务完成后减计数但没接着调用run,导致队列后面的任务永远得不到执行。
- 没有处理task本身抛出异常的情况。
提示:如果面试官让你扩展成支持“任务超时取消”或者“任务优先级”,本质都是在队列和调度逻辑上做文章,核心的调度器骨架不变。
3.2 数组扁平化、去重与排序的组合题
第二道编程题,给一个嵌套数组,比如[1, [2, 3], [4, [5, [6]]], 7, 8],要求扁平化后用某种规则去重(可能是取绝对值去重或者按某个字段去重),最后按升序排序,返回新数组。
这道题看似简单,但考察的恰好是前端基础是否扎实。最简单的写法:
function flatten(arr) { return arr.flat(Infinity); } function unique(arr) { return [...new Set(arr)]; } function sort(arr) { return arr.sort((a, b) => a - b); }如果就这道题本身来说,这样写没有任何问题。但笔试容易设坑的地方在于,如果数组里的元素是对象,或者要求去重的规则是“绝对值去重”,那new Set就没法直接用了,需要自己维护一个有序结构或者用Map。
我看过网上很多笔试复盘,这道题常见的低级错误是:用Array.prototype.flat()时不传参数,flat()默认只扁平化一层,嵌套两层的数组就处理不了。很多人以为flat()默认是无限扁平化,这是个知识盲区。
如果要求在面试中手写一个等效的flatten,用递归的方式:
function flatten(arr) { const result = []; for (let i = 0; i < arr.length; i++) { if (Array.isArray(arr[i])) { result.push(...flatten(arr[i])); } else { result.push(arr[i]); } } return result; }去重的部分如果不想用Set,也可以用reduce和includes,但时间复杂度会高一些。笔试没有明确禁止内置API时,尽量用简洁的写法,反而更稳妥,因为出错的概率低。
这道题真正的拉分点在于,对边界情况的处理。比如:空数组、数组里还有null和undefined、字符串数字混在一起、巨大的数字导致排序结果异常。这些情况平时练习时不太会注意,但在笔试用例里很可能出现。
另外要注意sort()默认是把元素转成字符串再排序。[1, 2, 10, 3].sort()会得到[1, 10, 2, 3],所以无论数组里是数字还是字符串,都建议显式传比较函数,不然容易翻车。这个细节在笔试题里经常被拿来挖坑。
4. 笔试中那些容易忽略的失分细节
4.1 时间分配与做题顺序的建议
90分钟做20道选择题加2道编程题,时间其实不算充裕。我当时的时间分配是:选择题控制在35分钟内,编程题每道20分钟,剩余15分钟检查。听起来很理想,但实际操作中选择题很容易超时,尤其是一些代码输出题,一旦中间卡住,10分钟就没了。
我的建议是:拿到卷子先花1分钟快速浏览编程题,心里对难度有个底,如果编程题第二道明显比第一道简单,可以倒过来先做第二道。笔试的分数是按通过用例比例给的,不是按提交顺序,先拿容易拿的分永远是优先级最高的事。
很多同学有一个习惯,遇到不会的选择题就死磕,结果编程题没时间做,这是最亏的。选择题不会的可以先随便选一个,标记一下,等编程题做完再回头改。一道选择题2分,但编程题一个用例可能就值好几分,要算清这笔账。
4.2 答题系统与代码环境的坑
牛客网的笔试系统有个特点:编程题支持JavaScript,但不支持ES Module,也不支持require,代码直接以脚本方式执行。所以你在本地写的import、export语句,在牛客上直接报语法错误。这一点一定要提前适应,平时在力扣刷题时用的也是这种“纯函数输出”模式,但如果你习惯在本地用webpack环境写代码,笔试前最好专门在牛客网上练几道题,感受一下这个环境。
另一个需要注意的点是,本地运行node.js和牛客网页端运行代码的宿主环境不同。牛客的JavaScript环境是Node.js还是浏览器环境,要看考试说明,但大多数情况下是Node.js,所以不涉及DOM操作。如果题目给了明确的函数签名,直接按签名实现函数体就行,不要自己包一层多余的代码。
我见过有人在笔试时把数组输入解析写在代码里,结果题目其实已经帮你解析好了,函数参数直接就是数组,多此一举反而出了错。答题之前一定要先看题目给的模板和输入输出约定。
还有一个非常现实的坑:牛客的在线IDE没有自动保存功能。如果你快交卷时浏览器崩了或者网断了,代码就没了。建议每写完一道题,先手动复制代码到本地存一份,再继续做下一道。我当时就是这样做的,虽然不能保证万无一失,但至少多了一道保险。
4.3 选择题里的“文字陷阱”与排除法技巧
选择题里最容易失分的不一定是知识点不会,而是选项看错。举例来说,有一道题问“下列哪种方式不能触发BFC”,选项里有overflow: hidden、overflow: auto、overflow: scroll、float: left。看起来前三个都能触发BFC,但仔细想想,overflow属性的值只要是非visible就能触发BFC,所以这三个都能,这题就不能选它们,正确答案可能是某个设置display: inline的选项。这类题考的就是一个“看仔细”的能力。
排除法在这种场景下非常管用。先把明显正确的排除掉,再把明显错误的排除掉,剩下的选项里再仔细分辨。一般两道三个选项之间差别很微妙,这个时候不要靠猜,回看题干,看它问的是“能”还是“不能”、“包含”还是“不包含”,这种关键词往往是解题的关键。
代码输出题的技巧是:不要真的自己脑内执行,太容易出错了。笔和纸(或者在线IDE的草稿纸功能)画一个执行时序表,把同步任务、微任务、宏任务分三列列出来,每遇到一个异步API就登记一下,最后再按队列顺序输出。这个方法虽然笨,但正确率极高,尤其是在事件循环这种复杂顺序题上,非常管用。
4.4 考前复习建议与资料方向
结合这次笔试的考点分布,给准备前端笔试的同学一个相对高效的复习方向。算法方面不用贪多,把常见的数组操作、字符串处理、Promise场景题、深拷贝、防抖节流、柯里化、并发控制吃透,基本能覆盖大部分笔试需求。力扣上没必要把Hard题全刷完,把热题HOT 100里的简单和中等题刷两遍,性价比最高。
八股文的部分,重点抓JavaScript基础、浏览器原理、CSS布局、React/Vue原理这四块。不是死记硬背,而是每个知识点都要能用自己的话说清楚,最好能写一个小demo验证。比如事件循环,你自己跑一遍代码和看别人写的解析,记忆深度完全不一样。
在小红书笔试里,项目经验没有直接考,但它会影响你对场景题的理解。比如并发控制这道编程题,如果你平时做过接口请求限流的工具,或者写过批量上传组件,看到题目会感到亲切很多,因为你已经知道它的应用场景了。这也提醒我:准备笔试不能只看题,平时工程实践里的积累会在无意间帮到你。
5. 笔试之后:复盘比分数更重要
笔试做完不是结束,复盘才是真正能拉开差距的部分。我当时考完把记得的题目全部列了出来,然后一道一道查漏补缺,尤其是那些做错的选择题,不只是把不会的知识点补上,还追了一下背后的原理。比如flex-shrink那道题,我后来专门去翻了MDN的文档,把flex-basis、flex-grow、flex-shrink三者的计算关系理清了。这种深入式的学习,比刷三套模拟题都有效。
复盘时有一个技巧:把错题按“知识点不熟”“粗心看错题干”“代码边界情况没考虑”三类归档。这样你就能清楚自己在哪一类问题上失分最多,下次复习时更有针对性。单纯把错题抄一遍是没用的,你要分析错因,而不是只看答案。
编程题复盘的方式是:先自己重新写一遍,然后去看讨论区里别人的解法,特别是有没有人用了更简洁的方式,比如用async/await改造并发调度器、用reduce实现扁平化。学习不同的解题思路,能帮助你在下一次遇到同类题时反应更快。
这里再分享一个我在笔试过程中积累的小技巧:在牛客系统的编辑器里,如果你用了ES6+语法,尽量不用那些特别新的特性,比如可选链、空值合并,虽然牛客支持,但某些老版本Node环境可能不认。选择题怎么考察浏览器兼容性是一回事,自己写代码时一定要保证在中低版本环境里能跑。笔试环境的坑能少踩一个是一个。
最后再补充一点:笔试不是面试的全部,它只是一个门槛,只要过了,面试环节才是真正展现能力的地方。所以如果某场笔试感觉不理想,不用太纠结,把精力放在复盘上,下一次笔试把这次踩的坑都避掉,就足够了。我这套复盘方法,陪我走完了整个秋招。希望这次笔试复盘的经验总结,也能在后续大家的求职路上帮上一点忙。