likes
comments
collection
share

Javascript之作用域相关概念的个人理解

作者站长头像
站长
· 阅读数 3

开头的废话

这篇文章也是不久前总结的。其实,这些东西也不是什么新鲜的概念,但是每次回忆起来的时候总是零零碎碎的,似懂非懂。索性总结一下,虽然很多观点不一定对,但是能帮助我理解就够了。当然,如果有错误还是希望大家指正,谢谢!

废话一大堆,下面开始一本正经的聊聊我写的bug,咳咳咳(严肃)!

1、执行上下文

首先,我们来回顾一下,函数的定义和函数的执行的区别:

  • 函数的定义是指函数的声明或创建的过程。

  • function test() {}
    
  • 函数的执行是指函数的调用过程。

  • test();
    

在函数执行的时候,会创建一个执行上下文。它可以理解成一个对象,这个对象包括函数执行所需的内容(例如:参数、变量、作用域等)。暂且无须关心执行上下文是存储在什么地方,简单的说,这是JavaScript解释器在执行代码的时候,自动创建,并且只能由解释器自己访问的东西。

2、作用域链

与执行上下文不同的是,作用域链是函数定义的时候创建的。这个作用域链被保存在函数内部的[[scope]]属性中。因为函数也是一个对象,这里[[scope]]可以当作是函数对象的属性。当然,这个属性也是无法被常规代码所访问的。

作用域链是一个对象列表。在函数定义的时候,作用域链包含当前定义的函数的外层函数,接下来是外层函数的外层函数,依次直到全局对象。

function a () {
    var prefix = 'log';
    return function () {
        console.log(prefix + '我是内部函数')
    }
}
var temp = a(); // 执行函数a
temp();

以上述代码为例,在执行函数a的时候,定义了一个内部函数。那么,在定义这个内部函数的时候,创建了一个作用域链[[scope]]储存在当前内部函数对象的属性中,这个作用域链的最顶端是函数a的活动对象,接下来就是全局对象。

3、活动对象

活动对象是在函数执行的时候被创建的,最开始它只包含arguments对象,以及单独每个参数的的参数名和参数值。在这里,活动对象等同于变量对象(顾名思义,保存变量的对象)。因此,函数执行过程中的局部变量也保存在这个活动对象中。

4、函数调用过程

我们仍然以上述代码为例:

  1. 调用函数a之后,内部函数被定义。根据上文所述,函数定义时做了一个事情:预先创建作用域链,作用域链中依次保存了函数a的活动对象、全局对象。
  2. 调用内部函数,开始创建函数执行上下文。同时,将第一步定义的作用域链复制一份,放在执行上下文中。
  3. 创建活动对象,并推入执行上下文的作用域链中。
  4. 执行内部函数,执行变量解析。为什么内部函数在函数a中定义,在函数a之外执行,却仍然可以访问函数a的变量。这是因为在内部函数的执行上下文的作用域链中,保存了一份函数a的活动对象。
  5. 关于内存泄漏,正是由于内部函数持有外层函数的活动对象,导致外层函数的活动对象无法被回收,才造成内存泄漏。仍然以上面的代码为例,函数a执行结束以后,返回内部函数并被变量temp所持有。也就是说,temp持有内部函数的引用。而定义内部函数时,所创建的作用域链一直包含函数a的活动对象。所以,只要内部函数还被其他变量所引用,那函数a的活动对象就无法被回收。
  6. 注意:虽然内部函数持有外部函数的活动对象,而外部函数的活动对象又含有arguments对象。但这并不意味着,内部函数可以直接访问外层函数的arguments对象。同样的,内部函数也无法访问外部函数的this引用(箭头函数除外)。目前存在这两个特例,如果需要访问,可以将它们放到局部变量中保存起来,如:var self = this

5、词法作用域

JavaScript采用的是词法作用域,也称为静态作用域。词法作用域最明显的特征就是,变量的作用域是在函数定义的时候确定的。例如:

var a = 2;
function test() {
    var a = 1;
    return function() {
        console.log(a);
    }
}
var func = test();
func(); // 输出1

这个例子的执行结果输出1,而不是输出2。这个原因就在于词法作用域的规则:作用域是在函数定义时确定的

首先,调用函数test()定义了一个内部函数。根据上文所述,内部函数在定义时,会预先创建一个作用域链。作用域的顶端是test的活动对象,接下来是全局对象。

然后,在调用func函数时,建立该函数的活动对象,并将这个活动对象推入作用域链的最顶端。

