【前端面试小册】JS-第14节:面试必会 - 闭包专题

一、闭包的概念

1.1 定义

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

核心理解:闭包 = 函数 + 函数能访问的自由变量

类比理解:闭包就像一个"背包",函数可以"背"着它所在作用域的变量,即使离开了原来的地方,仍然可以访问这些变量。

1.2 基本示例

function outer() {
    let name = '成都巴菲特';
    return function inner() {
        console.log(name);
    }
}

const innerFn = outer();
innerFn();  // 成都巴菲特

分析

  • inner 函数可以访问外层 outer 函数的变量 name
  • 即使 outer 函数执行完毕,inner 函数仍然可以访问 name
  • 这就是闭包

二、知识点准备:执行上下文

2.1 执行上下文类型

全局执行上下文

不在任何函数中的代码都位于全局执行上下文中。

它做了两件事:

  1. 创建 window 全局对象(在浏览器环境)
  2. this 指针指向这个全局对象 window

注意:一个程序中只能存在一个全局执行上下文。

函数执行上下文

每次调用函数,都会为该函数创建一个新的执行上下文。

每个函数都拥有自己的执行上下文,在函数被调用的时候才会被创建。

Eval 函数执行上下文

eval 函数中的代码也获得了自己的执行上下文。

小结:3 种执行上下文,关于闭包这里只对全局和函数执行上下文分析。

2.2 执行栈

概念

作用:存储在代码执行期间创建的所有执行上下文。

特点:LIFO(后进先出),也可被称为调用栈。

执行流程

  1. 当 JavaScript 引擎首次读取代码时,会创建全局执行上下文,并将其推入当前的执行栈
  2. 每当函数调用时,JS 引擎为该函数创建一个新的执行上下文,并将其推到当前执行栈的顶端
  3. JS 引擎会运行执行栈顶端的函数,当此函数运行完成后,其对应的执行上下文将会从执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文

Demo 案例分析

let name = '成都巴菲特';

function out() {
    console.log('out,内部1');
    inner();
    console.log('out,内部2');
}

function inner() {
    console.log('inner,内部');
}

out();
console.log('全局执行上下文内部');

执行流程

graph TD
    A[加载代码] --> B[创建全局执行上下文]
    B --> C[压入执行栈]
    C --> D[执行 out 函数]
    D --> E[创建 out 执行上下文]
    E --> F[压入执行栈]
    F --> G[执行 inner 函数]
    G --> H[创建 inner 执行上下文]
    H --> I[压入执行栈]
    I --> J[inner 执行完毕]
    J --> K[inner 上下文出栈]
    K --> L[out 执行完毕]
    L --> M[out 上下文出栈]
    M --> N[全局执行完毕]
    N --> O[全局上下文出栈]

分析

  1. 加载代码,JS 创建全局上下文(Global Execution Context)并压入当前执行栈
  2. 解析遇到 out() 函数执行,创建 out 函数的执行上下文,压入执行栈顶部
  3. out 函数内调用 inner(),同 2 一样创建 inner 上下文,并入栈
  4. inner 执行完毕,他的上下文出栈,控制流程进入 out 执行上下文
  5. out 执行完,他的上下文出栈,控制流程到达全局上下文
  6. 最后:当所有代码执行完毕,JS 引擎从当前栈中移除全局执行上下文

2.3 执行上下文:创建阶段

概览

JS 代码执行前,执行上下文会被创建,创建阶段会做三件事:

  1. 创建词法环境组件
  2. 创建变量环境组件
  3. 绑定 this

用代码展示如下:

ExecutionContext = {
    ThisBinding = <this value>,        // this 绑定
    LexicalEnvironment = { ... },      // 词法环境
    VariableEnvironment = { ... },     // 变量环境
}

词法环境组成

一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。

环境记录器:存储变量和函数声明的实际位置

外部环境的引用:作用是可以访问父级词法环境(作用域)

词法环境类型

全局环境

  1. 在全局执行上下文中,没有外部环境引用的词法环境
  2. 全局环境的外部环境引用是 null
  3. 全局环境中,环境记录器是对象环境记录器

函数环境

  1. 函数内部用户定义的变量存储在环境记录器中
  2. 引用的外部环境可能是全局环境,或者任何包含此内部函数的外部函数
  3. 在函数环境中,环境记录器是声明式环境记录器

注意:声明式环境记录器还包含了一个传递给函数的 arguments 对象和传递给函数的参数的 length

伪代码展示

// 全局环境
GlobalExectionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Object",  // 在这里绑定标识符
        },
        outer: <null>,
        this: <global object>
    }
}

// 函数环境
FunctionExectionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Declarative",  // 在这里绑定标识符
        },
        outer: <Global or outer function environment reference>,
        this: <depends on how function is called>
    }
}

变量环境

也是一个词法环境,所以也有这词法环境的所有属性(环境记录器 + 外部环境引用)。

其环境记录器:持有变量声明语句在执行上下文中创建的绑定关系。

ES6 中的区别

  • 词法环境:存储函数声明和变量(letconst)绑定
  • 变量环境:存储 var 变量绑定

变量环境 Demo 分析

let a = 1;
const b = 2;
var c;

function sum(d, e) {
    var f = 2;
    return d + e + f;
}

c = sum(1, 2);

执行上下文创建阶段

GlobalExectionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Object",
            // 在这里绑定标识符
            a: <uninitialized>,
            b: <uninitialized>,
            sum: <func>
        },
        outer: <null>,
        ThisBinding: <Global Object>
    },
    
    VariableEnvironment: {
        EnvironmentRecord: {
            Type: "Object",
            // 在这里绑定标识符
            c: undefined,
        },
        outer: <null>,
        ThisBinding: <Global Object>
    }
}

FunctionExectionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Declarative",
            // 在这里绑定标识符
            Arguments: {0: 1, 1: 2, length: 2},
        },
        outer: <GlobalLexicalEnvironment>,
        ThisBinding: <Global Object>
    },
    
    VariableEnvironment: {
        EnvironmentRecord: {
            Type: "Declarative",
            // 在这里绑定标识符
            f: undefined
        },
        outer: <GlobalLexicalEnvironment>
    }
}

注意

  1. 只有在函数 sum 调用,即 sum() 的时候函数的上下文才会被创建
  2. letconst 定义的变量没有关联任何值,varundefined
  3. 原因:变量提升

2.4 执行阶段

完成对所有这些变量的分配,最后执行代码。

三、闭包与执行上下文

3.1 闭包 DEMO 分析

function outer() {
    let name = '成都巴菲特';
    return function inner() {
        console.log(name);
    }
}

const innerFn = outer();
innerFn();  // 成都巴菲特

疑问

  1. 执行 outer(),其对应的执行上下文出栈并被销毁
  2. 然后执行 inner() 却能正确找到 name 的值并输出:成都巴菲特

问题name 是在 outer 的执行上下文中,而 outer 的执行上下文在第一步不是被销毁了吗?为什么 inner 还能找到 name 的值?

3.2 原因分析

其实就是变量查找的过程:

// 先查找 inner 词法环境是否有 name
inner.[[LexicalEnvironment]].[[EnvironmentRecord]]  // 没找到

// 没找到,通过 outer 查找
outer.[[LexicalEnvironment]][[EnvironmentRecord]]  // 找到

执行上下文关系图

graph TD
    A[outer 执行上下文] --> B[创建 name 变量]
    B --> C[返回 inner 函数]
    C --> D[outer 上下文出栈]
    D --> E[inner 函数保持对外部环境的引用]
    E --> F[inner 执行时查找 name]
    F --> G[通过 outer 引用找到 name]
    G --> H[输出 成都巴菲特]

关键理解:即使 outer 执行完了,但是由于 inner 还对 outer 变量有引用不会断开,所以闭包的变量会被存在内存中不会被销毁。

3.3 作用域链

说到闭包大家可能都会想到:

  1. 变量对象(Variable object,VO)
  2. 作用域链(Scope chain)
scopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope: undefined,
        f: reference to function f(){}
    },
    Scope: [AO, globalContext.VO],
    this: undefined
}

知识点回忆

  • 每定义一个函数,都会产生作用域链(scope chain)
  • 当 JS 寻找变量的过程叫做变量解析,总会优先在当前作用域链 [[scope]] 的第一个对象中查找属性
  • 如果找到,则直接使用这个属性
  • 否则,继续查找下一个对象的是否存在这个属性,这个过程会持续直至找到这个属性或者最终未找到引发错误为止

注意VOScope chain 都是 ES3 规范中的内容,ES5 之后就不再用了。可以理解 ES3 的 scope chain 被 ES5 的 outer 替代。所以闭包进行变量查找的时候在 ES5 就可以通过 outer 来实现。

四、闭包变量存在哪里

4.1 答案

闭包变量在堆内存中

4.2 Demo 证明

function outer() {
    var arr = new Array(1000000).join('x');  // 这里通过数组构造一个 1MB 大小字符串
    
    function inner() {  // 这里定义一个具名函数,方便我们查找
        console.log(arr);
    }
    return inner;
}

在浏览器执行这段代码,打开 devtools,切换至 memory 界面,点击 take heap(堆)snapshot 获取当前页面运行的内存快照,可以看到闭包变量 arr。所以我们可以确认变量是在堆内存中。

Tips

  • heap:堆
  • stack:栈

五、面试题

5.1 Demo 1:函数表达式 vs 函数声明

var foo = function () {
    console.log('foo1');
}

foo();  // foo1

var foo = function () {
    console.log('foo2');
}

foo();  // foo2

分析:函数表达式不会提升,只有变量声明提升。

5.2 Demo 2:函数声明优先级

demo();

var demo = function demo() {
    console.log('成都巴菲特');
}

function demo() {
    console.log('知识星球:前端职场圈');
}

demo();

输出

  1. 知识星球:前端职场圈
  2. 成都巴菲特

等价于

function demo() {
    console.log('知识星球:前端职场圈');
}

demo();  // 知识星球:前端职场圈

demo = function demo() {
    console.log('成都巴菲特');
};

demo();  // 成都巴菲特

分析:函数声明提升优先级高于变量声明。

5.3 Demo 3:变量提升

var name = '成都巴菲特';
function demo() {
    console.log(name);  // undefined
    var name = '公众号:前端面试资源';
    console.log(name);  // 公众号:前端面试资源
}

demo();

分析var 声明的变量会提升到函数顶部,初始值为 undefined

5.4 Demo 4:未声明的变量

var foo = '成都巴菲特';
function demo() {
    console.log(foo);  // 成都巴菲特
    foo = '公众号:前端面试资源';
}
demo();
console.log(foo);  // 公众号:前端面试资源

分析

  • foo 没有定义,查找上层变量即全局变量,所以第一次输出 成都巴菲特
  • foo = xx 这种形式虽然相当于 var foo = xx,但是得到执行的时候才会去声明变量,并执行
  • 这个时候 foo = '公众号:前端面试资源' 已经执行相当于声明全局变量,覆盖了外层的 foo

5.5 Demo 5:参数与变量

var foo = '成都巴菲特';
function demo(foo) {
    console.log(foo);  // 知识星球:前端职场圈
    foo = '公众号:前端面试资源';
}
demo('知识星球:前端职场圈');
console.log(foo);  // 成都巴菲特

等价于

运行 demo 传入了实参并赋值,第一个 log 直接找到实参 foo = '知识星球:前端职场圈'。已经函数已经声明了 foo 变量,这个时候 foo = '公众号:前端面试资源' 相当于改写了函数括号的 foo 变量的值,并不会改变外部(全局)作用的 foo 值。

5.6 Demo 6:闭包经典案例

var foo = '成都巴菲特';
function outer(foo) {
    var foo = '公众号:前端面试资源';
    return function inner() {
        console.log(foo);
    }
}
var inner = outer();
inner();  // 公众号:前端面试资源

分析

函数能够访问到的上层作用域,是在函数声明时候就已经确定了的!

关键点

  • 函数声明在哪里,上层作用域就在哪里,和拿到哪里执行没有关系
  • 这里,inner 被作为闭包返回并在外部调用,但它内部的作用域链引用到了父函数的变量对象中的 name,所以作用域链查找时,打印出来的是 公众号:前端面试资源

六、闭包的应用场景

6.1 模块化

function createModule() {
    let count = 0;  // 私有变量
    
    return {
        increment: function() {
            count++;
            return count;
        },
        decrement: function() {
            count--;
            return count;
        },
        getCount: function() {
            return count;
        }
    };
}

const counter = createModule();
console.log(counter.increment());  // 1
console.log(counter.increment());  // 2
console.log(counter.getCount());   // 2

6.2 函数工厂

function createMultiplier(multiplier) {
    return function(number) {
        return number * multiplier;
    };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5));  // 10
console.log(triple(5));  // 15

6.3 防抖和节流

function debounce(func, delay) {
    let timeoutId;
    return function(...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
            func.apply(this, args);
        }, delay);
    };
}

七、闭包的缺点

7.1 内存泄漏

闭包会导致变量无法被垃圾回收,可能导致内存泄漏。

function outer() {
    var largeData = new Array(1000000).fill('data');
    return function inner() {
        console.log('inner');
    };
}

const inner = outer();
// largeData 无法被回收,因为 inner 函数持有引用

7.2 性能问题

闭包会保持对外部作用域的引用,可能影响性能。

八、思考

执行环境与词法作用域关系:

执行上下文应该是一个抽象的环境,里面包含了当前函数调用的所有信息,包括当前函数是在哪里调用的、环境中的 this,当然也会包括词法作用域的相关信息。

作用域我理解是引擎根据名称查找变量的一套规则。执行上下文决定了函数或变量可以访问哪些数据,以及它们的行为,一个抽象的环境。

九、面试要点总结

核心知识点

  1. 闭包定义:函数可以记住并访问所在的词法作用域
  2. 闭包本质:函数 + 函数能访问的自由变量
  3. 闭包存储:闭包变量在堆内存中
  4. 作用域链:通过 outer 引用查找变量

常见面试题

Q1: 什么是闭包?

答:当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。闭包 = 函数 + 函数能访问的自由变量。

Q2: 闭包的变量存在哪里?

答:闭包变量在堆内存中。可以通过浏览器的内存快照工具验证。

Q3: 闭包的应用场景?

答:

  1. 模块化封装
  2. 函数工厂
  3. 防抖和节流
  4. 保存状态

实战建议

  • ✅ 理解闭包的本质和作用机制
  • ✅ 注意闭包可能导致的内存泄漏
  • ✅ 合理使用闭包,避免过度使用
  • ✅ 理解执行上下文和作用域链的关系
#前端面试##前端#
前端面试小册 文章被收录于专栏

每天更新3-4节,持续更新中... 目标:50天学完,上岸银行总行!

全部评论

相关推荐

评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务