☰
前端终面攻略:算法与业务场景真题解析
2026/10/13 23:22:51 网站建设 项目流程

这个系列写到第六篇,意味着你离真正拿到前端岗offer只差最后那一哆嗦。我们前面聊过简历、聊过一轮二轮的基础题、聊过框架原理,但终面跟前面完全不是一个物种:算法 + 业务场景,这两个词一出来,很多候选人的信心就开始打折。

先给还没走到这一步的朋友说说终面到底长什么样。某大厂前端方向的终面,通常由前端负责人或资深技术专家来面,时间控制在 60 到 90 分钟,其中一定会有 20 到 30 分钟的算法题,剩下时间全部围绕着“你做过什么、你怎么设计、你怎么排查问题”来展开。你以为终面会考什么“输出一个红黑树”或者“默写Vue源码”?不,终面更关心的是你有没有处理复杂业务的系统能力。算法题是手段,业务场景题才是终面真正的杀招。

这篇就是为终面准备的最后一课,算法部分我拆了两道高频题型,业务场景部分挑了三套最常考的场景框架,最后再补一段关于项目深挖的实战表达逻辑。全文没有废话,都是可以直接拿去做自测和复盘的东西。

1. 终面到底在考什么:算法 + 业务场景的考核逻辑

很多人会有一个误区,觉得终面既然是“面评最高层级”,那就应该考最难的算法。实际上,终面算法题很少出那种“你没见过就做不出来”的偏题怪题,它考的是你在压力环境下处理问题的几个底层能力:编码基本功、边界意识、复杂度分析、以及把代码和业务场景连接起来的能力。

举个例子。我模拟面试过一个候选人A,算法题是“手写一个带并发限制的异步调度器”,他花了五分钟写完了核心代码,功能没问题,但是直接交了。面试官追问了一句“这个调度器的并发数设为 1 和设为 正无穷 时,分别会产生什么效果?”他愣了一下,答不上来。这不叫代码写完了,这叫只做了一半。算法题本身只是载体,面试官真正想看到的是你对这个载体背后机制的深刻理解。

业务场景题则是另一个维度。终面没有哪个面试官指望你现场生产一个完整系统,他看的是你的拆解流程:拿到一个模糊需求,你是不是能先划出功能边界、列出技术选型的考虑要素、说出关键设计的取舍原因、最后落到数据或性能指标上。这套流程,说穿了就三步:

步骤你要做到什么常见错误
明确问题复述需求,问清非功能指标(性能、可用性、扩展性)没听懂就开干,方案写偏
拆分模块把大功能拆成独立的小模块,每个模块有关键指标只谈大方向,不谈模块细节
收敛方案明确选型原因、方案权衡、失败兜底只给一个答案,不讨论场景适应边界

注意,业务场景题不追求唯一标准答案。你给出的方案哪怕不是最优,只要每一层取舍逻辑说得通,面试官就不会否定你。反而最忌讳的是“万金油答案”:一说上传就喊大文件分片,一说性能就喊web worker,却说不清分片大小怎么定、worker 线程边界在哪里。终面要的不是口号,是思考纵深。

2. 终面算法真题拆解:两道高频题型与通用解法

终面算法题有个明显特征:题目短、逻辑简单、但边界和扩展问题多。两道题我强烈建议你考前一定亲手写一遍,不是背代码,而是把每行代码的“为什么”都讲清楚。

2.1 高频题一:带并发限制的异步调度器

题目一般长这样:

// 实现一个 Scheduler 类,保证并发执行的任务不超过 limit 个 // 任务是一个返回 Promise 的函数 const scheduler = new Scheduler(2); scheduler.addTask(() => Promise.resolve(1));

说实话这题并不算难,但它是压测候选人异步编程功底的利器。先看标准实现:

class Scheduler { constructor(limit) { this.limit = limit; this.running = 0; this.queue = []; } addTask(task) { return new Promise((resolve, reject) => { const runTask = () => { this.running++; task().then(resolve, reject).finally(() => { this.running--; this.runNext(); }); }; this.queue.push(runTask); this.runNext(); }); } runNext() { while (this.running < this.limit && this.queue.length) { const task = this.queue.shift(); task(); } } }

这里面的关键点有三个。

第一,addTask返回了一个新的 Promise,目的就是让外部调用方可以拿到任务完成的结果。这个设计在做接口封装时非常重要——你对外部暴露的 API 必须保留对结果的引用,否则调用方没法 await。

第二,为什么要用while而不是if?因为当一个任务结束时,队列里可能积压了好几个任务,需要一次性把所有能启动的任务都拉起来,直到达到并发上限。用if的话,队列里始终只会补一个任务,并发数会越来越不准。这也是面试官最爱追问的细节。

第三,finally的用法。任务结束有两种可能:resolve 或者 reject,无论哪种都要释放当前并发占用的名额。finally完美地把 release 逻辑收敛到一个位置,不会出现“resolve 时忘了释放”的遗漏。

面试官一定会追问的扩展点,目前我见过最多的是这三个:

  • 并发数设为 1,就成了一个串行任务队列,等价于async/await依次执行,可用在“登录后必须顺序执行初始化任务”的场景。
  • 并发数设为正无穷,等价于Promise.all,所有任务会在下一轮微任务时同时执行,失去了流控能力。
  • 如果某个任务 reject,外部如何感知?标准实现里每个addTask返回的 Promise 会单独 reject,不会影响其他任务继续执行,这一点设计和Promise.allSettled的并行容错思路一致。

还有一个更进阶的变体,是“支持动态并发数调整”或“支持任务优先级”,这属于加分项。如果面试时间充裕,你可以主动提一句“这里如果加一个优先级队列,就可以让紧急任务插队”,这比闷头写死代码更容易让面试官记住你。

2.2 高频题二:手写 LRU 缓存机制

LRU(Least Recently Used,最近最少使用)是前端终面算法题的常青树。它不考复杂算法,但特别考编码细节和数据结构选型能力。

class LRUCache { constructor(capacity) { this.capacity = capacity; this.cache = new Map(); } get(key) { if (!this.cache.has(key)) return -1; const value = this.cache.get(key); this.cache.delete(key); this.cache.set(key, value); return value; } put(key, value) { if (this.cache.has(key)) { this.cache.delete(key); } this.cache.set(key, value); if (this.cache.size > this.capacity) { const oldestKey = this.cache.keys().next().value; this.cache.delete(oldestKey); } } }

为什么用 Map 而不是对象或者数组?这是全题最关键的问题。数组实现 lookup 是 O(n),删除元素还要 splice,效率太差;普通对象虽然 lookup 是 O(1),但键值顺序不支持“访问后重新置后”。Map 完美兼具“插入有序”和“O(1) 快速查找”两个特性,键的遍历顺序就是插入顺序,这正好满足 LRU 的淘汰逻辑。**你每次 get 或者 put 时,把访问的 key 先 delete 再重新 set,它就会跑到 Map 的末尾,那么 Map 开头的 key 自然就是最久没有访问的。**这个思路决定了整个实现只有十几行代码。

再看边界条件。构造函数传入的 capacity 可能是 0 或负数,代码中完全没有对 capacity 做防御,但真的来了一个负数容量,size > capacity恒成立,任何一次 put 都会把刚插入的 key 删掉,缓存变成一个废容器。合理做法是在构造函数里加一行:

if (!(capacity > 0)) throw new Error('capacity must be positive');

面试官如果问你“这个限制要不要写”,你不仅要答“要”,还要说明“在真实项目中,缓存容量参数通常来自配置中心,异常值应该尽早暴露而不是静默失效”。这就是业务视角。

LRU 在业务里的出处也值得背下来:浏览器 HTTP 缓存淘汰策略、本地存储的 LRU 封装(当 localStorage 用满时优先删除最久不用的 key)、图片懒加载组件的预加载列表等等。面试官问“LRU 除了刷题还有什么用”,你要能一气呵成说出两三个真实场景。

3. 终面业务场景真题拆解:三套高分思考框架

算法只是终面的下半场,上半场开场往往是这样的问题:“说说你们项目中一个复杂功能的设计过程”。如果是大厂终面,90% 的概率会落到这几类场景:上传、长列表、监控。这三类场景基本覆盖了前端开发中“难在数据、难在交互、难在稳定性”的三个维度。

3.1 场景一:大文件分片上传与断点续传

面试官要是问“上传一个 2GB 的文件,怎么设计”,千万别上来就写代码。这个题考察的第一层是你能不能把需求拆清楚,第二层才是技术实现。

拆解下来,一个完整的上传方案至少有四个模块:

  • 分片:把大文件切分为大小一致的分片,每片独立上传
  • 并发控制:多个分片同时上传,但并发数要有上限
  • 断点续传:刷新页面或断网后,已上传的分片不重复上传
  • 秒传:通过文件指纹检测服务器是否已存在相同文件,存在则直接返回成功

分片大小的选择,是第一个必须说清楚的点。太小的分片(比如 64KB)会导致 HTTP 请求数量过多,建立连接的开销会超过传输本身;太大的分片(比如 100MB)又会浪费带宽,出错后重试成本高。通常电商网盘类的实现取 1MB ~ 5MB 之间。我见过一个成熟方案:默认 2MB 一片,但当文件小于 32MB 时不分片,直接整传,这样兼顾了大多数中小文件场景。

实现层面的伪代码长这样:

// 分片 const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB const chunks = []; for (let start = 0; start < file.size; start += CHUNK_SIZE) { chunks.push(file.slice(start, start + CHUNK_SIZE)); } // 并发控制:利用并发调度器限制同时上传的数量 const uploader = new Scheduler(3); const tasks = chunks.map((chunk, index) => uploader.addTask(() => uploadChunk(fileName, index, chunk)) ); await Promise.allSettled(tasks); // 断点续传:上传前先询问已上传分片 const uploaded = await fetch(`/api/uploaded-chunks?name=${fileName}`).then(res => res.json()); const pendingTasks = chunks .filter((_, index) => !uploaded.includes(index)) .map((chunk, index) => uploader.addTask(() => uploadChunk(fileName, index, chunk)));

断点续传的关键点是“已上传分片”的判定。前端最朴素的做法是维护一个 Set,记录已经成功上传的分片序号,上传前先从后端拉取一次已上传列表,然后只补传缺失的部分。但这里有个陷阱:分片上传成功不等于整体文件可用。后端还得维护一个“分片元信息”存储,在所有分片都到齐之后,才触发合并通知。合并阶段还要校验每个分片的 MD5 值,防止某个分片在传输中损坏。

面试追问怎么答:

  • 进度怎么算?已上传字节数 = 已成功分片数 × 分片大小,整体进度百分比用“已上传 + 正在上传的完成比例的估算值”来计算。
  • 取消上传怎么办?通知后端废弃该文件的分片记录,前端中止所有未完成请求。
  • 如果不同设备的同一文件怎么秒传?用文件内容抽样 + MD5 计算指纹,在文件开头和中间各取几段字节算出特征码。完整计算整个文件的 MD5 在浏览器端对于大文件性能压力很大,抽样算法是工程上更常用的折中。

3.2 场景二:长列表虚拟滚动

“有一个接口会返回一万条数据,怎么展示?”这道题简直是终面业务题的祖师爷,几乎每家都会问,但绝大多数人只答得出“分页加载”。分页加载确实是方案之一,但面试官下一句话就会堵死你:“产品要求保留用户已经浏览过的列表位置,不允许滚动到底部就触发分页刷新?”这时你就要拿出虚拟滚动。

