一、 说明

先浏览问题,判断自己对作用域、变量提升和闭包的掌握情况。然后了解概念和原理,再对照问题检查掌握情况。

二、 问题

  1. 下面代码输出的结果?(函数作用域)
  1. var name = 'window';
  2. console.log(name);
  3. function outer() {
  4. var name = 'outer';
  5. console.log(name);
  6. function inner() {
  7. console.log(name);
  8. }
  9. inner();
  10. }
  11. outer();

第一个name是全局作用域,打印’window’,第二个name是函数作用域,打印’outer’,第三个name是函数作用域,inner里面未定义name因此上溯到outer函数中,打印结果也是’outer’。

  1. 下面代码执行的结果?(块级作用域)
  1. function test() {
  2. if (true) {
  3. var a = 'true';
  4. }
  5. else {
  6. var a = 'false';
  7. }
  8. console.log(a);
  9. }
  10. test();
  11. function test1() {
  12. if (true) {
  13. const a = 'true';
  14. }
  15. else {
  16. const a = 'false';
  17. }
  18. console.log(a);
  19. }
  20. test1();

test()执行结果是’true’,因为是函数作用域,在函数内声明的变量都可以访问到。test1()执行结果报错,因为const和let声明的变量定义在块级作用域,只有语句块({}括起来的区域)可以访问到。

  1. 下面代码执行的结果?(变量提升)
  1. function test() {
  2. console.log(a);
  3. var a = 1;
  4. console.log(a);
  5. }
  6. test();
  7. function test1() {
  8. console.log(a);
  9. const a = 1;
  10. console.log(a);
  11. }
  12. test1();
  13. function test2() {
  14. console.log(inner());
  15. function inner() {
  16. return 'inner';
  17. }
  18. }
  19. test2();
  20. function test3() {
  21. console.log(inner());
  22. var inner = function () {
  23. return 'inner';
  24. }
  25. }
  26. test3();
  27. function test4() {
  28. var a = 1;
  29. function inner() {
  30. console.log(a);
  31. var a = 2;
  32. }
  33. }

写出变量提升后各个函数的等价形式不难得出结果。
test()结果是undefined、1
test1()结果是报错,因为const声明的变量不会提升,a访问不到
test2()结果是’inner’,因为函数声明提升了
test3()结果是报错,因为inner声明提前,但是定义没有提前,所以调用的时候取值是undefined
test4()结果是undefined,因为执行console.log(a)时候先在inner函数作用域内寻找,由于inner内部变量a提升,取值是undefined,因此打印undefined。

  1. 实现一个创建计数器的方法,支持增加计数和获取计数,对比下列两种实现方式。(闭包的应用场景)
  1. /*方法1*/
  2. var count = 0;
  3. function createCounter() {
  4. function increase() {
  5. count++;
  6. }
  7. function getCount() {
  8. return count;
  9. }
  10. return {
  11. increase: increase,
  12. getCount: getCount
  13. };
  14. }
  15. var counter = createCounter();
  16. counter.increase();
  17. console.log(counter.getCount());
  18. console.log(count);
  19. /*方法2*/
  20. function createCounter() {
  21. var count = 0;
  22. function increase() {
  23. count++;
  24. }
  25. function getCount() {
  26. return count;
  27. }
  28. return {
  29. increase: increase,
  30. getCount: getCount
  31. };
  32. }
  33. var counter = createCounter();
  34. counter.increase();
  35. console.log(counter.getCount());
  36. console.log(count);

