写作不易,Star是最大鼓励,感觉写的不错的可以给个Star⭐,请多多指教。本博客的Github地址

let a = 1;
function add(x) {
   let sum = x + 2;
   return sum;
}
let b = add(a);
console.log(b); // 3
1
2
3
4
5
6
7

为了理解JavaScript引擎是如何工作的,详细分析一下上述代码的执行过程:

  1. 在第1行中,在全局执行上下文中声明了一个变量a,并赋值为3;
  2. 第2行到第5行实际上是在一起的。在全局执行上下文中声明了一个名为add的新变量,并将一个函数定义分配给变量add。两个括号{}之间的任何内容都被分配给add,函数内部的代码没有被求值,没有被执行,只是存储在一个变量中以备将来使用。
  3. 在第6行。它看起来很简单,但是这里有很多东西需要拆开分析。首先,在全局执行上下文中声明一个变量b,变量一经声明,其值即为undefined。
  4. 接下来,仍然在第6行,看到一个赋值操作符。给变量b赋了一个新值,接下来是一个函数调用。当看到一个变量后面跟着一个圆括号()时,这就是函数调用的信号,接着,每个函数都返回一些东西(值、对象或undefined),无论从函数返回什么,都将赋值给变量b。
  5. 但是,首先我们需要调用add函数。JavaScript将在全局执行上下文的变量对象中查找名为add的变量。噢,它找到了一个,它是在第2 - 5行中定义的。变量add包含一个函数定义。注意,变量a作为参数传递给函数。JavaScript在全局执行上下文的变量对象中搜索变量a,找到它,发现它的值是3,并将数字3作为参数传递给函数,准备好执行函数。
  6. 现在执行上下文将切换,创建了一个新的函数执行上下文,我们将其命名为add执行上下文,执行上下文被推送到调用栈顶部。在add执行上下文中,我们要做的第一件事是什么?
  7. 你可能会说,在add执行上下文中声明了一个新的变量sum。这是不对的。正确的答案是:需要先看函数的参数。在add执行上下文中声明一个新的变量x,因为值3是作为参数传递的,所以变量x被赋值为3。
  8. 接下来是:在add执行上下文中声明一个新的变量sum。它的值被设置为undefined(第3行)。
  9. 仍然是第3行,需要执行一个相加操作。首先我们需要x的值,JavaScript会寻找一个变量x,它会首先在add执行上下文的变量对象中寻找,找到了一个值为3。第二个操作数是数字2。两个相加结果为5就被分配给变量sum。
  10. 第4行,我们返回变量sum的内容,在add执行上下文中查找,找到值为5,返回,函数调用结束。
  11. 第4-5行,函数执行结束。add执行上下文被销毁,变量x和sum被释放,它们已经不存在了。add执行上下文从调用栈中弹出,返回值返回给调用上下文,在这种情况下,调用上下文是全局执行上下文,因为函数add是在全局执行上下文中调用的。
  12. 现在我们继续第4步的内容,返回值5被分配给变量b,程序仍然在第6行。
  13. 在第7行,b的值3被打印到控制台中。

词法作用域

在函数执行上下文中有变量,在全局执行上下文中也有变量。JavaScript的一个复杂之处在于它如何查找变量,如果在函数执行上下文中找不到变量,它将在调用上下文中寻找它,如果在它的调用上下文中没有找到,就一直往上一级,直到它在全局执行上下文中查找为止。(如果最后找不到,它就是 undefined)。下面具体来分析一下:

let m = 2;
function multiplyThis(n) {
   let res = n * m;
   return res;
}
let multiplied = multiplyThis(6);
console.log('example of scope:', multiplied); // 12
1
2
3
4
5
6
7
  1. 在全局执行上下文中声明一个新的变量m,并将其赋值为2。
  2. 第2-5行,声明一个新的变量multiplyThis,并给它分配一个函数定义。
  3. 第6行,声明一个在全局执行上下文multiplied新变量。
  4. 从全局执行上下文内存中查找变量multiplyThis,并将其作为函数执行,传递数字6作为参数。
  5. 新函数调用(创建新执行上下文),创建一个新的 multiplyThis 函数执行上下文。
  6. 在multiplyThis执行上下文中,声明一个变量n并将其赋值为6。
  7. 第3行。在multiplyThis执行上下文中,声明一个变量res。
  8. 继续第3行。对两个操作数n和m进行乘法运算,在multiplyThis执行上下文中查找变量n。我们在步骤6中声明了它,它的内容是数字6。在multiplyThis执行上下文中查找变量m。multiplyThis执行上下文并没有标记为m的变量。我们向调用上下文查找,调用上下文是全局执行上下文,在全局执行上下文中寻找m。找到了,它在步骤1中定义,数值是2。
  9. 继续第3行。将两个操作数相乘并将其赋值给res变量,6 * 2 = 12,res现在值为 12。
  10. 返回res变量,销毁multiplyThis执行上下文及其变量res和n 。变量m没有被销毁,因为它是全局执行上下文的一部分。
  11. 回到第6行。在调用上下文中,数字 12 赋值给 multiplied 的变量。
  12. 最后在第7行,我们在控制台中打印 multiplied 变量的值

闭包

简单来说,闭包就是指有权访问另一个函数作用域中的变量的函数。MDN上面这么说:闭包是函数和声明该函数的词法环境的组合。它由两部分构成:函数,以及创建该函数的环境。环境由闭包创建时在作用域中的任何局部变量组成。

先来看个🌰:点击某个按钮,显示出点击的是第几个按钮。

<button>按钮1</button>
<button>按钮2</button>
<button>按钮3</button>
1
2
3

错误写法:

var btns = document.querySelectorAll('button');
    for (var i = 0, len = btns.length; i < len; i++) {
        var btn = btns[i];
        // 当回调函数执行的时候,for循环已经执行完了,i的值已经变为4
        btn.onclick = function() {
            console.log(`${i + 1}个按钮`);
        }
    }
1
2
3
4
5
6
7
8

正确写法1:

for (var i = 0, len = btns.length; i < len; i++) {
        var btn = btns[i];
        // 将btn所对应的下标保存在btn的index属性上
        btn.index = i;
        btn.onclick = function() {
            console.log(`${this.index + 1}个按钮`);
        }
    }
1
2
3
4
5
6
7
8

正确写法2:

// 这里的i是全局变量i
for (var i = 0, len = btns.length; i < len; i++) {
    var btn = btns[i];
    // 借助闭包实现
    btn.onclick = (function(i) { // 这个i是形参i,即局部变量i
        return function() {
            console.log(`${i + 1}个按钮`); // 这里的i是自由变量
            // 这里父作用域即全局作用域,因为js(ES6之前)没有块级作用域。
        };
    })(i); // 这个i是全局的i
}
1
2
3
4
5
6
7
8
9
10
11

3次循环,产生了3个不同的函数作用域。

理解闭包

  1. 如何产生闭包?
    • 当一个嵌套的内部(子)函数引用了嵌套的外部(父)函数的变量(函数)时,就产生了闭包
  2. 闭包到底是什么?
    • 使用chrome调试查看
    • 理解1:闭包是嵌套的内部函数(绝大部分人)
    • 理解2:包含被引用变量(函数)的对象(极少数人)
    • 注意:闭包存在于嵌套的内部函数中
  3. 产生闭包的条件?
    • 函数嵌套
    • 内部函数引用了外部函数变量对象中的数据(变量/函数)

来看个🌰:创建闭包最常见方式,就是在一个函数内部创建另一个函数

function fn1() {
    var a = 1;
    function fn2() { // 执行函数定义就会产生闭包(不需要调用内部函数,但外部函数必须调用,否则内部函数定义无法执行)
        console.log(a);
    }
    return fn2; // 返回闭包函数
}
var fn = fn1();
fn();
1
2
3
4
5
6
7
8
9

