美文网首页
作用域、变量提升和闭包

作用域、变量提升和闭包

作者: 刷题刷到手抽筋 | 来源:发表于2022-05-25 04:35 被阅读0次

一、 说明

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

二、 题目

  1. 下面代码输出的结果?(函数作用域)
var name = 'window';

console.log(name);

function outer() {
    var name = 'outer';
    console.log(name);

    function inner() {
        console.log(name);
    }

    inner();
}

outer();

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

  1. 下面代码执行的结果?(块级作用域)
function test() {
    if (true) {
        var a = 'true';
    }
    else {
        var a = 'false';
    }

    console.log(a);
}
test();

function test1() {
    if (true) {
        const a = 'true';
    }
    else {
        const a = 'false';
    }

    console.log(a);
}
test1();

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

  1. 下面代码执行的结果?(变量提升)
function test() {
    console.log(a);
    var a = 1;
    console.log(a);
}
test();

function test1() {
    console.log(a);
    const a = 1;
    console.log(a);
}
test1();

function test2() {
    console.log(inner());
    function inner() {
        return 'inner';
    }
}
test2();

function test3() {
    console.log(inner());
    var inner = function () {
        return 'inner';
    }
}
test3();

function test4() {
    var a = 1;
    function inner() {
        console.log(a);
        var a = 2;
    }
}

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

  1. 实现一个创建计数器的方法,支持增加计数和获取计数,对比下列两种实现方式。(闭包的应用场景)
/*方法1*/

var count = 0;

function createCounter() {
    function increase() {
        count++;
    }

    function getCount() {
        return count;
    }

    return {
        increase: increase,
        getCount: getCount
    };
}

var counter = createCounter();
counter.increase();

console.log(counter.getCount());
console.log(count);

/*方法2*/

function createCounter() {
    var count = 0;

    function increase() {
        count++;
    }

    function getCount() {
        return count;
    }

    return {
        increase: increase,
        getCount: getCount
    };
}

var counter = createCounter();
counter.increase();

console.log(counter.getCount());
console.log(count);

答案见闭包的概念

  1. 下面各个代码段执行结果?(闭包练习)
// 代码段1
function test() {
    var arr = [];
    for(var i = 0; i <= 5; i++) {
        arr[i] = function () {
            console.log(i);
        };
    }
    return arr;
}

var funcList = test();

funcList.forEach(function (func) {
    func();
});

// 代码段2
function test1() {
    var arr = [];
    for(let i = 0; i <= 5; i++) {
        arr[i] = function () {
            console.log(i);
        };
    }
    return arr;
}

var funcList = test1();

funcList.forEach(function (func) {
    func();
});

// 代码段3
function test2() {
    var arr = [];
    for(var i = 0; i <= 5; i++) {
        arr[i] = (function (i) {
            return function () {
                console.log(i);
            }
        })(i);
    }
    return arr;
}

var funcList = test2();

funcList.forEach(function (func) {
    func();
});

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. 作用域

说明

作用域是可访问变量的集合或者说范围(例如全局的范围、函数的范围、语句块的范围),在作用域内,变量可访问,在作用域外变量不可访问。例如

function test() {
    var name = 'test';
    console.log('inner', name);
}

test();
console.log('outer', name);

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

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

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

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

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

词法作用域和动态作用域

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

var a = 2;
function foo() {
    console.log( a );
}
function bar() {
    var a = 3;
    foo();
}
bar();

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

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

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

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

var name = 'win';
const obj = {
    name: 'obj',
    a: () => {
        console.log(this.name);
    }
};
obj.a();

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

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

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

全局作用域

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

函数作用域

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

function outer() {
    var name = 'outer';
    console.log(name); // outer
    function inner() {
        console.log(name); // outer
    }
    inner();
}

outer();

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

function outer() {
    var name = 'outer';
    console.log(name); // outer
    function inner() {
        var name = 'inner';
        console.log(name); // inner
    }
    inner();
}

outer();

块级作用域

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

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

function test() {
    if (true) {
        const variable = 'test';
        console.log(variable); // test
    }
    console.log(variable); // Error: variable is not defined
}

test();

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

不存在变量提升:

// var 的情况
console.log(foo); // 输出undefined
var foo = 2;

// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;

暂时性死区:

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

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

var tmp = 123;

if (true) {
  tmp = 'abc'; // ReferenceError
  let tmp;
}

不允许重复声明:

// 报错
function func() {
  let a = 10;
  var a = 1;
}

// 报错
function func() {
  let a = 10;
  let a = 1;
}

2. 变量提升

概念

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

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

这就是变量提升。

看下面例子

function test() {
    var name = 'test';
}
// 等价于
function test() {
    var name;
    name = 'test';
}


function test1() {
    console.log(name);
    var name = 'test';
}
// 等价于
function test1() {
    var name;
    console.log(name);
    name = 'test';
}


function test2() {
    exec();
    var exec = function () {
        console.log('exec');
    }
}
// 等价于
function test2() {
    var exec;
    exec();
    exec = function () {
        console.log('exec');
    }
}

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

var a = 1;
var a = 2;
console.log(a); // 2

// 等价于
var a = undefined;
a = 1;
a = 2;

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

function test() {
    exec();
    function exec() {
        console.log('exec');
    }
}

// 等价于

function test() {
    function exec() {
        console.log('exec');
    }
    exec();
}

