JS中的作用域

112 阅读8分钟

前言

工作了三年,但是每次面试的时候,被问到作用域和作用域链的理解时,总是感觉自己知道,但是又不能组织语言进行表达出来。存在一知半解的状态,因此通过参考别人的文章,我自己将这一块知识点进行总结一下。

作用域

js中会有一个被称为 作用域(Scope) 的概念。那么什么是作用域呢?简单来说作用域就是运行时规定了某些特定的变量,函数的可访问性,其实就是决定了变量和某些资源的可用性。可以用下面的例子来看看作用域的基本作用:

function outFun2() {
  // inVariable 变量声明在函数作用域内,因此只有在该作用域内才可以被访问。决定了其的可访问性
  var inVariable = "内层变量2";
}
outFun2();//要先执行这个函数,否则根本不知道里面是啥
console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined

作用域的作用是决定了变量的可访问性,那么我们可以理解成作用域是一个独立的空间或者是容器,不会让变量泄漏或者是暴露出去。那么也可以得出作用域的最根本的作用就是隔离变量,让不同作用域下的变量不冲突

作用域的类型

总所周知,在ES6之前只存在全局作用域和函数作用域两个概念,在ES6之后,多了一个块级作用域的概念,块级作用域通过let, const关键字来构成的。

全局作用域和函数作用域

什么是全局作用域,什么是函数作用域呢?用我自己的话来总结就是,最外层的定义的函数或者变量拥有全局作用域,函数内部定义的变量和函数拥有函数作用域。

全局作用域的几种情况:
  • 最外层定义的变量和函数拥有全局作用域
var outVariable = "我是最外层变量"; // 最外层定义的变量
function outFun() { // 最外层定义的函数
    var inVariable = "函数内定义的变量";
    function innerFun() { // 函数内定义的函数
      console.log(inVariable);
    }
    innerFun();
}
console.log(outVariable); // 最外层变量拥有全局作用域,因此代码中任何地方都可以被访问到
outFun(); // 同理最外层的函数也可以被访问到
console.log(inVariable); //inVariable is not defined --> 函数内定义的变量只能在函数作用域内访问
innerFun(); //innerFun is not defined --> 函数内定义的变量只能在函数作用域内访问

因此从上面的例子中可以看出,最外层定义的函数或者变量在代码中都可以被访问到,换种说法:在代码中都可以被访问到的变量,函数或者对象拥有全局作用域。函数作用域可以从字面上去理解,函数内部定义的变量或者对象,只能被函数内访问到。

  • 还有一种变量也会变相拥有全局作用域,所有未定义且直接赋值的变量自动升级为全局变量,拥有全局作用域。
function outFun2() {
  variable = "未定义直接赋值的变量"; // 不经过定义,且直接赋值的变量
  var inVariable2 = "内层变量2";
}
outFun2();//要先执行这个函数,否则根本不知道里面是啥
console.log(variable); //未定义直接赋值的变量
console.log(inVariable2); //inVariable2 is not defined
  • 浏览器中的window对象也属于全局对象。
全局作用域的弊端

全局作用域会容易污染全局命名空间, 容易引起命名冲突。当我们代码越来越多,变量的定义都定义在最外层,都拥有全局作用域,代码一多容易导致变量名称的重复和命名的冲突问题。这就是第三方的库,像JQuery等第三方库都是用自执行函数(function(){...})()进行包裹一层,预防其对其他库或者资源进行污染。

函数作用域

函数作用域很好理解,可以从字面上去理解,是指声明在函数内部的变量,和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到。

  • 需要注意的点:

块语句(大括号“{}”中间的语句),如 if 和 switch 条件语句或 for 和 while 循环语句,不像函数,它们不会创建一个新的作用域。 在块语句中定义的变量将保留在它们已经存在的作用域中。

if (true) {
  // 'if' 条件语句块不会创建一个新的作用域
  var name = 'Hammad'; // name 依然在全局作用域中
}
console.log(name); // logs 'Hammad'

块级作用域

块级作用域是通过let const的声明来形成的,所声明的变量在指定块的作用域外无法被访问。

