在前端面试中,闭包、数组操作和异步编程是高频考点。它们不仅考察候选人对 JavaScript 核心机制的理解,也检验实际编码能力。比如闭包,看似基础,但能延伸出内存管理、模块化设计等深层话题;数组去重和扁平化则常用来评估算法思维和对原生 API 的掌握;而 Promise 更是现代异步编程的基石,直接关系到工程实践中如何组织异步逻辑。
1. 对闭包的看法,为什么要用闭包?
闭包是 JavaScript 中一个核心概念,简单来说:一个函数能够访问并记住其外部作用域中的变量,即使这个函数在其外部作用域之外被执行,这种现象就叫闭包。
举个常见例子:
js
function createCounter() {
let count = 0;
return function () {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
这里 counter
函数虽然在全局执行,但它依然能访问到 createCounter
内部的 count
变量------这就是闭包。
为什么需要闭包?它的典型用途有哪些?
- 数据私有化:模拟私有变量,避免全局污染。
- 模块模式实现:封装内部状态和方法,只暴露接口。
- 函数柯里化 / 偏函数应用:保存部分参数。
- 事件回调、定时器中保持上下文 :比如
setTimeout
中引用外部变量。 - 防抖节流函数:需要保存定时器或上一次执行时间。
但闭包也有代价:可能造成内存泄漏。因为内部函数引用了外部变量,导致这些变量无法被垃圾回收,如果滥用或未及时解引用,会占用过多内存。
闭包执行机制图解
function createCounter() {
let count = 0;
return function() {
return ++count;
};
}
"]
end
subgraph EXECUTION[" 执行阶段"]
B["调用 createCounter()
"] C["创建局部变量
count = 0
作用域: createCounter"] D["返回匿名函数
捕获: count"] E["
counter = createCounter()
引用: 闭包对象"] end subgraph SCOPE[" 作用域链"] F["执行
counter()
执行栈帧"] G["查找
count
当前作用域: 无"] H["沿作用域链向上
"] I["在
createCounter
作用域中找到
count = 0
"]
J["读取并修改 count = 1
闭包引用"] end subgraph MEMORY[" 内存模型"] K[" 栈内存
counter
指向堆"]
L[" 堆内存闭包对象: { count: 1 }"] M[" GC 回收
因引用未断开, count 保留在堆中"] end subgraph LIFECYCLE[" 生命周期"] N["
counter()
再次调用新栈帧"] O["沿作用域链找到
count = 1
闭包引用"] P["读取并修改
count = 2
状态保持"] end A --> B B --> C C --> D D --> E E --> F F --> G G -->|否| H H --> I I --> J J --> K K --> L L --> M M --> N N --> O O --> P %% 视觉语义 classDef stackNode fill:#4e79a722,stroke:#4e79a7,stroke-width:2px,rx:10px classDef heapNode fill:#59a14f22,stroke:#59a14f,stroke-width:2px,rx:10px classDef scopeNode fill:#f28e2c22,stroke:#f28e2c,stroke-width:2px,rx:10px classDef closureNode fill:#e1575922,stroke:#e15759,stroke-width:2.5px,rx:10px classDef gcNode fill:#9c755f22,stroke:#9c755f,stroke-width:2px,rx:10px class B,C,D,E stackNode class L heapNode class F,G,H,I,J scopeNode class D,E,J,O,P closureNode class M gcNode %% 关键路径高亮 linkStyle 4 stroke:#e15759,stroke-width:3px linkStyle 10 stroke:#59a14f,stroke-width:2.5px linkStyle 13 stroke:#f28e2c,stroke-width:2.5px
图解说明:
createCounter()
被调用时,会创建一个新的执行上下文,其中包含count
。- 返回的内部函数虽然脱离了原作用域,但它的
[[Scope]]
链仍然指向createCounter
的词法环境。 - 当
counter()
执行时,JS 引擎在当前作用域找不到count
,于是沿着作用域链向上查找,在createCounter
的环境中找到它。 - 即使
createCounter
已经出栈,由于内部函数仍持有对其变量的引用,GC 不会回收这部分内存------这就是闭包的"记忆"能力,也是潜在的内存风险点。
🔍 面试官可能会追问:"如何避免闭包导致的内存泄漏?"
回答要点:及时解除引用(如设为
null
),避免在长生命周期对象中保存大量数据,合理使用 WeakMap/WeakSet。
2. 手写数组去重函数
数组去重是常见需求,比如处理用户标签、搜索历史等。实现方式多样,关键在于权衡性能、兼容性和数据类型支持。
最简单的思路是使用 Set
:
js
function unique(arr) {
// 🔍 Set 自动去重,适用于基本类型
return [...new Set(arr)];
}
但如果要考虑对象去重,或者根据某个属性去重,就需要更灵活的方式。
按对象属性去重(如 id)
js
function uniqueBy(arr, key) {
const seen = new Map();
return arr.filter(item => {
const k = item[key];
// 🔍 使用 Map 记录已出现的 key 值,避免重复
if (seen.has(k)) {
return false;
}
seen.set(k, true);
return true;
});
}
兼容所有类型(包括对象)的通用去重
js
function deepUnique(arr) {
const result = [];
const seen = new WeakMap(); // 🔍 WeakMap 可以以对象为键,且不影响垃圾回收
for (let item of arr) {
if (typeof item === 'object' && item !== null) {
if (!seen.has(item)) {
seen.set(item, true);
result.push(item);
}
} else {
// 基本类型用普通数组 indexOf 判断
if (result.indexOf(item) === -1) {
result.push(item);
}
}
}
return result;
}
⚠️ 注意:
indexOf
对 NaN 不敏感(NaN !== NaN),所以它不能正确识别多个 NaN。可以用Number.isNaN()
特殊处理。
性能对比图
arr
类型: [Number, String, Boolean, Object, NaN]"] end subgraph STRATEGY[" 去重策略"] B{"是否全是基本类型?
Number/String/Boolean/Symbol/undefined/null"} C[" 使用 Set 去重
[...new Set(arr)]
哈希表 O(1) 查找"] D{"是否含引用类型?
Object/Array/Function"} E[" 使用 Map 缓存
Map
序列化键"] F[" 使用 WeakMap
WeakMap
弱引用, 不影响 GC"] G[" 降级方案
arr.filter((v,i) => arr.indexOf(v) === i)
嵌套循环 O(n²)"] end subgraph IMPL[" 实现细节"] H[" Set 策略
const unique = [...new Set(arr)]
自动处理 NaN"] I[" Map 策略
const seen = new Map();
arr.filter(v => !seen.has(JSON.stringify(v)) && seen.set(JSON.stringify(v), true))
"]
J[" 降级策略arr.filter((v,i) => arr.indexOf(v) === i)
无法处理 NaN/Obj"] end subgraph COMPLEXITY[" 时间复杂度"] K["Set 去重: O(n)
空间: O(n)"] L["Map 缓存: O(n·k)
k=平均对象序列化长度"] M["降级方案: O(n²)
空间: O(1)"] end subgraph BENCHMARK[" 性能基准"] N["1000 条基本类型
Set: 0.2ms | indexOf: 12ms"] O["1000 条对象数组
Map: 8ms | indexOf: 1200ms"] P["1000 条含 NaN
Set: 自动去重 | indexOf: ❌ 无法处理"] end A --> B B -- 是 --> C B -- 否 --> D D -- 对象数组 --> E D -- DOM 元素 --> F B -- 用 indexOf --> G C --> H E --> I G --> J H --> K I --> L J --> M K --> N L --> O M --> P %% 视觉语义 classDef inputNode fill:#4e79a722,stroke:#4e79a7,stroke-width:2px,rx:10px classDef primitiveNode fill:#59a14f22,stroke:#59a14f,stroke-width:2.5px,rx:10px classDef complexNode fill:#f28e2c22,stroke:#f28e2c,stroke-width:2px,rx:10px classDef fallbackNode fill:#e1575922,stroke:#e15759,stroke-width:2px,rx:10px classDef perfNode fill:#9c755f22,stroke:#9c755f,stroke-width:2px,rx:10px class A inputNode class B,C,H,K,N primitiveNode class D,E,F,I,L,O complexNode class G,J,M,P fallbackNode class K,L,M,N,O,P perfNode %% 关键路径高亮 linkStyle 0 stroke:#4e79a7,stroke-width:2.5px linkStyle 3 stroke:#f28e2c,stroke-width:2.5px linkStyle 6 stroke:#e15759,stroke-width:2px linkStyle 9 stroke:#9c755f,stroke-width:2px
图解说明:
- Set 方案:底层基于哈希表,去重效率最高,适合现代浏览器。
- Map/WeakMap:适合对象去重,WeakMap 不阻止垃圾回收,更安全。
- indexOf / includes:写法简单,但每轮都要遍历已有结果,性能差,尤其对大数据量不友好。
🔍 面试官可能问:"Set 是怎么做到去重的?"
回答:Set 内部使用"Same-value-zero"比较算法,除了
NaN
等于自身外,其余遵循严格相等(===)。对于对象,比较的是引用地址。
3. 手写数组扁平化函数
数组扁平化是指将多维数组转为一维数组,例如 [1, [2, [3]]]
→ [1, 2, 3]
。
方法一:递归 + concat
js
function flatten(arr) {
let result = [];
for (let item of arr) {
if (Array.isArray(item)) {
// 🔍 递归处理子数组,并展开合并
result = result.concat(flatten(item));
} else {
result.push(item);
}
}
return result;
}
方法二:使用 reduce 优化写法
js
function flatten(arr) {
return arr.reduce((acc, item) => {
return acc.concat(Array.isArray(item) ? flatten(item) : item);
}, []);
}
方法三:迭代 + 栈(避免爆栈)
递归在深度过大时可能导致调用栈溢出。可以用栈模拟递归过程:
js
function flatten(arr) {
const result = [];
const stack = [...arr]; // 🔍 用数组模拟栈,避免递归
while (stack.length > 0) {
const next = stack.pop();
if (Array.isArray(next)) {
// 是数组则展开并压入栈
stack.push(...next); // 🔍 展开后逆序入栈,保证顺序一致
} else {
result.unshift(next); // 🔍 从前面插入,保持原顺序
}
}
return result;
}
更优做法:
result.push(next)
+ 最后reverse()
,避免频繁unshift
。
扁平化执行流程图(Mermaid 时序图)
图解说明:
- 使用栈替代递归,避免深层嵌套导致的栈溢出。
- 每次从栈顶取出元素,如果是数组就展开后重新压入(注意顺序)。
- 非数组元素直接加入结果数组。
- 最终顺序可通过
reverse
调整。
🔍 面试官可能问:"如何控制扁平化深度?"
回答:可以在函数中加一个
depth
参数,递归时递减,等于 0 时停止展开。
4. 介绍下 Promise 的用途和性质
Promise 是 ES6 引入的异步编程解决方案,用来更好地组织回调逻辑,解决"回调地狱"问题。
主要用途:
- 封装异步操作(如 AJAX、定时器、文件读取)。
- 实现链式调用
.then().then()
。 - 统一错误处理
.catch()
。 - 支持并发控制(
Promise.all
,Promise.race
)。
Promise 的三个状态:
- pending:初始状态,进行中。
- fulfilled:成功状态,表示操作完成。
- rejected:失败状态,表示操作失败。
状态一旦从 pending
变为 fulfilled
或 rejected
,就不可逆,这是 Promise 的核心特性之一。
Promise 的基本结构
js
const promise = new Promise((resolve, reject) => {
// 异步操作
setTimeout(() => {
Math.random() > 0.5 ? resolve('success') : reject('fail');
}, 1000);
});
promise
.then(res => {
console.log(res); // success
// 🔍 then 返回新 Promise,支持链式调用
return res + '!';
})
.then(res => {
console.log(res); // success!
})
.catch(err => {
console.error(err);
})
.finally(() => {
console.log('done');
});
Promise 状态流转图
" as pending pending --> fulfilled: resolve(value) state "fulfilled
" as fulfilled pending --> rejected: reject(reason) state "rejected
" as rejected fulfilled --> [*] rejected --> [*] note right of fulfilled .then(onFulfilled) 可被调用
异步微任务执行 end note note right of rejected .catch(onRejected) 可被调用
错误冒泡机制 end note %% 视觉语义 classDef pendingState fill:#4e79a722,stroke:#4e79a7,stroke-width:2px classDef fulfilledState fill:#59a14f22,stroke:#59a14f,stroke-width:2.5px classDef rejectedState fill:#e1575922,stroke:#e15759,stroke-width:2.5px classDef handlerNote fill:#9c755f11,stroke:#9c755f,stroke-dasharray: 5 5 class pending pendingState class fulfilled fulfilledState class rejected rejectedState class note handlerNote
图解说明:
- 初始状态为
pending
。 - 调用
resolve(value)
后变为fulfilled
,触发.then
中的成功回调。 - 调用
reject(reason)
后变为rejected
,触发.catch
或.then
的失败回调。 - 状态一旦改变,不会再变,保证了异步结果的确定性。
🔍 面试官可能问:"Promise 构造函数中的代码是同步还是异步执行?"
回答:构造函数内的函数体是同步执行 的,只有
resolve/reject
后的.then
回调才是异步微任务。
5. Promise 和 Callback 有什么区别?
Callback 是早期异步编程方式,但存在明显缺陷。Promise 是对其的改进。
对比维度:
维度 | Callback | Promise |
---|---|---|
可读性 | 回调地狱,嵌套深 | 链式调用,扁平化 |
错误处理 | 需每个回调写 error 判断 | 统一 .catch() |
状态管理 | 无状态,易重复执行 | 有状态,不可逆 |
并发控制 | 手动管理 | Promise.all / race |
执行时机 | 可能同步或异步,不统一 | .then 回调为微任务 |
典型 Callback 回调地狱
js
getData(function(a) {
getMoreData(a, function(b) {
getEvenMoreData(b, function(c) {
console.log(c);
});
});
});
改造成 Promise 链式调用
js
getData()
.then(a => getMoreData(a))
.then(b => getEvenMoreData(b))
.then(c => console.log(c))
.catch(err => console.error(err));
执行流程对比图(Mermaid 时序图)
图解说明:
- Callback:控制权交给异步函数,层层嵌套,逻辑分散。
- Promise:返回一个"承诺",主线程可以链式注册后续操作,逻辑集中,易于维护。
🔍 面试官可能问:"Promise 能解决所有异步问题吗?"
回答:不能完全解决。虽然解决了回调地狱,但
.then
链仍不够直观。ES7 引入async/await
,让异步代码看起来像同步,是目前更优的写法。
小结
这些题目看似基础,实则层层递进。面试官往往通过简单问题考察你的知识深度、编码习惯和系统思维。
- 闭包:不仅要会用,还要理解其背后的执行上下文和内存机制。
- 数组操作:写出可用代码只是第一步,要能分析时间复杂度,权衡不同方案。
- Promise:掌握状态机模型,理解微任务机制,能对比不同异步方案的优劣。
在面试中,建议采用"先简后深"的策略:
- 先给出简洁可行的实现(如用
Set
去重); - 主动说明局限性(如不支持对象);
- 再提出优化方案(Map + WeakMap);
- 最后补充边界情况(NaN、循环引用等)。
这样既能展示扎实基础,又能体现工程思维,更容易赢得面试官青睐。