注意:

  1. 函数声明可以提升,但是函数表达式不提升,具名的函数表达式的标识符也不会提升。
  2. 同名的函数声明,后面的覆盖前面的。
  3. 函数声明的提升,不受逻辑判断的控制。
// 函数表达式和具名函数表达式标识符都不会提升
test(); // TypeError test is not a function
log(); // TypeError log is not a function
var test = function log() {console.log('test')};

// 同名函数声明,后面的覆盖前面的
function test() {
    console.log(1);
}
function test() {
    console.log(2);
}
test(); // 2

// 函数声明的提升,不受逻辑判断的控制
// 注意这是在ES5环境中的规则,在ES6中会报错,原因后面说明
function test() {
    log();
        if (false) {
            function log() {
                console.log('test');
        }
    }
}
test(); // 'test'

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

规则描述如下

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

function f() { console.log('I am outside!'); }
(function () {
  if (false) {
    // 重复声明一次函数f
    function f() { console.log('I am inside!'); }
  }

  f(); // 'I am inside!'
}());


// 等价于
function f() { console.log('I am outside!'); }
(function () {
  function f() { console.log('I am inside!'); }
  if (false) {
  }
  f();
}());

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

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

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

function f() { console.log('I am outside!'); }
(function () {
  if (false) {
    // 重复声明一次函数f
    function f() { console.log('I am inside!'); }
  }

  f(); // Uncaught TypeError: f is not a function
}());


// 等价于
function f() { console.log('I am outside!'); }
(function () {
    var f = undefined;
    if (false) {
        function f() { console.log('I am inside!'); }
    }
    f();
}());

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

循环打印数字

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

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

上面代码会打印3个3。

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

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

for(var i = 0; i < 3; i++) {
    setTimeout((function(num) {
        return function() {
                console.log(num);
        }
    })(i), 100);
}

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

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

\

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

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

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

3. 闭包

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

下面是一个闭包的例子:

function outer() {
    var a = 1;
    function inner() {
        console.log(a);
    }
    return inner;
}

var b = outer();

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

function outer() {
    var a = 1;
    function inner() {
        console.log(a);
    }
    window.inner = inner;
}

outer();

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

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

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

var count = 0;
function createCounter() {
    function increase() {
        count++;
    }

    function getCount() {
        return count;
    }

    return {
        increase: increase,
        getCount: getCount
    };
}

var counter = createCounter();
counter.increase();

console.log(counter.getCount());
console.log(count);

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

function createCounter() {
    var count = 0;
    function increase() {
        count++;
    }

    function getCount() {
        return count;
    }

    return {
        increase: increase,
        getCount: getCount
    };
}

var counter = createCounter();
counter.increase();

console.log(counter.getCount());
console.log(count);

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

function createCounter() {
    var count = 0;

    function increase() {
        count++;
    }

    function getCount() {
        return count;
    }

    return {
        increase: increase,
        getCount: getCount
    };
}

createCounter();

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

function createCounter() {
    var count = 0;
    function increase() {
        count++;
    }

    function getCount() {
        return count;
    }

    return {
        increase: increase,
        getCount: getCount
    };
}

var counter = createCounter();
counter.increase();

console.log(counter.getCount());
console.log(count);

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

总结:

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

相关文章

  • 2023-01-12

    变量提升调用栈块级作用域作用域链和闭包 闭包 => 作用域链(词法作用域) => 调用栈(栈溢出) => 上下文...

  • javaScript门道之闭包

    闭包的学习路径:变量的作用域 -> 闭包的概念 ->闭包的应用 1.变量的作用域 变量的作用域分为作用于全局和作用...

  • 浓缩解读《JavaScript设计模式与开发实践》③

    三、闭包和高阶函数 3.1 闭包 3.1.1 变量的作用域 所谓变量的作用域,就是变量的有效范围。通过作用域的划分...

  • JS 闭包(Closure)

    参考阮一峰老师的JS 闭包 理解闭包前需要理解变量作用域、变量提升 JS作用域 那如何让它依次打印,12345呢;...

  • 2018-07-11

    深入理解闭包: 一、变量的作用域 要理解闭包,首先必须理解Javascript特殊的变量作用域。 变量的作用域无非...

  • 闭包和高阶函数学习笔记

    一、闭包 闭包的形成与变量的作用域以及变量的生存周期密切相关。 1.1 变量的作用域 变量的作用域:指变量的有效范...

  • JS总结:(二)执行上下文、this、作用域与闭包

    知识点: 1、执行上下文 & 作用域链 & 变量提升 2、this 的七种使用场景 3、作用域与闭包:什么是闭包,...

  • 闭包、定时器

    一、什么是闭包? 有什么作用 1.变量的作用域  要理解闭包,首先必须理解JavaScript的变量作用域。变量的...

  • 浅析关于 JS 作用域的几个高频知识点

    闭包 词法作用域 变量提升 变量提升 什么是变量提升 顾名思义,变量提升指的是,在声明变量的时候,变量的声明位置会...

  • 第3章闭包和高阶函数

    第3章闭包和高阶函数 3.1闭包 3.1.1 变量的作用域 3.1.2 变量的生存周期 3.1.3 闭包的更多作用...

网友评论

      本文标题:作用域、变量提升和闭包

      本文链接:https://www.haomeiwen.com/subject/uvipurtx.html