块级作用域被创建的情况:

  1. 一个函数的内部
  2. 在一个代码块(由一对花括号包裹)内部( if 和 switch 条件语句或 for 和 while 循环语句都会构成块级作用域)
块级作用域的特点
  • 声明变量不会提升到代码块顶部

let/const 声明并不会被提升到当前代码块的顶部,因此你需要手动将 let/const 声明放置到顶部,以便让变量在整个代码块内部可用。

function getValue(condition) {
  // 每个大括号内,如有let,const的声明,都会构成一个块级作用域。
  if (condition) {
    let value = "blue";
    return value;
  } else {
    // value 在此处不可用
    return null;
  }
  // value 在此处不可用
}
  • 禁止重复声明

简单理解:就是代码块中声明的变量不能同名,否则就会报错

var count = 30;
let count = 40; // Uncaught SyntaxError: Identifier 'count' has already been declared
  • 循环中的绑定块作用域的妙用

因为是块级作用域的概念,因为面试中可能常常遇到的循环后输出的问题

var a = [];
for (var i = 0; i < 10; i++) {
  a[i] = function () {
    console.log(i);
  };
}
a[6]; // 10

var b = [];
for (let i = 0; i < 10; i++) {
  b[i] = function () {
    console.log(i);
  };
}
b[6](); // 6

作用域链

我们都知道函数是可以相互嵌套的,因此作用域也是有分层的,最外层被叫做全局作用域。又因为内层的作用域可以访问外层作用域的变量,因此当内层作用域查找不到某变量时,就会去外层去寻找,就这样一层往外寻找,一直找到最外层。这种一层一层的关系就被称为作用链。

var a = 100
function F1() {
    var b = 200
    function F2() {
        var c = 300
        console.log(a) // a变量,顺作用域链向父作用域找(这种说法并不是很严谨)
        console.log(b) // b变量,顺作用域链向父作用域找(这种说法并不是很严谨)
        console.log(c) // 本作用域的变量
    }
    F2()
}
F1()

关于变量的取值,说到向父级作用域去取值并不是很严谨,下面取个例子去说明:

var x = 10;
function fn() {
  console.log(x)
}
function show(f) {
  var x = 20;
  function a() {
    console.log(x);
  }
  a() // 20
  f() // 10 -> 该函数执行时,取变量x的值时,要到哪个作用域中取?——要到创建fn函数的那个作用域去取,因此会取到10
}
show(fn)

总的来说,我们在去取变量的时候,要去函数创建即定义的地方的作用域去取。要到创建这个函数的那个域”。 作用域中取值,这里强调的是“创建”,而不是“调用”,这就是所谓的静态作用域 f()执行时,x的值是在fn的函数作用域内去取,当作用域内不存在,则在全局作用域内找到x=10。为了更好地说明,请看下面的例子:

var a = 10
function fn() {
  var b = 20
  function bar() {
    console.log(a + b) // 30
  }
  return bar
}
var x = fn(),
b = 200
x() // bar() --> 30
  • b的取值:在bar的函数作用域内无法找到,因此从上级找(fn的作用域),发现了b=20,因此停止了寻找。
  • a的取值:同理一层一层的往上找,直至在全局作用域中找到了a=10。

作用域和执行上下文

因为执行上下文也存在全局执行上下文和函数执行上下文的概念,因此我们经常会混淆作用域和执行上下文的概念。上面我们已经讲过了作用域的概念,那么我们为了去区分作用域和执行上下文,那么我们先去了解一下执行上下文。

我们都知道javaScript是解释性语言,它存在解释和执行两个阶段,并且两个阶段所做的事情都不一样:

  • 解释阶段:

    • 词法分析
    • 语法分析
    • 作用域规则确定
  • 执行阶段:

    • 创建执行上下文
    • 执行函数代码
    • 垃圾回收

JavaScript解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。执行上下文最明显的就是this的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。

作用域和执行上下文之间最大的区别是: 执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变。

一个作用域下可能包含若干个上下文环境。也有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。

具体要想了解执行上下文,可以看我的另一篇文章js的执行上下文

参考文献: