卷 A · 前端工程 · 高频面试题

通用软件工程师 / FDE 高频面试题库 · 主线卷 A 题源:牛客网公开面经(notes/corpus.jsonl,85 份独立面试经历帖) 题目统计:普通问答 46 · 手撕 8 · N 备选 0 · 合计 54 个条目。

难度分布(全卷,含手撕):L1 24 · L2 30 · L3 0

频次口径:🔥×N = N = 该题在独立面经帖中的命中数(同义题合并后计),检索键写在每题正文的 <!-- src: ... --> 里,由 tools/fill_freq.py 机械回填、CI 用 --check 校验。低频实录·1源 = 只有 1 份独立面经支撑;补充 = 非面经的整理题,不伪造频次。

使用方式:题目默认折叠,点开看答案。建议按 L1 → L2 → L3 顺序刷;同级内按频次从高到低。手机端原生支持。项目类 / 场景类问题的背景已用自然语言写进题干;「追问」列出常见后续考点。

与其他卷的关系:本卷考的是浏览器 + 语言 + 框架 + 工程化这条前端主线的能力,与卷 B(后端)、卷 C(操作系统)正交。适合岗位:前端开发 / 全栈 / FDE(前端交付面)。


01 JavaScript 语言与运行时(10 题)

L1 🔥×7 Q01 · Promise / async-await 的执行顺序是怎样的?宏任务和微任务怎么区分?

答:事件循环每轮取一个宏任务执行,然后清空整个微任务队列再进下一轮。

  • 宏任务:setTimeout、setInterval、I/O、UI 事件、整个 script。
  • 微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver。

关键:async 函数体是同步执行的,只有 await 之后的部分作为微任务续体。典型输出顺序:同步代码 → async 函数体 → 微任务 → 定时器。

易错:await 之后的代码排进微任务队列,不是等下一个宏任务;then 里再 then 会一直排在本轮微任务队列尾部。

追问:① queueMicrotask 和 setTimeout(0) 的最小延迟差?② Node.js 的事件循环和浏览器有几处不同(process.nextTick 优先级)?③ 微任务里再抛异常会怎样?

L1 🔥×7 Q02 · 闭包是什么?实际开发中用闭包做什么?

答:闭包 = 函数 + 其创建时所处的词法环境。内部函数引用了外层函数的变量,且外层函数返回后该变量仍被保留,就构成闭包。

用途:

  1. 私有变量:模块模式(IIFE + 私有变量),外部只能通过暴露的方法访问。
  2. 函数工厂 / 柯里化:makeCounter() 返回带计数的函数。
  3. 事件监听 / 循环里的 var:用 IIFE 或 let 捕获每次迭代的绑定。
  4. React Hook 的闭包陷阱:useEffect 里的 state 是那一次渲染的快照。

易错:闭包导致内存泄漏——闭包持有大对象引用,事件监听不解绑则对象无法回收;setInterval 里读到的变量是定义时的值而非最新值。

追问:① 为什么 for (var i...) 打印的是最后一个值,let 呢?② React 里怎么避免闭包读到旧 state(useRef / 函数式更新)?③ 闭包和垃圾回收的关系?

L2 🔥×7 Q03 · 原型链和继承怎么实现?`class` 与构造函数写法有什么区别?

答:每个对象有内部 [[Prototype]] 指针指向它的原型对象;属性查找沿原型链逐级上溯,直到 null。

  • 构造函数 + 原型:Foo.prototype.bar = fn 让所有实例共享方法(省内存)。
  • 继承:Child.prototype = Object.create(Parent.prototype) 再 Child.prototype.constructor = Child。
  • class 是语法糖,底层仍是原型,但额外提供:class 必须 new 调用、方法不可枚举、内部严格模式、派生类构造函数必须先 super()。

ES6 类 vs 构造函数:① class 声明有 TDZ(不能提升使用);② class 方法不可枚举;③ 类体内部是严格模式。

易错:忘记 Child.prototype.constructor = Child;把 class 当成"Java 式类"(JS 里函数才是基本单位,类是函数的形态);箭头函数没有 prototype、不能 new。

追问:① Object.create(null) 和 {} 的区别?② instanceof 的实现原理?③ ES6 的 Proxy 能做什么、原型链做不到什么?

L1 🔥×2 Q04 · `this` 的指向规则是什么?箭头函数的 `this` 有什么特殊?

答:普通函数的 this 由调用方式决定(与定义位置无关):

  1. 默认(严格模式下)→ undefined;非严格 → 全局对象。
  2. obj.fn() → obj。
  3. fn.call/apply/bind(x) → x。
  4. new Fn() → 新建的实例对象,且 this 指向它。
  5. 箭头函数没有自己的 this,它捕获定义时的外层 this(词法作用域),且不能被 call/apply/bind 改写。

易错:把方法直接传给回调(setTimeout(obj.fn, 0))会丢 this,因为变成默认调用;箭头函数里不能用 arguments,也没有 prototype 属性。

追问:① 箭头函数适合当类的方法吗(为什么不适合)?② bind 一次和每次 bind 的性能差异?③ with 严格模式下为什么禁用?

L2 🔥×5 Q05 · `==` 和 `===` 的区别?隐式类型转换的规则是什么?

答:=== 不做类型转换,类型不同直接 false;== 会先转成同一类型再比较。

抽象相等(==)的转换顺序:① null 与 undefined 互相相等,且只与彼此相等(与 0、false、'' 比较一律 false,不做数值转换);② 一方是对象则 ToPrimitive 转原始值(先 valueOf 再 toString);③ 两边都是字符串则按字符串比,否则都转数字再比。

易错:0 == ''、0 == false、null == undefined 都是 true,但 null == 0 是 false;NaN 与任何值(包括自己)都不相等。

追问:① [] == ![] 的结果与原因?② Object.is 和 === 的两点差异?③ 为什么 lint 规则禁止 ==?

L2 补充 Q06 · JavaScript 的类型有哪些?typeof 和 instanceof 的坑在哪?

答:基本类型 7 个:string、number、boolean、null、undefined、symbol、bigint;其余是 object(含数组、函数、Date、RegExp)。

typeof 的坑:typeof null === 'object'(历史 bug);typeof class {} 也是 'function';未声明变量返回 'undefined'。

instanceof 的坑:依赖原型链,跨 iframe / 跨 realm 失效(不同窗口的 Array 不是同一个);可被 Symbol.hasInstance 改写。

判断数组的正确方式:Array.isArray(x)。

易错:用 typeof x === 'object' 既想匹配 null 又想匹配数组;用 instanceof 判断跨 realm 的数组。

追问:① BigInt 和 Number 混算会怎样?② Symbol 的典型用途?③ Object.prototype.toString.call(x) 能区分哪些类型?

L2 🔥×9 Q07 · 深拷贝怎么实现?`JSON.parse(JSON.stringify())` 有什么问题?

答:structuredClone(浏览器原生)最省事,但不能克隆函数、DOM 节点、Symbol 键,且不支持原型链保留。

手写版要点:递归 + WeakMap 记录已克隆对象(解决循环引用);按类型分支处理 Date(getTime)、RegExp(source/flags)、Map/Set、ArrayBuffer。

JSON.parse(JSON.stringify()) 的问题:

  1. 丢 undefined、函数、Symbol;Date 变字符串;Map/Set 变 {}。
  2. BigInt 直接抛 TypeError。
  3. 循环引用抛错。
  4. toJSON 方法会被调用,语义可能变。

易错:浅拷贝用 {...obj} / Object.assign —— 嵌套对象仍共享引用;slice() 只对数组做浅拷贝。

追问:① Object.assign 与展开运算符在 getter 上的差别?② 怎么保留原型?③ 深拷贝的性能瓶颈在哪?

L2 🔥×3 Q08 · TypeScript 相比 JavaScript 解决了什么问题?`interface` 和 `type` 怎么选?

答:TS = JS + 静态类型 + 编译期检查 + 类型擦除(编译产物是纯 JS,运行时无类型信息)。

收益:编辑器智能提示、重构安全、接口契约显式化、提前发现跨模块不一致。

interface vs type:

  • interface:声明式、可声明合并、可 extends、报错信息更好。
  • type:表达式,支持联合(|)、交叉(&)、映射类型(in keyof T)、条件类型;继承需用交叉类型。

易错:以为 type 不能继承(可用交叉);以为 TS 类型会在运行时校验(不会,需 zod / class-validator 之类的运行时校验);any 会传染整条类型链。

追问:① 泛型 T extends 的约束写法?② unknown 比 any 好在哪?③ 怎么给第三方库补类型(@types / declare module)?

L1 低频实录·1源 Q09 · ES Module 和 CommonJS 有什么区别?循环依赖怎么处理?

答:

CommonJSES Module
语法require / module.exportsimport / export
加载同步、运行时静态、编译期(可 tree-shaking)
绑定导出值拷贝实时绑定(live binding)
顶层 thismodule.exportsundefined
严格模式否是

循环依赖:CJS 靠 require 缓存拿到的是部分初始化的对象(可能是 {});ESM 因为有 hoisting 和 live binding,函数声明可正常引用,但变量在初始化前访问会抛 ReferenceError(TDZ)。

易错:以为 ESM 的 import 是"编译时执行"——导入的代码仍然会执行,只是解析在编译期完成。

追问:① Vite 的 dev 用原生 ESM、prod 用 Rollup 打包,差异在哪?② 动态 import() 返回什么?③ exports 和 module.exports 混用会怎样?

L2 🔥×2 Q10 · 数组的常用方法有哪些?哪些会修改原数组?

答:

不改原数组:map(同长新数组)、filter(子集)、slice、concat、flat、flatMap、includes、indexOf、at。 修改原数组:push/pop/shift/unshift、splice、sort、reverse、fill、copyWithin。 原地但不改引用:sort / reverse 返回自身。

易错:sort 默认按字符串比较([10,9].sort() → [10,9]),数字排序要传比较函数;map/filter 回调里的 thisArg 容易漏;splice 会改原数组且返回被删元素(与 slice 混淆)。

追问:① flat 的默认深度?② 大数组用 map 好还是循环?③ reduce 的初始值为什么必须传?


02 浏览器与渲染(8 题)

L1 🔥×2 Q11 · 在浏览器输入 URL 后发生了什么?

答:分导航与渲染两条线:

  1. 解析 URL → 协议 / 域名 / 端口 / 路径 / 查询串。
  2. 查缓存:内存 → Service Worker → 磁盘;强缓存命中直接用。
  3. DNS 解析:hosts → 浏览器缓存 → 系统缓存 → DNS 服务器递归查询,得 IP。
  4. 建连:HTTPS 还要 TLS 握手(TCP 三次 + 证书校验 + 密钥协商)。
  5. 发 HTTP 请求,服务端返回响应(可能经 CDN / 反向代理)。
  6. 解析响应:状态码、重定向、Content-Type、缓存头。
  7. 下载解析 HTML:CSS 阻塞渲染,无 async/defer 的 JS 也阻塞。
  8. DOM 树 + CSSOM 树 → 渲染树 → 布局 → 绘制 → 合成。
  9. 请求子资源,可能触发重排重绘。

易错:只答"输入 URL → DNS → TCP → GET → 渲染"就完事——漏掉渲染流水线五个阶段(面试官追问点);把 DNS 说成一次往返(实际有递归 + 缓存)。

追问:① 关键渲染路径是什么?② 什么操作会触发重排而不是重绘?③ defer / async / type="module" 的区别?

L1 🔥×4 Q12 · 重排(reflow)和重绘(repaint)有什么区别?怎样做性能优化?

答:

  • 重排(layout):几何尺寸或位置变化 → 重算布局树,代价最高。
  • 重绘(paint):颜色、背景、阴影变化 → 重填像素,不动布局。
  • 合成(composite):只动 transform / opacity → 只在合成层处理,不触发重排重绘,最便宜。

触发重排:改 width/height/margin/padding;读取布局属性(offsetWidth、getBoundingClientRect())强制同步布局;DOM 增删。

优化:① 动画只改 transform / opacity;② 批量读写(先读完再写,避免 layout thrashing);③ will-change 提前提升合成层;④ 长列表虚拟滚动。

易错:will-change 越多越好是错的——合成层占显存,滥用更慢;rAF 回调里读写布局同样强制同步。

追问:① 合成层过多的代价?② 怎么用 DevTools 定位重排?③ content-visibility: auto 解决什么问题?

L1 🔥×4 Q13 · 强缓存和协商缓存有什么区别?对应哪些响应头?

答:

强缓存(不发请求,直接用本地副本):

  • Cache-Control: max-age=31536000(秒)、no-cache(仍缓存但每次校验)、no-store(不缓存)、private / public。
  • 旧版 Expires(绝对时间,优先级低于 Cache-Control)。

协商缓存(发请求,服务端用 304 Not Modified 告诉浏览器"你本地那份还能用"):

  • ETag + If-None-Match(内容哈希 / 版本号)。
  • Last-Modified + If-Modified-Since(最后修改时间,秒级精度)。

关键区别:强缓存完全不请求,协商缓存要请求但可能返回 304(省 body 不省 RTT)。

易错:no-cache 不是"不缓存"——它是"缓存但每次必须校验";真正不缓存是 no-store;ETag 精度高于 Last-Modified(后者同一秒内改动检测不到),所以两者可共存且 ETag 优先。

追问:① 强制刷新(Ctrl+F5)绕过了什么?② 哈希文件名资源可以设多长缓存?③ Vary 头的作用?

L1 🔥×7 Q14 · 跨域是什么?有哪些解决方案?CORS 预检什么时候发生?

答:同源策略限制脚本读取跨源资源。同源 = 协议 + 域名 + 端口三者全同。

方案:

  1. CORS(服务端加响应头)——主流方案。

    • 简单请求:Access-Control-Allow-Origin。
    • 预检(OPTIONS):非简单请求(Content-Type 为 application/json、带自定义头、PUT/DELETE 等)先发 OPTIONS 探路,问服务端"我能不能发这个请求",通过后才发真实请求。
  2. 反向代理 / Nginx 代理——把跨域请求变成同源请求(开发环境 vite proxy、线上网关)。
  3. JSONP(只能 GET,已基本淘汰)。
  4. postMessage(跨 iframe / 跨窗口)。

易错:以为"加了 CORS 头就万事大吉"——带 cookie 的跨域请求还需要 Access-Control-Allow-Credentials: true,且此时 Allow-Origin 不能是 *,必须写具体 origin;预检请求被 CDN / 网关拦掉是常见线上故障。

追问:① 预检请求的结果会被浏览器缓存多久(Access-Control-Max-Age)?② CORS 是浏览器行为还是服务端行为?③ credentials: 'include' 的影响?

L1 🔥×3 Q15 · cookie、session、localStorage、sessionStorage 有什么区别?

答:

cookiesessionlocalStoragesessionStorage
存储位置浏览器(随请求发送)服务端浏览器浏览器
生命周期expires / max-age服务端 session 过期永久关标签页即清
大小~4KB/域服务端内存~5MB~5MB
随请求发送是否(只传 id)否否

安全要点:cookie 可配 HttpOnly(JS 读不到,防 XSS 窃取)、Secure(仅 HTTPS)、SameSite(防 CSRF);localStorage 无 HttpOnly,一旦有 XSS 就被读走。

易错:以为 localStorage 天然安全(没有 HttpOnly,XSS 可直接读);把 JWT 放 localStorage 又在前端手写解析,遇 XSS 一样泄露。

追问:① 双 token(access + refresh)机制怎么设计?② session 存在内存还是 Redis,多实例怎么共享?③ cookie 的 SameSite=Lax/Strict/None 区别?

L2 补充 Q16 · 浏览器的存储机制:Cookie、Storage、IndexedDB、Cache API 怎么选?

答:

  • Cookie:小、随请求发、适合会话标识(HttpOnly/Secure/SameSite)。
  • localStorage / sessionStorage:同步 API,字符串键值,约 5MB,会阻塞主线程;只适合少量偏好设置。
  • IndexedDB:异步 API,支持索引与事务,可存 Blob/ArrayBuffer,容量大,适合离线数据 / 大结构化数据。
  • Cache API(Service Worker 专用):缓存 HTTP 请求与响应,用于离线可用。

易错:在循环里写 localStorage 造成主线程卡顿(应批量存 IndexedDB);忘了 localStorage 是同源共享且明文存储。

追问:① 存储配额满了会怎样?② 隐私模式下 storage 行为差异?③ Service Worker 的缓存和 HTTP 缓存怎么配合?

L2 🔥×6 Q17 · 页面性能的核心指标有哪些?LCP / INP / CLS 怎么优化?

答:Core Web Vitals 三指标:

  • LCP(最大内容绘制):首屏最大元素渲染完成,目标 ≤2.5s。优化:预加载关键资源、图片 WebP/AVIF + fetchpriority、SSR / 流式渲染、内联首屏 CSS。
  • INP(交互到下次绘制):反映响应速度,目标 ≤200ms。优化:拆分长任务、减少主线程 JS、主动 yield。
  • CLS(累积布局偏移):目标 ≤0.1。优化:为图片 / 广告预留尺寸、用 transform 代替改变布局的动画。

易错:只看 FCP 就认为首屏达标——FCP 只表示"有内容画出来";把 CLS 归咎于"网络慢"——CLS 是布局稳定性问题,与网络无关。

追问:① 怎么在真实用户环境(RUM)采集而不是实验室数据?② web-vitals 库上报到哪?③ 服务端渲染会不会让 LCP 更差?

L2 补充 Q18 · Service Worker 是什么?解决了什么问题?

答:Service Worker 是运行在独立线程的代理脚本,拦截页面的网络请求,可做缓存、后台同步、消息推送、离线兜底。

生命周期:install(预缓存资源)→ waiting → activate(清理旧版本缓存)→ activated。

核心能力:离线可用(缓存 App Shell);请求拦截与改写(cache-first / network-first / stale-while-revalidate);后台同步(离线时把写操作排队,联网后补发)。

易错:skipWaiting + clients.claim 会强制接管旧页面,可能导致版本不一致;Service Worker 必须跑在 HTTPS 或 localhost;缓存更新策略写错会拿到永久旧版本。

追问:① 怎么给用户一个"有新版本,点击刷新"的提示?② SW 缓存和 HTTP 缓存谁优先?③ 多标签页怎么协调 SW 版本?


03 CSS 与布局(7 题)

L1 🔥×3 Q19 · 移动端适配有哪些方案?rem / vw / 媒体查询怎么选?

答:

  • rem 方案:根字号按视口宽度动态计算(html { font-size: 宽度/10 }),设计稿按 750 宽等比缩放。优点是整体等比;缺点是大屏字会过大。
  • vw 方案:字号按视口宽度等比换算(750 稿的 100px = 13.33vw;375 稿的 16px = 4.26vw),同样等比,但不支持字号上下限(需 clamp() 兜底)。
  • 媒体查询断点:针对平板 / 桌面做布局切换,不做等比缩放,大屏体验最好。
  • 容器查询 @container:按父容器宽度而非视口响应,组件级复用更好。

工程做法:一般用 rem/vw 做小屏等比,用媒体查询在 ≥768px 切到固定宽度布局。

易错:只设 meta viewport 就以为适配完成了——缺 ` 页面会被缩放到 980px 宽;rem 方案忘了在 html 上设 font-size` 导致根字号不生效。

追问:① vw 在移动端浏览器的滚动条问题?② clamp() 怎么写字号上下限?③ 什么时候必须用 @container?

L2 补充 Q20 · Flex 布局的原理是什么?`justify-content` 和 `align-items` 有什么区别?

答:Flex 有主轴(flex-direction 决定)和交叉轴(垂直于主轴)。

  • justify-content:控制主轴分布(flex-start / center / space-between / space-around / space-evenly)。
  • align-items:控制交叉轴对齐(默认 stretch;还有 center / baseline 等)。
  • align-content:仅在换行且有剩余空间时控制多行之间分布;单行无效。

子项:flex = flex-grow / flex-shrink / flex-basis 简写。flex: 1 = 1 1 0%(均分剩余空间)。

易错:用 align-content 做单行居中(无效);flex: 1 与 flex: 1 1 auto 混用——basis 是 0% 还是 auto 决定初始宽度;min-width: auto 使子项无法收缩到内容宽度以下。

追问:① align-items: baseline 什么时候用?② flex 子项设 min-width: 0 解决什么问题?③ Grid 的 fr 和 flex 的 flex: 1 差别?

