作用域与作用域链

225 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情

每一种编程语言,它最基本的能力都是能够存储变量当中的值、并且允许我们对这个变量的值进行访问和修改。那么有了变量之后,应该把它放在那里、程序如何找到它们?这是不是需要我们提前约定好一套存储变量、访问变量的规则?这套规则,就是我们常说的作用域

更多的时候,我们提到作用域的时候,指的是这个规则约束下的一个变量、函数、标识符可以被访问的区域,这时它就更具体了。

要想理解作用域,需要先弄清楚JS 的编译原理

JS 的编译原理

var name = 'xiaoming'

在 JS 引擎眼里,它却包含了两个声明:

var name (编译时处理)
name = 'xiaoming' (运行时处理)

难道 JS 不是不存在编译阶段的 “动态语言” 吗?事实上,JS 也是有编译阶段的,它和传统语言的区别在于,JS 不会早早地把编译工作做完,而是一边编译一边执行。简单来说,所有的 JS 代码片段在执行之前都会被编译,只是这个编译的过程非常短暂(可能就只有几微妙、或者更短的时间),紧接着这段代码就会被执行。

来看看编译阶段和执行阶段阶段都发生了什么事情:

  • 编译阶段: 这时登场的是一个叫编译器的家伙。编译器会找遍当前作用域,看看是不是已经有一个叫 name 的家伙了。如果有,那么就忽略 var name 这个声明,继续编译下去;如果没有,则在当前作用域里新增一个 name。然后,编译器会为引擎生成运行时所需要的代码,程序就进入了执行阶段。
  • 执行阶段: 这时登场的就是大家常常听到的JS 引擎了。JS 引擎在执行代码的时候,仍然会找遍当前作用域,看看是不是有一个叫 name 的家伙。如果能找到,那么万事大吉,我来给你赋值。如果找不到,它也不会灰心,它会从当前作用域里 "探出头去",看看 "外面" 有没有,或者 "外面的外面" 有没有。如果最终仍然找不到 name 变量,引擎就会抛出一个异常。

也就是一段js代码的运行需要用到编译器和JS引擎来共同完成,前者生成JS引擎所需要的环境,包括数据和执行代码等。

这里出现了一个有趣的东西,就是我们引擎的查找过程 —— 何谓探出头去?何谓 "外面" 呢?这就引出了我们 JS 作用域里一个非常重要的概念 —— 作用域和作用域链。

作用域

作用域本质上就是程序存储和访问变量的规则

在 JS 世界中,目前已经有了三种作用域:

  • 全局作用域
  • 函数作用域
  • 块作用域

全局作用域

声明在任何函数之外的顶层作用域的变量就是全局变量,这样的变量拥有全局作用域:

var name = 'xiaoming'; // 全局作用域内的变量

// 函数作用域
function showName() {
    console.log(name);
}

// 块作用域
{
  name = 'BigBear'
}

showName(); // 输出 'BigBear'

函数作用域

在函数内部定义的变量,拥有函数作用域

var name = 'xiaoming'; // name 是全局变量
function showName(myName) {
  // myName 是传入 showName 的局部变量
  console.log(myName);
}
function sayHello() {
  // helloString 被定义成局部作用域变量
  var helloString = 'hello everyone';
  console.log(helloString);
}


showName(name); // 输出 'xiaoming'
sayHello(); // 输出 'hello everyone'
console.log(myName); // 抛出错误:myName 在全局作用域未定义
console.log(helloString); // 抛出错误:hello 在全局作用域未定义

{
  console.log(helloString, myName) // 抛出错误
}

块作用域

ES6 开始,我们迎来了了两个用于声明变量的新关键词: let 和 const。这两个关键字定义的变量,如果被一个大括号 { } 这样括住了,那么这个大括号就是一个代码块,大括号括住的这些变量就形成了一个块作用域:

{
  let a = 1;
  console(a);
}

 console(a); // 报错

function showA() {
  console.log(a) // 报错
}

这点和函数作用域比较相似 —— 它们都只在"自己的地盘"上生效,所以它们也统称为"局部作用域"。那js中有哪些块呢?

if(1){} 
while(1){} 
function foo(){} 
for(let i = 0; i<100; i++){} 
{}

作用域链

在我们实际开发中,通常不止用到一种作用域。当一个块或者一个函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。比如这样:

function add(a) {
  console.log(a + b)
  console.log(c) // 报错
}

var b = 1

add(2) //3

在这个例子中,有两个作用域:add的函数作用域和全局作用域。

我们试图在 add这个函数里访问变量 b 的时候,考虑到函数作用域内并没有对 b、c 这两个变量作定义,所以一开始肯定是找不到的。要想找到 b、c ,该怎么做?就是我们上文提到的"探出头去",对吧?探出头去,去上层作用域(全局作用域找),找到了 b ,那么就可以直接拿来用了;没找到 c,并且全局作用域已经没有上层作用域了(头探不出去了),那就歇菜,报错!这就是上面"执行阶段"里所描述的那个过程。

在这个查找过程中,层层递进的作用域,就形成了一条作用域链。

理解闭包

function addABC(){
  var a = 1,b = 2;
  
  function add(){
    return a+b+c;
  }
  return add;
}

var c = 3

var globalAdd = addABC()

console.log(globalAdd()) // 6

在这个例子里,作用域嵌套的情况展示如下:

js1.jpg

其中 add 这个函数,它嵌套在函数 addABC 的内部,想要查找 a、b、c 三个变量,它得去上层的 addABC 作用域里找,对吧?像 a、b、c 这样在函数中被使用,但它既不是函数参数、也不是函数的局部变量,而是一个不属于当前作用域的变量,此时它相对于当前作用域来说,就是一个自由变量。而像 add 这样引用了自由变量的函数,就叫闭包。

即引入了自由变量的函数叫做闭包

作用域模型

站在语言的层面来看,作用域其实有两种主要的工作模型:

  • 词法作用域:也称为静态作用域。这是最普遍的一种作用域模型,js的作用域遵循的就是词法作用域模型
  • 动态作用域:相对"冷门",但确实有一些语言采纳的是动态作用域,如:Bash 脚本、Perl 等
var name = 'xiaoming';

function showName() {
    console.log(name);
}

function changeName() {
    var name = 'BigBear';
    showName();
}

changeName();

这是一段 JS 代码,不难答出它的运行结果是'xiaoming'。这是因为 JS 采取的就是词法(静态)作用域,这段代码运行过程中,经历了这样的变量定位流程:

  • 在 showName 函数的函数作用域内查找是否有局部变量 name
  • 发现没找到,于是根据书写的位置,查找上层作用域(全局作用域),找到了 name 的值是 xiaoming,所以结果会打印 xiaoming。

这里我们作用域的划分,是在书写的过程中(例子中也就是在函数定义的时候,块作用域同理是在代码块定义的时候),根据你把它写在哪个位置来决定的。像这样划分出来的作用域,遵循的就是词法作用域模型。

那什么是动态作用域呢?动态作用域机制下,同样的一段代码,会发生下面的事情:

  • 在 showName 函数的函数作用域内查找是否有局部变量 name
  • 发现没找到,于是沿着函数调用栈、在调用了 showName 的地方继续找 name。这时大家看看它找到哪去了?是不是就找到 changeName 里去了? 刚好,changeName 里有一个 name,于是这个 name 就会被引用到 showName 里去。

所以如果是动态作用域,那么这段代码运行的结果就会是'BigBear'了。

总结一下,词法作用域和动态作用域的区别其实在于划分作用域的时机:

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

修改词法作用域

eval可以对作用域的修改,首先来看看eval函数的用法

function showName(str) {
  eval(str)
  console.log(name)
}

var name = 'xiaoming'
var str = 'var name = "BigBear"'

showName(str) // 输出 BigBear

大家知道,eval 函数的入参是一个字符串。当 eval 拿到一个字符串入参后,它会把这段字符串的内容当做一段 js 代码(不管它是不是一段 js 代码),插入自己被调用的那个位置。所以上面这个例子里,被 eval "改造" 过后的 showName 函数其实长这样了:

function showName(str) {
  var name = 'BigBear'
  console.log(name)
}

函数作用域内的 name 已经被 eval 传入的这行代码给修改掉了,所以作用域内 name 的值就从 ‘xiaoming’ 变成了 ‘BigBear’