在前端面试中,闭包、数组操作和异步编程是高频考点。它们不仅考察候选人对 JavaScript 核心机制的理解,也检验实际编码能力。比如闭包,看似基础,但能延伸出内存管理、模块化设计等深层话题;数组去重和扁平化则常用来评估算法思维和对原生 API 的掌握;而 Promise 更是现代异步编程的基石,直接关系到工程实践中如何组织异步逻辑。
1. 对闭包的看法,为什么要用闭包?
闭包是 JavaScript 中一个核心概念,简单来说:一个函数能够访问并记住其外部作用域中的变量,即使这个函数在其外部作用域之外被执行,这种现象就叫闭包。
举个常见例子:
function createCounter() {
let count = 0;
return function () {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
这里 counter 函数虽然在全局执行,但它依然能访问到 createCounter 内部的 count 变量——这就是闭包。
为什么需要闭包?它的典型用途有哪些?
- 数据私有化:模拟私有变量,避免全局污染。
- 模块模式实现:封装内部状态和方法,只暴露接口。
- 函数柯里化 / 偏函数应用:保存部分参数。
- 事件回调、定时器中保持上下文:比如
setTimeout中引用外部变量。 - 防抖节流函数:需要保存定时器或上一次执行时间。
但闭包也有代价:可能造成内存泄漏。因为内部函数引用了外部变量,导致这些变量无法被垃圾回收,如果滥用或未及时解引用,会占用过多内存。
闭包执行机制图解
%% Mermaid Theme: "ClosureScope"
%% COLORMAP: stack:#4e79a7, heap:#59a14f, scope:#f28e2c, closure:#e15759, gc:#9c755f
%% LAYER: setup(0) -> execution(1) -> scope(2) -> memory(3) -> lifecycle(4)
graph TD
subgraph SETUP["<i class='fa fa-code'></i> 函数定义"]
A["<code>function createCounter() {<br/> let count = 0;<br/> return function() {<br/> return ++count;<br/> };<br/>}</code>"]
end
subgraph EXECUTION["<i class='fa fa-play-circle'></i> 执行阶段"]
B["调用 <code>createCounter()</code><br/><i class='fa fa-arrow-down'></i>"]
C["创建局部变量 <code>count = 0</code><br/><span style='color:#4e79a7'>作用域: createCounter</span>"]
D["返回匿名函数<br/><span style='color:#e15759'>捕获: count</span>"]
E["<code>counter = createCounter()</code><br/><span style='color:#59a14f'>引用: 闭包对象</span>"]
end
subgraph SCOPE["<i class='fa fa-sitemap'></i> 作用域链"]
F["执行 <code>counter()</code><br/><span style='color:#4e79a7'>执行栈帧</span>"]
G["查找 <code>count</code><br/><span style='color:#f28e2c'>当前作用域: 无</span>"]
H["沿作用域链向上<br/><i class='fa fa-arrow-up'></i>"]
I["在 <code>createCounter</code> 作用域中<br/>找到 <code>count = 0</code>"]
J["读取并修改 <code>count = 1</code><br/><span style='color:#e15759'>闭包引用</span>"]
end
subgraph MEMORY["<i class='fa fa-memory'></i> 内存模型"]
K["<i class='fa fa-microchip'></i> 栈内存<br/><code>counter</code> 指向堆"]
L["<i class='fa fa-database'></i> 堆内存<br/>闭包对象: { count: 1 }"]
M["<i class='fa fa-recycle'></i> GC 回收<br/><span style='color:#9c755f'>因引用未断开, count 保留在堆中</span>"]
end
subgraph LIFECYCLE["<i class='fa fa-redo'></i> 生命周期"]
N["<code>counter()</code> 再次调用<br/><span style='color:#4e79a7'>新栈帧</span>"]
O["沿作用域链找到 <code>count = 1</code><br/><span style='color:#e15759'>闭包引用</span>"]
P["读取并修改 <code>count = 2</code><br/><span style='color:#59a14f'>状态保持</span>"]
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:
function unique(arr) {
// 🔍 Set 自动去重,适用于基本类型
return [...new Set(arr)];
}
但如果要考虑对象去重,或者根据某个属性去重,就需要更灵活的方式。
按对象属性去重(如 id)
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;
});
}
兼容所有类型(包括对象)的通用去重
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()特殊处理。
性能对比图
%% Mermaid Theme: "DedupeStrat"
%% COLORMAP: input:#4e79a7, primitive:#59a14f, complex:#f28e2c, fallback:#e15759, perf:#9c755f
%% LAYER: input(0) -> strategy(1) -> impl(2) -> complexity(3) -> benchmark(4)
graph LR
subgraph INPUT["<i class='fa fa-database'></i> 输入分析"]
A["输入数组 <code>arr</code><br/><span style='color:#4e79a7'>类型: [Number, String, Boolean, Object, NaN]</span>"]
end
subgraph STRATEGY["<i class='fa fa-filter'></i> 去重策略"]
B{"是否全是基本类型?<br/><span style='color:#59a14f'>Number/String/Boolean/Symbol/undefined/null</span>"}
C["<i class='fa fa-bolt'></i> 使用 Set 去重<br/><code>[...new Set(arr)]</code><br/><span style='color:#59a14f'>哈希表 O(1) 查找</span>"]
D{"是否含引用类型?<br/><span style='color:#f28e2c'>Object/Array/Function</span>"}
E["<i class='fa fa-key'></i> 使用 Map 缓存<br/><code>Map<JSON.stringify(item), index></code><br/><span style='color:#f28e2c'>序列化键</span>"]
F["<i class='fa fa-link'></i> 使用 WeakMap<br/><code>WeakMap<obj, true></code><br/><span style='color:#f28e2c'>弱引用, 不影响 GC</span>"]
G["<i class='fa fa-sync-alt'></i> 降级方案<br/><code>arr.filter((v,i) => arr.indexOf(v) === i)</code><br/><span style='color:#e15759'>嵌套循环 O(n²)</span>"]
end
subgraph IMPL["<i class='fa fa-code'></i> 实现细节"]
H["<i class='fa fa-hashtag'></i> Set 策略<br/><code>const unique = [...new Set(arr)]</code><br/><span style='color:#59a14f'>自动处理 NaN</span>"]
I["<i class='fa fa-map'></i> Map 策略<br/><code>const seen = new Map();<br/>arr.filter(v => !seen.has(JSON.stringify(v)) && seen.set(JSON.stringify(v), true))</code>"]
J["<i class='fa fa-exclamation-triangle'></i> 降级策略<br/><code>arr.filter((v,i) => arr.indexOf(v) === i)</code><br/><span style='color:#e15759'>无法处理 NaN/Obj</span>"]
end
subgraph COMPLEXITY["<i class='fa fa-tachometer-alt'></i> 时间复杂度"]
K["Set 去重: <span style='color:#59a14f'>O(n)</span><br/><span style='color:#9c755f'>空间: O(n)</span>"]
L["Map 缓存: <span style='color:#f28e2c'>O(n·k)</span><br/><span style='color:#9c755f'>k=平均对象序列化长度</span>"]
M["降级方案: <span style='color:#e15759'>O(n²)</span><br/><span style='color:#9c755f'>空间: O(1)</span>"]
end
subgraph BENCHMARK["<i class='fa fa-chart-line'></i> 性能基准"]
N["1000 条基本类型<br/><span style='color:#59a14f'>Set: 0.2ms</span> | indexOf: 12ms"]
O["1000 条对象数组<br/><span style='color:#f28e2c'>Map: 8ms</span> | indexOf: 1200ms"]
P["1000 条含 NaN<br/><span style='color:#59a14f'>Set: 自动去重</span> | 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
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 优化写法
function flatten(arr) {
return arr.reduce((acc, item) => {
return acc.concat(Array.isArray(item) ? flatten(item) : item);
}, []);
}
方法三:迭代 + 栈(避免爆栈)
递归在深度过大时可能导致调用栈溢出。可以用栈模拟递归过程:
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 时序图)
sequenceDiagram
participant Call as 调用者
participant Flat as flatten函数
participant Stack as 栈
participant Result as 结果数组
Call->>Flat: flatten([1,[2,[3]]])
Flat->>Stack: 初始化栈 [1,[2,[3]]]
loop 处理栈顶元素
Flat->>Stack: pop() -> [2,[3]]
Flat->>Stack: 展开并压入 3, [2]
Flat->>Stack: pop() -> [2]
Flat->>Stack: 展开压入 2
Flat->>Stack: pop() -> 2
Flat->>Result: push(2)
Flat->>Stack: pop() -> 3
Flat->>Result: push(3)
Flat->>Stack: pop() -> 1
Flat->>Result: push(1)
end
Flat->>Call: 返回 [1,2,3]
图解说明:
- 使用栈替代递归,避免深层嵌套导致的栈溢出。
- 每次从栈顶取出元素,如果是数组就展开后重新压入(注意顺序)。
- 非数组元素直接加入结果数组。
- 最终顺序可通过
reverse调整。
🔍 面试官可能问:“如何控制扁平化深度?”
回答:可以在函数中加一个depth参数,递归时递减,等于 0 时停止展开。
4. 介绍下 Promise 的用途和性质
Promise 是 ES6 引入的异步编程解决方案,用来更好地组织回调逻辑,解决“回调地狱”问题。
主要用途:
- 封装异步操作(如 AJAX、定时器、文件读取)。
- 实现链式调用
.then().then()。 - 统一错误处理
.catch()。 - 支持并发控制(
Promise.all,Promise.race)。
Promise 的三个状态:
- pending:初始状态,进行中。
- fulfilled:成功状态,表示操作完成。
- rejected:失败状态,表示操作失败。
状态一旦从 pending 变为 fulfilled 或 rejected,就不可逆,这是 Promise 的核心特性之一。
Promise 的基本结构
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 状态流转图
%% Mermaid Theme: "PromiseFlow"
%% COLORMAP: pending:#4e79a7, fulfilled:#59a14f, rejected:#e15759, handler:#9c755f
%% LAYER: state(0) -> transition(1) -> callback(2)
stateDiagram-v2
[*] --> pending
state "pending<br/><i class='fa fa-spinner fa-spin'></i>" as pending
pending --> fulfilled: resolve(value)
state "fulfilled<br/><i class='fa fa-check-circle'></i>" as fulfilled
pending --> rejected: reject(reason)
state "rejected<br/><i class='fa fa-times-circle'></i>" as rejected
fulfilled --> [*]
rejected --> [*]
note right of fulfilled
<i class='fa fa-hand-point-right'></i> .then(onFulfilled) 可被调用
<br/><span style='color:#9c755f'>异步微任务执行</span>
end note
note right of rejected
<i class='fa fa-hand-point-right'></i> .catch(onRejected) 可被调用
<br/><span style='color:#9c755f'>错误冒泡机制</span>
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 回调地狱
getData(function(a) {
getMoreData(a, function(b) {
getEvenMoreData(b, function(c) {
console.log(c);
});
});
});
改造成 Promise 链式调用
getData()
.then(a => getMoreData(a))
.then(b => getEvenMoreData(b))
.then(c => console.log(c))
.catch(err => console.error(err));
执行流程对比图(Mermaid 时序图)
%% Callback vs Promise 执行流程对比
sequenceDiagram
title: 该图展示 Callback 与 Promise 的执行流程差异(注:title 实际不渲染,此处为说明)
participant H as 主线程
participant A as 任务A
participant B as 任务B
participant C as 任务C
%% 回调方式:嵌套调用,回调地狱
Note over H,A: 回调方式(Callback Hell)
H->>A: 调用任务A(回调)
A->>B: 调用任务B(回调)
B->>C: 调用任务C(回调)
C->>H: 回调返回结果
H->>H: 错误需层层处理
%% Promise 方式:链式调用
Note over H,A: Promise 方式(链式调用)
H->>A: 调用任务A().then()
A-->>H: 返回 Promise
H->>B: .then(任务B)
B-->>H: 返回新 Promise
H->>C: .then(任务C)
C-->>H: 返回最终结果
H->>H: 统一 .catch() 处理错误
图解说明:
- Callback:控制权交给异步函数,层层嵌套,逻辑分散。
- Promise:返回一个“承诺”,主线程可以链式注册后续操作,逻辑集中,易于维护。
🔍 面试官可能问:“Promise 能解决所有异步问题吗?”
回答:不能完全解决。虽然解决了回调地狱,但.then链仍不够直观。ES7 引入async/await,让异步代码看起来像同步,是目前更优的写法。
小结
这些题目看似基础,实则层层递进。面试官往往通过简单问题考察你的知识深度、编码习惯和系统思维。
- 闭包:不仅要会用,还要理解其背后的执行上下文和内存机制。
- 数组操作:写出可用代码只是第一步,要能分析时间复杂度,权衡不同方案。
- Promise:掌握状态机模型,理解微任务机制,能对比不同异步方案的优劣。
在面试中,建议采用“先简后深”的策略:
- 先给出简洁可行的实现(如用
Set去重); - 主动说明局限性(如不支持对象);
- 再提出优化方案(Map + WeakMap);
- 最后补充边界情况(NaN、循环引用等)。
这样既能展示扎实基础,又能体现工程思维,更容易赢得面试官青睐。