核心原理不复杂:不要渲染一万个 DOM 节点,只渲染可视区内能看到的那几十个节点,滚动时通过计算偏移量来动态更新可见区域的内容。

假设列表项高度固定为 50px,可视区高度为 800px,那么可视区内最多同时显示 16 个列表项。你需要做的只是:

const containerHeight = 800; // 可视区高度 const itemHeight = 50; // 每项固定高度 const bufferCount = 5; // 上下缓冲数量,避免快速滚动时白屏 const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferCount); const endIndex = Math.min( totalCount, Math.ceil((scrollTop + containerHeight) / itemHeight) + bufferCount ); // 渲染的数据就是 data.slice(startIndex, endIndex) // 同时用 padding 撑起整体高度,保持滚动条准确

为什么要加 bufferCount?因为虚拟列表基于 scrollTop 计算渲染范围,滚动特别快时,新渲染的内容还没走到主线程,就可能出现一帧白屏。加 5 到 10 个缓冲项,滚动体验会平滑很多。这个细节很能体现你“真的做过而不是只背了概念”。

固定高度是虚拟列表最简单的情况。面试官必然会追问:“如果每条数据的高度不固定怎么办?”这时要用估算高度 + 动态校正:

  1. 预设一个estimatedItemHeight,比如 80px,算出总高度。
  2. 每个列表项渲染完成后,通过getBoundingClientRect()拿到真实高度,记录在positions数组中。
  3. 每次计算可见项时,用二分查找定位 scrollTop 对应的项索引,而不是直接用除法。

到这里基本可以展开一段比较深的技术讨论,说明你对渲染性能优化是有系统认知的。

还要记得虚拟滚动不是万能的。如果列表项内部有图片、视频、复杂交互组件,虚拟滚动会导致频繁的创建和销毁,反而更卡。这时候要考虑“分页加载 + 列表项懒渲染”的组合方案。面试官问“什么时候不适合虚拟滚动”,你能主动说出这个边界,才是真正的加分点。

3.3 场景三:前端监控体系设计

“项目上线后报错了,你怎么第一时间知道?”这道题考察的是你对生产环境稳定性保障的理解,以及从前端视角给出的完整闭环方案。

一个可落地的监控体系至少包括四层:

  • 采集层:捕获 JS 运行时错误、未处理的 Promise 异常、资源加载失败
  • 传输层:把错误信息上报到服务端,同时考虑请求量和失败率
  • 聚合层:服务端按错误消息 + 堆栈指纹聚合同一类错误,计算影响用户数
  • 告警层:设定阈值,触发通知到 IM 群

采集层的前端代码,很多人的认知停留在window.onerror,但实际生产环境还应该加上这些:

// JS 运行时错误 window.addEventListener('error', (event) => { report({ type: 'js', message: event.message, stack: event.error && event.error.stack, source: event.filename, line: event.lineno, col: event.colno }); }); // 未捕获的 Promise 异常 window.addEventListener('unhandledrejection', (event) => { report({ type: 'promise', reason: event.reason && event.reason.stack || event.reason }); }); // 资源加载错误(注意:需要在捕获阶段监听) window.addEventListener('error', (event) => { const target = event.target; if (target && target.tagName) { report({ type: 'resource', tag: target.tagName, src: target.src || target.href }); } }, true);

三个细节一定要记住。第一,资源加载错误必须用捕获阶段,因为资源错误不会冒泡;第二,如果是script跨域加载的错误,event.message只会显示 “Script error”,要拿到详细堆栈必须给 script 标签加crossorigin="anonymous"属性,同时服务端响应头要带上Access-Control-Allow-Origin;第三,函数形式的window.onerror和addEventListener同时使用时,推荐后者,因为可以注册多个错误处理器,避免覆盖其他监控逻辑。

白屏检测是进阶问题。“页面没有报错,但是用户看到的是白屏,怎么监控?”面试官问这个就是看你有没有处理过真实线上事故。面试时最快速的开放思路是:在页面根节点下选择几个关键子节点,比如#app下的头部区域、主体区域、底部区域,采样它们的 DOM 元素是否存在且有一定高度,如果核心节点为空,结合一份 URL 列表判断是正常空页面还是异常白屏。

传输层最容易被忽视。如果每个错误都发一个 HTTP 请求,崩溃瞬间可能同时产生几百个请求,反而把页面的最后一点性能拖垮。业界常用sendBeacon或Image打点来上报,sendBeacon的优点是页面卸载时也能保证数据送达,不阻塞主线程。批量上报则是在前端先攒 10 条或 5 秒内的错误,一次性打包发送。

function batchReport(events) { if (navigator.sendBeacon) { navigator.sendBeacon('/api/log', new Blob([JSON.stringify(events)], { type: 'application/json' })); } else { // 降级方案:使用 Image 打点,拼接参数 const image = new Image(); image.src = `/api/log?data=${encodeURIComponent(JSON.stringify(events))}`; } }

面试官必然还会追问“上报数据的去重怎么做”。相同错误在同一时间窗口内可能出现几十次,不做去重会让告警群被刷爆。简单方案是把错误信息中的 message + stack 做 hash,在服务端按 “hash + 小时维度” 做聚合,只有首次出现才触发告警。这个问题能答到这一层,基本就过关了。

4. 终面项目深挖与表达逻辑:怎么把过程讲成亮点

终面最常出现的一句话是:“你挑一个你觉得最有成就感的项目,讲讲前后端的设计过程。”很多人项目确实做了,但讲出来平淡如水。这不是表达能力问题,是结构化缺失。

一个有效可复用的表达框架是:背景目标 → 方案设计 → 落地过程 → 数据结果 → 复盘反思。记住,不要从 “这个项目用了 Vue 3 和 TypeScript” 这种技术栈罗列开始,面试官关心的是为什么用这些技术,以及你怎么一步步做出决策。

举个例子。如果项目是做数据可视化大屏,好的讲法是这样的:

当时业务方给的目标是大屏首屏展示时间控制在 3 秒以内。我们第一步先分析性能瓶颈,发现主要耗时在图表库初始化和接口数据量过大。于是把数据分成“首屏核心指标”和“次屏趋势明细”两个接口,首屏只用 3 个核心指标渲染,趋势数据延迟加载。图表初始化方面,把非首屏的图表组件改成异步加载,同时用 canvas 替换部分 DOM 型图表。最终首屏时间从 4.8 秒降到 2.1 秒。

这一段的每一个信息都是“原因 + 动作 + 结果”,没有一句废话。对照一下反面教材:

我们项目用了很多技术,有 mock 数据、有图表、有接口联调,我负责前端部分,主要写了页面。

这种说法信息量几乎为零,面试官根本无从判断你的能力边界。

还有一个高频追问是“你在这个项目中遇到最大的困难是什么”。回答这个问题有一个铁律:不要只说困难,一定要说你怎么定位问题、怎么排除原因、最后怎么解决。我建议你准备两个真实的小故事,比如一个线上 bug 的排查过程,或一个性能优化的完整链路,越具体越好。

常见模板如下:

  1. 现象描述:用户反馈某个操作偶发卡顿,但无法稳定复现。
  2. 排查过程:用 Performance 面板录制、采集用户设备信息、比对不同机型表现,最终定位到某版本浏览器的一个渲染层合成问题。
  3. 解决方案:通过强制will-change开启独立合成层,或者替换为该机型兼容性更好的动画实现方式。
  4. 收益总结:问题复现率降为 0,后续同类问题形成文档沉淀。

注意,项目深挖的核心是你必须对自己讲出的每个细节负责。你随口说“我们用 web worker 优化了大计算任务”,面试官就会追问 worker 通信的数据结构怎么设计,线程创建的开销如何权衡。如果答不上来,宁可不要为了显得厉害而硬编造,因为终面官的追问能力不是背题能扛住的。

5. 终面常见误区与避坑清单:这些坑我踩过

终面翻车往往不是技术深度不够,而是出在一些细节上。我做模拟面试这段时间,反复见到以下五类问题,挨个说清楚该怎么避开。

误区一:算法题不沟通需求,上来就写代码。哪怕题目看起来再简单,也先花 10 秒跟面试官确认输入输出范围。比如 LRU 缓存,你需要确认 capacity 能否为 0,get 不存在时返回什么。这不叫怯场,这是专业表现。面试官最怕的是候选人闷头写完一个自己想当然了半天的答案,跟真正的要求南辕北辙。

误区二:代码写完不主动自测。写完代码之后,自己举一个边界例子跑一遍逻辑,比如并发调度器的 limit=1 场景、LRU capacity=2 场景,主动说出预期输出是什么。这比面试官提问再回答要加分得多,因为自测体现了工程师的闭环思维。

误区三:业务场景题只给方案,不讲权衡。提到大文件上传就说分片,却不解释分片大小怎么定;提到监控就说上报错误,却不说怎么去重。每个方案的最佳实践都伴随“为什么”和“什么时候不一定适用”。记住一句话:面试官不听方案,听方案背后的人是怎么思考和取舍的。

误区四:对项目中的数据不敏感。“优化后渲染速度提升了很多”和“优化后首屏时间从 4.8 秒降到 2.1 秒,FCP 提升 56%”完全不是一个量级的信息。日常工作中要养成记录数据变化的习惯,哪怕是自己做的小 Demo,也建议用 console 或 Performance API 量一下耗时。

误区五:追问环节只问薪资和福利。终面最后一定会留时间让你反问。这是你展示技术热情和团队融入意愿的机会。建议准备两三个技术向问题,比如“当前团队前端最大的技术挑战是什么”“线上业务最常遇到的稳定性问题集中在哪一类”“新项目的技术选型是如何推进的”。这比直接问“加班多不多”更能留下正面印象。

下面还有一个速查表,可以把五类误区直接用起来:

误区正确做法一句话自检
不沟通需求直接写代码用 10 秒确认输入输出边界我了解清楚了吗
写完不验证就交自己抛 2 个边界用例走一遍边界场景会出问题吗
只讲方案不讲权衡每条方案附带适用条件和局限我为什么这么选
对项目数据不敏感记录优化前后量化指标数据对比在哪里
反问环节只问福利准备技术向问题展现投入度我问的问题有信息量吗

再送你一个独家小技巧。在终面回答业务场景题时,可以主动在白板上写一个“功能模块拆分”的草稿,哪怕只是一个简单的竖线分层图。不要小看这个动作,它会让面试官直观看到你的思考结构,同时也给了自己一个锚点,不至于回答过程中被细节带跑偏。

6. 一点个人体会

我每次跟候选人做终面模拟,最深的感受是:终面考的不只是你会不会做题,而是你有没有形成一套“见招拆招”的应对框架。算法题有套路,业务场景题有框架,项目深挖有表达结构,就连反问环节也能提前准备。这跟写业务代码一样,不是靠临场发挥,而是靠平时刻意练习积累出来的神经记忆。

考前那几天,我建议你用“口头复盘”的方式来练题:拿到一道算法题,先口头说一遍解题思路,再说一遍复杂度,再动手写代码,写完后自己讲一遍边界验证过程。业务场景题则对着空气把“需求拆解 → 模块拆分 → 关键设计 → 数据验证”讲三遍。你会发现,真正面试时思路会清晰得多,因为大脑已经跑过这条路径了。

这个系列写到这里,框架性的内容已经足够你去应对终面的大部分场景了。把上面这几道题亲手写一遍,把三个业务场景的框架吃透,再去整理一个属于你自己项目的深挖故事,终面通过率绝对能往上提一大截。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询