前端面试真题深度解析:闭包、数组操作与 Promise 机制

在前端面试中,闭包、数组操作和异步编程是高频考点。它们不仅考察候选人对 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 中引用外部变量。
  • 防抖节流函数:需要保存定时器或上一次执行时间。

但闭包也有代价:可能造成内存泄漏。因为内部函数引用了外部变量,导致这些变量无法被垃圾回收,如果滥用或未及时解引用,会占用过多内存。

闭包执行机制图解
%% 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/>&nbsp;&nbsp;let count = 0;<br/>&nbsp;&nbsp;return function() {<br/>&nbsp;&nbsp;&nbsp;&nbsp;return ++count;<br/>&nbsp;&nbsp;};<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
图解说明:
  1. createCounter() 被调用时,会创建一个新的执行上下文,其中包含 count
  2. 返回的内部函数虽然脱离了原作用域,但它的 [[Scope]] 链仍然指向 createCounter 的词法环境。
  3. counter() 执行时,JS 引擎在当前作用域找不到 count,于是沿着作用域链向上查找,在 createCounter 的环境中找到它。
  4. 即使 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() 特殊处理。

性能对比图
%% 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
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 时序图)
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 变为 fulfilledrejected,就不可逆,这是 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 状态流转图
%% 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 回调地狱
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 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:掌握状态机模型,理解微任务机制,能对比不同异步方案的优劣。

在面试中,建议采用"先简后深"的策略:

  1. 先给出简洁可行的实现(如用 Set 去重);
  2. 主动说明局限性(如不支持对象);
  3. 再提出优化方案(Map + WeakMap);
  4. 最后补充边界情况(NaN、循环引用等)。

这样既能展示扎实基础,又能体现工程思维,更容易赢得面试官青睐。

相关推荐
葫芦和十三26 分钟前
图解 MongoDB 14|Cache 与淘汰:WiredTiger 的内存治理
后端·mongodb·面试
IT_陈寒4 小时前
Vue这个坑我跳了两次,原来问题出在这
前端·人工智能·后端
kyriewen4 小时前
我用 50 行代码重写了 React Router 核心,终于搞懂了前端路由原理
前端·javascript·react.js
WebInfra5 小时前
Rspack 2.1 发布:React Compiler 提速 10 倍!
前端
李明卫杭州5 小时前
CSS 媒体查询详解:一文掌握响应式设计的核心技术
前端
lichenyang4536 小时前
从 H5 按钮到 OpenHarmony 能力调用:我如何理解 ASCF 的运行链路
前端
下家7 小时前
我放弃了 Vue/React,选择自研框架
前端·前端框架
Asize7 小时前
HTML5 Canvas 基础:从按帧动画到 ECharts 数据可视化
前端·javascript·canvas
默_笙7 小时前
🎄 后端给我一堆扁平数据,我 10 行代码把它变成了树
前端·javascript
Mahut7 小时前
我用 Electron + FFmpeg 做了一个本地视频处理工作站 ClipForge
前端·ffmpeg·electron