1. 写在前面:这道题到底值不值得做
第四范式2019校园招聘前端笔试题,放到今天来看,可能很多同学会觉得“过时了”。但如果你真花几个小时把这份卷子从头到尾捋一遍,会发现一个很有意思的事实:里面考的原型链、事件循环、手写Promise、构建优化,在2026年的前端面试里依然是高频考点。换句话说,前端行业技术迭代很快,但面试官想考察的能力模型,本质上没怎么变过。
我当年是校招季做的这套题,当时的感觉是“题目不算偏,但每一道都能往下深挖”。它不像某些公司喜欢考冷门API记忆,而是更看重你有没有真正理解JavaScript这门语言的核心机制,以及你在面对一个开放性问题时,能不能给出有层次、有取舍的方案。这篇文章我会把整套题的考察思路拆开讲清楚,并给出核心题目的参考解法和我自己的踩坑复盘。不管你是正在准备校招,还是工作几年想回头补基础,都值得花点时间看看。
需要说明的是,网上流传的版本可能略有出入,我这里按我当年实际做到的题目组合来讲,如果你拿到的题目不完全一样,思路是可以直接迁移的。
2. 整体考卷结构与考察思路拆解
2.1 从岗位画像反推考点设计
第四范式做的是AI平台和机器学习相关产品,前端岗位要支撑的产品形态,主要是模型管理后台、数据标注平台、可视化大屏、机器学习实验平台这类偏B端、偏重数据交互的系统。这类系统的前端特点可以总结成三句话:数据量大、交互状态复杂、对性能敏感。
所以笔试题不是随手出的,它背后对应的是岗位的实际工作场景。比如考察数组去重、排序、扁平化,是因为你在实验平台里经常要处理模型返回的嵌套数据;考察重排与重绘,是因为大屏可视化页面里,一次不当的DOM操作可能直接导致掉帧;考察事件循环和异步顺序,是因为前端要频繁处理接口请求的竞态、loading状态、批量提交这类问题。
明白这一点,你就知道为什么这套卷子里几乎没有考“display有哪些取值”或者“CSS选择器优先级口诀”这类死记硬背的题目,而是把考点埋在场景里。面试官真正想筛掉的,是那些只会背八股文、但一遇到真实问题就卡壳的候选人。
反过来,这对我们准备笔试也有一个很重要的启示:不要孤立地刷题,而是把每一道题和你未来可能做的业务场景绑定起来。你答的不是一道题,而是告诉面试官“我具备搞定这类业务问题的能力”。
2.2 题型分布与时间分配建议
我印象中这套卷子的题型大概分四块:基础选择题、手写代码题、算法题、开放设计题。题量不算特别大,但手写题比较费时间,如果不在前面控制节奏,很容易在最后的大题上仓促作答。
我根据自己的答题经验,做了一个时间分配参考表:
| 题型 | 大致题量 | 建议用时 | 考察重点 |
|---|---|---|---|
| 基础选择题 | 10道左右 | 20分钟 | JS基础、CSS基础、浏览器机制 |
| 手写代码题 | 3-4道 | 40分钟 | 原型链、闭包、异步、Promise |
| 算法题 | 2道左右 | 30分钟 | 数组/字符串处理、复杂度意识 |
| 开放设计题 | 1道 | 30分钟 | 工程化思维、性能优化方案 |
这里想多说一句,选择题的性价比其实很高,因为它是整张卷子唯一有确定答案的部分,拿分相对容易。但很多同学容易在前面的选择上纠结太久,遇到一道不太确定的题就想“再想想”,结果后面手写题时间不够,匆忙交上去一堆半成品,反而丢了大头分。我的建议是:选择题遇到不确定的,先凭第一感觉标记一个答案,整张卷子做完还有时间再回来细想,千万不要恋战。
2.3 为什么说这套题“入门容易答好难”
这卷子表面上没有那种让你完全无从下手的偏题怪题,但每道题都有往上发挥的空间。打个比方,选择题里问你typeof null是什么,你答“object”只能拿基础分;如果能在旁边补一句“这是历史遗留bug,ES规范里规定null是原始值,但typeof返回值是object,空值判断要用=== null而不是用typeof”,那就完全不一样了。
我在后来的面试复盘里发现一个规律:笔试分数并不是看你会不会做对每道题,而是看你在有限的作答里,能不能展示出超过同届候选人的深度。面试官每天看几百份卷子,什么叫“背过答案”什么叫“真正理解”,从答题的旁注、代码的注释、方案的取舍上,一眼就能分辨出来。
所以这篇复盘里,我不仅会给出标准解法,还会告诉你哪些地方可以“多写两笔”来加分,哪些地方写多了反而是画蛇添足。
3. 核心题目深度解析与参考解答
3.1 JS基础:原型链与闭包的那些“坑”
先说我印象最深的一道选择题,大概是考察new和原型链的。原题记不太清了,但核心考点是这样的:定义一个构造函数,给它的原型上挂了一个方法,然后new出两个实例,其中一个实例自己又定义了一个同名方法,问两个实例调用方法的输出结果。
function Person(name) { this.name = name; } Person.prototype.sayHello = function() { return 'Hello, I am ' + this.name; }; const p1 = new Person('Alice'); const p2 = new Person('Bob'); p2.sayHello = function() { return 'Hi, I am ' + this.name; }; console.log(p1.sayHello()); // 'Hello, I am Alice' console.log(p2.sayHello()); // 'Hi, I am Bob'这个知识点本身不难,就是实例属性会遮蔽原型属性。但如果你只是在选项里选了正确答案,这题的价值就没发挥出来。阅卷人更希望看到你理解背后的机制:当访问p2.sayHello时,JavaScript会沿着原型链一层层找,先在p2自身的属性上找到了,那就不需要再往上走。如果p2身上没有,才会去Person.prototype上找,再没有就继续往上到Object.prototype,最终找不到才返回undefined。
顺便说一句,原型链这块还有一个高频衍生考点,就是instanceof的原理。它检查的是构造函数的prototype是否出现在实例的原型链上。p1 instanceof Person返回true,因为Person.prototype确实在p1的原型链上。但如果手动修改了原型,比如Person.prototype = {},那之前创建的实例再instanceof Person就变成false了。这个坑在业务代码里不太常见,但面试题里经常拿来“钓鱼”,建议复习的时候把原型链的完整查找过程、prototype和__proto__的关系、instanceof的实现机制一起串起来理解。
闭包的题也很典型,我记得有一道是问循环里用var声明变量,setTimeout输出什么的。
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i); }, 100); } // 输出 3 3 3这道题的经典程度已经快被讲烂了,但每年面试还是有人答错。核心原因在于:var声明的变量是函数作用域,这里的i实际上只有一个,循环结束后i的值是3,三个定时器回调共享同一个i,执行时输出的自然都是3。解决方案也简单,要么把var改成let,利用块级作用域每次都生成一个新的绑定;要么用闭包包一层:
for (var i = 0; i < 3; i++) { (function(j) { setTimeout(function() { console.log(j); }, 100); })(i); } // 输出 0 1 2这里我想补一个很多人忽略的细节:let之所以能解决这个问题,是因为for循环的每次迭代都会创建一个新的词法环境,并把当前的i值绑定进去。这其实是ES6规范里专门针对循环做的处理,不是在setTimeout层面做文章。你能把这个机制讲明白,比单纯说“var改let就行”要有说服力得多。
3.2 手写代码:实现一个简易版Promise
这套卷子里的手写题,最让我头疼的是让实现一个Promise,具体要求是支持resolve、reject、then、catch,能做到链式调用,并且能处理异步的resolve调用。
当时我看到这道题心里是有点慌的,因为Promise的完整实现涉及到状态机、微任务队列、值穿透、数组化的回调队列,想在一个笔试环境里分分钟写完整很困难。但后来我想明白一件事:面试官未必期待你写出和规范完全一致的promise,而是想看你对异步流程控制的基本功。所以答题策略应该是:先实现最小可用版本,把状态机和then的注册逻辑写对,再补上链式调用的支持,这样已经能拿到大部分分了。
我写的最基础版本大概是这样的:
function MyPromise(executor) { this.state = 'pending'; this.value = undefined; this.reason = undefined; this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; const resolve = (value) => { if (this.state !== 'pending') return; this.state = 'fulfilled'; this.value = value; this.onFulfilledCallbacks.forEach((fn) => fn()); }; const reject = (reason) => { if (this.state !== 'pending') return; this.state = 'rejected'; this.reason = reason; this.onRejectedCallbacks.forEach((fn) => fn()); }; try { executor(resolve, reject); } catch (error) { reject(error); } } MyPromise.prototype.then = function(onFulfilled, onRejected) { if (this.state === 'fulfilled') { onFulfilled(this.value); } if (this.state === 'rejected') { onRejected(this.reason); } if (this.state === 'pending') { this.onFulfilledCallbacks.push(() => onFulfilled(this.value)); this.onRejectedCallbacks.push(() => onRejected(this.reason)); } };这个版本能跑通最基本的功能,但不支持链式调用,then里也没有返回新的Promise,更不用说值的穿透了。我当时在卷面上做了个标注,大意是“完整版的promise还需要处理链式调用和异步微任务,这里给出核心状态机逻辑”。事后复盘,这个标注其实是很重要的一步:它让面试官知道你知道完整的promise是什么样的,只是在笔试环境里没法写全。
现在回头想,如果当时时间更充裕,我会把链式调用补上,虽然不要求完全按Promise/A+规范来,但至少要体现“then返回新Promise”这个核心思想。下面这个版本是我后来在项目里整理过的,可以表演“then的返回值是Promise、里层返回值会被resolve”这个关键行为:
MyPromise.prototype.then = function(onFulfilled, onRejected) { const promise2 = new MyPromise((resolve, reject) => { const handleFulfilled = () => { try { const result = onFulfilled(this.value); resolve(result); } catch (error) { reject(error); } }; const handleRejected = () => { try { const result = onRejected(this.reason); resolve(result); } catch (error) { reject(error); } }; if (this.state === 'fulfilled') { handleFulfilled(); } else if (this.state === 'rejected') { handleRejected(); } else { this.onFulfilledCallbacks.push(handleFulfilled); this.onRejectedCallbacks.push(handleRejected); } }); return promise2; };这里有几个细节值得注意。第一,then回调里如果抛异常,要能被下一个catch捕获,所以handleFulfilled里必须有try/catch。第二,如果onFulfilled返回的本身是一个Promise,那严格来说需要递归调用resolvePromise来处理,这属于Promise/A+规范的进阶内容。笔试里你写出简化版并理解为什么需要递归处理,我觉得已经可以了。
3.3 布局与CSS:三栏布局与重排重绘
第四范式的业务页面上,三栏布局特别常见,左侧模型列表、中间主内容区、右侧参数面板,这是标准的B端产品形态。所以卷子里面有一道三栏布局题我完全不意外。
题目要求大概是这样的:左右两栏固定宽度,中间自适应,并且要求中间栏最先渲染。这里的关键点是“中间栏最先渲染”,因为它决定了你不能简单地用浮动把三栏排成一行,而是要利用flex或者绝对定位的顺序调整能力。
如果面试官没有特殊要求,直接用flex是最优雅的:
<div class="container"> <div class="main">中间自适应内容</div> <div class="left">左侧固定 200px</div> <div class="right">右侧固定 200px</div> </div>.container { display: flex; } .left { width: 200px; order: -1; } .right { width: 200px; } .main { flex: 1; }注意order这个属性,它的默认值是0,数值越小排列越靠前。把left的order设为-1,相当于强制它排在中间栏前面,但DOM结构上,中间栏确实是在最前面的,这样就满足了“中间栏最先渲染”的要求。为什么要强调这个?因为在浏览器解析HTML时,DOM顺序会影响首屏渲染时间。如果中间栏放最后,用户要先等左右两栏的资源加载完才看到核心内容,体感会差很多。
实现三栏布局的方法很多,除了flex,还有圣杯布局、双飞翼布局、grid布局。笔试里如果时间充裕,你可以多写一种方案,比如grid:
.container { display: grid; grid-template-columns: 200px 1fr 200px; } .main { grid-column: 2; } .left { grid-column: 1; } .right { grid-column: 3; }这个写法更简洁,但有一个问题:grid-template-columns是按照DOM顺序排的,如果中间栏在第一个,默认情况下它会占据第一列,而不是中间那一列。所以要配合grid-column显式指定每一栏的位置。这也是为什么grid在有些人看来“不够直觉”的原因。
我个人的建议是:笔试里优先写flex方案,因为它兼容性更好、理解成本低,阅卷人看起来也舒服。如果你能同时写出grid方案并解释两者的适用场景,那会是一个不错的加分项。
3.4 算法题:数组扁平化、去重与排序的手写姿势
算法题大概是这张卷子里让很多人纠结的部分。它不是LeetCode上那种复杂的动态规划题,而是更偏向工程场景的数组处理。比如让你实现一个数组扁平化函数,要求支持传入多级嵌套的数组,并顺便去重、排序。
如果只要求扁平化,一行flat(Infinity)就完了。但笔试题里一般会限制你不能用原生方法,考察的是递归和reduce的掌握程度:
function flatten(arr) { return arr.reduce((acc, current) => { return Array.isArray(current) ? acc.concat(flatten(current)) : acc.concat(current); }, []); } const result = flatten([1, [2, [3, [4, 5]]]]); // [1, 2, 3, 4, 5]这里有个容易被问到的细节:为什么用concat而不是push?因为concat返回的是新数组,不会修改原数组,这样在reduce里可以链式累积,逻辑更清晰。如果你用push,需要先acc.push(...)再return acc,也可以,但代码就没那么优雅了。
如果要求扁平化之后顺便去重和排序,可以这样组合:
function uniqueSort(arr) { const flat = flatten(arr); return Array.from(new Set(flat)).sort((a, b) => a - b); }注意这里的sort一定要传比较函数,否则默认按字符串编码排序,[10, 9, 8]会被排成[10, 8, 9],这是新手特别容易踩的坑。我在面试别人时,经常看到有人写sort()没传参,结果数字数组排出来的顺序不对,自己还半天没反应过来。
关于去重,还有一个高频追问是new Set和Array.from(new Set())的区别。其实Array.from只是把Set对象转回数组的其中一种方式,你也可以用展开运算符[...new Set(arr)],效果一样。但如果你面对的是大量数据处理,要注意Set的去重是基于SameValueZero算法的,NaN也可以被正确去重,而indexOf判断NaN就会出现问题。这也是一个可以写进注释里展示你懂原理的点。
3.5 工程化与性能优化:前端构建笔试的开放题
我还记得卷子最后有一道开放题,大意是:你的页面在线上加载很慢,白屏时间长,请给出分析和优化方案。这种题目没有标准答案,但恰恰是最能拉开分差的。大部分同学会写“压缩静态资源、开启CDN、懒加载”,这些当然没错,但如果你只写这三条,说明你还没从工程化的角度系统思考过问题。
我的答题思路一般是按“从输入URL到页面展示”这条链路来梳理,每个环节都找可以优化的点:
- 网络层:配置HTTP缓存(强缓存+协商缓存)、开启CDN加速、使用HTTP/2多路复用、合并请求减少RTT。
- 资源层:JS/CSS压缩混淆、图片格式换成WebP或AVIF、按路由拆分代码而不是打一个巨大的bundle。
- 渲染层:骨架屏减少白屏焦虑、关键CSS内联、提前预加载核心资源。
- 代码层:减少重排重绘、避免长任务阻塞主线程、合理使用
requestAnimationFrame来分批更新DOM。
如果能在答案里突出“首屏”这个关键词,并说明你的分析是基于“关键渲染路径”,那就更有深度了。比如我当年写的一点是:脚本放在head里会阻塞HTML解析,应该改用defer或async,或者把不关键的脚本移到body末尾。为什么要区分defer和async?defer会保证脚本按顺序执行,适合有依赖关系的脚本;async下载完就执行,顺序不保证,适合独立统计脚本。我后来在项目里用这个原则优化过数据上报脚本,确实有效果。
另外,如果这道题里cue到了Webpack,那你还得提一嘴SplitChunks插件。它的作用是把第三方库和你自己的业务代码分开,这样浏览器可以长期缓存那些不变的vendor包,而不是每次发布都让用户重新下载所有依赖。我第一次接触这个配置是在一个老项目里,所有代码打包成一个bundle,5MB大小,每次上线用户都要重新加载。后来改成SplitChunks拆分之后,虽然首次加载没小太多,但二次访问命中缓存的概率大幅提升,体感差异非常明显。
4. 我当年的作答过程与复盘
4.1 时间分配:先做会的,再做难的
说句实话,我当年做这套卷子的过程并不是很顺利。前面的选择题还算顺手,到了手写Promise那道题,我卡了大概十分钟,反复改了好几版都觉得不对劲。当时我心里有点慌,因为后面还有算法题和开放题。
后来我强制自己停下来,采取了“跳题策略”:先把有把握的题全做完,最后再回来啃硬骨头。原理很简单,笔试的核心目标是拿分最大化,而不是证明某一道题你能做到完美。卡在一道题上超过五分钟,边际收益就开始下降了,这时候果断跳过去,把确定能拿的分先装进口袋,心态也能稳下来。
第二个策略是“先写骨架,再补细节”。遇到手写题,先别急着写具体逻辑,先把函数签名、核心变量、整体流程用注释写出来。比如实现Promise,我先写了三行注释:1. 状态管理 pending/fulfilled/rejected、2. 成功/失败回调数组、3. then返回新Promise。然后再往里面填代码。这样做的好处是,即使最后代码没写完,阅卷人也能通过注释看出你的思路是完整的。
4.2 我在笔试现场踩过的坑
这里整理几个我当时踩过、以及后来帮别人复盘时常见的坑,专门列成一张速查表,供你对照自查:
| 容易踩的坑 | 具体表现 | 解决办法 |
|---|---|---|
| 数组排序不传比较函数 | [10, 9].sort()得到[10, 9] | 写成sort((a, b) => a - b) |
typeof null判断失误 | 以为typeof null === 'null' | 记住它是object,空值判断用=== null |
| 事件循环执行顺序混乱 | 微任务和宏任务的输出顺序搞反 | 记住先微任务再宏任务,Promise.then比setTimeout先执行 |
| 原型链查找方向搞反 | 以为会从顶往下找 | 记住是从实例自身往原型链顶层找 |
| 手写题只写最终代码不写思路 | 代码有bug时被判零分 | 先用注释写思路,让阅卷人看到过程 |
| 忽略题目里的“最先渲染” | 三栏布局没有调整DOM顺序 | 用flex的order或grid的grid-column控制视觉顺序 |
第一行和第三行看起来很简单,但我在实际批改简历笔试时,发现真的有好多人犯。尤其事件循环,很多人背过口诀“先微后宏”,但一遇到await就乱了。这里补一个最简单的分析框架:await后面跟的表达式会立即执行,但await之后的代码相当于被放进了微任务队列。所以:
async function test() { console.log(1); await console.log(2); console.log(3); } test(); console.log(4); // 1 2 4 3这里console.log(2)是立即执行的,因为它是await后面的表达式;console.log(3)被推迟到微任务里;console.log(4)是同步代码,所以先在宏任务里执行。以后做题遇到async/await,就把“await之后的部分看作微任务”这个规则记牢,基本不会再错。
4.3 面试官视角:什么样的答卷会被判高分
我后来参加过一次技术部门的笔试阅卷,多少能了解一点评分逻辑。阅卷人看一份卷子,短则两三分钟,长也就五六分钟。在这么短的时间里,他们重点看的就是:一是代码能不能跑、二是思路是否清晰、三是答题态度是否认真。第三点听起来很虚,但体现在卷面上其实很具体:你的变量命名是否语义化、关键地方有没有注释、边界条件有没有考虑到。
比如数组去重那道题,大部分人写的都是[...new Set(arr)]。有一个人额外写了如果数组里有对象、Set的去重会失效,因为对象是按引用来判断是否相等的,所以如果需要按对象某个字段去重,得自己实现一个reduce函数。我看到的时候真的有点印象分加成,因为这明显是做项目时踩过坑的人才会注意到的角度。这种“基于实践的补充说明”,比你在卷子上多写十行没必要的代码都管用。
5. 备考建议:2026年了,这套题还有意义吗
5.1 经典考点为什么一直考
你可能会想,2019年的题目,放到2026年,React都出了新版本、Vue都迭代好几轮了,这题还有参考价值吗?我的回答是:有,而且价值不小。前端框架每年都在变,但JavaScript语言的核心机制、浏览器的渲染原理、网络加载的流程、工程化构建的思路,这些东西的变化速度远没有框架那么快。原型链还是那套原型链,事件循环还是那套事件循环,重排重绘也还是那套重排重绘。
更重要的是,这类考点代表着一种“抽象能力”。你如果只会在Vue里写页面,遇到问题就搜答案,那笔试一遇到没见过的场景,立刻就会露馅。但如果你的基础足够扎实,即使没做过某个业务,也能通过原理推导出大概的解决方案。面试官考这些老题,本质上不是在为难你,而是想看看在新技术层出不穷的环境下,你有没有把地基打牢。
在我看来,准备这类笔试题最有效的方式,不是把题目背下来,而是把每道题背后的知识点铺开,画一张思维导图,把一个考点延伸到它附近的所有内容。比如看到原型链,就要联想到new的过程、Object.create、class语法糖、instanceof原理;看到闭包,就要联想到模块模式、柯里化、防抖节流、内存泄漏。这样复习一遍,你覆盖的知识面会非常可观。
5.2 现在还需要补充哪些新内容
当然,只复习老题是不够的。2026年的前端面试,有些新方向是2019年基本不会考的,需要你们自己额外补上。
- 工程化方面,Vite已经成了很多新项目的标配,它的开发服务器启动速度比Webpack快好几个量级,关键原理是浏览器原生ESM,不用提前打包。面试官可能会问你Vite和Webpack的区别,以及它们各自的性能瓶颈。
- 性能优化方面,除了传统的加载优化,现在更关注Web Vital指标,比如LCP(最大内容绘制)、CLS(布局偏移)、INP(交互到下一次绘制延迟)。这些指标直接关系到用户体验,也是很多公司做性能优化的复盘核心。
- AI工具的使用也成了面试里的一个话题。比如用AI辅助写代码时,如何审查它生成的代码、如何避免它写出多余的逻辑、如何让它遵循项目的既有规范。这不是笔试硬性考点,但面试聊项目的时候可能会被问到。
我自己在带新人的时候,会建议他们不要把AI工具当成“答案生成器”,而是当成“思路讨论者”。遇到一个不懂的问题,先自己想一遍,再让AI给一个参考实现,对比一下自己的想法哪里不对,哪里可以优化。这种学习方式,比纯粹刷题要高效得多。
6. 一点个人体会
做这套题已经是好几年前的事了,现在回头看,真正让我受益的并不是“会做某道题”,而是备考过程中被逼着把JavaScript基础重新啃了一遍。我后来在工作中遇到过很多棘手的问题,比如线上偶发的白屏、大数据量列表的渲染卡顿、第三方SDK加载顺序导致的冲突,最后定位到根因时,发现都离不开原型链、事件循环、渲染机制这些最基础的知识。
所以如果你现在正在准备笔试,我的建议是:不要只盯着“这道题答案是什么”,多问自己一句“为什么是这个答案”。想不明白的地方,去翻ES规范、去读源码、去跑个小demo验证,都比死记硬背有价值得多。笔试只是第一关,能让你真正走的更远的,是那种遇到问题敢往下钻、能往下钻的能力。这套题刷完了,希望你能带走的不只是几个标准答案,而是这种对基础的敬畏,和一个更扎实的前端知识体系。