前端面试真题深度解析:闭包、数组操作与 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[" 函数定义"] A["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
图解说明:
  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[" 输入分析"] A["输入数组 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 时序图)
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
" 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 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、循环引用等)。

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

相关推荐
司宸8 分钟前
学习笔记八 —— 虚拟DOM diff算法 fiber原理
前端
阳树阳树8 分钟前
JSON.parse 与 JSON.stringify 可能引发的问题
前端
让辣条自由翱翔13 分钟前
总结一下Vue的组件通信
前端
dyb14 分钟前
开箱即用的Next.js SSR企业级开发模板
前端·react.js·next.js
前端的日常15 分钟前
Vite 如何处理静态资源?
前端
前端的日常16 分钟前
如何在 Vite 中配置路由?
前端
兮漫天16 分钟前
bun + vite7 的结合,孕育的 Robot Admin 靓仔出道(一)
前端
PineappleCoder17 分钟前
JS 作用域链拆解:变量查找的 “俄罗斯套娃” 规则
前端·javascript·面试
兮漫天17 分钟前
bun + vite7 的结合,孕育的 Robot Admin 靓仔出道(二)
前端
用户479492835691522 分钟前
面试官:为什么很多格式化工具都会在行尾额外空出一行
前端