|

作用域与作用域链以及闭包的本质

本文主要整理介绍有关 JavaScript 的作用域与作用域链,介绍总结变量在那种情况下可以访问,那种情况下会出现变量覆盖,以及闭包的为何会产生,即闭包的本质问题。

作用域

先看一个简单的例子:

let name = "Tom";
function hello (name) {
    let age = 20;
    console.log("hello " + name, "age " + age);
}

// 执行 hello 函数
hello();        // hello undefined age 20
hello('Sean');  // hello Sean age 20

上面的代码中,函数 hello 创建了自己的作用域,在这个作用域中有两个变量:nameage,其中 name 是实参,通过调用传值,age 是函数内部的变量,默认值是 20 。

通过 hello() 输出包含了 undefined ,原因是实参 name 没有传值,所以相当于有定义但是没有初始化,所以为 undefined 。

调用 hello('Sean') 输出了 hello Sean age 20,name 不再是 undefined ,是由于实参传递了有效的值,这里的值是一个字符串,所以可以正常输出确定值。

上面的示例中并没有输出 Tom,是由于函数内的作用域会覆盖函数外的作用域。在这个示例中,全局作用域中包含了一个变量 name ,并初始化为 Tom,同时函数 hello 内部也有一个同名变量,由于全局函数 hello 内部已经有了 name 定义,所以执行器执行时可以在最近的作用域中找到 name 定义,就不会再向上查找了,也就导致了全局作用域中的 name 没有表现。

上面就是一个典型的作用域的概念。

作用域链

一个代码块就可以创建一个作用域,多个代码块嵌套就形成了作用域链。作用域链与原型链类似,在查找变量时也是从本层域内逐层向上查找,如果找到了变量定义,则返回变量的值,如果什么也找不到,则为 undefined 。

由于基于作用域链的查找在找到后立刻终止,所以内嵌作用域内的变量就会覆盖外层作用域的变量。

看一个示例:

for (var i = 0; i < 5; i++) {  
    setTimeout(function() {
        console.log(i);
    }, 100);
}
// 输出:
// 5
// 5
// 5
// 5
// 5

可见输出并不是符合预期的 0,1,2,3,4,这是什么原因?

还是作用域的问题!

在这个示例中,setTimeout 回调的匿名函数构成了一个作用域,for 所在的代码块也构成了一个作用域,且是子块匿名函数的父作用域。于是从匿名函数的作用域到 for 所在的作用域就构成了一个两级的作用域链。

上面这个描述是很好理解的。

这个示例的精妙之处并非止于此,其实还有一个宏任务的概念,暂且不表,后面会专门写文章介绍。

由于执行 for 循环的优先级更高,且执行 for 循环是同步的,所以当 for 循环执行完成后,setTimeout 的回调函数都还没有执行,而此时 for 循环的循环变量 i 已经变成了 5 。同时,由于 setTimeout 回调函数中对变量 i 有引用,所以根据 JavaScript 的垃圾回收机制将不会销毁 i ,所以变量 i 会继续驻留在内存中,此时 i 的作用域在 for 所在的块内。

当 setTimeout 的回调函数开始执行时,开始访问变量 i ,发现本层作用域没有这个变量,于是查找上层 for 代码块的作用域内的 i ,也没有找到;继续向上查找,由于 i 驻留在 for 循环所在的作用域内,所以在这个作用域中找到了 i ,此时的 i 已经变成了 5 。

以上便是输出全部是 5 的原因。

上面的示例等价于:

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

如果希望上面能按照预期输出 0,1,2,3,4,可以用 let 来定义循环变量:

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

何为闭包

闭包这个概念在 JavaScript 中非常普遍,几乎只要面试必定会问到闭包。简单来说,闭包就是在函数或对象的外部调用某个函数,以便访问这个函数或对象内部的变量或方法。

看下面一个例子:

function person (name, age) {
    return {
        getName: function () {
            return name;
        },
        getAge: function () {
            return age;
        }
    }
}
let man = person("Tom", 20);
console.log(man.getName());     // Tom
console.log(man.getAge());      // 20

这个例子是非常简单的,定义了一个 person 函数,有 name 和 age 两个参数,函数执行完成后返回一个对象,对象包含了 getName 和 getAge 两个方法。