接下来,执行到console.log函数需要使用变量a。顺着作用域链进行变量解析,发现当前函数的活动对象上无法找到变量a。于是,只能向上一层函数的活动对象查找,发现变量a有定义,直接打印输出1

换而言之,无论在何地调用函数func,函数func总是可以访问预先定义的作用域链中的变量。除非在函数func中定义局部变量或参数,覆盖作用域链上原来的变量。

试想,如果是动态作用域呢?在调用func函数时,先查找本函数中有没有定义变量a,发现未定义。再查找调用该函数的环境中是否存在变量a,发现有定义,打印输出2

6、闭包

理解词法作用域,闭包就非常好理解了。闭包是可以访问上层函数作用域中的变量的函数。从某种意义上来说,所有的Javascript函数都是闭包,全局函数可以看作是有权访问全局作用域的闭包。而内部函数有权访问所有上层函数的变量。

7、函数定义表达式和函数声明表达式

函数定义表达式:

var a = function() {}

函数声明表达式:

function a() {}

上文提到函数在创建的时候,会预先创建作用域链。但在这两种表达式中,所谓的“函数的创建”,时机稍有不同。

函数声明表达式会出现提前声明,也就是函数提升到当前作用域的顶端进行声明。也就是说,函数声明表达式在相应的作用域顶端就已经“创建”了。

函数定义表达式不会提前声明。上面的函数定义表达式中,用var定义的变量a也会提前声明,但赋值语句仍然留在原地,那么赋值语句右侧的函数必须在代码执行到这里时,函数才会“创建”。

8、经典的示例分析和个人理解

8.1、示例一

function test() {
    var n = 1;
    return function() {
        return ++n;
    }
}
var temp1 = test();
var temp2 = test();
temp1(); // 输出2
temp1(); // 输出3
temp2(); // 输出2

在上面的例子中,调用两次test函数获得两个闭包。运行两次temp1之后,n的值已经为3。但为什么再调用temp2却输出2呢?

首先,调用test函数时,定义了内部函数并且创建了作用域链,作用域链中包括test函数的活动对象和全局对象。

然后,再调用test函数,内部函数又被重新定义(在多层嵌套函数中,每调用一次外层函数,内部函数都会重新定义)。同时,再次创建作用域链,该作用域链仍然包含的是test函数的活动对象和全局对象。

按照这个逻辑,两条作用域链一致。其中一个闭包修改test函数的变量,另一个闭包再访问时,应该得到的是修改后的值。但此处却恰恰相反,两个闭包访问的变量都是独立的。

个人的理解是:两次调用test函数,都重新创建了该函数的活动对象。于是,虽然上面两个闭包都包含test的活动对象,但实际却是两个完全不同的对象。因此,两个闭包才能访问到完全独立的变量。

8.2、示例二

function test() {
    var arr = [];
    for(var i = 0; i < 5; i++) {
        arr.push(function() {
            console.log(i);
        })
    }
    return arr;
}
var closures = test();
for(var i = 0; i < closures.length; i++) {
    closures[i](); 
}
// 输出都是5

在此处,本期望依次输出15,但结果每个闭包都输出5。

原因是:循环创建了5个闭包,他们都可以访问外部函数的变量i。但这些闭包实际访问变量i的时候,循环已经结束,变量i的值已经是5。因此,每个闭包的输出就都是5

9、this对象

this对象是在函数执行时确定的。在函数被调用时,当前函数的活动对象自动初始化arguments对象和this对象。

this对象的指向有如下几个规律:

  1. 全局函数的this指向window(非严格模式下,严格模式指向undefined
  2. 对象方法的this指向方法的调用对象。
  3. 事件函数的this指向调用这个事件函数的DOM元素。
  4. 匿名函数的this具有全局函数的特性,指向window
  5. 箭头函数的this指向上层执行环境中的this,也就是说,箭头函数没有自己的this指向。

以上是正常情况的this指向,通过applycall方法能改变this指向。其次,下面的方法也能改变this指向。

function test() {
    console.log(this);
}
test(); // 输出"Window"
var a = {
    func: null
}
a.func = test;
a.func(); // 输出"{func: ƒ}",即对象a

上文有提到,内部函数的作用域链中持有外层函数的活动对象,所以内部函数可以访问外层函数中定义的变量。有两个特殊情况是,内层函数无法访问外层函数活动对象上的argumentsthis。如果要访问上层函数的this,只能用var self = this的方式将this对象先缓存到局部变量中,供内部函数访问。但ES6的箭头函数是一个特例,它可以访问外层函数的this。因为箭头函数没有自己的this指针,箭头函数也不能作为构造函数被调用。