闭包的作用域链包含它自己的作用域,以及包含它(即定义它)的函数的作用域和全局作用域。

常见的闭包

将函数作为另一个函数的返回值

来看个🌰:

function fn1() {
    var a = 1;
    function fn2() {
        a++;
        console.log(a);
    }
    return fn2;
}
// 闭包的个数与外部函数调用的次数有关系
var f = fn1(); // 产生一个闭包
f(); // 2
f(); // 3
var f2 = fn1(); // 产生一个新的闭包
f2(); // 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14

再来看个🌰:

function fn() {
    var a = 2; // 如果父作用域里没有找到,则会去全局中查找
    return function() {
        console.log(a); // a是自由变量,会去父作用域查找
    }
}

let f = fn();
var a = 3;
f();
1
2
3
4
5
6
7
8
9
10

将函数作为实参传递给另一个函数调用

来看个🌰:

function fn() {
    var a = 2;
    return function() {
        console.log(a); // a是自由变量,会去父作用域查找
    }
}

let f = fn();
function fn2(fn) {
    var a = 3;
    fn();
}
fn2(f); // 2 因为作用域是静态,在函数定义的时候就已经确定了
1
2
3
4
5
6
7
8
9
10
11
12
13

再来看个🌰:

function sleep(msg, time) {
    setTimeout(function() {
        console.log(msg);
    }, time);
}
sleep('延迟了', 2000);
1
2
3
4
5
6

这里产生了闭包:

  1. 调用了外部函数sleep;
  2. 内部函数引用了外部函数的局部变量msg

闭包的作用

  1. 使函数内部的变量在函数执行完后,仍然存活在内存中(延长了局部变量的生命周期)
  2. 让函数外部可以操作(读写)到函数内部的数据(变量/函数)

问题:

  1. 函数执行完后,函数内部声明的局部变量是否还存在?一般是不存在了,只有存在于闭包中的变量才存在。
  2. 在函数外部能直接访问函数内部的局部变量吗? 不能,但是可以通过闭包的方式让外部可以操作它。

来看个🌰:

function fn1() {
    // 如果没有闭包的话,外部函数执行完,外部函数中的局部变量将会自动释放
    var a = 1;
    function fn2() {
        a++;
        console.log(a);
    }
    return fn2;
}
// 闭包的个数与外部函数调用的次数有关系
var f = fn1(); // 产生一个闭包
f(); // 2
f(); // 3
1
2
3
4
5
6
7
8
9
10
11
12
13

fn1执行完后,变量fn2会自动释放(只有存在于闭包中的变量(这里是变量a)还会存在),而fn2指向的函数对象并不会自动释放,原因在于:var f = fn1();将函数对象赋值给全局变量f,函数对象被引用了,所以不会自动释放。

闭包的生命周期

  1. 产生:在嵌套的内部函数定义执行完时就产生了(而不是在内部函数调用的时候)
  2. 死亡:在嵌套的内部函数成为垃圾对象时

来看个🌰:

function fn1() {
    // 这里闭包就已经产生了(因为函数声明提升,内部函数对象已经创建了)
    var a = 1;
    function fn2() {
        a++;
        console.log(a);
    }
    return fn2;
}
var f = fn1(); // 产生一个闭包
f(); // 2
f(); // 3
f = null; // 闭包死亡,切断了全局变量f与包含闭包的函数对象的引用关系,使得包含闭包的函数对象成为垃圾对象。
1
2
3
4
5
6
7
8
9
10
11
12
13

闭包应用

闭包的主要应用场景:设计私有方法和变量

创建自定义js模块(设计私有方法和变量)

  • 具有特定功能的js文件
  • 将所有的数据和功能都封装在一个函数内部(私有的数据)
  • 只向外暴露一个包含n个方法的对象或函数
  • 模块的使用者只需要通过模块暴露的对象调用方法来实现对应的功能

任何在函数中定义的变量,都可以认为是私有变量,因为不能在函数外部访问这些变量。私有变量包括函数的参数、局部变量和函数内定义的其他函数。把有权访问私有变量的公有方法称为特权方法(privileged method)。