对象 man 是执行 person 的返回值,调用 man 的 getName 和 getAge 方法分别得到输出。这里的 man 对象就是一个闭包。

对象 man 内部是没有 name 和 age 变量的,这两个变量在其上级作用域内才有定义,由于 person 函数返回的对象内包含了对上层作用域 name 和 age 的引用,所以这两个变量不会被销毁,而是驻留在内存中,直到所有的引用被销毁;

再看一个例子,比如前端开发中经常会使用的防抖函数。防抖函数可以控制用户在多次连续触发某个事件时,事件的回调函数只会最终执行一次,也就是可以防止多次连续调用影响性能。

// 防抖
function debounce (func, timer) {
    if (typeof func !== 'function') {
        throw '"func" must be a function';
    }
    var timerHandle = null;
    return function () {
        if (timerHandle) {
            clearTimeout(timerHandle);
        }
        timerHandle = setTimeout(func, timer);
    }
}

防抖函数就是一个闭包,它返回一个函数对象,该函数对象内部引用了 debounce 函数作用域内的变量 timerHandle ,所以该变量将会驻留在内存中。

测试这个函数:

// 测试防抖
var testDebounce = debounce(function () {
    console.log("debounce");
}, 500);

// 执行测试
var times = 0;
var testHandle = setInterval(function () {
    testDebounce();
    if (times++ > 10) {
        clearInterval(testHandle);
    }
}, 100);

测试结果为代码运行的大约 1500 毫秒后输出 debounce 字符串。

闭包的本质

从上面的几个例子可以看出,闭包的本质其实是作用域与作用域链的作用,当某个对象需要引用其上级作用域内的变量或者函数时,就会导致垃圾回收机不能回收上级作用域内的相关变量,那么变量就会驻留,返回的内部对象由于是一个引用,所以也不会被销毁,于是就构成了一个局部的作用域链,这个作用域链就是闭包,这也是闭包的本质。

闭包的利弊

从原理可以的中,闭包虽然尤其优势,可以使得代码更灵活,但也有弊端,那就是对资源的消耗,对内存的占用。

由于一个闭包的产生是由于有未被销毁的变量构成的作用域链,所以这些变量会被驻留在内存中,过量使用闭包会使得内存占用巨大,所以开发中也要根据实际情况,酌情使用闭包特性。

引申:var 与 let 的本质区别

使用 var 定义的变量会在其所在的函数块内驻留,相当于在函数中定义的变量;而是用 let 定义的变量只在所在块内有效,用完即销毁。这两点在 for 的循环遍历中非常常见:

function test () {
    for (var i = 0; i < 10; i++) {
        // do something
    }
    console.log(i);
}

test();     // 10

上面的代码会在函数执行完成后输出 10 ,如果不理解,可以看下下面的等效代码:

function test () {
    var i = 0;
    for (; i < 10; i++) {
        // do something
    }
    console.log(i);
}

test();     // 10

for 循环中的循环遍历 i 相当于函数 test 的一个变量,所以不仅在 for 的函数块内有效,在 test 函数块内也依然有效。

再看下面使用 let 的例子:

function test () {
    for (let i = 0; i < 10; i++) {
        // do something
    }
    console.log(i);
}

test();     // 报错,i is not defined

这个例子使用 let 定义循环变量 i ,所以 i 只会在 for 的代码块中有效。所以最后输出 i 时会报错,是因为 console.log 的作用域在 test 函数的代码块中,而 i 是在 for 的代码块中;父作用域不能访问子作用域内的变量,所以会报错。

所以 var 与 let 的本质区别就在于其作用域不同。

引申:局部变量覆盖全局变量的本质

变量覆盖其实和原型链中的属性覆盖基本相同,都是有访问特性所决定的。

代码执行过程中,访问一个变量时会首先在当前作用域内查找,如果没有找到那么就到上级作用域中查找,直到找到变量或者完全没有找到停止,一旦找到了变量,那么就会使用这个变量,后面作用域链中的变量就不再查找了。

所以,距离当前作用域最近的变量将会被找到并使用,所以出现了感觉上的变量覆盖,其实作用域链中的同名变量并没有消失,依然在那里,覆盖只是由于访问特性导致的一种错觉。当脱离了当前作用域来到外层的作用域后,外层作用域中的变量依然是可以访问的。

类似文章

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注