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

177 阅读7分钟

在前端面试中,闭包、数组操作和异步编程是高频考点。它们不仅考察候选人对 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/>&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

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 变为 fulfilledrejected,就不可逆,这是 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 是对其的改进。

对比维度:
维度CallbackPromise
可读性回调地狱,嵌套深链式调用,扁平化
错误处理需每个回调写 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:掌握状态机模型,理解微任务机制,能对比不同异步方案的优劣。

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

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

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