在这里,我们需要理解两个概念:

  • 模块模式(The Module Pattern):为单例创建私有变量和方法。
  • 单例(singleton):指的是只有一个实例的对象。JavaScript一般以对象字面量的方式来创建一个单例对象
<script src="./module.js"></script>
<script src="./module2.js"></script>
<script>
    var obj = myModule();
    obj.foo(); // foo--- LISI
    obj.bar(); // bar--- lisi
    module.foo(); // foo--- WANGWU
    module.bar(); // foo--- wangwu
</script>
1
2
3
4
5
6
7
8
9

module.js:

function myModule() {
    // 私有数据(私有变量)
    var name = 'lisi';
    // 操作数据的函数,称为特权函数
    function foo() {
        console.log('foo---', name.toUpperCase());
    }
    function bar() {
        console.log('bar---', name.toLowerCase());
    }
    // 向外暴露对象(给外部使用的方法)
    return {
        foo,
        bar
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

module2.js:推荐使用,不需要调用函数。

(function(window) { // 这里的window是形参,是一个局部变量
    // 私有数据
    var name = 'wangwu';
    // 操作数据的函数,称为特权函数
    function foo() {
        console.log('foo---', name.toUpperCase());
    }
    function bar() {
        console.log('bar---', name.toLowerCase());
    }
    // 向外暴露对象,将要暴露的对象挂载到window对象上
    window.module = {
        foo,
        bar
    };
})(window); // 这里window可传可不传,传了方便代码压缩
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

匿名函数最大的用途是:创建闭包,并且还可以构建命名空间,以减少全局变量的使用。从而使用闭包模块化代码,减少全局变量的污染。

在上述代码中函数foo和bar都是局部变量,但我们可以通过全局的window对象使用它们,这就大大减少了全局变量的使用,增强了网页的安全性。

封装变量,收敛权限

// 闭包实际应用:可以用于封装变量,收敛权限
function isFirstLoad() {
    var _list = [];
    return function (id) {
        if (_list.indexOf(id) >= 0) {
            return false;
        } else {
            _list.push(id);
            return true;
        }
    }
}
// 使用
let firstLoad = isFirstLoad();
console.log(firstLoad(1)); // true
console.log(firstLoad(1)); // false
console.log(firstLoad(2)); // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

闭包的缺点和解决

  1. 缺点
  • 函数执行完后,函数内的局部变量没有释放,常驻内存会增大内存使用量
  • 使用不当很容易造成内存泄露(内存泄露:内存白白被占用,无法使用)
  1. 解决
  • 能不用闭包就不用,如果不是因为某些特殊任务而需要闭包,在没有必要的情况下,在其它函数中创建函数是不明智的,因为闭包对脚本性能具有负面影响,包括处理速度和内存消耗。
  • 及时释放

通常,函数的作用域及其所有变量都会在函数执行结束后被销毁。但是,在创建了一个闭包以后,这个函数的作用域就会一直保存到闭包不存在为止。

function outer(a) {
    return function(b) {
        return a + b;
    }
}
var add = outer(1); // 创建一个闭包
var add2 = outer(2); // 创建一个新的闭包

console.log(add(3)); // 4
console.log(add(4)); // 5
// 及时释放对闭包的引用
add = null;
add2 = null;
1
2
3
4
5
6
7
8
9
10
11
12
13

从上述代码可以看到add和add2都是闭包。它们共享相同的函数定义,但是保存了不同的环境。在add的环境中,a为1。而在add2中,a则为2。最后通过null释放了add和add2对闭包的引用。

在javascript中,如果一个对象不再被引用,那么这个对象就会被垃圾回收机制回收;如果两个对象互相引用,而不再被第3者所引用,那么这两个互相引用的对象也会被回收。

闭包只能取得包含函数中的任何变量的最后一个值

function fn() {
    var arr = [];
    for (var i = 0; i < 10; i++) {
        arr[i] = function() {
            return i;
        }
    }
    return arr;
}

fn().forEach(item => {
    console.log(item());
});
1
2
3
4
5
6
7
8
9
10
11
12
13

上述代码中,arr数组中包含了10个匿名函数,每个匿名函数都能访问外部函数的变量i,那么i是多少呢?

当fn执行完毕后,其作用域被销毁,但它的变量对象仍保存在内存中,可以被匿名访问,这时i的值为10。要想保存在循环过程中每一个i的值,需要在匿名函数外部再套用一个匿名函数,在这个匿名函数中定义另一个变量并且立即执行来保存i的值。

function fn() {
    var arr = [];
    for (var i = 0; i < 10; i++) {
        arr[i] = (function(num) {
            return function() {
                return num;
            }
        })(i);
    }
    return arr;
}

fn().forEach(item => {
    console.log(item());
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这时最内部的匿名函数访问的是num的值,所以数组中10个匿名函数的返回值就是0-9。

面试题

题目1(闭包中的this对象)

牢牢记住,this指向只有在函数调用时才能确定,跟函数定义时没关系。

var name = 'The Window';

var obj = {
  name: 'My Object',

  getName: function() {
    return function() {
      return this.name;
    };
  }
};

console.log(obj.getName()());  // The Window
1
2
3
4
5
6
7
8
9
10
11
12
13

obj.getName()()实际上是在全局作用域中调用了匿名函数,this指向了window。这里要理解函数名与函数功能(或者称函数值)是分割开的,不要认为函数在哪里,其内部的this就指向哪里。匿名函数的执行环境具有全局性,因此其this对象通常指向window。

var name = 'The Window';

var obj = {
  name: 'My Object',

  getName: function() {
    var that = this;
    return function() { // 产生闭包,引用了that
      return that.name;
    };
  }
};

console.log(obj.getName()());  // My Object
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在闭包中,arguments与this也有相同的问题。下面的情况也要注意:

var name = 'window';
var obj = {
    name: 'object',
    getName: function() {
        return this.name;
    }
}
console.log(obj.getName()); // object
console.log((obj.getName = obj.getName)()); // window
1
2
3
4
5
6
7
8
9

obj.getName();这时getName()是在对象obj的环境中执行的,所以this指向obj。

(obj.getName = obj.getName)赋值语句返回的是等号右边的值,在全局作用域中返回,所以(obj.getName = obj.getName)();的this指向全局,要把函数名和函数功能分割开来。

obj.getName = obj.getName // 输出如下:
ƒ () {
        return this.name;
    }
1
2
3
4

题目2

function fn(n, o) {
    console.log(o);
    return {
        fn: function(m) { // 产生闭包,引用了局部变量n
            return fn(m, n);
        }
    }
}

var a = fn(0); // undefined
// a.fn(1)会产生新的闭包,因为没有变量接收,产生的新闭包会释放
// 因此下面3句一直是使用的变量a引用的闭包
a.fn(1); // 0
a.fn(2); // 0
a.fn(3); // 0

// fn(0)产生闭包,值是undefined
// fn(0).fn(1)产生新的闭包,值是0
// fn(0).fn(1).fn(2)产生新的闭包,值是1
// fn(0).fn(1).fn(2).fn(3)产生新的闭包,值是2
var b = fn(0).fn(1).fn(2).fn(3); // undefined 0 1 2
var c = fn(0).fn(1);// undefined 0

// 下面2句一直是使用的变量c引用的闭包
c.fn(2); // 1
c.fn(3); // 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

需要注意:新的闭包只有在调用外部函数的时候才会产生。

具体参考:什么是闭包?闭包的作用是什么?

参考文档

  1. MDN-闭包
  2. 深入浅出Javascript闭包
  3. JavaScript 闭包
  4. 前端基础进阶(六):在chrome开发者工具中观察函数调用栈、作用域链与闭包
  5. 我从来不理解JavaScript闭包,直到有人这样向我解释它

评 论: