Javascript之作用域相关概念的个人理解
开头的废话
这篇文章也是不久前总结的。其实,这些东西也不是什么新鲜的概念,但是每次回忆起来的时候总是零零碎碎的,似懂非懂。索性总结一下,虽然很多观点不一定对,但是能帮助我理解就够了。当然,如果有错误还是希望大家指正,谢谢!
废话一大堆,下面开始一本正经的聊聊我写的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、函数调用过程
我们仍然以上述代码为例:
- 调用函数
a
之后,内部函数被定义。根据上文所述,函数定义时做了一个事情:预先创建作用域链,作用域链中依次保存了函数a
的活动对象、全局对象。 - 调用内部函数,开始创建函数执行上下文。同时,将第一步定义的作用域链复制一份,放在执行上下文中。
- 创建活动对象,并推入执行上下文的作用域链中。
- 执行内部函数,执行变量解析。为什么内部函数在函数
a
中定义,在函数a
之外执行,却仍然可以访问函数a
的变量。这是因为在内部函数的执行上下文的作用域链中,保存了一份函数a
的活动对象。 - 关于内存泄漏,正是由于内部函数持有外层函数的活动对象,导致外层函数的活动对象无法被回收,才造成内存泄漏。仍然以上面的代码为例,函数
a
执行结束以后,返回内部函数并被变量temp
所持有。也就是说,temp
持有内部函数的引用。而定义内部函数时,所创建的作用域链一直包含函数a
的活动对象。所以,只要内部函数还被其他变量所引用,那函数a
的活动对象就无法被回收。 - 注意:虽然内部函数持有外部函数的活动对象,而外部函数的活动对象又含有
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
在此处,本期望依次输出1
到5
,但结果每个闭包都输出5。
原因是:循环创建了5个闭包,他们都可以访问外部函数的变量i
。但这些闭包实际访问变量i
的时候,循环已经结束,变量i
的值已经是5
。因此,每个闭包的输出就都是5
。
9、this对象
this
对象是在函数执行时确定的。在函数被调用时,当前函数的活动对象自动初始化arguments
对象和this
对象。
this对象的指向有如下几个规律:
- 全局函数的
this
指向window
(非严格模式下,严格模式指向undefined
) - 对象方法的
this
指向方法的调用对象。 - 事件函数的
this
指向调用这个事件函数的DOM
元素。 - 匿名函数的
this
具有全局函数的特性,指向window
。 - 箭头函数的
this
指向上层执行环境中的this
,也就是说,箭头函数没有自己的this
指向。
以上是正常情况的this
指向,通过apply
和call
方法能改变this
指向。其次,下面的方法也能改变this
指向。
function test() {
console.log(this);
}
test(); // 输出"Window"
var a = {
func: null
}
a.func = test;
a.func(); // 输出"{func: ƒ}",即对象a
上文有提到,内部函数的作用域链中持有外层函数的活动对象,所以内部函数可以访问外层函数中定义的变量。有两个特殊情况是,内层函数无法访问外层函数活动对象上的arguments
和this
。如果要访问上层函数的this
,只能用var self = this
的方式将this
对象先缓存到局部变量中,供内部函数访问。但ES6的箭头函数是一个特例,它可以访问外层函数的this
。因为箭头函数没有自己的this
指针,箭头函数也不能作为构造函数被调用。
转载自:https://juejin.cn/post/6844904017164763143