小红书前端笔试复盘:题型解析、算法题AC思路与备考策略 时间过得真快2023 届秋招早就落下帷幕但后台时不时还有学弟学妹问我小红书前端笔试到底考什么。我翻了下当时记的笔记把小红书-前端岗-第一批笔试的完整复盘整理了出来。这篇文章里没有那些虚头巴脑的面经汇总全是我自己坐在考场里真实遇到的原题、当时卡壳的地方以及事后复盘才想明白的解法。先说结论小红书的前端笔试在互联网大厂梯队里属于中等偏上难度不会像硬核算法厂那样直接上 Hard 压轴但也绝对不像某些业务厂那样走个过场。它考察的维度很清晰——前端基础与 JS 功底选择 中等偏上的算法题编程 场景题问答。只要你刷过 LeetCode 前 150 题里的 Hot 100再把 JS 事件循环、原型链、Promise 这几个八股高频点吃透过笔试线问题不大。但这篇文章的价值不在于告诉你考什么而在于带你把每道题从读题到 AC 的完整思路走一遍包括我踩进去的坑和后来才反应过来的最优解。1. 笔试整体节奏与题型分布1.1 考试形式与时间分配2023 年秋招小红书前端岗第一批笔试是在牛客网上进行的我记得时间大概在 9 月上旬。整体限时 90 分钟题目结构非常清晰题型题量分值占比我的实际用时不定项选择20 题约 40%25 分钟编程题3 题约 50%55 分钟简答/场景题1 题约 10%10 分钟这里有个很关键的细节选择题是不定项选择不是单选多选、少选、错选都不得分。这意味着你做选择题时必须对每个选项有确切的判断不能像单选那样用排除法蒙一个就完事。我身边就有同学在选择题上吃了大亏觉得差不多选了就行结果出来一对答案发现错了小一半。时间分配上我强烈建议你压缩选择题的时间。因为编程题才是拉开差距的地方20 道选择题最多给 30 分钟遇到拿不准的先标记跳过别死磕。我见过太多人选择题磨了 50 分钟最后编程题连题都没读完就到点交卷——这是新手最容易犯的错误。1.2 题型构成与考点侧重从题目内容上看小红书前端笔试更偏向**计算机基础 JS 语言特性 前端工程化**的组合而不是堆砌各种冷门 API。选择题的考点分布我记得大概是这样的JavaScript 核心机制事件循环Event Loop、闭包与作用域、原型链与继承、this 指向、Promise 与异步控制浏览器与网络页面渲染过程、HTTP 缓存、跨域方案、WebSocket 基础CSS 与布局盒模型、BFC、Flex 布局、Grid 布局的适用场景框架Vue/React组件通信方式、生命周期、虚拟 DOM 与 diff 算法、响应式原理计算机基础数据结构栈/队列/二叉树、网络分层、简单的时间复杂度分析编程题三道的难度梯度做得很好第一题是能轻松 AC 的送分题第二题是稍加思考能用常见算法解决的中档题第三题就需要你有一些技巧和扎实的代码功底了。这三题选得很有水平基本能筛掉刷题背答案党和真会写代码的人。2. 编程题逐题拆解从读题到 AC 的完整思路2.1 第一题最长无重复字符子串滑动窗口这是整个笔试里最温和的一道题也是 LeetCode 的第 3 题原题。题目描述大概是这样给定一个字符串请你找出其中不含有重复字符的最长子串的长度。看到这道题我第一反应是滑动窗口。窗口里维护一个 Set或 Map记录当前窗口内出现过的字符。right 指针不断向右扩展如果遇到重复字符就移动 left 指针直到窗口里没有重复字符为止然后更新最大长度。/** * param {string} s * return {number} */ function lengthOfLongestSubstring(s) { const set new Set(); let left 0; let maxLen 0; for (let right 0; right s.length; right) { const ch s[right]; // 如果窗口内已经有这个字符就收缩左边界 while (set.has(ch)) { set.delete(s[left]); left; } set.add(ch); maxLen Math.max(maxLen, right - left 1); } return maxLen; }这道题思路本身不复杂但有两个细节值得注意。第一个细节是**while 还是 if**。如果窗口内已经存在重复字符必须用 while 循环不断收缩左边界直到彻底移除那个重复字符为止。有些同学写成 if以为收缩一次就够了但遇到 abca 这样的情况就会出错当 right 走到第二个 a 时left 指向 b收缩一次只能删除 b窗口还是包含 a必须继续收缩到 left 越过第一个 a 才行。第二个细节是 Set 和 Map 的选择。虽然这道题用 Set 就能解决但如果题目改成最长不含重复字符的子串并要求你返回这个子串本身那你就需要 Map 来记录每个字符最后出现的位置这样可以做到 left 指针直接跳跃到重复字符的下一个位置而不需要一格一格地挪。我当时答题时留了个心眼用 Map 做了优化版本虽然写起来稍微多几行但时间复杂度依然是 O(n)空间复杂度同样是 O(字符集大小)。笔试环境里时间紧张用最稳妥的 Set 版本也完全够用没必要过度优化。2.2 第二题合并区间排序 贪心第二题是合并区间也是 LeetCode 第 56 题的原题变种。题目大概是以数组 intervals 表示若干个区间的集合其中单个区间为 intervals[i] [start, end]start end。请你合并所有重叠的区间并返回一个不重叠的区间数组该数组需恰好覆盖输入中的所有区间。这道题我愿称之为大厂笔试最爱的区间类题目没有之一。因为它考察的是一个很重要的算法思维——贪心 排序而且代码量不多、边界情况明确非常适合作为中档题。解题套路非常固定首先把区间数组按照左端点从小到大排序遍历所有区间如果当前区间的左端点大于结果数组中最后一个区间的右端点说明两个区间不重叠直接把当前区间加入结果数组否则说明有重叠更新结果数组中最后一个区间的右端点为它自己原本的右端点和当前区间的右端点中的较大值/** * param {number[][]} intervals * return {number[][]} */ function merge(intervals) { if (intervals.length 1) return intervals; // 按照左端点升序排序 intervals.sort((a, b) a[0] - b[0]); const result [intervals[0]]; for (let i 1; i intervals.length; i) { const current intervals[i]; const last result[result.length - 1]; if (current[0] last[1]) { // 有重叠合并 last[1] Math.max(last[1], current[1]); } else { result.push(current); } } return result; }这里最关键的一步是排序。为什么必须按左端点排序因为只有左端点有序你才能保证每次处理新区间时它只可能和结果数组的最后一个区间重叠而不可能和更早的区间重叠。如果不排序两个不相邻的区间也可能存在重叠关系就得用更复杂的算法去处理。还有一个细节排序时一定要写成(a, b) a[0] - b[0]不是a[0] b[0]。虽然 ES2019 之后规范要求 sort 是稳定的但显式返回差值是最稳妥的写法也避免了隐式类型转换的坑。笔试时我在这个排序上犹豫过一下要不要对右端点做二次排序后来想明白了不需要。因为左端点排好序后右端点怎么排都不影响合并逻辑我们只需要在合并时取较大的右端点即可。把排序写得太复杂只会浪费自己的时间。2.3 第三题带权最短路径变形BFS 或 Dijkstra这道题算是三题里最有区分度的一道。它没有直接考最短路径的裸题而是套了一层场景小红书社区里有一个笔记浏览链路的概念现在给出一系列笔记 ID 与相关笔记 ID的映射关系连接两篇笔记有一个亲密度权重要求从起点笔记出发找到到达目标笔记的最小总代价路径其中代价定义为路径上所有权重的乘积。这个场景包装其实没有改变题目的本质——它就是一个带权无向图的最短路问题。但由于权重是乘积形式你没法直接套用 Dijkstra 的加法逻辑需要先转换思路。我当时第一反应是乘积的最短路径不能用加法最短路的思路直接做。但我很快意识到如果所有权重都是大于 1 的整数那么最短路径的这个乘积最小问题等价于对每条边取对数把乘积变成求和。不过在笔试环境下你不可能用一个受浮点误差影响的技巧来冒险。实际上这道题更简单的解法是BFS 优先队列Dijkstra 思想因为边权是正数Dijkstra 是有效的。虽然 Dijkstra 一般是用加法但它的贪心逻辑同样适用于乘积最小的代价函数——只要代价函数满足单调不减的性质。乘法的最短代价有另一个特性如果所有权重都是正数大于 1那路径越长乘积越大所以我们依然可以用 Dijkstra 的贪心选择当前最小代价策略。/** * 带权图最短乘积路径用 Dijkstra 思想 * param {number} n 节点数 * param {number[][]} edges [u, v, weight] 无向边 * param {number} start 起点 * param {number} end 终点 * return {number} 最小乘积无法到达返回 -1 */ function minProductPath(n, edges, start, end) { // 邻接表构建 const graph Array.from({ length: n }, () []); for (const [u, v, w] of edges) { graph[u].push([v, w]); graph[v].push([u, w]); } // dist[i] 表示从 start 到 i 的当前最小代价 const dist new Array(n).fill(Infinity); dist[start] 1; // 起点代价为 1不经过任何边 // 小顶堆优先队列每次取出代价最小的节点 // 这里用数组模拟笔试时更推荐直接用数组 sort 或手写简单最小堆 const pq [[1, start]]; // [代价, 节点] while (pq.length 0) { pq.sort((a, b) a[0] - b[0]); // 每次取最小笔试够用 const [cost, node] pq.shift(); if (node end) return cost; if (cost dist[node]) continue; for (const [neighbor, weight] of graph[node]) { const nextCost cost * weight; if (nextCost dist[neighbor]) { dist[neighbor] nextCost; pq.push([nextCost, neighbor]); } } } return -1; }重点说说我在这里踩的坑。我当时第一版代码用的是纯 BFS没有优先队列结果在搜索时可能出现先访问到代价较大路径导致后面更优路径被跳过的问题。因为 BFS 天然是按跳数扩展的而不是按代价扩展的这在每步代价不同的带权图中必然出错。我 debug 了大概 8 分钟才反应过来带权图的最短路径问题本质上是 Dijkstra不是 BFS。如果你在笔试里遇到带权图最短路径大脑里要立刻弹出 Dijkstra 而不是 BFS。另一个细节是数组模拟优先队列的效率问题。笔试的数据量一般控制在几百个节点、几千条边以内所以就算我用了每次 sort 取最小这种笨方法也完全能 AC。但如果你在本地跑大样例发现超时就需要手写一个二叉堆来优化。我建议平时就练一练手写最小堆笔试时直接默写出来会稳很多。第三题还有一个容易漏掉的条件路径代价是乘积所以初始代价要从 1 开始而不是从 0 开始。很多同学习惯性地把起点 dist 设成 0这在小规模测试下可能没错但一旦遇到起点和终点是同一个节点或起点到终点只有一条边的情况答案就会出错。3. 选择题里的前端拦路虎这些概念最容易丢分3.1 事件循环与 Promise 混着出选择题里比较恶心的就是事件循环的变体题。它不会单纯问你宏任务和微任务哪个先执行而是给你一长串代码里面有 setTimeout、Promise.resolve、async/await、requestAnimationFrame 混在一起让你写出输出顺序。我当时遇到的一道题大概是这样的变体console.log(1); setTimeout(() console.log(2), 0); Promise.resolve().then(() { console.log(3); setTimeout(() console.log(4), 0); }); async function test() { console.log(5); await Promise.resolve(); console.log(6); } test(); console.log(7);这道题的输出顺序是什么呢当时我的判断逻辑是同步代码先执行依次输出 1、5然后遇到 awaitawait 让出线程继续执行同步代码输出 7本轮同步代码结束后执行微任务队列先输出 3这是 Promise.resolve().then 注册的回调然后在 3 的回调里注册了一个 setTimeout(4)接着执行 await 后面的 console.log(6)输出 6微任务队列清空后执行宏任务队列先输出 setTimeout(2) 的 2再输出 setTimeout(4) 的 4最终输出顺序是1、5、7、3、6、2、4。这个考点背后是一个很关键的原则每次执行完一个宏任务后必须清空整个微任务队列再取下一个宏任务。而await本质上就是一个微任务不是直接在原地同步等待。很多同学会把 async/await 理解成同步等待其实它在同步代码执行到 await 时就会返回一个 Promise后续代码被包裹成一个微任务放回队列。这个理解不到位遇到稍微复杂点的混编题就会错。我复习时有个习惯把事件循环题目归类成纯宏微任务、含 Promise 链、含 async/await、含 DOM 渲染四种每类找几道题刷透。笔试实际碰到的题目基本都是这个套路换汤不换药。3.2 闭包、原型链与 this 的三合一考法第二个容易丢分的方向是闭包 原型链 this 指向结合出题。它会给你一个构造函数、一个实例方法、一个箭头函数让你分析某次调用时 this 指向谁。举个例子function Person(name) { this.name name; this.showName function() { console.log(this.name); }; } Person.prototype.sayName function() { console.log(this.name); }; const p new Person(xiaohong); const fn1 p.showName; fn1(); const fn2 () p.sayName(); fn2();这里fn1()调用时showName里的 this 指向的是 window或者 undefined取决于是否开启严格模式而不是 p 实例所以输出 undefined 或报错。而fn2()是箭头函数它的 this 是定义时所在作用域的 this也就是全局作用域的 this但箭头函数内部调用的是p.sayName()这个方法方法里的 this 依然是 p 实例所以输出 xiaohong。这道题考察的东西很综合普通函数的 this 动态绑定箭头函数的 this 词法绑定原型链方法的调用方式。难点不在单个知识点而在于你能不能在一段代码里同时分析出这三层关系。我在复习时总结了两个判断 this 的土办法看调用方式obj.method()形式调用this 就是 obj直接调用函数fn()this 是全局对象或 undefined看函数类型箭头函数直接忽略调用方式只看定义时所在的作用域如果是构造函数里的 this那就看它是不是被new调用了。new会创建一个新对象并把 this 绑定到新对象上然后构造函数里的 this 就是那个新对象。有时候选择题里还会加上严格模式这个变量。如果你看到代码开头有use strict那么fn()直接调用时 this 就是 undefined而不是全局对象。很多老八股题默认非严格模式但新题会专门加严格模式来钓鱼审题时一定注意看。3.3 HTTP 缓存与浏览器渲染的配合题前端笔试卷子里几乎必考 HTTP 缓存。我记得那次考的是两问第一问问强缓存和协商缓存的区别第二问问「Cache-Control: no-cache」和「Cache-Control: no-store」有什么区别。这类题特别容易混。我的记忆方法是no-cache允许缓存但使用前必须去服务器验证是否新鲜即可以缓存但必须协商no-store完全禁止缓存不能存到任何地方名字上看着no-cache像是不缓存但它实际上是每次都要确认而 no-store 才是真正的绝不缓存。这是几乎所有前端都会踩的坑笔试里出这道题基本就是送分题变送命题就看你记不记得准。浏览器渲染过程也是高频考点从输入 URL 到页面展示完整链路是DNS 解析 → TCP 连接 —— 如果 HTTPS 还需要 TLS 握手 → 发送 HTTP 请求 → 服务器返回资源 → 浏览器解析 HTML 构建 DOM 树、解析 CSS 构建 CSSOM 树 → 合并成渲染树 → 布局Layout→ 绘制Paint→ 合成Composite。选择题里它不会让你写全链路而是会在某个环节设置陷阱比如DOM 树构建过程中遇到 script 标签会怎样——答案通常是暂停 DOM 构建先执行脚本如果有 defer/async 则不同。你要是没有实际做过性能优化很容易在这类题上栽跟头。3.4 Vue 与 React 的双压考点小红书前端岗的笔试里Vue 和 React 都会考。不考框架细节的 API 怎么拼写而是考思想层面的东西比如Vue 的响应式原理Object.defineProperty / Proxy 的区别虚拟 DOM 和 diff 算法的基本流程组件通信方式父传子、子传父、兄弟组件、跨层级Vue 用 provide/inject 或 Vuex、React 用 Context 或 Redux生命周期钩子的执行时机我记得选择题里有一道是关于 Vue 3 响应式原理的题目给出的几个选项分别描述了Proxy和Object.defineProperty的行为差异。这道题本身不难选「Proxy 可以监听对象属性的新增和删除」就行但当时还是有很多人漏选了「Proxy 可以监听数组索引变化」这个选项。很多人只知道 Object.defineProperty 监听数组有局限但不知道为什么。原因是Vue 2 改写数组的 7 个方法push、pop、shift、unshift、splice、sort、reverse来触发更新但直接通过索引修改数组元素arr[0] x无法被侦测到。而 Proxy 天然支持对整个数组的代理包括索引赋值。这种细节题在笔试里很受欢迎因为它能精准地区分背过文档和真正理解原理的人。4. 简答/场景题手动实现一个可取消的请求工具笔试的最后有一道场景题我记得题目大概是这样的在小红书的信息流场景中用户快速滑动页面时会频繁触发网络请求。请设计一个可取消的请求工具函数要求支持传入一个 Promise 并返回一个合并了取消逻辑的新 Promise。当取消被触发时该新 Promise 的状态变为已取消并且不会影响后续代码的异常捕获。这道题考察的核心其实就是AbortController或手动控制 Promise 状态的技巧。在浏览器端最常见的是用 AbortController 来取消 fetch 请求在通用场景下可以用一个外部的 resolve/reject 来控制 Promise。我当时给的实现大概是这样的/** * 包装一个 Promise使其支持外部取消 * param {Promise} promise 原始请求 * param {AbortSignal} signal 用于取消的信号 * returns {Promise} 可取消的包装 Promise */ function withAbort(promise, signal) { return new Promise((resolve, reject) { // 如果已经取消了直接拒绝 if (signal.aborted) { reject(new DOMException(The operation was aborted., AbortError)); return; } const abortHandler () { reject(new DOMException(The operation was aborted., AbortError)); }; signal.addEventListener(abort, abortHandler, { once: true }); promise.then( (value) { signal.removeEventListener(abort, abortHandler); resolve(value); }, (error) { signal.removeEventListener(abort, abortHandler); reject(error); } ); }); }这样调用的时候你可以给 fetch 请求传入一个 AbortSignal然后在组件卸载或用户离开页面时调用controller.abort()从而取消请求。这个题的加分点在于你能不能答出为什么需要在finally或then里移除事件监听。如果一直挂在 signal 上不移除每次请求都会多一个永远执行不到的 abort 回调长时间运行后可能造成内存泄漏。虽然影响不大但面试官问你这里有什么隐患时你能答出来就是亮点。另外还顺带考察了一个常见的架构设计问题请求失败时要不要弹全局错误提示我的思路是要在请求工具层做统一的错误码判断但不是所有失败都弹 toast只有「用户被动感知」的错误才弹比如登录过期、网络中断而「代码主动捕获」的错误比如参数错误、业务返回码为 0应该由业务代码自己处理。这种场景题没有标准答案核心是你能否在通用性和业务侵入性之间找到平衡。5. 笔试备考的三个核心策略5.1 刷题别贪多把 Hot 100 吃透就够了如果你只剩两周就要笔试我强烈建议你放弃刷完 500 题的幻想老老实实把 LeetCode 热题 100Hot 100给刷透。小红书的笔试题基本就是 Hot 100 的原题或简单变体。我当时整理了一个优先级清单你可以参考优先级题型代表题目笔记重点P0滑动窗口无重复字符最长子串、最小覆盖子串窗口扩展/收缩的边界条件P0双指针三数之和、接雨水排序 左右指针的移动逻辑P0二叉树遍历层序遍历、最近公共祖先递归/迭代两种写法P1动态规划最长递增子序列、打家劫舍dp 状态的转移方程推导P1图岛屿数量、课程表拓扑排序深搜/广搜都能解决的模板P2堆前 K 个高频元素、合并 K 个有序链表手写堆/使用优先队列重点不是刷了多少道而是每种题型都能不看答案写对。我见过太多同学看答案秒懂、自己写卡死的情况这在笔试里是最致命的——笔试考的是你在 30 分钟内的写出正确代码能力不是理解能力。5.2 JS 核心知识点以手写实现为纲前端笔试的选择题和简答题归根结底都在考你能否手动实现某个功能。比如让你手写Promise.all、手写debounce、手写event emitter、手写深拷贝。这些内容在牛客上搜前端手写题能找到一大堆。我的备考方式是以手写实现为纲来复习知识点。比如复习事件循环我就手写一个简单的scheduler来控制并发复习闭包我就手写一个once函数复习原型链我就手写new操作符。每写一个实现你对这个知识点的理解就会深一层。手写题有几个高频考点我列出来你在家可以自己练Promise.all / Promise.race / Promise.allSettleddebounce / throttlenew操作符的模拟实现instanceof的模拟实现call / apply / bind的模拟实现Object.create深浅拷贝一定要记住手写题不是背代码而是在写的过程中理解每一步的触发条件和边界情况。比如bind实现里有当返回的函数作为构造函数被 new 调用时this 失效这个边界情况你不亲手写一遍很难记住。5.3 场景题/简答题积累业务话术与方案储备场景题不像算法题那样有唯一答案它考的是你怎么把一个业务需求拆解成可落地的技术方案。我建议你在准备时把常见的场景题分类整理性能优化类首屏优化、列表渲染优化、大量数据渲染防卡顿工程化类如何设计组件库、如何管理公共状态、如何做权限控制网络请求类请求取消、并发请求控制、请求重试、接口缓存前端安全类XSS、CSRF、点击劫持、CSP回答这类问题时我总结了一个现象 → 原因 → 方案 → 收益的答题模板。先描述业务场景里出现的具体问题再分析问题的本质原因接着给出技术方案最后说这个方案带来什么收益。笔试的简答题通常没有字数要求严格限制但一份结构完整的答案一定会比卷面混乱的得分高。比如上面那道请求取消题我的答案结构就是先说取消请求的常见情境用户快速滑动页面再说浏览器级方案AbortController然后给出通用 Promise 包装方案最后补充事件监听的清理细节。这就能让阅卷人一眼看到你的技术深度和工程思维。6. 笔试现场的一些真实体会最后聊几句现场的体会吧这些在正规面经里很少人写但我觉得比多刷几道题更有价值。第一牛客笔试环境的编辑器很基础没有代码补全和格式化所以你平时写代码时不要过度依赖编辑器的自动补全。变量名写全、括号括号对上这些基础习惯在笔试现场能帮你省下大量 debug 时间。我第一题就吃过少写一个右括号、编辑器不报错的亏肉眼排查浪费了 3 分钟。第二遇到不熟悉的题先跳过别死磕。编程题总耗时只有 55 分钟左右如果某道题卡了 15 分钟还没有清晰思路立刻切下一题。等做完后面有把握的题再回头啃难点。我当时第三题虽然一开始想错了方向但因为我保证前两题都 AC心里比较稳才有余裕去 debug 第三题的 BFS 问题。第三选择题的得分策略是拿不准就不选。不定项选择少选可以吗大部分情况下不可以。小红书这个考试是不定项选择少选、多选、错选都不得分。所以如果你对某个选项没有十足把握宁愿不选也不要碰运气。再强调一遍这个规则和漏选得一半分的多选不一样一定要看清题目要求。现在回想起来小红书这批笔试的整体风格很务实算法题不偏不怪选择题重基础原理场景题贴近真实社区业务的痛点。只要前期准备充分、心态不崩通过笔试并没有想象中那么难。希望这份复盘能帮到后面参加笔试的学弟学妹。