L1 🔥×2 Q21 · 盒模型有哪几种?`box-sizing` 的影响?

答:W3C 标准盒模型(content-box)= content + padding + border + margin,width 只算内容区;IE 盒模型(border-box)把 padding / border 也算进 width。

box-sizing:

  • content-box(默认):width 只算内容区。
  • border-box:width 包含 padding + border(最常用,写 * { box-sizing: border-box })。

易错:默认 content-box 下 width: 100% 的元素再加 padding 会溢出父容器(border-box 正是用来吸收这层 padding 的);另外 min-width: 0 是 flex / grid 子项 min-width: auto 无法收缩的问题,与盒模型 padding 无关,别混为一谈。

追问:① width: 100% 的元素加 padding 为什么会溢出?② 垂直方向上的 margin 折叠是什么?③ box-sizing 对 min-width 有影响吗?

L2 补充 Q22 · 什么是 BFC?哪些属性会创建 BFC?

答:BFC 是块级盒子的独立格式化上下文——内部浮动不影响外部,外部浮动不影响内部。

创建方式:float 非 none、position 为 absolute/fixed、display 为 flex/grid/inline-block/flow-root、overflow 非 visible、contain: layout。

用途:① 清除浮动(display: flow-root 比 overflow: hidden 更语义化);② 隔离 margin 折叠。

易错:用 overflow: hidden 清浮动会裁剪溢出内容(弹窗、下拉被切);margin 折叠只在块级流发生,flex / grid 容器内不折叠。

追问:① display: flow-root 比 overflow: hidden 好在哪?② margin 折叠在什么情况下发生?③ BFC 和 IFC 的区别?

L2 🔥×2 Q23 · DOM 事件冒泡和捕获是什么?怎样实现"祖先先触发再触发子节点"?

答:DOM 事件流分三阶段:捕获阶段(window → document → 祖先 → target)→ 目标阶段 → 冒泡阶段(target → 祖先 → document → window)。addEventListener(type, fn, capture) 的第三个参数选阶段(true 捕获、false/省略冒泡)。

实现"祖先先触发":给祖先注册捕获监听器(addEventListener('click', fn, true)),因为捕获阶段自顶向下。

事件委托:把监听器挂在容器上,靠 event.target 判断来源——动态子元素无需单独绑定。

易错:以为 stopPropagation() 只是阻止冒泡——它同时阻止捕获阶段的后续传播(对同一事件);stopImmediatePropagation() 还会阻止同一元素上其他监听器。

追问:① target 和 currentTarget 的区别?② 事件委托的 closest() 用法?③ passive: true 解决什么问题?

L2 低频实录·1源 Q24 · CSS Modules 是什么?解决了什么问题?

答:CSS Modules 在构建期给每个 class 名加上唯一哈希后缀(如 .title__a1b2c),从而让 CSS 天然局部作用域,避免全局命名冲突。

配套能力:composes 复用样式、:global() 声明全局样式、CSS 变量做主题。

与 scoped 的区别:Vue 的 scoped 靠属性选择器([data-v-xxx])实现;CSS Modules 是构建期改写类名。前者依赖框架运行时约定,后者与框架无关,可在任意构建工具使用。

