前言
扎实的 JS 基础是每一名前端的必要条件,在JS中,最难理解、容易犯错的两个部分 ——— 闭包和原型。闭包对于新手来说过于抽象,要想理解闭包,有一个非常重要的前置知识要掌握,那就是作用域与作用域链。
全文概要
1 作用域
每一种编程语言,它最基本的功能都是存储变量的值,并对这个值进行使用和修改。有了变量之后,应该把它放到哪里,我们如何使用,这时候就需要一套规则,这套规则就是作用域,即作用域就是变量起作用的范围和区域。 作用域的目的是隔离变量,保证不同作用域下同名变量不会冲突。
在JS中分为作用域分三种:
- 全局作用域
- 函数作用域
- 块作用域
1.1 全局作用域
以下三种情形拥有全局作用域。第一种:最外层变量以及函数。
var num = 10; //最外层变量
function f1(){ //最外层函数
var num2 = 20; //函数内变量
function f2(){ //内层函数
console.log(num2);
}
console.log(num)
}
console.log(num); // global
f1(); // global
console.log(num2); // not defined
console.log(f2); // not defined
从上面例子中可以看出num
变量和f1
函数在任何地方都可以访问到,反之不在全局作用域下的变量只能在当前作用域下使用。
第二种:不使用var声明的变量。
function f1(){
num = 10;
var num2 = 20
}
f1();
console.log(num); // global
console.log(num2); // not defined
从这个例子我们看出,不使用var声明的变量会进行变量提升(提到全局作用域下),所以num
变量在任何作用域下可以访问到。 有时候,我们也将不使用var声明的变量我们称作隐式全局变量。
第三种:window对象所有属性和函数拥有全局作用域。
window对象代表的是整个浏览器窗口,我们尝试着在浏览器打印window对象。
window对象具有双重角色,一是上图中JS访问浏览器的一个接口。window对象下的所有属性和函数都拥有全局作用域,例如我们经常用到过的: window.innerHeight
、 window.alert()
、 window.setTimeout()
等等。
二是ECMAScript中规定的Global对象。 在全局作用域中使用var所创建的变量都会作为window对象的属性保存;全局作用域中所有的函数都会作为window对象的方法保存(如下图)。
注:全局作用域在网页打开时创建,在网页关闭时销毁。
全局作用域有个弊端,就是如果我们在全局作用域中写了很多变量(如下代码),如果命名冲突,后面的变量会覆盖前面同名变量,从而污染全局命名空间。
a.js文件
var num = 10
b.js文件
var num = 20
c.html文件
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="a.js"></script>
<script src="b.js"> </script>
<script>
console.log(num); // 输出后引入的20
</script>
</head>
</html>
所以我们在引入JS文件中,一般将所有的代码都会放在(functuin(){})
中,因为放在里面所有的变量,都不会被外泄和暴露,不会污染到外面,不会对其他库或者JS脚本造成影响,这是函数作用域的一个体现。
a.js文件
(function(){
var num = 10
}())
复制代码
b.js文件
(function(){
var num = 20
}())
c.html文件
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="a.js"></script>
<script src="b.js"> </script>
<script>
console.log(num); // not defined
</script>
</head>
</html>
1.2 函数作用域
在函数内部定义的变量,拥有函数作用域。
var num = '小明';//name全局变量
function sayHi(){
// str是函数中的局部变量
var str ='hi word'
console.log(str)
}
function showName(myName){
// 函数的形参也是局部变量
console.log(myName);
}
sayHi(); // 输出'hi word'
showName(); // 输出'小明'
console.log(str); // 抛出错误:str在全局作用域未定义
console.log(myName); // 抛出错误:myName 在全局作用域未定义
在这个例子中,str
和myName
都是函数内部定义的变量,他们的作用域也就仅限于函数内部,全局作用域中不会访问到。
注:函数调用时创建,调用结束作用域随之销毁。 每调用一次产生一个新的作用域,之间相互独立。
1.3 块级作用域
使用let或const声明的变量,如果被一个大括号括住,那么这个大括号括住的变量就形成了一个块级作用域。
{
let num = 10;
console.log(num);
}
console.log(num); // 报错
在这个例子中,我们可以看出:块级作用中定义的变量只在当前块中生效,这和函数作用域类似,他们都是只在自己的地盘内生效。
2 作用域链
以上,我们对作用域有了基本认识,我们不难发现,只要是代码,就至少拥有一个作用域。写在函数内部的函数作用域,如果函数中还有函数,那么这个作用域中就又可以诞生一个作用域。比如这样:
var num = 10 ;
function fn(){ // 外部函数
var num = 20;
function fun(){ // 内部函数
console.log(num)
}
fun()
}
fn()
在这个例子中,有三个作用域,全局作用域、fn的函数作用域、fun的函数作用域,它们的关系示意如下:
作用域链关系如下:
当我们试图在 fun 这个函数里访问变量 num 的时候,此时函数作用域内没有num变量,当前作用域找不到。要想找到num,根据作用域链的查找规则,我们需要去上层作用域(fn函数作用域),在这里我们找到了 num ,就可以拿来使用了。
我们把作用域层层嵌套,形成的关系叫做作用域链,作用域链也就是查找变量的过程。 查找变量的过程:当前作用域 --》上一级作用域 --》上一级作用域 .... --》直到找到全局作用域 --》还没有,报错。
3 作用域实现机制
要想理解作用域实现的机制,我们需要结合JS编译原理来看,我们先来看一个简单的声明语句:
var name = '小明'
在这段代码中,有两个阶段:
- 编译阶段:编译器在当前作用域中声明一个变量name
- 执行阶段:JS引擎在当前作用域中查找该变量,找到name变量并为其赋值
证明以上说法:
console.log(name); // undefined
var name = '小明'
我们直接输出name变量,此时并没有报错,而是输出undefined,说明输出的时候改变量已经存在了,只是没有赋值而已。
其实,上面这段代码包含两种变量查找方式:输出变量值时候查找方式RHS,找到变量为其赋值查找方式是LHS。LHS(Left-hand Side)、RHS(Right-hand Side)是JS引擎执行代码的时候,查询变量的两种方式。这里的“Left”和“Right”,是相对于赋值操作来说,当变量出现在赋值操作左侧时,执行LHS操作。
var name = '小明'
LHS 意味着 变量赋值或写入内存,,他强调是写入这个动作,所以LHS查询的是这个变量对应的内存地址。
当变量出现在赋值操作右侧或没有赋值操作时,是RHS。
var Myname = name
console.log(name)
RHS意味着 变量查找或读取内存,它强调的是读这个动作,查询的是变量的内容。
4 作用域模型
4.1 词法作用域和动态作用域
我们说过,作用域本质是一套规则,而这个规则的底层遵循的就是词法作用域模型,简单来说,“词法作用域”就是作用域的成因。
从语言的层面来说,作用域模型分两种:
- 词法作用域:也称静态作用域,是最为普遍的一种作用域模型
- 动态作用域:相对“冷门”,bash脚本、Perl等语言采纳的是动态作用域
要想理解这种模型区别,我们看以下例子:
var num = 10;
function f1(){
console.log(num)
}
function f2(){
var num = 20;
f1()
}
f2();
因为JS基于的是词法作用域,不难得出它的运行结果是10。这段代码经历了这样的执行过程:
- f2函数调用,f1函数调用
- 在f1函数作用域内查找是否有局部变量num
- 发现没找到,于是根据书写位置,向上一层作用域(全局作用域)查找,发现num,打印num=10
此时,这段代码的作用域关系如下图:
作用域关系如下:
这里我们作用域的划分遵循的就是词法作用域,即在词法分析时生成的作用域,词法分析阶段,也可以理解为代码书写阶段,当你把函数(块级作用域同理)书写到某个位置,不用执行,它的作用域就已经确定了。
与之相对应的动态作用b域,我们也分析这段代码的的执行过程:
- f2函数调用,f1函数调用
- 在f1函数作用域内查找是否有局部变量num
- 发现没找到,于是沿着调用栈,在调用f1函数地方继续找,也就是在f2函数中查找,刚好,f2函数中有num,此时就会打印20
此时,作用域关系如下:
我们总结一下。词法作用域和动态作用域最根本区别在于生成作用域的时机:
- 词法作用域:在代码书写时完成划分,作用域沿着它定义的位置往外延伸
- 动态作用域:在代码运行时完成划分,作用域链沿着他的调用栈往外延伸
4.2 修改词法作用域
修改词法作用域也又叫做“欺骗词法作用域”,前面我们说过JS遵循词法作用域模型已定,那么为什么在运行过程中将划分好的词法作用域改掉呢?怎样才能在运行时"修改"(欺骗)词法作用域呢?
在JS中有两个函数来实现这个目的,分别是eval
和with
4.21 eval函数
我们先看以下代码:
function f1(str){
eval(str);
console.log(num);
}
var num = 10;
var str ="var num = 20"
f1(str)
我们知道,eval函数入参是一个字符串。当eval拿到一个字符串入参后,它会把这段字符串的内容当做js代码(不管它是不是一段代码),插入到自己被调用的那个位置,所以上面代码,被eval“改造后”,就变成了:
function f1(str){
var num =20
console.log(num);
}
var num = 10;
f1(str)
这时当再我们打印num时,函数作用域内的num已经被eval传入的这行代码给修改掉了,所以打印结果就由10变成了20。 eval它成功修改了词法作用域规则,在书写阶段就划分好的作用域。
4.22 with函数
with
函数是引用对象的一种简写方式。当我们去引用一个对象中的多个属性时,可不用重复引用对象本身。
var obj={
a:1
}
// 打印属性
console.log(obj.a);
// 使用with简写
with(obj){
console.log(a);
}
接下来我们来看with
是如何改变词法作用域的,请看看例子:
function fn(obj){
with(obj){
a = 2
}
}
var f1 = {a:3}
var f2 = {b:3}
fn(f1)
console.log(f1.a) // 3
fn(f2)
console.log(f2.a) // 输出undefined
console.log(a) // 2
当fn函数第一次调用时,with会为f1这个对象凭空创造出一个新的作用域(如下图),这使得我们在这个作用域内可以直接访问a对象属性。
当第二次调用fn函数时,with也会为f2这个对象创造出一个新的作用域(如下图),使得我们可以在这个作用域内直接访问b这个对象属性,此时a属性已不存在。
当我们直接打印a时,会打印全局变量2。这是为什么呢?事实上这是因为我们使用with,在非严格模式下,使用with声明的a因为没有var,所以是一个隐式全局变量,隐式全局变量在任何位置都能访问到,这个前面我们也说过。
我们总结下with改变作用域的方式:
- with 会原地创建一个全新的作用域,这个作用域内的变量集合,其实就是传入 with 的目标对象的属性集合。
- 因为 “创建” 这个动作,是在 with 代码实际已经被执行后发生的,因此with实现了对书写阶段就划分好的作用域进行修改。
事实上,在我们实际开发中很少用到这两个函数,因为存在性能问题,它会导致我们的代码变得很慢,而且还会像上面例子一样“横空出世全局变量”。以上,eval和with不建议实际开发使用,这里呢仅做知识扩充...
总结
- 作用域:变量起作用的范围和区域,目的是隔离变量。分类:全局、函数、块级作用域。
- 全局作用域:网页打开时创建,关闭时销毁,全局变量在任何地方都可以访问到。
- 全局作用域三种情形:最外层函数和变量、未用var声明的变量、window对象属性和方法。
- 函数作用域:函数内部声明的变量具有全局作用域特性。
- 块级作用域:使用let或const声明+大括号括住。
- 作用域链:作用域的层层嵌套,形成的关系叫做作用域链,查找变量的过程。
- 查询变量的两种方式:LHS查询和RHS查询。
- LHS查询:当变量出现在赋值操作左侧时,变量赋值操作,强调写入内存。
- RHS查询:当变量出现在赋值操作右侧或没有赋值操作时,变量查找操作,强调从内存中读取。
- 词法作用域:在词法分析时确定的作用域,在代码书写时完成划分,作用域沿着它定义的位置往外延伸。
- 动态作用域:在代码运行时完成划分,作用域链沿着他的调用栈往外延伸。
- 欺骗词法作用域:eval(在运行时修改词法作用域)和with(在运行时创建新的词法作用域)。但由于性能问题实际开发中不会使用,这里仅做了解。
结语
本篇文章就到此为止啦,由于本人经验水平有限,难免会有纰漏,对此欢迎指正。如觉得本文对你有帮助的话,欢迎点赞收藏❤❤❤,写作不易,持续输出的背后是无数个日夜的积累,您的点赞是持续写作的动力,感谢支持。