JavaScript 面试题
1. 闭包是什么?利弊?如何解决弊端?
极简口述总结(面试背诵)
闭包是内层函数引用外层函数变量,并在外部执行形成的作用域环境
优点:封装私有变量、缓存、柯里化、保持状态
缺点:变量无法自动回收,易内存泄漏,查找作用域性能差
解决方案:用完手动置空引用、清除定时器 / 事件、缩短闭包生命周期、减少不必要的闭包
什么是闭包
标准定义:闭包 = 内层函数 + 内层函数对外层函数作用域变量的引用
简单说:函数 A 内部有函数 B,B 访问了 A 的局部变量,B 被外部执行,就形成闭包
口述面试:当一个内部函数引用了外部函数的局部变量,且内部函数在外部函数执行完后仍能被访问调用,这个内部函数连同它所持有的外层作用域环境,就称为闭包
形成闭包的3个必要条件:(面试必背)
- 存在函数嵌套(内层函数、外层函数)
- 内层函数引用外层函数的局部变量 / 参数
- 内层函数在外层函数作用域之外被调用
基础示例
1 function outer() { 2 let count = 0; // 自由变量 3 // 内层函数,引用count 4 function inner() { 5 count++; 6 console.log(count); 7 } 8 return inner; // 返回内层函数 9 } 10 const fn = outer(); 11 fn(); // 1 12 fn(); // 2
闭包的好处(用途,面试举例)
- 保存私有变量,实现模块化、封装:外部无法直接修改内部变量,只能通过暴露的方法操作,数据安全
1 function person() { 2 let age = 18; // 私有变量 3 return { 4 getAge: () => age, 5 addAge: () => age++ 6 } 7 } 8 const p = person(); 9 console.log(p.getAge()); // 18 10 p.addAge(); 11 console.log(p.getAge()); // 19 12 // p.age 访问不到
- 变量持久化,缓存数据:计数器、节流防抖缓存、单例、缓存函数结果
- 实现柯里化、函数偏应用:拆分参数,延迟执行
1 function add(a) { 2 return b => a + b; 3 } 4 const add2 = add(2); 5 add2(3); // 5
- 解决循环定时器 / 事件绑定变量捕获问题
1 for(var i = 0; i < 3; i++){ 2 setTimeout(()=>console.log(i), 1000) 3 } 4 // 输出3,3,3 无闭包问题 5 // 用闭包解决 6 for(var i = 0; i < 3; i++){ 7 (function(j){ 8 setTimeout(()=>console.log(j),1000) 9 })(i) 10 } 11 // 0,1,2
- 模拟块级作用域(ES5 无 let/const 时代):立即执行函数 IIFE + 闭包创立独立作用域
闭包的弊端(核心考点)
- 内存泄漏 / 内存占用过高:闭包会持有外层作用域,自由变量不会被 GC 垃圾回收,大量创建闭包、长期不销毁会持续占用堆内存
- 容易造成内存泄漏场景
- 闭包绑定 DOM 元素,页面销毁时未解除引用
1 function bindBtn() { 2 const btn = document.getElementById('btn'); 3 let num = 1; 4 btn.onclick = () => { 5 num++; 6 } 7 } 8 // 页面卸载时,btn、num永远无法回收
-
- 全局长期存储闭包函数,持续引用大量数据
- 定时器、事件监听使用闭包,忘记清除定时器 / 解绑事件
- 变量污染、难以调试:私有变量无法直接打印查看,多层闭包变量追踪复杂;多人协作易出现意外修改自由变量
- 性能耗损:作用域链查找变量比普通函数慢;大量闭包会增加 GC 负担
如何解决闭包带来的弊端(面试官必追问)
- 主动切断引用,手动释放内存:将闭包引用的变量、DOM、定时器置为 null,断开引用链,让 GC 回收
1 function bindBtn() { 2 const btn = document.getElementById('btn'); 3 let num = 1; 4 function click() { 5 num++; 6 } 7 btn.onclick = click; 8 9 // 页面销毁时执行清除 10 function clear() { 11 btn.onclick = null; // 解绑事件 12 num = null; 13 } 14 return clear; 15 }
- 及时清除定时器、事件监听
- clearTimeout / clearInterval 清除计时器
- removeEventListener 解绑 DOM 事件
- 避免全局长期保存闭包实例:不要将闭包函数挂载在全局变量长期持有,用完及时置空
let fn = outer(); fn(); fn = null; // 释放闭包
- 合理控制闭包生命周期:临时一次性场景使用,执行完毕立即销毁,不要长期缓存闭包
- 尽量使用 ES6 let/const、块级作用域替代冗余闭包:ES6 有块级作用域,很多 ES5 需要 IIFE 闭包的场景可以直接替换,减少闭包使用
- 模块化规范管理私有变量:使用 ESModule、class 私有属性 #、模块化方案,减少多层嵌套闭包
- 调试:Chrome 内存面板排查泄漏:Memory 面板快照,查找未回收的闭包作用域、DOM 引用,定位内存泄漏
2. 深拷贝和浅拷贝的区别
极简口述总结
浅拷贝只复制第一层,嵌套引用共享内存,修改深层会污染原数据;
深拷贝递归复制全部层级,生成全新独立对象,修改互不影响;
浅拷贝性能好但有数据污染风险,深拷贝数据安全但性能开销更大。
核心底层原理
JS 数据分两类:
- 基本数据类型:String/Number/Boolean/null/undefined/Symbol/BigInt,存栈内存,赋值直接复制值
- 引用数据类型:Object/Array/Function/Date 等,栈存地址指针,堆存真实数据
浅拷贝:只复制一层栈地址,新对象和原对象共用堆里的深层数据;只拷贝第一层属性,嵌套引用类型还是共享同一个堆地址
深拷贝:递归复制所有层级,完全开辟新堆内存;新对象、原对象彻底隔离,修改任意一方互不影响
两者区别(面试分点背诵)
| 维度 | 浅拷贝 | 深拷贝 |
| 拷贝层级 | 仅拷贝第一层属性 | 递归拷贝所有嵌套层级 |
| 内存关系 | 引用类型共用堆内存地址 | 全部开辟全新堆内存,完全独立 |
| 修改影响 | 修改嵌套对象,原对象同步变化 | 无论改多少层,互不干扰 |
| 性能 | 速度快、开销小 | 递归遍历,大数据性能较差 |
| 适用场景 | 简单单层对象,无嵌套引用 | 多层嵌套对象、后端回显数据隔离 |
浅拷贝常见实现方式(面试要列举)
- 展开运算符 ...
- Object.assign({}, obj)
- 数组方法:slice() / concat()
- 手动循环遍历第一层赋值
共同缺陷:只能处理一层,嵌套引用会共享内存
深拷贝实现方式 + 优缺点(高频考点)
- JSON.parse(JSON.stringify())
1 const newObj = JSON.parse(JSON.stringify(oldObj))
-
- 优点:写法极简,日常接口库数据常用
- 缺点(面试必说坑)
- 无法拷贝 undefined、Symbol、函数,直接丢失
- 日期会转为字符串、正则 / Error 对象丢失特性
- 存在循环引用直接报错
- 无法拷贝 Map、Set
- 递归手写深拷贝(面试官最爱让手写)
核心逻辑:
-
- 判断类型:基础类型直接返回
- 区分数组 / 对象,创建空容器
- 循环遍历键,递归赋值
1 function deepClone(obj) { 2 // 基础类型/ null 直接返回 3 if (obj === null || typeof obj !== 'object') return obj; 4 // 区分数组、对象 5 const target = Array.isArray(obj) ? [] : {}; 6 // 递归拷贝每一个属性 7 for (let key in obj) { 8 if (obj.hasOwnProperty(key)) { 9 target[key] = deepClone(obj[key]); 10 } 11 } 12 return target; 13 }
优化扩展:兼容 Date、RegExp、处理循环引用、Map/Set
- structuredClone(浏览器原生 API,ES2022)
1 const copy = structuredClone(obj)
-
- 优点:原生支持 Date、RegExp、Map、Set,可处理循环引用
- 缺点:不支持函数、DOM 元素,低版本浏览器不兼容
- 第三方库 Lodash _.cloneDeep():生产项目常用,兼容性强,处理边界完整
3. 实现拷贝的方法有哪些?
浅拷贝:数组可以用拓展运算符 [ ...arr ],或者 slice();对象可以用 Object.assign((), obj)
深拷贝:JSON.parse(JSON.stringify(obj)),或封装递归方法,或使用第三方库的方法,比如JQuery的$.extend({}, obj),或者lodash的cloneDeep
4. JSON.parse(JSON.stringify(obj)) 处理的缺点
- 如果对象中有属性是function或undefined,处理后会被过滤掉
- 如果属性值是对象,且由构造函数生成的实例对象,会丢弃对象的constructor
5. 当执行 setTimeout、new Promise、fetch 时,JS引擎、Web APIs、调用栈、宏任务队列、微任务队列是如何协同工作的?为什么需要事件循环机制?
JS 主线程拥有调用栈,异步操作交给浏览器 Web APIs 多线程处理,任务完成后回调进入对应队列,由事件循环调度。
- setTimeout(宏任务):执行到 setTimeout 时,回调函数交给 Web APIs 定时器线程计时。计时完成后,回调被推入宏任务队列。等到调用栈清空、所有微任务执行完毕,事件循环才会取出该回调执行。
- new Promise:Promise 构造器内的函数是同步代码,直接在调用栈执行。调用 resolve/reject 后,只会把
.then、.catch回调放入微任务队列,不进入 Web APIs。当前同步代码执行完,会一次性清空全部微任务队列。 - fetch(网络请求,宏任务):fetch 网络请求交由 Web APIs 网络线程异步发起,主线程不会阻塞。后端数据返回后,成功回调被加入宏任务队列,等待本轮微任务全部执行结束后再运行。
整体协作流程
- 先执行全局同步代码,同步函数入栈执行。
- 遇到异步方法:定时器、网络请求交给 Web APIs 后台执行;Promise 的 then 回调存入微任务队列。
- 调用栈清空后,优先一次性执行完所有微任务。
- 再从宏任务队列取出一个宏任务放入调用栈执行。
- 每执行完一个宏任务,再次清空新增微任务,如此循环,这就是事件循环。
- JavaScript 是单线程语言,为了防止多线程同时操作 DOM 引发冲突,主线程只能串行执行代码。
- 如果耗时的定时器、网络请求同步等待,会阻塞页面渲染与用户交互。因此把耗时操作交给浏览器多线程 Web APIs 后台运行,实现非阻塞。
- 后台线程完成任务后不能随意打断主线程代码。事件循环作为调度机制,保证主线程空闲时再有序执行异步回调,兼顾执行顺序与运行效率。
极简口述版:JS 主线程维护调用栈,耗时异步操作交给浏览器 Web APIs。setTimeout 和 fetch 完成后回调进入宏任务队列,Promise.then 进入微任务队列。事件循环规则:先跑完所有同步代码,栈空后清空全部微任务,再执行一个宏任务,不断循环。
之所以要有事件循环,是因为 JS 单线程避免 DOM 竞争冲突,同时借助浏览器多线程实现异步非阻塞,依靠事件循环有序调度所有异步回调,防止主线程阻塞卡死。
6. 如何判断空对象?如何区分数据类型?
判断空对象
用JSON的stringify转成字符串后,跟‘{}’对比;
用ES6,判断Object.keys(targetObject)返回值数组的长度是否为0;
用ES5,判断Object.getOwnPropertyNames(targetObject)返回的数组
7. 闭包和作用域的关系?
作用域是静态代码结构,闭包是运行时产生的作用域环境,闭包延长了作用域生命周期
8. GC 垃圾回收为什么回收不了闭包变量?
GC 判断规则:是否存在有效引用。闭包函数内部持有自由变量的引用,引用不为空,变量就不会被回收
9. let/const 会形成闭包嘛?
会,只要内部函数引用外部块 / 函数变量并外部调用,就构成闭包,和 var 无关
10. class 私有属性是闭包嘛?
不是,# 私有属性是语法层面封装,不依赖函数作用域闭包实现
11. 赋值=、浅拷贝、深拷贝三者区别?
- 直接赋值 let x = obj:完全共用同一个地址,改任意一层都会互相影响
- 浅拷贝:第一层独立,深层共用
- 深拷贝:所有层级完全独立
12. 什么时候用浅拷贝,什么时候用深拷贝
- 浅拷贝:对象只有单层属性、简单数组,追求性能
- 深拷贝:表单回显、表格编辑、多层嵌套树形数据,需要隔离原始数据防止污染
13. 手写递归深拷贝要考虑哪些边界?
null、基础类型、数组、对象、Date、正则、循环引用、Symbol 键、Map/Set

浙公网安备 33010602011771号