答案见闭包的概念

  1. 下面各个代码段执行结果?(闭包练习)
  1. // 代码段1
  2. function test() {
  3. var arr = [];
  4. for(var i = 0; i <= 5; i++) {
  5. arr[i] = function () {
  6. console.log(i);
  7. };
  8. }
  9. return arr;
  10. }
  11. var funcList = test();
  12. funcList.forEach(function (func) {
  13. func();
  14. });
  15. // 代码段2
  16. function test1() {
  17. var arr = [];
  18. for(let i = 0; i <= 5; i++) {
  19. arr[i] = function () {
  20. console.log(i);
  21. };
  22. }
  23. return arr;
  24. }
  25. var funcList = test1();
  26. funcList.forEach(function (func) {
  27. func();
  28. });
  29. // 代码段3
  30. function test2() {
  31. var arr = [];
  32. for(var i = 0; i <= 5; i++) {
  33. arr[i] = (function (i) {
  34. return function () {
  35. console.log(i);
  36. }
  37. })(i);
  38. }
  39. return arr;
  40. }
  41. var funcList = test2();
  42. funcList.forEach(function (func) {
  43. func();
  44. });

test()打印6 6 6 6 6 6,因为test内部返回的函数访问的i是函数作用域,在test函数内部,随着for的执行i一直自增至6,所以调用arr的函数时候,这些func函数访问的i取值都是6

test1()结果是0 1 2 3 4 5。原因是let声明的i具有块级作用域,每次循环会生成一个块级作用域,在这个块里面i是随自增而改变的。

test2()结果也是0 1 2 3 4 5,与test不同,test2对arr赋值时候是用了一个函数嵌套另一个函数并返回,这样就形成了一个闭包,内部函数访问的i是闭包内的变量,即匿名函数的参数i,循环6次,就生成了6个闭包,这6个闭包的参数分别是0 1 2 3 4 5,因此arr的函数执行打印的是0 1 2 3 4 5

三、 概念和原理

1. 作用域

说明

  1. 作用域是可访问变量的**集合**或者说**范围**(例如全局的范围、函数的范围、语句块的范围),在作用域内,变量可访问,在作用域外变量不可访问。例如
  1. function test() {
  2. var name = 'test';
  3. console.log('inner', name);
  4. }
  5. test();
  6. console.log('outer', name);

test函数内部可以访问到变量name,而外部则访问不到。

作用域也可以理解为引擎查找变量的规则,js引擎执行代码,访问变量时候,引擎会按照规则查找该变量,如果能找到则执行相应的操作,找不到则报错。

确定变量访问范围的阶段的角度,可以分为2类,词法作用域和动态作用域,js是词法作用域。

变量查找的范围的角度,分为3类,全局作用域,函数作用域和块级作用域。

下面介绍不同的作用域类型。

词法作用域和动态作用域

词法作用域是在词法分析阶段就确定的作用域,变量的访问范围仅由声明时候的区域决定。动态作用域则是在调用时候决定的,它是基于调用栈的。

  1. var a = 2;
  2. function foo() {
  3. console.log( a );
  4. }
  5. function bar() {
  6. var a = 3;
  7. foo();
  8. }
  9. bar();

如果处于词法作用域,也就是现在的javascript环境。变量a首先在foo()函数中查找,没有找到。于是顺着作用域链到全局作用域中查找,找到并赋值为2。所以控制台输出2。

如果处于动态作用域,同样地,变量a首先在foo()中查找,没有找到。这里会顺着调用栈在调用foo()函数的地方,也就是bar()函数中查找,找到并赋值为3。所以控制台输出3。

作用域查找从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止,因此如果内部和外部具有同名的标识符,内部的会被首先查找到,从而“遮蔽”外面的,这叫做“遮蔽效应”。

普通的函数中的this指向有动态作用域的特性,和调用时的对象有关。而箭头函数则使用词法作用域规则。箭头函数的this,就在定义箭头函数的范围内寻找,具体地说,就是外层最近的一个非箭头函数内,或者语句块内,或者全局。看下面的示例:

  1. var name = 'win';
  2. const obj = {
  3. name: 'obj',
  4. a: () => {
  5. console.log(this.name);
  6. }
  7. };
  8. obj.a();

我看可以看到,obj.a声明时候,外层就是全局作用域,因此this指向window。

全局作用域、函数作用域和块级作用域

js有三种作用域:全局作用域、函数作用域和块级作用域(es6)。

全局作用域

直接编写在 script 标签之中的JS代码,或者是一个单独的 JS 文件中的,都是全局作用域。全局作用域在页面打开时创建,页面关闭时销毁。在全局作用域中有一个全局对象 window(代表的是一个浏览器的窗口,由浏览器创建),可以直接使用。

函数作用域

JavaScript的函数作用域是指在函数内部声明的变量,在函数内部和函数内部声明的函数中都可以访问到。访问变量时候先在函数内部找,找不到则在外层函数中找,直到最外层的全局作用域,形成“作用域链”。
变量在函数内部可访问的含义是,在函数内部的语句中或者函数内部声明的函数中都可以访问,比如

  1. function outer() {
  2. var name = 'outer';
  3. console.log(name); // outer
  4. function inner() {
  5. console.log(name); // outer
  6. }
  7. inner();
  8. }
  9. outer();

函数outer内部定义了变量name,在outer内部可以访问,在outer内部定义的inner也可以访问到。
在访问变量时候,先在当前函数作用域内寻找是否有该变量,如果有则使用之,如果没有则向上寻找上层函数的作用域,一直到全局作用域,如果都没有,则报错。

  1. function outer() {
  2. var name = 'outer';
  3. console.log(name); // outer
  4. function inner() {
  5. var name = 'inner';
  6. console.log(name); // inner
  7. }
  8. inner();
  9. }
  10. outer();

块级作用域

(关于块级作用域详细内容,请参考let和const ——《ECMAScript 6 入门》

变量只在语句块内可访问。通过const和let关键字创建的变量都是在声明的语句块内才可访问。

  1. function test() {
  2. if (true) {
  3. const variable = 'test';
  4. console.log(variable); // test
  5. }
  6. console.log(variable); // Error: variable is not defined
  7. }
  8. test();

块级作用域有几个特性:不存在变量提升、暂时性死区、不允许重复声明

不存在变量提升:

  1. // var 的情况
  2. console.log(foo); // 输出undefined
  3. var foo = 2;
  4. // let 的情况
  5. console.log(bar); // 报错ReferenceError
  6. let bar = 2;

暂时性死区:

只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。

在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。

  1. var tmp = 123;
  2. if (true) {
  3. tmp = 'abc'; // ReferenceError
  4. let tmp;
  5. }

不允许重复声明:

  1. // 报错
  2. function func() {
  3. let a = 10;
  4. var a = 1;
  5. }
  6. // 报错
  7. function func() {
  8. let a = 10;
  9. let a = 1;
  10. }

2. 变量提升

概念

JavaScript在执行之前会先进行预编译,主要做两个工作:

  1. 将全局作用域或者函数作用域内所有函数声明提前。
  2. 将全局作用域或者函数作用域内所有var声明的变量提前声明,并赋值为undefined。

这就是变量提升。

看下面例子

  1. function test() {
  2. var name = 'test';
  3. }
  4. // 等价于
  5. function test() {
  6. var name;
  7. name = 'test';
  8. }
  9. function test1() {
  10. console.log(name);
  11. var name = 'test';
  12. }
  13. // 等价于
  14. function test1() {
  15. var name;
  16. console.log(name);
  17. name = 'test';
  18. }
  19. function test2() {
  20. exec();
  21. var exec = function () {
  22. console.log('exec');
  23. }
  24. }
  25. // 等价于
  26. function test2() {
  27. var exec;
  28. exec();
  29. exec = function () {
  30. console.log('exec');
  31. }
  32. }

另外,多个变量声明,后面会覆盖前面的

  1. var a = 1;
  2. var a = 2;
  3. console.log(a); // 2
  4. // 等价于
  5. var a = undefined;
  6. a = 1;
  7. a = 2;

函数的声明也会提升,提升到最前面

  1. function test() {
  2. exec();
  3. function exec() {
  4. console.log('exec');
  5. }
  6. }
  7. // 等价于
  8. function test() {
  9. function exec() {
  10. console.log('exec');
  11. }
  12. exec();
  13. }

注意:

  1. 函数声明可以提升,但是函数表达式不提升,具名的函数表达式的标识符也不会提升。
  2. 同名的函数声明,后面的覆盖前面的。
  3. 函数声明的提升,不受逻辑判断的控制。
  1. // 函数表达式和具名函数表达式标识符都不会提升
  2. test(); // TypeError test is not a function
  3. log(); // TypeError log is not a function
  4. var test = function log() {console.log('test')};
  5. // 同名函数声明,后面的覆盖前面的
  6. function test() {
  7. console.log(1);
  8. }
  9. function test() {
  10. console.log(2);
  11. }
  12. test(); // 2
  13. // 函数声明的提升,不受逻辑判断的控制
  14. // 注意这是在ES5环境中的规则,在ES6中会报错,原因后面说明
  15. function test() {
  16. log();
  17. if (false) {
  18. function log() {
  19. console.log('test');
  20. }
  21. }
  22. }
  23. test(); // 'test'

在块级作用域内声明函数会是什么效果呢?这在ES5和ES6环境中是不同的,详细的说明可以参考块级作用域与函数声明

规则描述如下

ES5环境中,语句块中的函数声明将被提升到函数作用域前面

  1. function f() { console.log('I am outside!'); }
  2. (function () {
  3. if (false) {
  4. // 重复声明一次函数f
  5. function f() { console.log('I am inside!'); }
  6. }
  7. f(); // 'I am inside!'
  8. }());
  9. // 等价于
  10. function f() { console.log('I am outside!'); }
  11. (function () {
  12. function f() { console.log('I am inside!'); }
  13. if (false) {
  14. }
  15. f();
  16. }());

ES6环境中,如果在语句块中声明函数,按照正常的规范,函数声明应该被封闭在语句块里面,因此应该打印”I am outside!”,但是为了兼容老代码,因此语法标准允许其他的实现:

  • 允许在块级作用域内声明函数。
  • 函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
  • 同时,函数声明还会提升到所在的块级作用域的头部。

所以上面代码在ES6中表现是

  1. function f() { console.log('I am outside!'); }
  2. (function () {
  3. if (false) {
  4. // 重复声明一次函数f
  5. function f() { console.log('I am inside!'); }
  6. }
  7. f(); // Uncaught TypeError: f is not a function
  8. }());
  9. // 等价于
  10. function f() { console.log('I am outside!'); }
  11. (function () {
  12. var f = undefined;
  13. if (false) {
  14. function f() { console.log('I am inside!'); }
  15. }
  16. f();
  17. }());

注意:在前面已经提到过,const和let定义的变量不会提升。

循环打印数字

下面看一个经典的例子,循环打印数字

  1. for(var i = 0; i < 3; i++) {
  2. setTimeout(function() {
  3. console.log(i);
  4. },100);
  5. }

上面代码会打印3个3。

由于变量提升的特性,变量i会被提升到函数作用域或者全局作用域首部,因此setTimeout中的回调方法访问到的是外层的变量i,当循环结束时候,i变为3,因此每次打印的都是3。

如果想要打印0, 1, 2。要利用闭包的特性。

  1. for(var i = 0; i < 3; i++) {
  2. setTimeout((function(num) {
  3. return function() {
  4. console.log(num);
  5. }
  6. })(i), 100);
  7. }

上面代码每次循环生成一个闭包,每个闭包都保存了一个循环变量i的值,这样就能够打印正确的数值了。

也可以使用let生成块级作用域,来实现打印0, 1, 2的效果。

  1. for(let i = 0; i < 3; i++) {
  2. setTimeout(function() {
  3. console.log(i);
  4. },100);
  5. }

为什么使用let可以实现打印连续数字的功能呢?

因为使用let声明循环变量,js引擎执行循环语句时候会在每个循环体(每个循环体是一个独立的语句块)内重新重新声明变量i,并且js引擎会记录上一次循环的值,所以每个循环体中的i相互不影响,setTimeout的回调中访问到的是块级作用域自身中的i。

  1. 闭包

    函数和函数内部能访问到的变量的总和,就是一个闭包。
    如何生成闭包?函数内嵌套函数,并且函数执行完后,内部函数会被引用,这样内部函数可以访问外部函数中定义的变量,于是就生成了一个闭包。

下面是一个闭包的例子:

  1. function outer() {
  2. var a = 1;
  3. function inner() {
  4. console.log(a);
  5. }
  6. return inner;
  7. }
  8. var b = outer();

注意,如果没有将outer()执行结果赋值给b,那么内部函数不会被引用,因此没有形成闭包。如果把inner挂在window下面也形成了对inner的引用,也可以生成闭包:

  1. function outer() {
  2. var a = 1;
  3. function inner() {
  4. console.log(a);
  5. }
  6. window.inner = inner;
  7. }
  8. outer();

闭包的作用是什么?可以让内部的函数访问到外部函数的变量,避免变量在全局作用域中存在被修改的风险。
比如我们要实现一个计数器,支持增加计数和获取计数的功能。计数器使用方法如下

  1. var counter = createCounter();
  2. counter.increase(); // +1
  3. console.log(counter.getCount()); /

我们首先可以想到,全局作用域的变量在函数内部可以访问到,所以可以这样实现

  1. var count = 0;
  2. function createCounter() {
  3. function increase() {
  4. count++;
  5. }
  6. function getCount() {
  7. return count;
  8. }
  9. return {
  10. increase: increase,
  11. getCount: getCount
  12. };
  13. }
  14. var counter = createCounter();
  15. counter.increase();
  16. console.log(counter.getCount());
  17. console.log(count);

但是变量count放在全局,很容易被其他模块修改从而导致不可预知的问题。因此我们希望count变量不会被其他模块访问到,于是需要把count放在函数作用域中:

  1. function createCounter() {
  2. var count = 0;
  3. function increase() {
  4. count++;
  5. }
  6. function getCount() {
  7. return count;
  8. }
  9. return {
  10. increase: increase,
  11. getCount: getCount
  12. };
  13. }
  14. var counter = createCounter();
  15. counter.increase();
  16. console.log(counter.getCount());
  17. console.log(count);

这样函数createCounter中的increate和getCount两个函数可以访问到createCounter内部定义的count,这样就形成了闭包。而count只能被createCounter内部定义的函数访问到,因此不会有被随意修改的风险。
通常情况下函数中定义的变量在函数执行完成后会被销毁,例如:

  1. function createCounter() {
  2. var count = 0;
  3. function increase() {
  4. count++;
  5. }
  6. function getCount() {
  7. return count;
  8. }
  9. return {
  10. increase: increase,
  11. getCount: getCount
  12. };
  13. }
  14. createCounter();

通常执行完createCounter()方法之后,内部的所有变量都被从内存中销毁(因为没有其他地方使用了)。但是如果生成了闭包(即有对内部嵌套函数的引用),则内部变量不会被销毁(因为还有其他地方在用,嵌套的内部函数还在使用),还是以上面createCounter闭包为例

  1. function createCounter() {
  2. var count = 0;
  3. function increase() {
  4. count++;
  5. }
  6. function getCount() {
  7. return count;
  8. }
  9. return {
  10. increase: increase,
  11. getCount: getCount
  12. };
  13. }
  14. var counter = createCounter();
  15. counter.increase();
  16. console.log(counter.getCount());
  17. console.log(count);

由于createCounter返回的方法们被引用,因此形成闭包,所以内部变量count不会被销毁,而是会继续被increase和getCount使用。
生成闭包之后,如果我们不再需要使用counter可以执行counter = null;这样失去了对内部嵌套函数的引用,浏览器就会将方法内资源都销毁调了。因此当我们使用完闭包之后如果后续不再需要使用,最好通过取消引用来释放闭包的资源。

总结:

  1. 什么是闭包?函数和函数内部能访问到的变量的总和,就是一个闭包。
  2. 如何生成闭包? 函数嵌套 + 内部函数被引用。
  3. 闭包作用?隐藏变量,避免放在全局有被篡改的风险。
  4. 使用闭包的注意事项?不用的时候解除引用,避免不必要的内存占用。
  5. 闭包的缺点:使用时候不注意的话,容易产生内存泄漏。