Javascript 面试题
一、语言核心机制
作用域 & 闭包
词法作用域:函数能访问哪些变量,在定义时就确定了。闭包 = 函数 + 它定义时捕获的外层环境引用。
核心点
var:声明提升 + 初始化为undefined,函数作用域,挂windowlet/const:只提升声明不初始化(TDZ),块级作用域,访问抛ReferenceError- 循环陷阱:
var所有迭代共享同一个i,let每次迭代产生新绑定
项目价值:防抖/节流保存 timer、模块私有变量、memoize 缓存结果、函数工厂
风险:持有大对象 / DOM 节点 / 定时器未清理 → 内存泄漏
- Vue:
onUnmounted清理 - React:
useEffectreturn 清理
追问
- 闭包一定泄漏?→ 不会,只有长期持有无法释放的资源才泄漏
- React stale closure?→ 异步回调捕获了旧 render 的 state,用
useRef或调整依赖数组解决 - 闭包和垃圾回收的关系?→ 只要内部函数还被引用,外层变量就不会被 GC,这是闭包能工作的原因,也是泄漏的根源
this 绑定
this在调用时决定,不是定义时(箭头函数除外)。
四种规则(优先级从高到低)
| 优先级 | 规则 | 结果 |
|---|---|---|
| 1 | new 绑定 | 新创建的对象 |
| 2 | 显式绑定 call/apply/bind | 指定的对象 |
| 3 | 隐式绑定 obj.fn() | obj |
| 4 | 默认绑定 fn() | 严格模式 undefined,否则 window |
箭头函数:没有自己的 this,捕获定义时所在词法作用域的 this,call/apply/bind 无法改变
项目中丢失 this 的高频场景
- 解构方法调用:
const { login } = store; login()→ this 丢失 - 回调传递:
setTimeout(this.fn, 0)→ this 丢失,改用箭头函数或 bind - 事件回调中的方法:
el.addEventListener('click', this.handler)→ this 丢失
原型链 & 继承
每个对象有
[[Prototype]],属性查找沿原型链向上直到null。class是语法糖,底层仍是原型链。
class vs ES5 的差异
- ES5 寄生组合继承:
Animal.call(this)继承实例属性 +Object.create(Animal.prototype)继承原型方法 class等价,但静态方法继承、super、严格模式默认开启、不提升- 寄生组合继承是最优方案:只调用一次父构造函数,原型链完整,没有多余属性
instanceof 原理:沿原型链查找 Constructor.prototype 是否存在
- 跨 iframe 失效:每个 iframe 独立 JS 环境,
Array.prototype不是同一个引用 - 安全判断类型用
Object.prototype.toString.call(val)
类型系统
==先看类型,不同类型按规则转换;===不转换,直接比较。
== 转换规则(能推导不用背)
null == undefined→true(特殊规定),它们不等于其他任何值- 有
Boolean→ 先转Number - 有
Number+String→String转Number - 有
Object+ 原始值 →Object调valueOf/toString
经典题:[] == false → []→''→0,false→0 → true;[] == ![] → ![]→false→0,[]→0 → true
其他
typeof null === 'object':历史 bug,null 机器码全 0,和 object 标签 000 一样NaN !== NaN:唯一不等于自身的值,判断用Number.isNaN()Object.is(+0, -0)→false;Object.is(NaN, NaN)→true(比===更精确)
二、异步编程
Promise 深度
Promise 是状态机:
pending → fulfilled / rejected,状态一旦改变不可逆。
链式调用
.then返回普通值 → 包装成 resolved Promise 继续传递.then返回 Promise → 等待该 Promise settle 再继续.then抛错 → 转为 rejected,被下一个.catch捕获
四种静态方法
| 方法 | rejected 时机 | 适用场景 |
|---|---|---|
Promise.all | 任意一个 reject 立即 reject | 全部成功才继续(并行接口) |
Promise.race | 任意一个 settle | 超时控制 |
Promise.allSettled | 永不 reject | 需要每个结果,不管成败 |
Promise.any | 全部 reject 才 reject | 多个备用接口取最快成功的 |
微任务执行顺序:同步 → 微任务(Promise.then)→ 渲染 → 宏任务(setTimeout)
async/await 陷阱
循环串行陷阱
forEach不等待await,三个请求实际并行- 串行用
for...of,并行用Promise.all + map
错误处理选择
try/catch:多个 await 共用一个处理,但无法区分哪一步出错- 独立
.catch():每步单独处理,能精确定位
未捕获 rejection
- 浏览器:
window.addEventListener('unhandledrejection', handler) - Node 15+:未捕获直接退出进程
实际工程问题
并发请求限流:维护一个执行中的 Set,达到上限时用 Promise.race 等最快完成的释放槽位,再补充新任务
竞态条件(搜索框)
- 方案 1:
AbortController取消上一个请求(推荐) - 方案 2:版本号标记,回调时对比是否是最新请求,不是则丢弃
轮询 vs 长连接
| 方式 | 特点 | 适用 |
|---|---|---|
短轮询 setInterval | 简单,无效请求多 | 低频更新 |
| 长轮询 | 挂起请求到有数据 | 中频 |
SSE EventSource | 服务端推送,断线重连 | 消息流/日志 |
| WebSocket | 全双工,最实时 | 聊天/协同 |
三、事件循环
单线程协调同步、异步和渲染。核心:一个宏任务 → 清空所有微任务 → 可能渲染 → 下一个宏任务。
宏任务:setTimeout setInterval MessageChannel I/O UI事件微任务:Promise.then/catch/finally queueMicrotask MutationObserver
完整一轮顺序
- 执行当前宏任务(同步代码)
- 清空微任务队列(含微任务里新产生的微任务)
- 如需渲染:执行
requestAnimationFrame→ 样式计算 → Layout → Paint - 取下一个宏任务
高频易错点
rAF不是宏任务也不是微任务,在渲染阶段执行,在微任务之后MutationObserver是微任务setTimeout(fn, 0)实际有 1ms+ 延迟,嵌套 5 层以上强制 4msqueueMicrotask和Promise.resolve().then效果相同,都是微任务
四、内存管理
垃圾回收
V8 分代回收:新生代(短命对象,Scavenge)+ 老生代(长命对象,标记清除 + 标记整理)。
- 标记清除:从 GC Root 出发标记所有可达对象,清除未标记的
- 引用计数:循环引用时引用计数永远 > 0,无法回收 → 现代引擎不用此方案
内存泄漏
常见场景
- 定时器未清除:
setInterval的回调一直持有外部变量 - 事件监听未解绑:组件销毁后监听仍在
- 游离 DOM:从 DOM 移除但全局变量还持有引用
- 闭包持有大对象:事件回调捕获了不必要的大数据
WeakMap / WeakRef:弱引用,不阻止 GC,适合以 DOM 或对象为 key 的私有数据缓存
Chrome DevTools 排查流程
- Memory → Heap Snapshot
- 操作一次 → 快照 1;重复操作 → 快照 2
- Comparison 视图,过滤
+新增对象 - 点击可疑对象 → 看 Retainers 链,找到谁持有引用
五、现代特性
Proxy & Reflect
Proxy 在对象和外界之间加拦截层,可定义 13 种操作的自定义行为。Vue 3 响应式核心。
Vue 3 换 Proxy 的原因
| 对比 | Object.defineProperty | Proxy |
|---|---|---|
| 拦截粒度 | 属性级,逐个定义 | 对象级,一次代理 |
| 新增/删除属性 | 无法感知(需 $set) | 自动拦截 |
| 数组变化 | 只能劫持特定方法 | 完整支持 |
| 初始化性能 | 递归遍历(差) | 惰性代理(好) |
Reflect 的意义:让对象操作变函数形式;配合 Proxy 保持正确的 receiver(解决继承时 this 问题);失败返回 false 而不是抛异常
Proxy 局限:无法代理私有字段 #field;Date/Map 等内置对象的内部槽无法被代理正确转发
Generator & Iterator
Generator = 可暂停的函数,每次
yield暂停并向外传值,next()恢复。本质是协程。
async/await底层就是 Generator + 自动执行器(Promise 驱动next())for...of原理:调用对象的[Symbol.iterator]()获取迭代器,反复调用.next()直到done: true- 自定义迭代器:对象实现
[Symbol.iterator]方法即可被for...of/ 展开运算符消费
ES2020+ 实用特性
可选链 ?. 和空值合并 ??
?.:左侧为null/undefined立即短路返回undefined,不抛错??:只在左侧为null/undefined时取右值(区别于||,||对0 / '' / false也触发)
深克隆方案对比
| 方案 | 缺陷 |
|---|---|
JSON.parse(JSON.stringify) | 丢 undefined/函数/Symbol,Date 变字符串,不支持循环引用 |
| 手写递归 + WeakMap | 处理循环引用,支持 Date/RegExp/Map/Set |
structuredClone | 不支持函数/DOM 节点,部分环境不支持 |
逻辑赋值
&&=:左侧 truthy 才赋值||=:左侧 falsy 才赋值??=:左侧null/undefined才赋值(最精确,推荐)
六、请求层设计
如何封装统一 request?
目标:业务代码只关心入参和返回值,鉴权、错误处理、loading 全部在封装层处理。
核心分层
- 底层:axios 实例,统一 baseURL、timeout、headers
- 请求拦截器:注入 token、添加 traceId、标记 loading
- 响应拦截器:统一解包(
res.data)、状态码判断、错误抛出 - 业务层:只调用封装后的方法,不接触 axios 细节
追问
- 为什么不直接用 axios?→ 便于统一切换底层库(fetch/axios),集中维护鉴权和错误逻辑
- 如何支持不同 baseURL?→ 创建多个 axios 实例,各自配置
如何处理 token 过期和刷新?
无感刷新:请求返回 401 时,自动用 refresh token 换新 token,然后重放原请求,业务层无感知。
实现要点
- 拦截 401 响应,标记"正在刷新"状态
- 刷新期间的其他请求进队列等待,不能重复触发刷新
- 刷新成功 → 用新 token 重放队列里所有请求
- 刷新失败 → 清除 token,跳转登录页
- refresh token 本身过期 → 不重试,直接登出
追问
- 为什么要防重复刷新?→ 并发请求都 401 时,同时触发多次刷新会导致 refresh token 失效
- refresh token 放哪里?→ httpOnly Cookie 最安全,防 XSS 窃取
如何处理重复请求、取消请求、重试、loading?
重复请求去重
- 用请求的 url + method + params 生成唯一 key
- 同 key 的请求未完成时,取消新请求或取消旧请求(按场景选)
- 用
AbortController或 axios 的CancelToken实现取消
取消请求
AbortController:controller.abort()取消,signal传给 fetch- 路由跳转时取消当前页所有请求:维护一个 controller 集合,
beforeEach时全部 abort
重试
- 网络错误或 5xx 才重试,4xx 不重试(客户端错误重试没意义)
- 指数退避:第 1 次等 1s,第 2 次等 2s,第 3 次等 4s,避免瞬间打爆服务
- 最多重试 3 次,超过直接报错
loading
- 请求拦截器计数 +1,响应拦截器计数 -1,为 0 时关闭 loading
- 支持单个接口单独控制(传
silent: true跳过全局 loading)
如何做接口错误统一收敛?
分层处理
- 网络层错误(超时/断网):响应拦截器统一 toast 提示
- 业务错误(code !== 0):拦截器统一处理,特殊 code 单独映射
- 特殊码统一跳转:401 → 登录,403 → 无权限页,500 → 错误页
- 业务层只处理成功场景,不写 catch(错误已在拦截器兜底)
追问
- 有些接口需要自己处理错误怎么办?→ 传
customError: true跳过拦截器的错误处理,业务自己 catch - 如何上报接口错误?→ 在拦截器里统一接入 Sentry / 埋点,带上 traceId、url、status、耗时
七、高性能 JS
Long Task 是什么?如何处理?
Long Task:JS 执行超过 50ms 的任务。会阻塞主线程,导致页面无法响应输入和渲染,用户感知到卡顿。
为什么 50ms?:浏览器每帧 16.6ms,JS 超过 50ms 就会跨越多帧,用户感知到明显延迟
检测:Chrome Performance 面板红色标记 / PerformanceObserver 监听 longtask 类型
处理思路
- 分片执行:把大任务拆成小块,用
requestIdleCallback或setTimeout(fn, 0)在空闲时执行 - 移到 Web Worker:计算密集型任务完全脱离主线程
- 减少不必要的同步计算:缓存结果、惰性计算、按需触发
如何处理 10 万条数据?
核心原则:不要一次性渲染 10 万个 DOM,浏览器撑不住。
方案对比
| 方案 | 原理 | 适用 |
|---|---|---|
| 虚拟列表 | 只渲染可视区域的 DOM,滚动时复用 | 列表展示(推荐) |
| 分页 / 懒加载 | 按需加载数据 | 数据可分页 |
| 时间切片 | 用 requestIdleCallback 分批插入 DOM | 必须全部渲染时 |
| Web Worker | 计算在 Worker,结果传回主线程渲染 | 数据需要大量计算 |
虚拟列表核心:监听滚动位置,计算起始/结束索引,只渲染这个范围内的条目,用 padding 或 transform 撑开容器高度
JSON 大数据解析卡顿怎么办?
- 大 JSON 的
JSON.parse是同步的,会阻塞主线程 - 方案 1:放进 Web Worker 解析,完成后
postMessage传回 - 方案 2:后端分页返回,避免一次传大 JSON
- 方案 3:流式解析库(如
stream-json),适合 Node 端 - 方案 4:用
structuredClone替代序列化传递(Worker 内通信)
Web Worker 适合什么场景?
Web Worker 在独立线程运行,不能操作 DOM,通过
postMessage和主线程通信。
适合:大数据计算、JSON 解析、图片处理、加密/解密、复杂排序过滤、Excel 导出生成
不适合:需要操作 DOM、依赖全局状态、计算量小(通信开销反而更大)
追问
- SharedArrayBuffer 是什么?→ Worker 和主线程共享内存,避免数据拷贝开销,但需要跨域隔离头
- Worker 如何调试?→ Chrome DevTools Sources 面板有独立的 Worker 线程
如何拆分长任务?
requestIdleCallback:浏览器空闲时执行,适合低优先级任务(日志、预计算),不保证执行时机
requestAnimationFrame:每帧执行,适合和渲染相关的任务(动画、DOM 更新批处理)
setTimeout(fn, 0):简单粗暴的切片,兼容性好,但最小延迟约 4ms
scheduler.postTask(新 API):可以指定优先级(user-blocking / user-visible / background)
选择原则:和渲染相关用 rAF → 低优先级后台任务用 rIC → 通用切片用 setTimeout → 需要优先级控制用 scheduler
八、业务数据结构
扁平数组转树
后端常返回带
id / pid的扁平列表,前端需要转成嵌套树结构用于菜单、组织架构、评论等。
思路:先用 Map 建立 id → node 的索引,再遍历一次:有 pid 的挂到父节点的 children,没有 pid 的是根节点
时间复杂度:O(n),比递归查找的 O(n²) 快得多,数据量大时差距明显
注意点:pid 可能是 0 或 null 或 undefined,判断根节点时要统一处理;children 默认给 [] 还是不加,根据业务决定
树转扁平数组
思路:DFS 或 BFS 遍历,每个节点去掉 children,push 进结果数组
场景:权限勾选后把树结构传给后端、搜索过滤时需要遍历所有节点
权限树查找
场景:用户有一组权限 code,需要在权限树中找出所有匹配节点,或判断是否有某个权限
思路
- 初始化时把权限 code 存进
Set,查找 O(1) - 树形权限:父节点权限包含子节点时,需要递归展开所有子 code 再存 Set
- 按钮级权限:直接
permissionSet.has(code)判断
菜单路由生成
思路:后端返回用户有权限的菜单树 → 前端递归转成 vue-router / react-router 的 routes 配置 → addRoute 动态注册
注意点
- 菜单显示和路由权限是两件事,菜单可以隐藏但路由要保留(或反过来)
- 刷新时路由丢失:动态路由在
router.beforeEach里判断,没有则重新拉取并注册
字典缓存设计
字典(数据字典):状态码 → 文字的映射,如
1 → 启用,0 → 禁用。
常见问题:每个组件各自请求字典接口 → 重复请求、数据不一致
方案
- 全局请求一次,存 Pinia / Vuex / 全局变量
- 按需懒加载:第一次用到某类字典时请求,后续命中缓存
- 字典 Map:
Map<typeCode, Map<value, label>>,查询 O(1) - 时效控制:字典变化频率低,可以加本地缓存(localStorage + 版本号),减少请求
表格 schema 数据转换
后端返回字段名和前端展示名不一致,或需要格式化,统一用 schema 配置驱动
核心思路:定义列配置数组(字段名、标题、格式化函数),渲染时统一走 schema 处理,业务代码不写硬编码的字段映射
追问
- 为什么不直接在模板里处理?→ 逻辑分散,字段改了要改多处;schema 集中管理,改一处全部生效
- 如何处理嵌套字段?→ 用 lodash
_.get(row, 'a.b.c')或自己实现路径取值
九、模块化与工程设计
ESM 和 CommonJS 区别
| 对比 | CommonJS(CJS) | ES Module(ESM) |
|---|---|---|
| 语法 | require / module.exports | import / export |
| 加载时机 | 运行时动态加载 | 编译时静态分析 |
| 输出 | 值的拷贝 | 值的实时绑定(live binding) |
| 异步 | 不支持 | 支持 import() 动态导入 |
| 环境 | Node.js 原生 | 浏览器原生,Node 12+ 支持 |
| this | module 对象 | undefined |
live binding:ESM 导出的变量,模块内修改后,导入方拿到的是最新值;CJS 是拷贝,修改后导入方不变
Tree shaking 为什么依赖 ESM?
Tree shaking = 打包时自动删除未使用的代码。
原因:ESM 是静态结构,import/export 在编译时就能确定依赖关系,打包工具(Rollup/Webpack)可以静态分析哪些导出没有被使用,直接删掉
CJS 的 require 是运行时动态执行,打包工具无法静态确定哪些会被用到,所以 tree shaking 不了
追问
- 为什么有时候 tree shaking 没生效?→ 副作用没标记(package.json 的
sideEffects字段)、或用了 CJS 库、或 Babel 把 ESM 编译成 CJS 了 sideEffects: false是什么?→ 告诉打包工具这个包没有副作用,可以放心删未使用的导出
循环依赖有什么问题?
A 引用 B,B 引用 A,形成循环。
CJS:循环依赖时,require 返回的是未完成的对象(半成品),可能拿到 undefined,很难排查
ESM:通过 live binding 支持循环,但仍然要注意初始化顺序,变量在使用时可能还未赋值
如何避免:把公共部分抽成第三个模块 C,A 和 B 都引用 C,打破循环
如何设计 utils,而不是垃圾桶?
utils 最常见的问题:所有人都往里扔,最后变成一个什么都有、没有人敢删的文件。
原则
- 按职责分文件:
date.ts / string.ts / array.ts / request.ts,不放同一个utils.ts - 只放纯函数:无副作用,输入确定输出确定,方便测试和 tree shaking
- 有明确归属的逻辑不放 utils:业务逻辑放 composables/hooks,请求放 api 层
- 定期清理:用 ESLint 的
no-unused-vars或打包分析找出没人用的函数
如何设计 SDK / 插件机制?
SDK:给外部使用的封装库。插件机制:让使用者扩展核心功能而不修改核心代码。
SDK 设计要点
- 最小化对外暴露:只暴露必要的 API,内部实现细节不导出
- 初始化配置化:
SDK.init({ apiKey, baseURL, timeout }) - 链式调用 / Builder 模式:让调用更流畅
- 错误处理统一:SDK 内部消化异常,对外抛出语义清晰的错误
插件机制核心:定义钩子(hook)点,插件注册时把回调挂到对应钩子,核心流程执行时依次调用
- Vue 插件:
app.use(plugin)→ 调用plugin.install(app) - Webpack 插件:Tapable 的 hooks,
compiler.hooks.emit.tap('MyPlugin', callback) - 自己实现:维护一个 hooks Map,
register(hookName, fn)注册,call(hookName, ...args)触发
追问
- 插件和中间件的区别?→ 中间件是线性链路(洋葱模型),插件是挂载到特定事件点,两者都是扩展机制
- 如何保证插件执行顺序?→ 注册时带优先级,或按注册顺序严格执行