【JavaScript】深入理解JS中的词法作用域与作用域链

3,918 阅读12分钟

前言

扎实的 JS 基础是每一名前端的必要条件,在JS中,最难理解、容易犯错的两个部分 ——— 闭包和原型。闭包对于新手来说过于抽象,要想理解闭包,有一个非常重要的前置知识要掌握,那就是作用域与作用域链。

全文概要

image.png

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对象。

image.png

window对象具有双重角色,一是上图中JS访问浏览器的一个接口。window对象下的所有属性和函数都拥有全局作用域,例如我们经常用到过的: window.innerHeightwindow.alert()window.setTimeout()等等。

二是ECMAScript中规定的Global对象。 在全局作用域中使用var所创建的变量都会作为window对象的属性保存;全局作用域中所有的函数都会作为window对象的方法保存(如下图)。

image.png

注:全局作用域在网页打开时创建,在网页关闭时销毁。

全局作用域有个弊端,就是如果我们在全局作用域中写了很多变量(如下代码),如果命名冲突,后面的变量会覆盖前面同名变量,从而污染全局命名空间。

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 在全局作用域未定义

在这个例子中,strmyName都是函数内部定义的变量,他们的作用域也就仅限于函数内部,全局作用域中不会访问到。

注:函数调用时创建,调用结束作用域随之销毁。 每调用一次产生一个新的作用域,之间相互独立。

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的函数作用域,它们的关系示意如下:

image.png

作用域链关系如下:

image.png

当我们试图在 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

此时,这段代码的作用域关系如下图: image.png

作用域关系如下: image.png

这里我们作用域的划分遵循的就是词法作用域,即在词法分析时生成的作用域,词法分析阶段,也可以理解为代码书写阶段,当你把函数(块级作用域同理)书写到某个位置,不用执行,它的作用域就已经确定了。

与之相对应的动态作用b域,我们也分析这段代码的的执行过程:

  • f2函数调用,f1函数调用
  • 在f1函数作用域内查找是否有局部变量num
  • 发现没找到,于是沿着调用栈,在调用f1函数地方继续找,也就是在f2函数中查找,刚好,f2函数中有num,此时就会打印20

此时,作用域关系如下:

image.png

我们总结一下。词法作用域和动态作用域最根本区别在于生成作用域的时机

  • 词法作用域:在代码书写时完成划分,作用域沿着它定义的位置往外延伸
  • 动态作用域:在代码运行时完成划分,作用域链沿着他的调用栈往外延伸

4.2 修改词法作用域

修改词法作用域也又叫做“欺骗词法作用域”,前面我们说过JS遵循词法作用域模型已定,那么为什么在运行过程中将划分好的词法作用域改掉呢?怎样才能在运行时"修改"(欺骗)词法作用域呢?

在JS中有两个函数来实现这个目的,分别是evalwith

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它成功修改了词法作用域规则,在书写阶段就划分好的作用域。

image.png

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对象属性。

image.png

当第二次调用fn函数时,with也会为f2这个对象创造出一个新的作用域(如下图),使得我们可以在这个作用域内直接访问b这个对象属性,此时a属性已不存在。

image.png

当我们直接打印a时,会打印全局变量2。这是为什么呢?事实上这是因为我们使用with,在非严格模式下,使用with声明的a因为没有var,所以是一个隐式全局变量,隐式全局变量在任何位置都能访问到,这个前面我们也说过。

我们总结下with改变作用域的方式:

  • with 会原地创建一个全新的作用域,这个作用域内的变量集合,其实就是传入 with 的目标对象的属性集合。
  • 因为 “创建” 这个动作,是在 with 代码实际已经被执行后发生的,因此with实现了对书写阶段就划分好的作用域进行修改。

事实上,在我们实际开发中很少用到这两个函数,因为存在性能问题,它会导致我们的代码变得很慢,而且还会像上面例子一样“横空出世全局变量”。以上,eval和with不建议实际开发使用,这里呢仅做知识扩充...

总结

  • 作用域:变量起作用的范围和区域,目的是隔离变量。分类:全局、函数、块级作用域。
  • 全局作用域:网页打开时创建,关闭时销毁,全局变量在任何地方都可以访问到
  • 全局作用域三种情形:最外层函数和变量、未用var声明的变量、window对象属性和方法。
  • 函数作用域:函数内部声明的变量具有全局作用域特性。
  • 块级作用域:使用let或const声明+大括号括住
  • 作用域链:作用域的层层嵌套,形成的关系叫做作用域链,查找变量的过程。
  • 查询变量的两种方式:LHS查询和RHS查询。
  • LHS查询:当变量出现在赋值操作左侧时,变量赋值操作,强调写入内存。
  • RHS查询:当变量出现在赋值操作右侧或没有赋值操作时,变量查找操作,强调从内存中读取。
  • 词法作用域:在词法分析时确定的作用域,在代码书写时完成划分,作用域沿着它定义的位置往外延伸。
  • 动态作用域:在代码运行时完成划分,作用域链沿着他的调用栈往外延伸。
  • 欺骗词法作用域:eval(在运行时修改词法作用域)和with(在运行时创建新的词法作用域)。但由于性能问题实际开发中不会使用,这里仅做了解。

结语

本篇文章就到此为止啦,由于本人经验水平有限,难免会有纰漏,对此欢迎指正。如觉得本文对你有帮助的话,欢迎点赞收藏❤❤❤,写作不易,持续输出的背后是无数个日夜的积累,您的点赞是持续写作的动力,感谢支持。