快手这家公司的前端面试,整体风格给我的感觉是:基础考得细,框架问得深,场景题特别务实。不像有些公司上来就甩一堆偏题怪题,快手更关注你“有没有真的做过东西”,以及“做的时候有没有想过为什么”。这篇文章我就把当时的面经完整复盘一遍,把每道题背后的考察点、我当时怎么答的、后来复盘觉得应该怎么答更好,全部拆开讲清楚。如果你是准备跳槽的前端,或者正在面快手,这篇内容应该能帮你少走不少弯路。
1. 面试全流程回顾:从简历筛选到HR面
1.1 快手的整体面试流程与节奏
快手的面试流程一般是三轮技术面加一轮HR面,部分地区或者部门可能会有加面,但整体节奏比较紧凑。我当时走完整个流程用了大概两周时间,每轮面试间隔三到五天,不会让你等得特别焦虑。
第一轮通常是基础面,主要考察JavaScript、CSS、浏览器原理这些基本功,偶尔会穿插一两道简单的算法题。第二轮开始偏向框架和工程化,React或者Vue的原理、组件设计、打包工具、性能优化都会涉及,这一轮也最容易刷人。第三轮一般是leader面或者交叉面,这时候不再单纯问知识点,而是给你一个实际场景,看你怎么分析和解决问题,更像是在考察你的综合能力。
这里要提醒一下,快手的面试官普遍喜欢顺着你的回答往下追问。比如你说自己用过某个API,他会接着问这个API的源码实现,再问如果让你自己实现一个类似的你会怎么做。所以面试前一定要把自己简历里写的东西吃透,没有真正实践过的内容宁可不写,否则追问两三轮就会露馅。
1.2 面试前的准备策略与心态调整
面快手之前,我其实刚经历了另一家公司的面试打击,那家公司在框架原理上追得特别深,我有一半问题都答得磕磕绊绊。所以这次我调整了策略,没有一上来就刷题,而是先把前端知识体系重新梳理了一遍。
具体做法是画了一张脑图,按照JavaScript、CSS、浏览器、网络、框架、工程化、性能优化这几个大方向,把每个方向下的核心知识点列出来,然后逐一自问自答。比如JavaScript下面我会列出来原型链、闭包、作用域、事件循环、异步方案这些,然后问自己“如果让我给一个刚入门的人讲清楚闭包是什么,我能不能做到”。如果发现某个问题讲不清楚,就说明这块还有盲区。
心态上也要调整好。面快手这种体量的公司,面试官见过太多候选人,你的水平几斤几两,基本上十分钟之内他就摸清楚了。所以不要试图伪装或者背答案,大大方方承认自己哪些地方没深入过,反而比硬撑着胡编要好得多。当然,承认不会之后最好补一句“但是我的理解是大概怎样,我打算面试结束后去深入看一下”,这样至少能体现你的学习意识。
2. 第一轮技术面:JavaScript基础与浏览器原理
2.1 高频考点:事件循环、闭包与异步机制
第一轮面试刚开始,面试官先是让我做了个自我介绍,然后就直接切入了JavaScript基础。第一个问题就是经典的事件循环,不过他没有直接问“什么是事件循环”,而是给了一段代码让我说出输出顺序。
console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('script end');这道题考察的是宏任务与微任务的执行顺序,核心结论是:同步代码先执行,然后执行当前宏任务队列中的所有微任务,微任务执行过程中如果产生了新的微任务也会一起执行完,最后才会轮到下一个宏任务。
所以输出顺序是:script start、script end、promise1、promise2、setTimeout。我当时答对了,但面试官紧接着追问了一句“如果setTimeout和Promise嵌套在一起会怎样”,这才是真正的考点。比如:
setTimeout(function() { console.log('timeout1'); Promise.resolve().then(function() { console.log('promise in timeout'); }); }, 0); setTimeout(function() { console.log('timeout2'); }, 0);这里要注意的是,两个setTimeout都属于宏任务,先执行第一个setTimeout的回调,打印timeout1,然后它内部产生的微任务promise in timeout会被加入微任务队列,但需要等当前宏任务执行完毕后才会处理。所以输出是timeout1、promise in timeout、timeout2。这个细节很容易答错,大家一定要记住:每个宏任务执行完之后,都会清空当前的微任务队列,再进入下一个宏任务。
闭包也是必考的点。面试官让我“说一段闭包的代码,并解释为什么能访问外部变量”,然后继续追问了闭包的内存泄漏问题。生产环境常见的坑是循环引用,比如把DOM元素保存在数组里,又在DOM上挂了一个引用指向这个数组,就会形成循环引用导致内存无法释放。现在大部分浏览器引擎的垃圾回收器已经能处理循环引用了,但闭包本身持有外部作用域的引用,如果这个外部作用域包含了很大的对象,即使闭包本身很小,也会把大对象一直留在内存里。所以闭包不是不能用,而是要注意控制作用域范围。
2.2 经典问题:原型链与继承方案对比
面试官问完事件循环之后,直接跳到原型链。他给我画了一个简单的对象关系图,让我说出obj、Obj.prototype、Obj.__proto__、Function.prototype之间的关系。这个问题其实是在考察你是否真正理解JavaScript里“函数也是对象”这个概念。
我当时的回答思路是这样的:
obj是一个普通对象,它的__proto__指向Obj.prototypeObj是个构造函数,它的__proto__指向Function.prototypeObj.prototype是一个对象,它的__proto__指向Object.prototypeFunction.prototype的__proto__指向Object.prototype
画出来就是一条完整的链路:obj -> Obj.prototype -> Object.prototype -> null,而构造函数那一侧是Obj -> Function.prototype -> Object.prototype -> null。
接着他问继承的实现方式,我列了原型链继承、构造函数继承、组合继承、寄生组合继承这几种,然后重点说了ES6的class继承其实也是基于寄生组合继承实现的。Class里面的extends关键字做的事情,本质上是把子类的原型对象指向父类原型对象的一个新对象,同时修改子类的__proto__指向父类构造函数。这个细节可能很多写业务代码的人没注意过,但在面试里答出来会很加分。
关于原型链这块,我后来复盘觉得有一个点值得补充:不要死记硬背继承的代码实现,而要理解prototype和__proto__的指向关系。面试官考察继承的目的,不是想让你背代码,而是想看你是否理解“JavaScript通过原型链实现属性查找”这个底层机制。你只要把“对象查找属性 -> 顺着原型链向上找 -> 找到就返回,找不到就返回undefined”这条链路想清楚,很多问题都能迎刃而解。
3. 第二轮技术面:框架原理与工程化实践
3.1 框架考察方向:Diff算法、Hooks闭包陷阱与响应式原理
进入第二轮,面试官明显更关注框架深度。我面的是React方向,所以上来先问了React的Diff算法。
这个问题属于“人人都能说几句,但很少人能说透”的高频题。我当时的回答分了三层:
第一层是“同层比较”:React的Diff不会跨层级比较节点,而是逐层比较,一旦发现某个节点类型不同就直接替换整个子树,不做进一步比较。
第二层是“key的作用”:在同一个列表里,React通过key来判断节点是复用还是重新创建。高效的Diff需要尽可能减少节点移动,所以在用key的时候要选稳定且唯一的标识,不能选数组下标。因为如果列表头部插入了新元素,用index作为key会导致后面的所有元素都被认为改变了。
第三层是“Fiber的引入”:在React 16之后,Diff过程变得可中断了。Fiber把整个渲染过程拆成一个个小单元,每完成一个单元就让出主线程,这样就不会因为一次大渲染把页面卡死。这个设计其实是把同步的、不可中断的递归调用,改成了异步的、可中断的链表遍历。
面试官听完之后追问了一个场景:如果列表顺序发生了调换,用index作为key会有什么问题?这个我确实踩过坑,当时答得还算顺利。用index作为key,当列表顺序调换时,React并不知道这些节点只是换了位置,它会认为每个位置上的节点类型和内容都变了,于是销毁重建全部DOM。而在某些情况下,如果这个列表项是有内部状态的组件,比如输入框,位置的调换会导致输入框内容错乱,因为React复用了组件实例,但props和state没有正确对应起来。
Hooks闭包陷阱也是现在React面试的必问内容。面试官给了一个场景:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { setInterval(() => { console.log(count); }, 1000); }, []); return <div>{count}</div>; }这段代码的问题在于:useEffect传入的是空数组,所以effect只会在组件挂载时执行一次,闭包里捕获的count始终是初始值0。即使后面count变了,setInterval里的回调拿到的还是旧的闭包变量。
解决方式有几种:一是把count加入依赖数组,让effect重新执行,但这样会导致定时器被反复清除和重建;二是用ref来保存最新的count值;三是用函数式更新,比如setCount(prev => prev + 1)这种。
面试官继续追问:为什么依赖数组里的值变化了,闭包就能拿到新值?因为useEffect在依赖变化时会清理上一次的effect,然后重新创建新的effect。新的effect在创建时会捕获当前这次渲染的count值,所以闭包里的值就是新的。这个解释把“闭包陷阱”和“effect的清理机制”串起来了,我在回答的时候也明显感觉到面试官比较满意。
3.2 工程化考点:Webpack构建优化与Vite对比
快手这种体量的公司,前端代码库非常庞大,所以工程化能力一定是考察重点。面试官问的是:如果线上项目首屏加载很慢,你会怎么定位和优化。
这个问题很开放,我按“从外到内”的思路来答:
先看网络层面:资源体积是否过大,有没有开启gzip或者br压缩,CDN节点是否覆盖到位。接着看代码层面:首屏有没有懒加载,路由是不是按需拆分的,有没有加载了当前页面用不到的第三方库。再看缓存策略:静态资源的contenthash有没有配好,能不能做到长期缓存。最后看渲染层面:有没有SSR或者预渲染,白屏时间到底花了多久。
面试官听完之后,直接让我结合Webpack说具体配置方案。我提到了这几个点:
- 用
splitChunks把第三方库拆成单独chunk,这样业务代码更新时不会导致第三方库缓存失效 - 用
thread-loader或者terser-webpack-plugin开启多进程压缩,加快构建速度 - 用
cache-loader或者Webpack 5内置的持久化缓存,二次构建速度能快很多 - 用
import()语法做路由懒加载,配合React.lazy使用
然后他问了我一个对比问题:既然Webpack已经能完成这些事,为什么还要用Vite。我的理解是:开发阶段Vite利用浏览器原生ESM能力,启动速度比Webpack快非常多,因为Webpack启动时要先打包整个项目,而Vite只有在你请求某个模块时才实时编译那个模块。但在生产构建阶段,Vite还是通过Rollup打包,所以如果你遇到的是构建速度问题,Vite的优化效果主要体现在开发环境,而不是生产环境。
4. 场景题与综合能力考察:实际问题分析与方案设计
4.1 场景题:设计一个支持并发控制的请求队列
第三轮面试是leader面,开场没有直接问技术题,而是先聊了一下我在上一家公司的项目经历,然后抛出来一个场景题:如果前端需要向后端发送大量请求,比如1000个,但后端接口同时只能承受10个并发,你会怎么处理。
这种题在快手这种业务流量大的公司特别常见,因为他们的页面里确实会碰到大量数据请求的场景,比如推荐流的批量数据上报、批量查询用户状态等等。我当时的思路是设计一个带并发控制的请求队列,核心逻辑是:
- 初始化一个任务队列,把所有请求都放进队列里
- 设置一个最大并发数,比如10
- 一开始先启动10个任务并行发送
- 每完成一个任务,从队列里再取出一个任务补上来
- 直到队列清空,所有请求完成
我用JavaScript伪代码写了一个版本,核心内容是这样的:
function limitRequest(urls, limit = 10) { const results = []; let current = 0; let finished = 0; const total = urls.length; return new Promise((resolve) => { function run() { while (current < total && results.length - finished < limit) { const index = current++; fetch(urls[index]) .then(res => res.json()) .then(data => { results[index] = data; }) .catch(err => { results[index] = err; }) .finally(() => { finished++; if (finished === total) { resolve(results); } else { run(); } }); } } run(); }); }说实话这段代码我写的时候稍微有点紧张,results.length - finished < limit这个条件其实写得不严谨,因为results数组在并发过程中会有空洞,直接取length有误差。如果换一种更严谨的方式,应该维护一个当前正在执行的任务数计数器,每次发起请求时加一,请求完成时减一,然后用循环判断是否还能继续发起请求。不过面试官看过之后没有太纠结代码细节,而是继续问我:如果某个请求一直挂起不返回,队列会不会被卡死。
这个问题就是考察你有没有考虑到超时和失败重试。我补充了AbortController来做请求超时控制,比如超过10秒就主动中断,再从队列里拿出下一个请求执行。同时也想了一下失败重试的策略:不能无脑重试,因为后端如果已经处于过载状态,重试只会加重压力,所以一般要用退避策略,比如第一次失败后等1秒重试,第二次等2秒,最多重试3次。
4.2 手写题:实现一个带过期时间的localStorage封装
场景题结束后,面试官说“来一道手写题吧”,给了我一个需求:封装一个localStorage的读写方法,支持设置过期时间,过期后读取返回null。
这个题目本身不算难,但他的要求是“写一个生产可用的版本”,这就意味着需要考虑异常情况,比如存储空间满了、JSON解析失败、单条数据格式损坏等。
我当时写了一个大概的版本:
const storage = { set(key, value, expireSeconds) { const data = { value, expire: expireSeconds ? Date.now() + expireSeconds * 1000 : null }; try { localStorage.setItem(key, JSON.stringify(data)); } catch (e) { // 处理存储已满的情况 console.error('存储失败', e); } }, get(key) { const raw = localStorage.getItem(key); if (!raw) return null; try { const data = JSON.parse(raw); if (data.expire && Date.now() > data.expire) { localStorage.removeItem(key); return null; } return data.value; } catch (e) { // 数据损坏直接清除 localStorage.removeItem(key); return null; } }, remove(key) { localStorage.removeItem(key); } };写完之后面试官问我,如果同一个key被设置了很多次,之前的过期时间怎么管理。其实这里有个细节:localStorage本身没有索引机制,同一个key被覆盖写,旧值就丢了,所以只需要存储最新的过期时间即可,不需要管理历史版本。
后面复盘时我想到,还可以考虑一个更完善的方向:做一个lru淘汰策略。当存储空间满了,优先清除那些即将过期的数据,或者是最久没被访问的数据。不过这个方向在面试时间限制内一般不会展开要求,只要能说出思路和实现方案就够了。
4.3 性能优化面:从首屏加载到交互流畅度
快手作为短视频和直播平台,对页面性能的要求比一般公司高很多。leader面里有一道题让我印象很深,他问我:一个资讯类的Web页面,首屏要在1秒内达到可交互状态,你会怎么做。
我按流程拆解了一下:
- 资源加载阶段,做DNS预解析、建立HTTP/2多路复用、关键资源加preload、非关键资源加defer
- 数据获取阶段,能用SSR的首屏数据就用SSR,SSR的数据注入到客户端可以减少一次请求往返;如果是纯CSR,考虑在HTML里内联首屏数据
- 渲染阶段,避免首屏出现长任务,把耗时的计算拆成多个小任务,用
requestIdleCallback处理不紧急的工作 - 图片优化,首屏的图片用CDN的二倍图或者WebP,小图标用SVG内联或者iconfont
面试官继续问:如果你的页面在低端机上滚动时有明显的卡顿,你会怎么排查。这个问题我实战经验不算多,所以回答中规中矩,说了检查是否有频繁的layouts、长列表渲染、大量使用box-shadow这类会造成重绘的样式。后来面试官给了个比较实用的排查思路:先在Performance面板录制一段滚动操作,看看每个帧的耗时分布,是脚本执行占用太高还是渲染本身占用太高,然后再针对性地优化。低端机性能弱,很多在高端机上跑得很顺畅的动画,在低端机上会卡到没法用,所以做性能优化时最好在低端机上测试一遍。
5. 算法与手写题专项:题目难度与解题策略
5.1 面试中的算法题:从简单到中等的梯度设计
快手技术面的算法题难度整体处于LeetCode简单到中等之间,不会出现特别极端的hard题,但会结合实际业务场景来做变形。我遇到的两道题都跟业务场景有绑定关系。
第一道题出现在一面,是字符串反转的变体:给定一个字符串,按单词反转,比如"hello world foo"变成"foo world hello",要求不使用split和reverse等内置方法。这道题其实考察的是双指针思路。我当时用双指针从尾部往头部扫描,遇到空格就切一个单词,拼接到结果里。需要注意的是题目的边界条件:开头和结尾可能有多个空格,单词之间也可能有多个空格,如果事先搞不清楚这些要求在面试中会反复调整代码。
第二道题出现在二面,是个数组去重加排序的变形:给定一个整数数组,返回出现频率最高的前K个元素。这道题最常见的解法是用哈希表统计频率,然后用桶排序或者小顶堆取出频率前K的元素。我当时用了小顶堆的方式,时间复杂度是O(n log k),空间复杂度是O(n)。面试官接着问如果n很大,比如上亿,内存装不下怎么办。这时候就需要考虑外部排序、分治或者近似算法,比如用Count-Min Sketch这种概率性数据结构来统计频率。
5.2 手写题:实现Promise.all与防抖函数
手写Promise.all是现在前端面试的标配,几乎每个公司都会问。快手这轮也没有例外,但面试官加了一个小限制条件:不能使用async/await,只能基于原生的Promise实现。
写Promise.all时最关键的点是要处理两种情况:一是全部成功时按传入顺序返回结果数组,二是有任意一个失败时直接reject这个错误。我当时用了一个计数器来判断是否全部完成,结果数组用索引赋值的方式保证顺序一致。还处理了传入空数组的情况,直接resolve一个空数组。
防抖函数也是高频手写题。这个函数的核心是:在事件被连续触发时,只有最后一次触发后等待指定时间才会执行。我在实现时用闭包保存定时器ID,每次触发时先清除定时器再重新设置。面试官还问到了immediate参数,也就是第一次触发时立刻执行,后面的连续触发不执行,直到停止后才重新reset。这里要特别小心this的指向问题,如果用普通函数写法,内部要用context来保存this,用箭头函数则会丢失this指向。
6. 面试总结:常见陷阱与个人经验体悟
6.1 面试过程中暴露的薄弱点与改进方向
整个面试下来,我最大的感受是:快手面试官问问题的逻辑性很强,不是零散地考知识点,而是会围绕一个核心主题层层深入。比如他在问React Diff的时候,是从“diff是什么”问到“为什么需要key”,再到“Fiber为什么能中断”,最后到这个设计对开发者有什么影响。这个追问链条,其实是在模拟一个真实的工作场景:当你在开发中遇到性能问题时,需要一层层往下定位,而不是只会说“组件加载慢加个memo”。
我自己的薄弱点在CSS布局和浏览器渲染细节上。一面的时候面试官问了一个flex布局中flex: 1的含义,我没有完整答出它其实是flex-grow: 1; flex-shrink: 1; flex-basis: 0%的缩写形式,只说了会占满剩余空间。这个点虽然在业务上够用,但在面试里会显得理解不够深入。后来我复盘时专门把CSS的常用布局、BFC、层叠上下文这些内容重新过了一遍。
6.2 给准备面试的朋友的几点实操建议
结合这次面快手的经验,我想给正在准备前端面试的同学几个建议,尤其是对标这个方向的朋友:
第一是不要只背面试题,要理解题目背后的原理。前端面试题在网上随处可见,但如果你只是把答案背下来,面试官换个角度追问就露馅了。最好的状态是能用自己的话把一个知识点顺下来,并且能举例说明。
第二是刷题要有针对性。算法题不用刷太多,但要让自己的思路保持熟练。重点复习哈希表、双指针、二叉树遍历、动态规划、数组和字符串操作这几种类型,概率最大。
第三是面试前把简历里的项目彻底复盘一遍。想想你做的每个功能,技术方案是怎么选的,有没有其他方案,为什么没用,上线后效果如何。这些内容是面试官追问的主要来源,也是你展现自己技术深度的最好机会。
第四是提前准备反问环节的问题。面试官最后一般都会问“你有什么想问我的”,这时候别问那种明显能从官网找到答案的问题,也别直接说“没有”。可以问团队的主要技术栈是什么,当前团队在做的核心项目是什么,或者对新人来说最重要的能力是什么。这既能显示出你对这个机会是认真的,也能帮你了解自己进去之后要面对的是什么。
6.3 我自己踩过的坑与一些小经验
最后再分享几个我亲身踩过的坑,这些细节如果没人提醒,真的很容易吃亏。
第一个坑是面试的时候不要为了展示自己懂得多而主动把话题带偏。比如面试官在问闭包,你觉得这个话题很简单,就开始扯垃圾回收机制、V8引擎的优化策略,结果面试官顺势追问,发现你的垃圾回收理解也就停留在表面,反而暴露了短板。面试是展示自己最强的地方,而不是把所有会的都倒出来,更不是在自己最弱的地方硬展开。
第二个坑是手写题的时候一定要先想清楚再动笔,不要边写边改。我二面的时候写Promise.all,一开始没想清楚空数组的处理逻辑,写了十几行发现有问题又回头改,整个过程看起来就很不专业。后来面试官跟我说,其实他不在乎你的代码写得多么完美,更在意的是你遇到问题时的处理方式。是慌慌张张乱改,还是先想清楚再动手,这两者的差距是很明显的。
第三个坑跟情绪有关,不要被上一轮的失利影响下一轮的心态。快手的面试节奏比较快,可能你刚觉得一面某个问题没答好,第二天就开始二面了。但面试官之间是独立评价的,一面没答好的问题,二面可能会从另一个角度重新验证你的能力。所以每一轮都把它当成全新的开始,不要背负上一轮的心理包袱。
我在实际面试过程中发现一个规律:真正让你通过面试的,往往不是你答对了多少道题,而是你面对不熟悉的问题时,能不能沉住气,按照“分析问题 -> 拆解问题 -> 提出思路”的方式来处理。如果你能做到这一点,哪怕最后方案不完美,面试官也会觉得你是个有潜力的人。这一点在快手的面试中表现得特别明显,也是我认为整个面试过程中最值得借鉴的地方。