最近陆陆续续面了几家中大型互联网公司,快手也算其中比较有代表性的一家。坦白讲,快手的面试风格跟我预想的不太一样——没有太多刁钻古怪的偏题,更多是把常见的基础题往深处挖,一题追问到你说不出来为止。前端开发这块尤其明显,Vue3、事件循环、手写题、性能优化这些热搜词看着普通,但每一道都能梯度递增,从"会不会"考到"懂不懂"。
这篇文章先把快手前端面试中最高频的一批题目和参考答案整理出来,分成上下两篇。上篇聚焦JavaScript基础、浏览器原理、网络缓存和手写题;下篇再聊Vue3响应式、工程化、性能监控这些偏框架和实战的内容。不管你是在准备校招还是社招跳槽,这套思路都能直接用。
1. 快手前端面试到底在考什么
1.1 从JD拆解考察点
我翻过不少快手的招聘JD,核心诉求其实就三块:第一是基础扎实,JavaScript的原型链、作用域、事件循环这类必须张口就来;第二是框架深度,Vue或React至少要精通一个,能讲清响应式原理和diff流程;第三是工程化能力,Webpack/Vite的配置优化、CI流程、性能监控都要有实战经验。近两年热词里反复出现的"vue3面试题""web前端开发""前端开发实战",基本都落在这三个范围内。
面试官一般会从简历里你最熟悉的项目切入,然后一路追问下去。我印象最深的一次,面试官让我讲项目里做过的一个性能优化,我滔滔不绝讲了半天图片懒加载,他直接问:"那你倒是说说,IntersectionObserver的回调是异步还是同步?触发时机跟滚动事件相比有什么优势?"这一下就把"做过"和"真正理解"区分开了。所以准备面试的时候,别只顾着刷面试题,每个方案背后的原理都得能往下接三层。
1.2 面试轮次与侧重点
快手的面试流程通常是技术一面、二面、三面加HR面。一面侧重基础和编码能力,二面侧重项目深度和场景设计,三面更多是综合能力和方向匹配度。一面如果卡住,后面根本没机会展示你多会写业务,所以基础题一定要稳。这里有一个反直觉的经验:现在的面试官不太喜欢"标准答案背得飞起"的候选人。你说"防抖是事件停止触发后再执行,节流是每隔一段时间执行一次",他们马上会追问:"如果节流用时间戳实现,最后一次触发会怎样?如果用定时器实现,第一次触发会怎样?"这类边界问题才是真正拉分的点。
我还发现一个规律:快手面试官特别喜欢"追问链"。比如你先答出"浏览器缓存分为强缓存和协商缓存",他会顺着往下问"no-cache和no-store的区别""ETag和Last-Modified的优先级""如果服务器和客户端时间不一致怎么办"。这些题目本身不算难,但连续追问会让你暴露知识盲区。应对策略很简单:每个知识点都尽量准备到"原理层",而不是停留在"用法层"。
2. JavaScript必考题型与参考答案
2.1 闭包:从定义到内存泄漏
题目:什么是闭包?请手写一个使用闭包实现私有变量的例子。
参考答案要点:闭包是函数创建时捕获其外部作用域的能力,本质上由函数和其引用环境的组合构成。JavaScript中每当函数被定义并引用外部变量时,就会形成闭包。可以用闭包模拟私有变量:
function createCounter() { let count = 0; return { increment() { count++; return count; }, getCount() { return count; } }; } const counter = createCounter(); counter.increment(); counter.increment(); console.log(counter.getCount()); // 2 console.log(counter.count); // undefined,外部无法直接访问这里count只能通过暴露的方法访问,外部无法直接修改,这就是私有变量的效果。追问方向一般有两个:一是闭包的内存泄漏问题——如果闭包持有大量外部变量而长期不释放,这块内存就一直无法回收。解决办法是在不需要时把引用置为null,或者用弱结构存储。二是闭包在循环中捕获变量的经典问题:
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // 输出 3, 3, 3原因在于var声明是函数作用域,循环结束后i已经变成3,而定时器回调执行时读取的是同一个i。改成let声明就能得到0, 1, 2,因为let在每次循环迭代中创建了新的词法环境。如果不允许改声明方式,也可以用IIFE包裹一层形成闭包:
for (var i = 0; i < 3; i++) { (function (j) { setTimeout(() => console.log(j), 0); })(i); }这个题几乎是必考的,我建议从"作用域链查找机制"讲起,再落到实际例子,面试官会觉得你理解得比较系统。
2.2 事件循环:宏任务与微任务的先后
题目:请说明setTimeout、Promise、async/await的执行顺序。
这类题的经典样例是:
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。解释分三个层级:第一,同步代码在调用栈中依次执行,所以两个console.log先打印;第二,Promise的回调属于微任务,在当前宏任务结束、微任务队列清空时立即执行;第三,setTimeout的回调属于宏任务,要等到下一个事件循环的宏任务阶段才执行。注意浏览器还有渲染时机的问题——微任务清空后可能会先渲染再进入下一个宏任务,所以setTimeout的0毫秒也不是真正的0毫秒。
追问环节经常会出async/await的题目。await本质上就是Promise的语法糖,await后面的代码相当于被包进一个隐式的.then回调里。比如:
async function test() { console.log('start'); await Promise.resolve(); console.log('end'); } test();start先打印,end作为微任务延后打印。如果后面还有普通同步代码,它们会先于end执行。记住一句话:凡是被await切开的部分,后面的代码全部进微任务队列。这个判断准没错。
2.3 Promise链式调用与错误捕获
题目:Promise.prototype.then返回的到底是什么?throw在链式调用中如何影响后续回调?
这是快手一面很喜欢考的题,核心考察点是"Promise的状态传递机制"。then方法总是返回一个新的Promise,这个新Promise的状态由回调函数的返回值决定。如果返回普通值,新Promise会变成fulfilled并把该值传给下一个then;如果返回一个Promise,新Promise会跟随这个Promise的状态;如果回调内throw了异常,或者返回一个rejected的Promise,新Promise就会变成rejected,后续会跳过最近的then,直接进入catch。
Promise.resolve(1) .then((v) => { console.log(v); // 1 throw new Error('boom'); }) .then(() => { console.log('不会执行'); }) .catch((e) => { console.log('捕获:', e.message); // 捕获: boom });这里关键是不要误解成"catch是then的附属品",实际上catch也只是then(null, handler)的语法糖,它返回的仍然是一个Promise,所以catch后面还能继续then。面试官喜欢追问的一个变体是:在catch中又throw了一个新错误,后面能不能拦住?答案是能,只要后续再挂catch就能继续捕获,因为整个链条始终是层层包装的Promise。
另一个常考变体是Promise.all、Promise.race、Promise.allSettled的区别。快手场景里比较关注Promise.all的"快速失败"特性——只要一个请求失败,整体就失败。如果要实现"多个请求互不拖累"的效果,要么给每个请求单独catch,要么直接用allSettled。这部分建议配合实际场景回答,比如移动端页面同时拉取多个接口,用allSettled能保证部分失败时不阻塞整体渲染。
3. 浏览器原理与网络优化题
3.1 从URL输入到页面渲染完整链路
题目:浏览器从输入URL到页面渲染,中间经历了哪些关键步骤?
这块几乎是每场面试的保留节目,考察的是知识广度。完整链路大概是:DNS解析拿到IP地址 → 建立TCP连接(HTTPS还要做TLS握手)→ 发送HTTP请求并接收响应 → 解析HTML构建DOM树 → 解析CSS构建CSSOM树 → 将DOM和CSSOM合成渲染树 → 布局计算每个节点的几何位置 → 绘制到屏幕上,最后通过合成器完成图层合成。这个流程要能讲顺,但更重要的是能接住追问。
追问通常会集中在几个阻塞点上:第一,CSS会阻塞渲染吗?答案是会,CSSOM没有构建完成前,渲染树无法生成,所以CSS默认是阻塞渲染的关键资源。第二,script标签的defer和async有什么区别?两者都是异步下载脚本,但defer要等HTML解析完才执行,async是下载完立即执行,因此async可能阻塞DOM解析,而且不保证执行顺序。第三,如果JS里操作了DOM修改样式,布局和绘制会不会重新跑?这涉及到回流和重绘,改几何属性触发回流,只改颜色触发重绘。快手这类重体验的信息流产品,特别在意滚动场景下的布局抖动,所以面试官会顺着问"如何减少回流",常见答法是批量修改DOM、用requestAnimationFrame合并操作、避免强制同步布局。
3.2 HTTP缓存策略:强缓存与协商缓存
题目:浏览器HTTP缓存有哪些类型?各自的字段和优先级是怎样的?
这道题问到的概率极高,而且特别适合用来区分"背过文档"和"真正调过接口的人"。缓存分两大类:强缓存和协商缓存。强缓存直接从本地取,不发请求,相关字段是Expires和Cache-Control;协商缓存需要向服务器验证资源是否过期,字段是Last-Modified/If-Modified-Since和ETag/If-None-Match。用表格整理一下:
| 缓存类型 | 缓存字段 | 判定依据 | 请求是否发出 |
|---|---|---|---|
| 强缓存 | Expires | 服务器绝对时间 | 不发请求,直接读本地 |
| 强缓存 | Cache-Control | 相对时间,如max-age=31536000 | 不发请求,直接读本地 |
| 协商缓存 | Last-Modified/If-Modified-Since | 文件最后修改时间 | 发请求,服务器比对时间 |
| 协商缓存 | ETag/If-None-Match | 文件内容哈希指纹 | 发请求,服务器比对指纹 |
必背的结论有四条:Cache-Control优先级高于Expires,因为Expires是HTTP/1.1之前的方案,受本地时间影响;ETag优先级高于Last-Modified,因为时间戳精度不够高,同一秒内文件变化无法感知;no-cache的意思是"允许缓存,但每次使用前必须向服务器验证",不是"不缓存";no-store才是完全不缓存。最后一个很容易被记混,面试官抓到就会追问:你在项目里的接口和静态资源分别怎么配的?正确做法是静态资源用Cache-Control: max-age=31536000配合文件名哈希,接口一般用no-cache甚至按需禁用缓存。
3.3 核心性能指标与优化手段
题目:前端常用的性能指标有哪些?你会如何监控和优化?
快手的信息流产品对首屏速度和滚动流畅度极其敏感,所以这题是高频中的高频。需要熟记几个关键指标:FP(首次绘制)、FCP(首次内容绘制)、LCP(最大内容绘制)、CLS(累计布局偏移)和INP(交互到下一次绘制)。其中LCP衡量首屏主内容的加载速度,CLS衡量页面稳定性。面试官会让你说出一个可量化的优化案例,这时候不要空谈"做了性能优化",要说清楚指标基线、优化手段和优化后数据。
一个我验证过多次的链路是:先用Lighthouse和Performance API采集基线数据,再做三项优化——第一,图片全部改用WebP/AVIF格式,并加loading="lazy",首屏图片用fetchpriority="high";第二,把首屏外的组件做动态导入,配合路由懒加载;第三,对重复请求做缓存和合并。优化后LCP从2.8秒降到1.6秒,CLS从0.24降到0.08。面试官想听的正是这种"能感知到变化"的表达。另外还要能说清PerformanceObserver和web-vitals库的关系,因为快手内部很多监控平台都是统一采集这些指标的。
4. 高频手写题与解题思路
4.1 防抖与节流:边界条件的考验
题目:请分别实现防抖和节流函数。
这道题手写概率极高,但很多人只写得出基本版本,边界一多就翻车。防抖的核心思路是:事件持续触发时不执行,等停止触发后规定的延迟时间到了再执行。适用于输入框搜索、窗口resize等场景。节流的核心思路是:规定时间内最多执行一次。适用于滚动监听、按钮防重复提交等场景。参考实现:
function debounce(fn, wait) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); }, wait); }; } function throttle(fn, interval) { let last = 0; return function (...args) { const now = Date.now(); if (now - last >= interval) { last = now; fn.apply(this, args); } }; }常被追问的两个边界问题:第一,防抖的this指向要保留调用者的上下文,所以这里必须用function而不是箭头函数,并且通过fn.apply(this, args)把外层this传进去,这是手写时最容易忽略的细节;第二,节流还要考虑"最后一次触发能不能执行"。时间戳版本的节流在停止触发后,如果now - last < interval,最后一次触发会被直接丢弃;定时器版本的节流则能确保最后一次延迟执行。更完整的实现可以同时支持首尾触发,要用上一次执行时间和计时器双向判断。面试时能主动把这两种差异说清楚,属于明显的加分表现。
4.2 深拷贝:从简单到循环引用
题目:请实现一个能够处理Date、RegExp、Map、Set以及循环引用的深拷贝函数。
这题看着简单,其实是手写题里的能力分层题。只会写JSON深拷贝的只能算及格,能处理复杂类型的才算优秀。JSON的局限性很明显:无法复制函数、undefined、Symbol,无法处理循环引用,会把Date变成字符串,Map和Set变成空对象。参考实现可以这样写:
function deepClone(value, cache = new WeakMap()) { if (value === null || typeof value !== 'object') return value; if (cache.has(value)) return cache.get(value); if (value instanceof Date) return new Date(value); if (value instanceof RegExp) return new RegExp(value.source, value.flags); if (value instanceof Map) { const result = new Map(); cache.set(value, result); value.forEach((v, k) => result.set(deepClone(k, cache), deepClone(v, cache))); return result; } if (value instanceof Set) { const result = new Set(); cache.set(value, result); value.forEach((v) => result.add(deepClone(v, cache))); return result; } const result = Array.isArray(value) ? [] : {}; cache.set(value, result); Reflect.ownKeys(value).forEach((key) => { result[key] = deepClone(value[key], cache); }); return result; }这个实现里最关键的技巧是WeakMap的缓存机制。如果不用缓存,循环引用会让递归无限嵌套,最终栈溢出。用WeakMap还有一个好处:它不会阻止垃圾回收,避免因为克隆操作本身造成内存泄漏。Reflect.ownKeys能同时处理字符串键和Symbol键,这是Object.keys做不到的。面试时如果能把WeakMap为什么比普通Map合适讲清楚,这道题基本就拿下了。
4.3 手写Promise实例:掌握状态机
题目:请实现一个满足Promise基本行为的MyPromise。
快手的手写题不要求你写成完整的Promise/A+规范,但基本状态机必须清晰。核心三点:pending、fulfilled、rejected三个状态只能单向迁移;then要在状态变化时注册回调;then必须返回新Promise以支持链式调用。精简参考实现:
class MyPromise { constructor(executor) { this.state = 'pending'; this.value = undefined; this.reason = undefined; this.onFulfilled = []; this.onRejected = []; const resolve = (value) => { if (this.state === 'pending') { this.state = 'fulfilled'; this.value = value; this.onFulfilled.forEach((fn) => fn()); } }; const reject = (reason) => { if (this.state === 'pending') { this.state = 'rejected'; this.reason = reason; this.onRejected.forEach((fn) => fn()); } }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { const next = new MyPromise(() => {}); if (this.state === 'fulfilled') { try { next.resolve?.(onFulfilled ? onFulfilled(this.value) : this.value); } catch (err) { next.reject?.(err); } } return next; } }严格来说,一个合格的完整实现要处理"回调返回值是Promise的情况""回调异步执行"以及"状态已经变化后的直接调用",篇幅会很长,面试时也不一定要写到那么完整。我个人建议:写之前先把状态机和链式调用讲清楚,这是面试官最想听到的;代码能写出构造函数、resolve、reject和基本then就可以拿大部分分数。另外要注意,executor内部抛异常需要用try/catch捕获并转成reject,这个细节很多人漏掉。
5. 面试实战技巧与避坑指南
5.1 项目描述要遵循的STAR逻辑
快手这类公司非常看重候选人能不能清晰表达项目价值,而不仅仅是描述功能。我见过很多候选人简历写得密密麻麻,面试时却说不出"这个项目解决了什么问题、你怎么做的、效果是什么"。正确的表达方式用STAR逻辑:背景(Situation)、任务(Task)、行动(Action)、结果(Result)。描述一个项目时,先用一句话说清背景和目标,再讲你的具体实现方案,最后一定要带上量化结果。
比如:"我之前负责的是一个资讯类H5的首屏优化。当时LCP是3.2秒,跳出率偏高。我做了三件事:把首屏接口改为并行请求、用WebP替换原图、对首页组件按路由拆分。上线后LCP降到1.9秒,跳出率下降了约15%。"这种描述比"我做了性能优化"有说服力得多。面试官通常会针对行动部分的某个技术点追问,所以你在准备项目时就要把"为什么选这个方案""还有没有其他方案""你遇到的最大难点是什么"这三个问题提前想透。
5.2 遇到不会的题目怎么处理
技术面试最大的考验其实不是你会多少,而是你不会的时候怎么办。我的建议是三步走:第一步,先把题目用自己的话复述一遍,确认没有理解偏;第二步,讲出你已知的部分和大概思路,哪怕只是猜测也要基于已有的知识推演;第三步,如果完全没思路,直接说明"这块我平时接触较少,但是根据我的理解,它可能和XX相关"。绝不会比沉默更糟。
举一个真实发生的例子。面试官问了我一个关于浏览器渲染合成层的问题,我当时只记得Paint和Composite的概念,具体细节答不上来。我没有慌,而是先复述了自己的理解:"我理解你说的合成层,就是浏览器在绘制完成后把多个图层合并输出的过程,但是我不太清楚主动创建合成层的具体触发条件。"面试官反而顺势跟我聊了will-change和transform动画的关系。很多时候,面试官考察的就是你面对未知问题的反应速度和思维路径,而不是一定要从你嘴里听到标准答案。
5.3 最后分享一个实操中的小技巧
我在准备快手面试时踩过一个坑:花大量时间刷"偏题怪题",结果把高频基础题忽略了。实际上,快手的题库复用率很高,前端基础题集中在事件循环、闭包、缓存、Vue响应式和手写题这几类。更好的策略是把这些核心题整理成自己的"面试笔记本",每个题至少能讲出三层:标准答案、底层原理、实际项目关联。比如讲到缓存,就能顺手说出自己在项目中如何为静态资源配Cache-Control、如何排查"修改了代码但浏览器还是旧版本"的问题。这种联系能力,才是最终让面试官给你发offer的关键。
以上是上篇的全部内容,下一部分我会继续整理Vue3响应式原理、diff算法、工程化构建优化和性能监控类的题目。希望这份参考答案能帮你少走一些弯路。如果你在准备面试过程中遇到有意思的题目,也欢迎在评论区里分享,我们一起讨论。