易错:动态拼接类名(` btn-${type} )在 CSS Modules 下**匹配不到**(哈希丢失),需要用 composes 或映射表;忘记给全局样式加 :global()` 导致第三方库样式被破坏。

追问:① CSS-in-JS 的取舍?② 零运行时(zero-runtime)的 CSS 方案有哪些?③ 主题切换怎么做(CSS 变量 vs 类名切换)?

L2 补充 Q25 · 怎样避免 CSS 动画引起的卡顿?

答:只用 transform 和 opacity 做动画(走合成层,不触发重排重绘);给动画元素加 will-change: transform(或提前提升合成层),动画结束后移除 will-change 以释放显存。

其他要点:用 requestAnimationFrame 驱动逐帧更新;长列表动画用 CSS 过渡而非 JS 逐帧改样式;避免在动画里读布局属性。

易错:will-change 滥用会导致合成层过多、显存占用高反而更卡;transition: all 会让不该动的属性也参与过渡,性能差且难调试。

追问:① 合成层过多为什么更慢?② transform: translateZ(0) 强制提升合成层有什么副作用?③ 长列表滚动动画怎么做才不掉帧?


04 框架:Vue 与 React(8 题)

L1 🔥×5 Q26 · Vue 2 和 Vue 3 的主要区别是什么?为什么 Proxy 比 defineProperty 好?

答:

Vue 2Vue 3
响应式Object.definePropertyProxy
组件写法Options API+ Composition API
生命周期beforeDestroy/destroyedbeforeUnmount/unmounted
diff双端 diff同层比较 + patchFlag
TS 支持差原生支持

Proxy 优于 defineProperty:① 能拦截新增 / 删除属性(defineProperty 只能监听已存在属性,需 Vue.set);② 数组监听更自然(能拦截 push/splice);③ 无需遍历所有属性。

易错:说"Proxy 性能更好"是错的——Proxy 代理整个对象,defineProperty 是逐属性定义,Proxy 通常更慢;Vue 3 的性能优势来自编译优化、静态提升、patchFlag 精准更新。真正的好处是能力(能拦截新增 / 删除)。

追问:① Vue 3 的 diff 为什么用 patchFlag?② shallowRef / shallowReactive 什么时候用?③ Composition API 解决了什么问题?

L2 低频实录·1源 Q27 · Vue 的双向数据绑定是怎么实现的?

答:Vue 2 用 DefineProperty 劫持 + Dep/Watcher:

  1. initData 时用 Object.defineProperty 把 data 属性改成 getter/setter。
  2. getter 收集依赖(Dep.target 指向当前渲染 Watcher),setter 触发 dep.notify()。
  3. Watcher 的 update() 把渲染函数重新入队 → 重新渲染。

Vue 3 换成 Proxy 拦截 get/set:get 时 track(target, key),set 时 trigger(target, key),用 Map<target, Map<key, Dep>> 存依赖。

易错:说成"Vue 通过双向绑定实现数据更新"是循环论证——数据更新靠响应式,双向绑定只是 v-model 这个语法糖(本质是 :value + @input 的组合)。

追问:① v-model 在自定义组件上怎么实现?② Vue 3 的 effect / effectScope 是什么?③ 响应式数据在 setup 里解构会丢失响应性吗(为什么)?

L1 🔥×2 Q28 · React 组件间怎么通信?父子、兄弟、跨层级分别怎么做?

答:

  • 父 → 子:props。
  • 子 → 父:props 传回调函数(onChange),父组件更新自己的 state。
  • 兄弟:状态提升到共同父组件,再通过 props 分发;或用 Context;或状态库(Redux / Zustand)。
  • 跨层级:React Context(适合"主题、语言、用户信息"这类低频变化的全局数据);复杂跨组件通信用状态管理库。

现代 React(16.8+):跨层传递可用 useContext(Hooks 引入于 16.8);数据获取推荐框架方案(SWR / React Query)替代手写 effect。

易错:以为"props 是单向的"所以子组件不能改——子组件不应直接改 props,应通过回调通知父组件改;滥用 Context 会让所有消费者全部重渲染(值变化时)。

追问:① useMemo / useCallback 能解决什么问题?② Context 和状态库怎么选?③ React.memo 什么时候失效?

L2 🔥×15 Q29 · React Hooks 有哪些使用规则?为什么不能把 Hook 放在条件里?

答:常用 Hooks:useState、useEffect、useRef、useMemo、useCallback、useReducer、useContext、useLayoutEffect。

两条规则(React 靠调用顺序关联 Hook 状态,顺序变了状态就串位):① 只在顶层调用;② 只在函数组件或自定义 Hook 里调用。

为什么:useState 内部按调用顺序把状态存进第 N 个槽位。放在条件里,条件切换时"第一个 state"会变成"第二个 state",读到别的数据。

useEffect vs useLayoutEffect:前者异步在绘制后执行(适合请求、定时器);后者同步在绘制前执行(适合读 DOM 尺寸、避免闪烁)。

易错:把 useEffect 当 componentDidMount——它依赖变化后也会执行,且清理函数在下一次执行前调用;effect 里直接 setState 容易造成无限循环。

追问:① useEffect 的清理函数什么时候执行?② 依赖数组少写会怎样?③ 自定义 Hook 的价值在哪?

L2 补充 Q30 · 虚拟 DOM 的原理是什么?它一定比直接操作 DOM 快吗?

答:虚拟 DOM = 用 JS 对象镜像真实 DOM 结构。更新时先在内存里对新旧两棵树做 diff,只把差异以最小操作批量同步到真实 DOM。

关键前提:diff 划算的前提是"少量改动"。如果每次更新都重写整个 DOM,虚拟 DOM 反而比直接操作更慢(多了一层对象创建 + diff 开销)。

收益:① 声明式编程(写 UI 状态而非操作步骤);② 跨平台(一套渲染逻辑输出到 DOM / Native / Canvas);③ 可预测(更新是批处理的,减少强制同步布局)。

易错:说"虚拟 DOM 一定比原生 DOM 快"是错的——原生 DOM 在局部高频更新(如动画、拖拽)下更快,此时应用 requestAnimationFrame 直接操作 DOM。

追问:① Vue 的虚拟 DOM 和 React 的 diff 策略差异?② 哪些场景应该绕开虚拟 DOM?③ key 的作用与选错的后果?

L1 补充 Q31 · React 里 `key` 有什么作用?用 index 作 key 有什么问题?

答:key 是 React 在协调(reconciliation)阶段识别节点的唯一标识,用于判断"这是同一个组件还是新组件"。

必须用 key 的场景:列表渲染。

用 index 作 key 的问题:

  1. 状态错位:在列表头部插入一行时,后续所有项的 index 变化,React 认为是同一个组件 → 复用旧组件实例,导致 useState / 非受控 input 的值串位。
  2. 性能浪费:本该复用已有 DOM,diff 却做了多余工作。

正确做法:用数据的稳定唯一 id(后端返回的主键 / uuid)。

易错:说"key 只影响性能"——它还影响组件状态归属,选错会导致功能性 bug;key 必须在兄弟节点间唯一(不需要全局唯一)。

追问:① 为什么 key 不传会警告?② Fragment 上能加 key 吗?③ useId 什么时候用?

L2 🔥×3 Q32 · 前端状态管理有哪些方案?什么时候需要用状态库?

答:

  • 局部状态:useState / ref —— 组件内。
  • 提升状态:父组件持有,传 props + 回调 —— 父子、兄弟。
  • Context:跨层级、低频变化的全局数据(主题、语言、当前用户)—— 会让所有消费者重渲染。
  • 状态库(Redux / Zustand / Pinia / MobX):跨页面、跨模块、复杂派生、需持久化 / 中间件 / 时间旅行调试时使用。

选型判据:先问"这份状态需要在几个地方读 / 写"。只在两个兄弟组件间传 → 提升即可;跨路由、需持久化 → 状态库。

易错:为了一两个字段引入 Redux,增加样板代码;用 Context 存高频变化数据(如输入框值)导致整树重渲染。

追问:① Redux Toolkit 相比 Redux 解决了什么?② Zustand 为什么更轻?③ 状态持久化的坑(SSR 水合不一致)?

L2 🔥×6 Q33 · 前后端分离的架构是怎样的?SSR 和 CSR 怎么选?

答:前后端分离 = 前端独立部署(SPA)+ 通过 HTTP / WebSocket 与后端 API 通信,跨域用 CORS 或网关代理解决。

CSR(客户端渲染):HTML 壳 → 浏览器下载 JS → 执行后渲染内容。优点是交互快、部署简单;缺点是首屏慢、SEO 差。

SSR(服务端渲染):服务端生成 HTML → 浏览器直接显示 → 再水合(hydrate)接管。优点是首屏快、SEO 好;缺点是服务端压力大、TTFB 依赖后端。

选择:内容型 / 需要 SEO(官网、电商、博客)→ SSR 或 SSG;后台管理 / 登录后应用(内部系统、SPA 工具)→ CSR;折中是流式 SSR / 边缘渲染。

易错:以为 SSR 就是"更快"——服务端渲染把 TTFB 压力从客户端转移到服务端,后端慢时整体更慢;SSR 的水合(hydration)要求服务端与客户端首屏 HTML 一致,否则 React 会警告并重渲染。

追问:① SSG 是什么,什么场景用?② hydration 不匹配怎么排查?③ 同构应用的目录怎么组织?


05 工程化与协作(10 题)

L1 🔥×6 Q34 · webpack 和 vite 的区别是什么?为什么 Vite 启动快?

答:

  • webpack:打包器。开发时用 bundle(打包) 模式——先把所有模块打成一个大 bundle 再启动 dev server,所以启动慢,HMR 需重新构建整个依赖图。
  • vite(开发):不打包,用浏览器原生 ESM,按需 transform 单个模块(类似"按需编译"),启动秒开;依赖用 pre-bundle(esbuild)预构建以兼容 CommonJS。
  • vite(生产):用 Rollup 打包。

其他差异:webpack 生态更成熟(loader / plugin 体系),配置灵活;vite 配置更简洁,插件生态基于 Rollup。

易错:说"Vite 生产也比 webpack 快"不准确——Vite 生产用 Rollup,大项目打包速度不一定优于 webpack 的持久化缓存(webpack 5 有 filesystem cache)。

追问:① Vite 的依赖预构建解决什么问题?② HMR 的原理?③ 怎么给项目做产物分析(bundle analyzer)?

L1 🔥×6 Q35 · 前端工程化包含哪些环节?每个环节解决什么问题?

答:

  1. 脚手架:模板化初始化(create-vue / Vite 模板),统一目录与配置。
  2. 编译转译:Babel 降级、TS 编译、Sass/Less 转 CSS。
  3. 打包:代码分割、Tree-shaking、压缩、资源指纹(hash)、publicPath。
  4. 代码规范:ESLint + Prettier,Git hooks(Husky + lint-staged)在提交时强制。
  5. 质量保障:单元测试(Vitest / Jest)、E2E(Playwright / Cypress)、覆盖率。
  6. CI/CD:自动 lint / test / build → 产物上传 → 部署(灰度、回滚)。
  7. 监控:埋点、错误监控(Sentry)、性能监控(RUM)。

易错:把"工程化"只理解成"用打包工具"——规范、测试、CI、监控同样属于工程化,且是面试里最能体现工程素养的部分。

追问:① monorepo(pnpm workspace / turborepo / nx)解决了什么?② 产物 hash 与缓存策略怎么设计?③ 怎么控制别人提交不规范的代码?

L2 🔥×3 Q36 · 如何减少前端产物体积?Tree-shaking 什么时候会失效?

答:

减包手段:

  1. Tree-shaking:摇掉未使用的导出(依赖 ESM + 静态 export)。
  2. 按需引入:import { Button } from 'antd' 而非整包 import。
  3. 代码分割:import() 动态导入 + 路由懒加载。
  4. 替换大依赖:moment → dayjs、lodash → lodash-es(ESM 版可摇树)。
  5. 资源优化:WebP/AVIF + loading="lazy"、SVG symbol、字体子集化。

Tree-shaking 失效:依赖是 CommonJS;类成员不会被摇掉;缺 sideEffects: false;Babel 把 ESM 转成 CJS。

易错:只做代码分割不做 tree-shaking;lodash 整包引入(400KB+);忽略非首屏 chunk 也在阻塞下载。

追问:① 怎么分析产物体积?② sideEffects: false 的风险?③ 首屏体积的预算怎么定?

L1 🔥×2 Q37 · Git 的分支策略怎么定?rebase 和 merge 怎么选?

答:

分支模型:

  • GitHub Flow:主干 main 永远可发布,每个改动开 feature/* 分支,PR + review 后合并、删除分支。适合持续部署。
  • Git Flow:develop + feature / release / hotfix,适合有明确版本发布节奏的产品。

rebase vs merge:

  • merge:保留分支的真实历史,生成一个合并提交;公共分支上用。
  • rebase:把提交"重放"到目标分支之上,历史线性;自己的 feature 分支同步主干时用,合并前必须 rebase 一次。

铁律:不要 rebase 已经推送给别人的公共分支(会改写他人历史)。

易错:把 git stash 和 git stash pop 用混(pop 会删除 stash,apply 保留);冲突解决后忘记 git add;git reset --hard 在错误的分支上执行会丢改动。

追问:① 怎么撤销已推送的提交?② git cherry-pick 和 rebase 的区别?③ 怎么用 git bisect 定位回归?

L2 🔥×14 Q38 · 设计模式有哪些?前端里最常用的是哪几个?

答:GoF 共 23 个,分三类:

  • 创建型(5):单例、工厂方法、抽象工厂、建造者、原型。
  • 结构型(7):适配器、装饰器、组合、代理、桥接、享元、外观。
  • 行为型(11):策略、模板方法、观察者、迭代器、责任链、命令、状态、访问者、中介者、备忘录、解释器。

前端高频:观察者(事件机制、EventEmitter、发布订阅)、单例(全局配置、事件总线)、策略(把易变逻辑抽成策略函数)、适配器(抹平第三方 SDK 差异)、装饰器(给函数加缓存 / 日志 / 防抖)、工厂(按配置产出不同实现)。

易错:滥用模式——为两三行代码建一堆类和文件;用事件总线做全局状态,导致数据流不可追踪(应改用状态库或 Context)。

追问:① 观察者 vs 发布订阅的区别?② 策略模式在表单校验里怎么落地?③ 哪些模式在 React / Vue 里是"反模式"?

L2 🔥×4 Q39 · 微前端有什么解决方案?什么场景才需要它?

答:微前端 = 把一个前端应用拆成多个独立可部署的子应用,运行时组合。

方案:

  • 基于容器:qiankun(基于 single-spa)、micro-app、Module Federation(webpack 5 / Vite)。
  • 基于 iframe:天然隔离(样式、JS 全隔离),但通信复杂、SEO 与首屏差。
  • 基于 Web Component / Shadow DOM:样式隔离最干净。

隔离手段:样式隔离用 Shadow DOM(真隔离)/ scoped 属性 / CSS 前缀(编译期重写);JS 隔离用 Proxy 拦截 window 属性(沙箱);路由隔离由主应用分发路径前缀给子应用。

什么时候才需要:多团队独立开发、独立发布、不同技术栈共存、存量系统渐进迁移。

易错:为"技术先进"拆微前端——单体应用拆微前端会引入跨应用通信、状态同步、部署编排三重复杂度;样式隔离若用 Shadow DOM,全局样式(弹窗挂载)会失效。

追问:① 主子应用怎么通信?② 微前端下怎么统一登录态?③ 跨应用共享依赖怎么避免重复加载?

L2 🔥×5 Q40 · 前端如何做单元测试和端到端测试?测试金字塔怎么用?

答:

  • 单元测试(Vitest / Jest):测纯函数、工具、组件逻辑。快,数量多。
  • 组件测试(Testing Library / Vue Test Utils):测渲染结果与交互,用用户视角查询(getByRole)而非实现细节。
  • E2E(Playwright / Cypress):跑真实浏览器,测关键用户路径。慢,数量少。
  • 视觉回归(Storybook + Chromatic):防 UI 意外变更。

测试金字塔:单元测试最多、速度最快;E2E 最少、速度最慢。E2E 只覆盖核心链路(登录 → 下单)。

易错:测试实现细节(内部 state)而非用户可见行为,改实现就大面积报错;mock 过度导致测试通过但线上崩;E2E 不稳定(网络、时间、随机数据)却频繁失败导致被忽略。

追问:① 怎么提高 E2E 稳定性?② 覆盖率多少算合理?③ CI 里测试怎么并行和分片?

L2 🔥×3 Q41 · 前端如何做错误监控和埋点?线上问题怎么定位?

答:

错误监控:接入 Sentry / 自建平台,全局捕获 window.onerror、unhandledrejection;同时做代码级 source map 上传(生产环境必须上传,否则堆栈是压缩后的),并按 release 版本管理。

埋点分三类:① PV/UV(页面访问);② 行为埋点(点击、曝光、下单等关键路径,带业务属性);③ 性能与错误(Web Vitals、接口失败率)。

常见坑:采样(高频埋点要采样,否则量大噪声大);去重聚合(同一错误按 stack 聚成 issue,避免刷屏);隐私合规(不能上报手机号、身份证等敏感信息)。

定位链路:用户反馈 → 按用户 ID / 时间 / 版本查日志 → source map 还原堆栈 → 复现。

易错:生产环境不上 source map——本地有堆栈、线上没有,等于没监控;只报 message 不报堆栈和上下文。

追问:① 怎么衡量监控上报本身的可靠性?② 灰度期间怎么对比错误率?③ 前端日志怎么和后端链路串起来(traceId)?

L1 🔥×17 Q42 · 性能优化怎么"量化"?你如何证明优化有效?

答:性能优化必须是先测量、再定位、再优化、再验证的闭环,不能凭感觉。

四步:

  1. 定指标:用 Web Vitals(LCP / INP / CLS)+ 业务指标(首屏可用时间、转化率)定义目标,而非"感觉快一点"。
  2. 测量:实验室用 Lighthouse、DevTools;真实用户用 RUM、Apdex。
  3. 定位瓶颈:Performance 面板看主线程长任务,火焰图找耗时函数,Network 面板看资源体积与瀑布图。
  4. 验证:同一指标同一场景前后对比,看分布(P50/P95)而非平均值。

常见优化项:资源体积、关键路径(预加载 / 代码分割)、渲染(合成层、避免强制同步布局)、请求(合并 / 缓存)、主线程(Web Worker)。

易错:只报"快了 0.5 秒"而无指标、无测量方法——面试官无法判断可信度;拿本地开发环境的数字说事。

追问:① 实验室数据和真实用户数据不一致时信哪个?② P95 优化不动但均值降了,说明什么?③ 优化引入的复杂度怎么评估?

L1 🔥×2 Q43 · 项目里怎么用 AI 辅助开发?AI 给的 API 查不到怎么办?

答:AI 辅助开发的合理定位是加速样板代码与检索,而非替代判断。

典型用法:写测试用例、样板代码、类型定义、正则;在陌生代码库里做语义检索;生成文档注释、重构建议、边界 case 清单;辅助排查(贴报错栈让 AI 列出可能原因与验证步骤)。

"AI 给的 API 官网查不到"时:说明它可能是幻觉(尤其小众库或新版本 API)。处理方式:① 优先查本地安装版本的类型定义 / 源码(node_modules 里的 .d.ts)——最权威;② 查官方文档与 changelog,确认版本是否匹配;③ 用最小可运行片段验证,以运行结果为准,不采信未经验证的示例。

易错:把 AI 生成的代码直接合入主干而不读不跑——这是面试官最想听的反面案例;把 AI 当"搜索引擎"用却不做版本核对。

追问:① 怎么给 AI 提供足够的上下文(仓库级检索)?② AI 生成的代码怎么保证质量(评审 + 测试)?③ 团队里怎么规范 AI 使用?


06 安全与跨端(3 题)

L1 🔥×2 Q44 · XSS 和 CSRF 是什么?怎么防御?

答:

XSS(跨站脚本):注入恶意脚本。类型:存储型(入库)、反射型(URL 参数)、DOM 型。

防御:① 输出编码(按上下文转义 HTML / 属性 / JS / URL)——最根本;② CSP(script-src 'self');③ 避免 innerHTML、eval,用 textContent;④ Cookie 设 HttpOnly。

CSRF(跨站请求伪造):诱导浏览器带凭证发起非预期请求。防御:① SameSite cookie(Lax/Strict)——现代浏览器主防线;② CSRF token;③ 敏感操作二次确认;④ 不用 cookie 存凭证(用 Authorization 头 + token,浏览器不会自动附带)。

易错:把 CORS 当 XSS 防御(无关);把 CORS 当 CSRF 防御(同源策略不阻止跨站表单提交);只在前端做转义(后端接口也要校验)。

追问:① DOM 型 XSS 绕过转义怎么防?② 富文本编辑器怎么既保留 HTML 又防 XSS?③ 跨域携带 cookie 需要哪些响应头?

L2 🔥×3 Q45 · 小程序 / App 的跨端方案有哪些?适配策略上要注意什么?

答:跨端方案分三类:

  1. 一套代码多端编译:React Native / Flutter / uni-app / Taro —— 复用逻辑与视图,性能接近原生。
  2. WebView 套壳 + H5 页面:改造成本最低,但性能与原生能力受限。
  3. 小程序 / 大前端平台:微信 / 支付宝 / 字节小程序,各自语法与 API 有差异。

适配注意:

  • 单位与安全区:rem/vw 适配 + env(safe-area-inset-bottom) 适配刘海屏。
  • 交互差异:小程序无 hover、部分 CSS 不支持;键盘弹起顶起页面(用 adjust-position)。
  • 能力差异:localStorage 无持久保证、不能用 eval、域名需备案配白名单。
  • 包体积:主包有上限,需分包加载。

易错:把小程序当浏览器写——window / document 不存在;忽略真机与模拟器差异(iOS 滚动惯性、WebView 版本)。

追问:① 小程序分包怎么设计?② 跨端框架的渲染性能瓶颈在哪?③ 如何做一端多发的差异抹平?

L2 🔥×3 Q46 · 前端如何保证用户数据安全?token 应该存在哪里?

答:token 不要放 localStorage(无 HttpOnly,XSS 可读)。首选 HttpOnly + Secure + SameSite cookie 承载会话标识;纯 API 场景用 Authorization: Bearer 头 + 短期 access token + refresh token 轮换。

要点:

  • access token 短期(15 min ~ 1 h)放内存或 cookie;refresh token 长期且放 HttpOnly cookie(配合轮换与失效检测)。
  • 敏感操作(改密码、支付)二次验证。
  • 密码加盐哈希(argon2 / bcrypt)存储,绝不明文或可逆加密。
  • 前端不做权限判断(只是 UI 隐藏,服务端必须再校验)。

易错:把 access token 存 localStorage 又加 HttpOnly(cookie 才支持 HttpOnly);refresh token 永不过期;把"前端隐藏按钮"当权限控制。

追问:① token 被窃取怎么办(设备绑定 / 短时效 / 主动吊销)?② 登出怎么让服务端失效(黑名单 vs 白名单)?③ 怎么防重放攻击(nonce / 时间戳)?


07 前端手撕代码(8 题)

L1 🔥×4 ✍ H01 · 手撕防抖 debounce 与节流 throttle

考察点:防抖是"最后一次触发后延迟执行",节流是"固定间隔内最多执行一次";都要能取消、带参。

实现:

function debounce(fn, wait = 300, immediate = false) {
  let timer = null;
  return function (...args) {
    const callNow = immediate && timer === null;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) fn.apply(this, args);
    }, wait);
    if (callNow) fn.apply(this, args);
  };
}

function throttle(fn, wait = 300) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn.apply(this, args);
    }
  };
}

易错:忘了保存 this(箭头函数捕获的是定义时的 this);debounce 的 timer 未置空导致 immediate 判断失效;节流用 setInterval 而非时间戳判断(尾部不执行)。

L1 🔥×2 ✍ H02 · 手撕发布订阅(事件总线 / EventEmitter)

考察点:on / off / emit 三个动作;解绑必须支持(否则内存泄漏);emit 时要遍历副本。

实现:

class EventEmitter {
  constructor() { this.handlers = new Map(); }
  on(event, fn) {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event).add(fn);
    return this;                       // 支持链式调用
  }
  off(event, fn) { this.handlers.get(event)?.delete(fn); return this; }
  emit(event, ...args) {
    const set = this.handlers.get(event);
    if (!set) return false;
    for (const fn of [...set]) fn(...args);   // 副本:回调里 off 不会影响本次遍历
    return true;
  }
}

易错:emit 时直接遍历 Set,回调里 off 同一函数会导致遍历中断(JS 的 Set 迭代会跳过已删元素);忘了 once 语义;把全局事件总线当全局状态用,导致数据流不可追踪。

L1 低频实录·1源 ✍ H03 · 手撕 Promise.all / Promise.allSettled / Promise.race

考察点:三者对失败的处理不同;Promise.all 快失败(fail-fast);都要正确处理非 Promise 值。

实现:

Promise.all = function (promises) {
  return new Promise((resolve, reject) => {
    const results = [];
    let left = promises.length;
    if (left === 0) return resolve(results);
    promises.forEach((p, i) => {
      Promise.resolve(p).then(
        v => { results[i] = v; if (--left === 0) resolve(results); },
        reject                      // 第一个 reject 即整体 reject
      );
    });
  });
};

Promise.allSettled = function (promises) {
  return Promise.all(
    promises.map(p => Promise.resolve(p).then(
      value => ({ status: 'fulfilled', value }),
      reason => ({ status: 'rejected', reason })
    ))
  );
};

Promise.race = function (promises) {
  return new Promise((resolve, reject) => {
    for (const p of promises) {
      Promise.resolve(p).then(resolve, reject);   // 第一个 settle 的胜出
    }
  });
};

易错:误以为 Promise.all 会等全部完成才 reject(第一个 reject 就整体 reject,其余仍在跑,可能引发 unhandledRejection);忘记 Promise.resolve(p) 包装(非 Promise 值也能传入)。

L2 🔥×8 ✍ H04 · 手撕 new Promise 封装异步请求 + 超时 + 重试

考察点:把回调风格包成 Promise;必须有超时(否则请求挂死);重试要有退避、次数上限,且只重试可重试错误。

实现:

function request(url, { timeout = 5000, retries = 2, backoff = 300 } = {}) {
  let attempt = 0;
  function once() {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      const timer = setTimeout(() => {
        xhr.abort();
        const e = new Error('timeout');
        e.retryable = true;              // 超时属网络失败,可重试
        reject(e);
      }, timeout);
      xhr.open('GET', url);
      xhr.onload = () => {
        clearTimeout(timer);
        if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
        else {
          // 4xx 是客户端错误,重试无意义;5xx 与网络错误才重试
          const e = new Error(`HTTP ${xhr.status}`);
          e.retryable = xhr.status >= 500;
          reject(e);
        }
      };
      xhr.onerror = () => {
        clearTimeout(timer);
        const e = new Error('network error');
        e.retryable = true;
        reject(e);
      };
      xhr.send();
    });
  }
  return (async () => {
    while (true) {
      try { return await once(); }
      catch (e) {
        if (attempt++ >= retries || !e.retryable) throw e;
        await new Promise(r => setTimeout(r, backoff * attempt));  // 线性退避
      }
    }
  })();
}

易错:没有超时;重试没有退避(会把后端打挂);4xx 这类业务错误也重试(只有网络错误和 5xx 才该重试)。

L2 低频实录·1源 ✍ H05 · 手撕数组拍平 flatten(指定深度)

考察点:递归 + 深度控制;语义要对齐 Array.prototype.flat;稀疏数组与 null 的处理。

实现:

function flatten(arr, depth = 1) {
  if (depth < 0) return arr.slice();
  const out = [];
  for (const item of arr) {
    if (Array.isArray(item) && depth > 0) {
      out.push(...flatten(item, depth - 1));
    } else {
      out.push(item);
    }
  }
  return out;
}

易错:不判 depth 导致无限递归;语义与 flat 不一致(flat(0) 不展开、flat(1) 只展一层);... 展开大数组可能爆栈(数据量大时改用循环 push)。

L1 🔥×9 ✍ H06 · 手撕深拷贝(带循环引用处理)

考察点:用 WeakMap 记录已克隆对象,解决循环引用;按类型处理 Date / RegExp。

实现:

function deepClone(obj, cache = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (cache.has(obj)) return cache.get(obj);          // 循环引用
  if (obj instanceof Date) return new Date(obj.getTime());
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
  const clone = Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj));
  cache.set(obj, clone);
  for (const key of Reflect.ownKeys(obj)) {
    clone[key] = deepClone(obj[key], cache);
  }
  return clone;
}

易错:不用 WeakMap 导致循环引用无限递归;漏掉 Date / RegExp(会变成普通对象);用 for...in 会遍历到原型链上的属性。

L2 🔥×7 ✍ H07 · 手撕 LRU 缓存(JS 版 Map 优化)

考察点:JS 的 Map 保持插入顺序,配合 delete + set 即可把被访问的 key 移到末尾,无需自己写链表。

实现:

class LRUCache {
  constructor(capacity) {
    this.cap = capacity;
    this.map = new Map();          // Map 保序:最旧的在迭代头部
  }
  get(key) {
    if (!this.map.has(key)) return -1;
    const v = this.map.get(key);
    this.map.delete(key);          // 删后重设 → 移到末尾(最新)
    this.map.set(key, v);
    return v;
  }
  put(key, value) {
    if (this.cap <= 0) return;                        // 容量 0 不缓存
    if (this.map.has(key)) this.map.delete(key);
    else if (this.map.size >= this.cap) {
      this.map.delete(this.map.keys().next().value);  // 淘汰最旧
    }
    this.map.set(key, value);
  }
}

易错:忘了 delete 再 set(Map 不自动更新顺序);Object.keys 无顺序保证(须用 map.keys());容量为 0 时仍会写入一条,必须提前 return。

L2 🔥×8 ✍ H08 · 手撕并发请求限流(并发数控制)

考察点:限制同时在飞的请求数,而不是每秒请求数;完成后要释放名额。

实现:

function limitConcurrency(tasks, limit) {
  const results = new Array(tasks.length);
  let index = 0;
  async function worker() {
    while (index < tasks.length) {
      const i = index++;
      try {
        results[i] = await tasks[i]();      // 失败时该 worker 退出,需 try/catch
      } catch (e) {
        results[i] = { error: e };
      }
    }
  }
  const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker);
  return Promise.all(workers).then(() => results);
}

易错:用"每秒 N 个"的时间窗限流代替并发数限制(语义不同);某个 task 抛错会 reject 整个 Promise.all(应逐个 catch);index++ 在 JS 单线程下无竞态,但换成多线程语言就要加锁。


低频备选(未入主表)