变量作用域
在JavaScript中声明变量的关键字有:var
、let
、const
,不同关键字声明出来的变量,作用域大不相同,接下来我们来逐步分析下它们的作用域。
函数作用域
使用var
声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。
如果变量未声明直接初始化,那么它就会自动添加到全局上下文。
我们举个例子来验证下上述话语:
function getResult(readingVolume, likes) { var total = readingVolume + likes; globalResult = total; return total; } let result = getResult(200, 2); console.log("globalResult = ", globalResult); // 202 console.log(total); // ReferenceError: total is not defined
上述代码中:
- 我们声明了一个名为
getResult
的函数,接受两个参数 - 函数内部使用
var
声明了一个名为total
的变量,并赋值为两个参数之和。 - 在函数内部,我们还直接初始化了一个名为
globalResult
的变量,并赋值为total
的变量值 - 最后,返回total的值。
我们调用getResult
函数,传递参数200
和2
,随后,打印globalResult
与total
的值,我们发现globalResult
的值正常打印出来了,total
则会报错未定义,执行结果与上述话语完全吻合。
执行结果如下:
image-20210320204933307
使用var
声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前,这个现象就叫变量提升。
变量提升会导致同一作用域的代码可以在声明前使用,我们举个例子来验证下,如下所示:
console.log(name);// undefined var name = "神奇的程序员"; function getName() { console.log(name); // undefined var name = "大白"; return name; } getName();
上述代码:
- 我们先打印了name变量,然后才使用
var
关键词进行了声明,打印的值为undefined
- 随后,我们声明了一个名为
getName
的函数,在函数内部先答应name变量,随后才声明,答应的值为getName
- 最后,调用getName方法。
无论是在全局上下文还是函数上下文中,我们在声明前调用一个变量它的值为undefined
,没有报错就证明了var
声明变量会造成变量提升。
块级作用域
使用let
关键字声明的变量,会有自己的作用域块,它的作用域是块级的,块级作用域由最近的一对的花括号{}
届定。也就是说,if
、while
、for
、function
的块内部用let声明的变量,它的作用域都界定在{}
内部,甚至单独的块,在其内部用let声明变量,它的作用域也是界定在{}
内部。
我们举个例子来验证下:
let result = true; if (result) { let a; } console.log(a); // ReferenceError: a is not defined while (result) { let b; result = false; } console.log(b); // ReferenceError: b is not defined function foo() { let c; } console.log(c); // ReferenceError: c is not defined { let d; } console.log(d); // ReferenceError: a is not defined
上述代码中,我们在if、while、function、以及单独的{}内都声明了变量,在块外部调用其内部的变量时都会报错ReferenceError: xx is not defined
,除function外,如果我们在块内部使用var
关键字去声明,那么在块外部就能正常访问到块内部的变量。
运行结果如下:
image-20210320214600031
使用let
声明变量时,同一个作用域内不能重复声明,如果重复则抛出SyntaxError
错误。
我们举个例子来验证下:
let a = 10; let a = 11; console.log(a); // SyntaxError: Identifier 'a' has already been declared var b = 10; var b = 11; console.log(b); // 11
上述代码中:
- 我们使用let重复声明了两个同名变量
a
- 我们使用var重复声明了两个同名变量
b
我们在打印a时,会报错SyntaxError: Identifier 'a' has already been declared
我们在打印b时,重复的var声明则会被忽略,哪个在后,结果就是哪个,所以值为11
注意⚠️:严格来讲,let声明的变量在运行时也会被提升,但是由于“暂时性死区”的缘故,实际上不能在声明之前使用let变量。因此从
JavaScript
代码的角度来说,let的提升跟var是不一样的。
常量声明
使用const
关键字声明的变量,必须赋予初始值,一经声明,在其生命周期的任何时候都不能再重新赋予新值。
我们举个例子来验证下:
const name = "神奇的程序员"; const obj = {}; obj.name = "神奇的程序员"; name = "大白"; obj = { name: "大白" };
上述代码中:
- 我们使用const声明了两个变量
name
、obj
- 为obj添加name属性,我们没有重新给obj赋值,因此它可以正常添加
- 紧接着,我们给name赋了新值,此时就会报错
TypeError: Assignment to constant variable.
- 最后,我们给obj赋了新值,同样的也会报错。
运行结果如下:
image-20210320222904217
上述例子中使用const声明的obj
可以修改它的属性,如果想让整个对象都不能修改,可以使用Object.freeze()
,如下所示:
const obj1 = Object.freeze({ name: "大白" }); obj1.name = "神奇的程序员"; obj1.age = 20; console.log(obj1.name); console.log(obj1.age);
运行结果如下:
image-20210320223429928
注意⚠️:由于const声明暗示变量的值是单一类型且不可修改,JavaScript运行时编译器可以将其所有实例都替换成实际的值,而不会通过查询表进行变量查找(V8引擎就执行这种优化)。
变量的生存周期
接下来,我们来看下变量的生命周期。
- 变量如果处在全局上下文中,如果我们不主动销毁,那么它的生存周期则是永久的。
- 变量如果处在函数上下文中,它会随着函数调用的结束而被销毁。
我们举个例子来说明下:
var a = 10; function getName() { var name = "神奇的程序员"; }
上述代码中:
- 变量
a
处在全局上下文中,它的生存周期是永久的 - 变量
name
处在函数上下文中,当getName
执行完成后,name变量就会被销毁。
理解闭包
通过上述章节的分析,我们知道函数上下文中的变量会随着函数执行结束而销毁,如果我们通过某种方式让函数中的变量不让其随着函数执行结束而销毁,那么这种方式就称之为闭包 。
我们通过一个例子来讲解下:
var selfAdd = function() { var a = 1; return function() { a++; console.log(a); }; }; const addFn = selfAdd(); addFn(); // 打印2 addFn(); // 打印3 addFn(); // 打印4 addFn(); // 打印5
上述代码中:
- 我们声明了一个名为
selfAdd
的函数 - 函数内部定一个了一个变量a
- 随后,在函数内部又返回了一个匿名函数的引用
- 在匿名函数内部,它可以访问到
selfAdd
函数上下文中的变量 - 我们在调用
selfAdd()
函数时,它返回匿名函数的引用 - 因为匿名函数在全局上下文中被继续引用,因此它就有了不被销毁的理由。
- 因此,这里就产生了一个闭包结构,
selfAdd
函数上下文中的变量生命就被延续了
接下来,我们通过一个例子来讲解下闭包的作用:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>学习闭包</title> <script type="text/javascript" src="js/index.js"></script> </head> <body> <div>1</div> <div>2</div> <div>3</div> <div>4</div> <div>5</div> </body> </html>
window.onload = function() { const divs = document.getElementsByTagName("div"); for (var i = 0; i < divs.length; i++) { divs[i].onclick = function() { alert(i); }; } };
上述代码中,我们获取了页面中的所有div标签,循环为每个标签绑定点击事件,由于点击事件是被异步触发的,当事件触发时,for循环早已结束,此时变量i
的值已经是6,所以在div的点击事件函数中顺着作用域链从内到外查找变量i时,找到的值总是6。
我们的预想结果并非这样,此处我们可以借助闭包,把每次循环的i值都封闭起来,如下所示:
window.onload = function() { const divs = document.getElementsByTagName("div"); for (var i = 0; i < divs.length; i++) { (function(i) { divs[i].onclick = function() { alert(i); }; })(i); } };
上述代码中:
- 在for循环内部,我们用了一个自执行函数,把每次循环的i值都封闭起来
- 当在事件函数中顺着作用域链查找变量i时,会先找到被封闭在闭包环境中的i
- 代码中有5个div,因此这里的i分别就是
0, 1, 2, 3, 4
,符合了我们的预期
巧用块级作用域
在上述代码的for循环表达式中,使用var
定义了变量i
,我们在函数作用域章节讲过,使用var
声明变量时,变量会被自动添加到最接近的上下文,此处变量i
被提升到window.onload
函数的上下文中,因此当我们每次执行for循环时,i
的值都会被覆盖,同步代码执行完后,异步代码执行时,获取到的值就是覆盖后的值。
我们除了使用闭包解决上述问题,还可以let来解决,代码如下所示:
window.onload = function() { const divs = document.getElementsByTagName("div"); for (let i = 0; i < divs.length; i++) { // let的隐藏作用域,可以理解成 // {let i = 0} // {let i = 1} // {let i = 2} // {let i = 3} // {let i = 4} divs[i].onclick = function() { alert(i); }; } };
上述代码的for循环表达式中,我们使用let
声明了变量i
,我们在块级作用域章节讲过,使用let
关键字声明的变量,会有自己的作用域块,所以在for
循环表达式中使用let
等价于在代码块中使用let,因此:
for (let i = 0; i < divs.length; i++)
这段代码的括号之间,有一个隐藏的作用域for (let i = 0; i < divs.length; i++) {循环体}
在每次循环执行循环体之前,JS引擎会把i
在循环体的上下文中重新声明并初始化一次
因为let在代码块中都有自己的作用域,所以在for循环中的表达式中使用let它的每一个值都会单独存在一个独立的作用域中不会被覆盖掉。
表层应用
接下来,我们通过几个例子来巩固下我们前面的所讲内容。
作用域提升
代码如下所示,我们在一个块内声明了一个函数foo()
,初始化了一个foo
变量,赋值为1。再次声明foo()
函数,再次修改变量foo
的值。
{ function foo() { console.log(1111); } foo(); // 2222 foo = 1; // 报错:此时foo的值已经是1了,而并非一个函数 // console.log(foo()); function foo() { console.log(2222); } foo = 2; console.log(foo); // 2 } console.log(foo); // 1
上述代码中:
- 在块内部,函数
foo()
声明了两次,由于JS引擎的默认行为函数会被提升,因此最终执行的是后者声明的函数 foo = 1
属于直接初始化行为,它会自动添加到全局上下文。- 由于在块作用域内,
foo
是一个函数,在执行foo = 1
时会开始找作用域链,在块作用域内找到了foo
,因此将它赋值为了1。 - 同样的,
foo = 2
也会开始找作用域链,在块作用域内找到了foo
,因此将它赋值为了2。
综合上述,在块内给foo
赋值时,它都优先在块作用域内找到了这个变量对象,并没有改变全局上下文中的foo
,因此块外的console.log(foo)
的值仍然是块内部第一次初始化时变量提升时的值。
执行上下文栈
接下来我们举个例子来巩固下执行上下文栈的知识,代码如下所示:
var name = "神奇的程序员"; function changeName() { var name = "大白"; function f() { return name; } return f(); } const result = changeName(); console.log(result);// 大白
var name = "神奇的程序员"; function changeName() { var name = "大白"; function f() { return name; } return f; } const result = changeName()(); console.log(result); // 大白
上述两段代码中,最后的执行结果都相同,不同之处在于:
- 第一段代码,
changeName()
函数内部调用了f()
函数并返回其执行结果 - 第二段代码,
changeName()
函数内部直接返回了f
函数的引用,形成了闭包结构。
它们在执行上下文栈的中的存储顺序也大不相同,我们先来分析下第一段代码:
- 执行
changeName()
函数时,创建一个执行上下文,并将其压入上下文栈 changeName()
函数内部调用了f()
函数,创建一个执行上下文,并将其压入上下文栈f()
函数执行完毕,出栈changeName()
函数执行完毕,出栈
我们画个图来讲解下上述过程,如下所示:
image-20210322104014150
最后,我们分析下第二段代码:
- 执行
changeName()
函数时,创建一个执行上下文,并将其压入上下文栈 changeName()
函数执行完毕,出栈,返回f()
函数引用- 执行
f()
函数时,创建一个执行上下文,并将其压入上下文栈 f()
函数执行完毕,出栈
我们画个图来讲解下上述过程,如下所示:
image-20210322105200831
函数柯里化
函数柯里化是一种思想,它会把函数的结果缓存起来,它属于闭包的一种应用。
我们举个 未知参数求和 的例子来讲解下柯里化,代码如下所示:
function unknownSum() { // 存储每次函数调用时的参数 let arr = []; const add = (...params) => { // 拼接新参数 arr = arr.concat(params); return add; }; // 对参数进行求和 add.toString = function() { let result = 0; // 对arr中的元素进行求和 for (let i = 0; i < arr.length; i++) { result += arr[i]; } return result + ""; }; return add; } const result1 = unknownSum()(1, 6, 7, 8)(2)(3)(4); console.log("result1 =", result1.toString());
未知参数求和:函数可以无限次调用,每次调用的参数都不固定。
上述代码中:
- 我们声明了名为
unknownSum()
的函数 - 函数内部声明了
arr
数组,用于保存每次传进来的参数 - 函数内部实现了一个
add
函数,用于将传进来的参数数组传递拼接到arr
数组 - 函数内部重写了
add
函数的toString()
方法,对arr
数组进行了求和并返回结果 - 最后,在函数内部返回
add
函数的引用,形成一个闭包结构
我们在调用unknownSum
函数时,第一次调用()
会返回add
函数的引用,后续的调用()
调用的都是add
函数,参数传递给add
函数后,由于闭包的缘故函数内部的arr
变量并未销毁,因此add
函数会把参数缓存到arr
变量里。
最后调用add
函数的toString
方法,对arr
内缓存的参数进行求和。
执行结果如下:
image-20210322112033471
代码地址
本文为《JS原理学习》系列的第3篇文章,本系列的完整路线请移步:JS原理学习 (1) 》学习路线规划
本系列文章的所有示例代码,请移步:js-learning
写在最后
- 公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