ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

有赞校招前端笔试复盘:从基础原理到手写代码的硬核考点

2026/8/29 23:11:51 拓冰建站 浏览量
有赞校招前端笔试复盘:从基础原理到手写代码的硬核考点 有赞2019校招前端笔试第二批这套题算得上当年校招圈里的一股“清流”了。不夸张地说很多同学是在这套题上第一次意识到“前端面试不是背背八股文就能过的”。到现在我偶尔翻到当年的笔记还是会觉得里面的考点设置很有代表性——基本功、工程化、原理理解、手写能力全都覆盖了而且难度梯度拉得特别开从送分题到劝退题一应俱全。这篇文章就来完整复盘一下这套题。我结合自己在当年刷题、后来帮学弟学妹改简历和模拟面试时积累的经验把高频考点、容易踩坑的地方、手写题的套路全部拆开揉碎讲一遍。不管你是正在准备暑期实习还是秋招冲刺这套题都值得认真刷一遍。毕竟前端面试的底层逻辑这么多年其实没怎么变过——基础不牢地动山摇。1. 这套笔试究竟考了些什么1.1 整体结构题型与时间分配有赞2019校招前端笔试第二批的整体结构其实和现在大多数中大型公司的前端笔试差别不大主要分四个部分选择题、简答题、手写代码题、综合设计题。从时间安排来看整套题给的时间大约是90到120分钟。选择题建议控制在20分钟内简答题40分钟左右剩下所有时间砸在手写题和设计题上。这个建议不是随便给的——我当年就是选择题磨蹭太久结果最后一道手写题只写了一半血泪教训。先说选择题部分。这一块考察的知识点覆盖面非常广HTML语义化、CSS盒模型、JavaScript数据类型、作用域与闭包、原型链、事件循环、HTTP状态码、浏览器缓存、跨域方案等都有涉及。乍一看很杂但实际上每个选项都在暗暗考察你对“原理”的理解而不是简单的概念记忆。简答题部分开始上强度了。常见的有描述浏览器从输入URL到页面渲染的完整过程、解释JavaScript中的事件委托及其应用场景、说说你对前端工程化的理解等。这类题目没有标准答案但阅卷人能从你的回答里看出你是真懂还是背的。手写代码题是拉开差距的核心。数组去重、防抖节流、深拷贝、Promise实现、函数柯里化、实现一个简单的EventEmitter等基本是当年的标配。这些题目单独看都不算难但要在有限时间内写得规范、考虑边界条件、代码风格干净就非常考验平时积累。综合设计题则偏工程化比如让你设计一个前端组件库的目录结构或者给你一个业务场景让你做技术选型并说明理由。这类题目考察的是你的架构思维和工程意识不是靠刷题能速成的。1.2 有赞的命题风格基础为王原理优先刷过有赞这套题之后我最大的感受是这个公司是真的在筛选“基础扎实且能干活”的人而不是在筛“背题机器”。举几个例子。选择题里有一道考察 typeof 的题表面上是问typeof null的结果但如果你只记得答案是object而对“为什么 typeof null 返回 object”这一历史遗留问题没有深入了解后面跟一个关于Object.prototype.toString.call(null)返回什么的问题你就很容易翻车。再比如手写题里的数组去重如果你只会Array.from(new Set(arr))这种写法那么当题目追加“如果数组里有对象怎么去重”时你就会卡住。有赞的命题人很擅长在这种“你以为你会的题目”上做文章一层一层往深里挖。所以这套题真正想考察的是你在面对问题时有没有“向下钻取”的能力。这种能力怎么培养没有捷径就是多看源码、多思考每个 API 背后的实现机制、多动手写。我在后面几个章节里会逐个考点拆解把该补的知识盲区全部补齐。2. 核心考点逐题拆解从送分到劝退2.1 数据类型与判断typeof 的坑远不止一个先说选择题里最基础也最阴险的一块——数据类型判断。几乎每套前端笔试题都会考 typeof 和 instanceof但有赞这套题在选项设计上确实动了心思。一个典型考法是这样的console.log(typeof null); // object console.log(typeof undefined); // undefined console.log(typeof []); // object console.log(typeof new Function()); // function如果你只是背下了结果那这道题对你来说就是碰运气。真正要理解的是typeof 的返回值只有undefined、boolean、string、number、object、function、bigint、symbol这八种。对于null返回object是 JS 语言设计早期的一个 Bug因为null的二进制表示全是0而引擎判断对象类型的标记位恰好也是0这个 Bug 因为兼容性考虑一直没修。接下来要理解的就是instanceof的机制了。它检查的是构造函数的 prototype 是否出现在实例的原型链上。所以[] instanceof Array是 true[] instanceof Object也是 true因为 Array.prototype 的上一级就是 Object.prototype。但有赞这道题真正恶心的地方在后面让你判断Object.prototype.toString.call()的返回值。Object.prototype.toString.call([]); // [object Array] Object.prototype.toString.call({}); // [object Object] Object.prototype.toString.call(null); // [object Null] Object.prototype.toString.call(undefined); // [object Undefined] Object.prototype.toString.call(/test/); // [object RegExp]这才是最靠谱的类型判断方式。我在实际项目里经常封装一个getType工具函数底层就是用的这个方法。如果你在笔试现场写出了这个方案并且简单解释了 Symbol.toStringTag 的存在这道题基本就稳了。2.2 数组去重五种解法背后的思路进化数组去重也是出现率极高的手写题。有赞这套题的问法是给出一个数组实现去重并说明每种方法的优缺点。我第一次看到这个题目时觉得很简单直接写const unique (arr) [...new Set(arr)];但如果你只写这一种这道题的分基本拿不满。这道题真正想考察的是你有没有“多方案对比”的思维。我当时在笔记里整理了五种解法这里直接分享出来方法一双重循环。最原始的做法时间复杂度 O(n²)性能最差但兼容性最好不需要任何 ES6 特性。function unique(arr) { const result []; for (let i 0; i arr.length; i) { let isDuplicate false; for (let j 0; j result.length; j) { if (arr[i] result[j]) { isDuplicate true; break; } } if (!isDuplicate) { result.push(arr[i]); } } return result; }方法二利用 indexOf 或 includes 去重。本质还是循环但代码更简洁隐藏了内层循环的细节。方法三利用对象键值对去重。把数组元素作为对象的 key利用对象 key 的唯一性去重。这里有坑——对象的 key 会被转为字符串所以1和1会被当成同一个 key。我当时在这个问题上翻过车后来学会了用typeof item item做 key。方法四先排序再相邻比较去重。排序的时间复杂度 O(n log n)去重本身 O(n)整体比双重循环高效但会改变原数组的顺序。方法五ES6 的 Set。代码最简洁性能也最优但需要注意 Set 的去重基于严格相等SameValueZero所以NaN和NaN会被去重而{}和{}不会被去重。如果你在笔试里能把这五种方法都写出来并且说清楚每种方法的适用场景这道题拿高分是板上钉钉的。2.3 事件循环与 Promise输出题是重灾区有赞这套题的选择题有时也出现在简答题中里有一类必考题事件循环输出题。比如给你这样一段代码console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); Promise.resolve().then(() { console.log(promise1); }).then(() { console.log(promise2); }); console.log(script end);让你写出输出顺序。答案是script start script end promise1 promise2 setTimeout这道题要是答错了说明你对事件循环的理解还停留在背结论的阶段。正确的理解方式是JavaScript 是单线程的但通过事件循环机制实现异步。执行顺序如下同步代码先执行遇到微任务Promise.then、queueMicrotask、MutationObserver就放到微任务队列遇到宏任务setTimeout、setInterval、I/O就放到宏任务队列。同步代码执行完之后把微任务队列里的任务全部执行完。微任务清空后从宏任务队列里取一个任务执行然后再次清空微任务队列循环往复。这套题里有一个很经典的进阶版本把 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);这里的关键点在于await async2()相当于把async2()后面的代码放进了微任务队列而不是同步执行。所以输出顺序是script start async1 start async2 promise1 script end async1 end promise2 setTimeout这道题我当年答错了后来看了一个讲事件循环的视频才彻底搞明白。这里给各位一个建议遇到这种题别只背答案自己在 DevTools 里实测一遍把每个状态打点记录下来理解才会深。2.4 原型链与继承构造函数、原型、实例三者关系原型链是前端笔试的另一大高频考点。有赞这套题的简答题里出现过请解释构造函数、原型对象、实例三者之间的关系并说明如何实现继承。这个知识点怎么讲才清晰我一般用“家庭关系”来类比构造函数是“妈妈”实例是“孩子”原型对象是“家族的基因库”。孩子身上没有的东西会去基因库里找基因库里没有就往上一级基因库找——这就是原型链。关于继承ES6 的 class extends 写法谁都会但笔试现场往往要求你写出 ES5 的组合继承或者寄生组合继承。当时让我写组合继承我写成了这样function Parent(name) { this.name name; this.hobbies [reading, coding]; } Parent.prototype.sayName function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); this.age age; } Child.prototype new Parent(); Child.prototype.constructor Child;这个写法有一个问题Child.prototype 指向一个 Parent 实例这个实例上会有name和hobbies属性虽然不影响使用但从设计上是有缺陷的。更优的方案是寄生组合继承function inherit(Child, Parent) { const prototype Object.create(Parent.prototype); prototype.constructor Child; Child.prototype prototype; }Object.create 创建一个以 Parent.prototype 为原型的新对象避免执行 Parent 的构造函数既保持了原型链又没有多余的实例属性。这道题我能拿高分关键就在这个细节上。2.5 CSS 居中与布局看似简单实则有坑CSS 考题里居中问题几乎百发百中。有赞这套题的问法是实现一个元素在父容器中水平垂直居中写出尽可能多的方案。这里列几种主流方案并说说各自的适用场景flex 布局父容器display: flex; align-items: center; justify-content: center;。最推荐思路简单兼容性也好。grid 布局父容器display: grid; place-items: center;。代码最简洁但老项目里用得少。absolute margin: auto子元素position: absolute; top: 0; right: 0; bottom: 0; left: 0; margin: auto;。适合父容器已有定位属性的情况。absolute 负 margin子元素position: absolute; top: 50%; left: 50%; margin-top: -50px; margin-left: -50px;。需要知道子元素的宽高自适应性差。absolute transform子元素position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);。不需要知道子元素的宽高但需要注意 transform 会影响子元素内部的 fixed 定位。这套题的高阶版本还会跟进一个深坑如果子元素的宽度是由内容撑开的并且内容会动态变化那方案 4 就不适用了。如果你能在答案里主动指出每个方案的局限性和适用场景阅卷人对你的好感度会直线上升。3. 高频手写题现场还原这些套路必须刻进DNA3.1 防抖与节流面试官最爱看你写边界条件防抖debounce和节流throttle是前端面试手写题里出镜率最高的两位选手。有赞这套题也未能免俗但它的问法更细不仅要求写出函数还要求说明适用场景以及如何处理this指向和参数传递。先看防抖function debounce(func, wait, immediate) { let timer null; return function(...args) { const context this; if (immediate !timer) { func.apply(context, args); } clearTimeout(timer); timer setTimeout(() { timer null; func.apply(context, args); }, wait); }; }这里有几个关键细节缺一不可第一clearTimeout必须放在生成新定时器之前否则 debounce 就失效了变成单纯延迟执行。第二func.apply(context, args)里的context必须在外层保存下来否则当函数作为对象方法调用时this会丢失。第三immediate参数控制是否立即执行一次这个需求在实际场景里非常常见比如按钮点击后立即禁用防止重复提交。节流和防抖的核心区别在于防抖是“最后一次说了算”节流是“固定频率执行一次”。一个标准节流实现function throttle(func, wait) { let previous 0; return function(...args) { const context this; const now Date.now(); if (now - previous wait) { func.apply(context, args); previous now; } }; }我建议你把这个previous初始值设为 0这样第一次调用会立即执行。如果想第一次不立即执行可以初始化为Date.now()。这种细节在笔试时可能就是扣分点。3.2 深拷贝从 JSON 到递归的进化之路深拷贝这道题有赞那年也考了。对于新手来说最直接的答案就是const clone JSON.parse(JSON.stringify(target));但你要是敢在笔试里只写这个基本就告别 offer 了。因为JSON.stringify有三大缺陷不处理函数、不处理 undefined、不处理循环引用。所以你至少需要写出手写递归深拷贝function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } if (map.has(target)) { return map.get(target); } if (target instanceof Date) { return new Date(target); } if (target instanceof RegExp) { return new RegExp(target); } const cloneTarget Array.isArray(target) ? [] : {}; map.set(target, cloneTarget); Reflect.ownKeys(target).forEach(key { cloneTarget[key] deepClone(target[key], map); }); return cloneTarget; }这段代码里的WeakMap是解决循环引用的关键。如果不用它当对象存在自引用时如a.self a递归会无限循环导致栈溢出。使用WeakMap还有一个好处它是弱引用不会内存泄漏。这一版的深拷贝其实已经能达到不错的水平了笔试拿高分完全够用。如果你在面试中被追问“怎么拷贝 Symbol 属性”可以用Reflect.ownKeys来回答它同时涵盖了字符串键和 Symbol 键。3.3 手写 Promise最考验功底的题目之一有赞这套题里最难的手写题应该就是手写 Promise 了。不过好消息是它通常不会要求你写出完整版而是让你实现Promise.resolve、Promise.all中某些特定方法或者实现一个“简版 Promise”。如果只考Promise.all我的实现是这样的function promiseAll(promises) { return new Promise((resolve, reject) { if (!Array.isArray(promises)) { return reject(new TypeError(promises must be an array)); } const results []; let count 0; promises.forEach((promise, index) { Promise.resolve(promise).then(value { results[index] value; count; if (count promises.length) { resolve(results); } }).catch(reject); }); }); }这里有一个非常重要的细节results[index] value不能改成results.push(value)。因为 Promise 的异步性导致先 resolve 的不一定是数组里下标小的那个如果用 push最终结果里元素的顺序就乱了。如果考简版 Promise 构造函数核心就是实现 then 的链式调用和状态管理class MyPromise { constructor(executor) { this.state pending; this.value undefined; this.reason undefined; this.onResolvedCallbacks []; this.onRejectedCallbacks []; const resolve (value) { if (this.state pending) { this.state fulfilled; this.value value; this.onResolvedCallbacks.forEach(fn fn()); } }; const reject (reason) { if (this.state pending) { this.state rejected; this.reason reason; this.onRejectedCallbacks.forEach(fn fn()); } }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { if (this.state fulfilled) { onFulfilled(this.value); } if (this.state rejected) { onRejected(this.reason); } if (this.state pending) { this.onResolvedCallbacks.push(() onFulfilled(this.value)); this.onRejectedCallbacks.push(() onRejected(this.reason)); } } }这段代码是个简化版忽略了很多边界情况比如 then 返回 Promise、值的穿透等但笔试现场能写到这个程度已经很不错了。我当时写完还留了一行注释说明哪些地方还能继续完善让阅卷人看到我的设计意识。3.4 函数柯里化学会这个套路很多题都能解柯里化这道题在有赞的笔试里属于加分题出现在手写代码题的最后一道。题目要求实现一个add函数使得add(1)(2)(3)的结果等于 6并且支持add(1, 2)(3)这种混合传参。实际写出来也不难function curry(fn) { return function curried(...args) { if (args.length fn.length) { return fn.apply(this, args); } return function(...nextArgs) { return curried.apply(this, args.concat(nextArgs)); }; }; } function add(a, b, c) { return a b c; } const curriedAdd curry(add);但笔试现场有个坑——使用柯里化后add(1)(2)(3)可以正常返回 6但如果你调用curriedAdd(1)(2)函数的返回值是一个函数这时候你打印它会看到函数体而不是数字。所以你在笔试里需要在实现时处理隐式转换的需求即在返回的函数上自定义toString或valueOffunction add(...args) { const sum args.reduce((acc, cur) acc cur, 0); const innerAdd (...nextArgs) add(sum, ...nextArgs); innerAdd.toString () sum; return innerAdd; }这样String(add(1)(2)(3))就是 6同时add(1)(2)(3) 6也为真。这种技巧在笔试里属于“自己挖坑自己填”能做完基本就是加分项。4. 容易被忽略的选择题暗坑网络与浏览器知识4.1 HTTP 状态码别只记 200 和 404选择题里关于 HTTP 状态码的题有赞也考了。而且它考的很刁钻不问你最常见那几个而是问301和302的区别、304的含义、401和403的区别。我整理了一个速查表建议直接背下来状态码含义考点200请求成功常见基本不会单独考301永久重定向会改变请求方法为 GETSEO 权重转移302临时重定向保持原请求方法搜索引擎不会转移权重304协商缓存命中服务器没有返回 body浏览器用本地缓存401未认证需要登录或 token但不代表没权限403禁止访问已认证但权限不足500服务器内部错误通用错误码502网关错误上游服务挂了504网关超时上游服务处理超时这里最容易被忽视的是 301 和 302 的区别。我做项目时踩过坑——接口误配了 302 重定向结果在 fetch 请求里 method 从 POST 变成 GET导致线上事故。所以在笔试里如果提到 301 和 302 的区别一定要说“301 会改变请求方法302 不一定”。4.2 浏览器缓存强缓存与协商缓存的完整流程浏览器缓存的题目在有赞的笔试中也占了不小的比重。这种题喜欢让你画出请求流程但更常见的是给你几个 HTTP 响应头让你判断命中强缓存还是协商缓存。梳理一下完整流程浏览器发起请求先查内存缓存memory cache再查磁盘缓存disk cache。命中强缓存Cache-Control: max-age 或 Expires直接使用缓存不发请求状态码显示 200from disk cache 或 from memory cache。未命中强缓存浏览器带上 If-Modified-Since 或 If-None-Match 发起请求。服务器返回 304表示协商缓存命中浏览器使用本地缓存。服务器返回 200表示资源更新了响应体返回最新内容。笔试里常见的问题是Cache-Control的字段过期了但ETag没变这时候是命中强缓存还是协商缓存答案是协商缓存。因为强缓存已经失效浏览器会带着 ETag 去问服务器资源有没有变服务器比对后返回 304。这个知识点我会建议你手动画一遍流程图把请求和响应的字段标出来理解会深很多。4.3 跨域方案不只是 JSONP 和 CORS跨域问题几乎每个前端都遇到过笔试里也必考。有赞这套题的选择题里考了三种跨域方案的适用场景。第一种是 JSONP。它利用script标签不受同源策略限制的特性只能发 GET 请求而且需要后端配合返回一段可执行的 JS 代码。优点是兼容性好缺点是只能 GET、有安全风险。第二种是 CORSCross-Origin Resource Sharing。这是目前的主流方案后端设置Access-Control-Allow-Origin等响应头即可。需要区分简单请求和预检请求OPTIONS当请求方法为 PUT、DELETE 或 Content-Type 为 application/json 等时浏览器会先发一个预检请求。第三种是 proxy 代理。开发环境下用 webpack-dev-server 或 Vite 的 proxy 配置把/api前缀的请求代理到后端服务器。生产环境则用 Nginx 反向代理。这是最安全的方案因为浏览器端感知不到跨域的存在。选择题里还有一个关于postMessage的选项它主要用于 iframe 之间的通信是另一种跨域神器但很多人在刷题时容易忽略。5. 考后复盘与避坑清单5.1 一个真实的踩坑案例bind 与 apply 的区别我当时笔试时有一道选择题是以下哪个方法不会立即执行函数A.func()B.func.call(context)C.func.apply(context)D.func.bind(context)答案是 D。bind会返回一个新函数并不会立即执行。但我当时在考场上犹豫了一下因为我把bind和call的作用效果记混了。后来复盘时我意识到这种错误本质上是把“相似的知识点”放在了一起背却没有记住它们之间的关键区别。这里给大家一个建议每学一个 API主动问自己三个问题——它接收什么参数它返回什么它对原函数/原对象有没有副作用这种“三问法”对记忆非常有效。5.2 时间分配与做题顺序策略我做这套题最大的遗憾就是时间分配失败。选择题部分有些选项设置得很隐蔽我反复斟酌结果浪费了快 30 分钟。现在回过头来看正确的策略应该是先扫一遍全部题目把简单题、中档题、难题分类。简单题比如说出typeof null的结果、写一个 flex 居中直接秒杀中档题比如实现防抖节流、深拷贝按部就班写难题比如手写 Promise、设计题留在最后写多少算多少。笔试不是考试它是筛选器。你不需要拿满分你只需要比同场竞争的人多拿几分所以“战略性放弃”完全合理。5.3 从这套题延伸出的知识点清单复盘这套题之后我整理了一份知识点自查清单建议你也对照着过一遍数据类型判断的四种方式及各自适用场景数组的常见方法map、filter、reduce、forEach、some、every及手写实现闭包的概念、应用场景及内存泄漏风险事件委托的原理及冒泡机制原型链的完整图示及继承的多种实现Promise 的三种状态及状态迁移规则async/await 的编译原理generator promise事件循环的宏任务与微任务优先级HTTP 缓存头字段的优先级关系浏览器渲染流程构建 DOM 树、CSSOM、布局、绘制、合成前端性能优化的常用手段懒加载、图片优化、代码分割、Gzip前端工程化Webpack 核心概念、Loader 与 Plugin 的区别安全攻防XSS、CSRF 的原理与防御这套清单里的每一行拉出来都能单独写一篇文章。我当时刷完这套题后用了一周时间把每个知识点都过了一遍后面再笔试明显感觉顺畅很多。5.4 关于这套题的结局刷题之外还要有项目底气最后讲个真实情况。我当时笔试成绩其实不错选择题和手写题都答得挺好但面试环节还是挂了。后来复盘时发现面试官问的问题大多围绕我简历上的项目展开而我的项目深度明显不够——只有业务需求开发没有技术难点、没有性能优化、没有工程化改造所以在聊方案时露怯了。这也解释了一个常见困惑为什么笔试过了面试还是挂了因为笔试筛的是“基础能力下限”面试筛的是“工程能力上限”。两者缺一不可。所以我的建议是如果你想进有赞这样的公司笔试刷题是必须的它是敲门砖但真正决定你能不能拿 offer 的还是你有没有拿得出手的项目以及你对自己项目的理解有没有深度。比如你的项目里有没有做过性能优化、有没有做过组件库设计、有没有处理过复杂的异步逻辑这些都得提前准备好能在面试官追问的时候讲清楚。如果重新来一遍我会在刷题的同时花同样精力打磨 1 到 2 个有亮点的项目梳理清楚技术选型、难点突破、优化数据。这样笔试面试都不慌。有赞这套题已经过去很多年了但它的考点设置现在回看依然很有参考价值。前端技术迭代很快框架换了一茬又一茬但 JavaScript 的核心机制、网络原理、工程化思维这些东西始终是面试考察的锚点。能把这些基础啃透的人不管去面哪家公司都会有底气。