详解JavaScript作用域和作用域链

4,801 阅读4分钟

JavaScript 中有一个被称为作用域(Scope)的特性。虽然对于许多新手来说,作用域的概念并不是很容易理解,但我会尽我所能用最简单的方式来解释作用域和作用域链,让我们来看看吧~

1.作用域

作用域就是一个独立的区域,讲得具体点就是在我们的程序中定义变量的一个独立区域,它决定了当前执行代码对变量的访问权限。

在 JavaScript 中有两种作用域:

  • 全局作用域
  • 局部作用域

如果一个变量在函数外面,或者在代码块外也就是大括号{}外声明,那么就定义了一个全局作用域,在ES6之前局部作用域只包含了函数作用域,ES6为我们提供的块级作用域,也属于局部作用域

function fun() { 
    //局部(函数)作用域
    var innerVariable = "inner"
} 
console.log(innerVariable) 
// Uncaught ReferenceError: innerVariable is not defined

上面的例子中,变量innerVariable是在函数中,也就是在局部作用域下声明的,而在全局作用域没有声明,所以在全局作用域下输出会报错。

也就是说,作用域就是一个让变量不会向外暴露出去的独立区域。作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。

function fun1(){
    var variable = 'abc'
}
function fun2(){
    var variable = 'cba'
}

上面的例子中,有两个函数,分别都有同名的一个变量variable,但它位于不同的函数内,也就是位于不同的作用域中,所以他们不会产生冲突。

ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数(局部)作用域。块语句({}中间的语句),如 ifswitch 条件语句或 forwhile 循环语句,不像函数,它们不会创建一个新的作用域

if(true) {
    var a = 1
}
for(var i = 0; i < 10; i++) {
    ...
}
console.log(a) // 1
console.log(i) // 9

2.全局作用域

在代码中任何地方都能访问到的对象拥有全局作用域,一般来说以下几种情形拥有全局作用域:

  1. 最外层函数和在最外层函数外面定义的变量拥有全局作用域
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

上面例子中,调用outFun()后,里面的innerFun函数执行,输出inVariable变量,因为内层作用域可以访问外层作用域,所以能正常输出内层变量。但是下一行输出inVariable是在全局作用域中,不能访问局部作用域的变量,所以该变量会访问不到。

  1. 所有末定义直接赋值的变量(也称为意外的全局变量),自动声明为拥有全局作用域
function outFun2() {
    variable = "未定义直接赋值的变量";
    var inVariable2 = "内层变量2";
}
outFun2();                //要先执行这个函数,否则根本不知道里面有什么
console.log(variable);    //“未定义直接赋值的变量”
console.log(inVariable2); //inVariable2 is not defined
  1. 所有window对象的属性拥有全局作用域

一般情况下,window对象的内置属性都拥有全局作用域,例如window.name、window.location、window.document、window.history等等。

全局作用域有个弊端:如果我们写了很多行 JS 代码,变量定义都没有用函数包括,那么它们就全部都在全局作用域中。这样就会污染全局命名空间, 容易引起命名冲突

// A写的代码中
var data = {a: 1}

// B写的代码中
var data = {b: 2}

这就是为何 jQuery、Zepto 等库的源码,所有的代码都会放在(function(){....})()(立即执行函数)中。因为放在里面的所有变量,都不会被外泄和暴露,不会污染到外面,不会对其他的库或者 JS 脚本造成影响。这是函数作用域的一个体现。

3.局部作用域

和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到。局部作用域分为函数作用域和块级作用域。

3.1函数作用域

函数作用域,是指声明在函数内部的变量或函数。

function doSomething(){
    var name="Rockky";
    function innerSay(){
        console.log(name);
    }
    innerSay();
}
console.log(name); //name is not defined
innerSay(); //innerSay is not defined

作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行。我们看个例子,用泡泡来比喻作用域可能好理解一点: image.png 最后输出的结果为 2, 4, 12

  • 泡泡1是全局作用域,有标识符foo;
  • 泡泡2是作用域foo,有标识符a,bar,b;
  • 泡泡3是作用域bar,仅有标识符c。

3.2块级作用域

ES5 只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景。

第一种场景,内层变量可能会覆盖外层变量。

var tmp = new Date();
function f() {
  console.log(tmp);
  if (false) {
    var tmp = 'hello world';
  }
}
f(); // undefined

上面代码的原意是,if代码块的外部使用外层的tmp变量,内部使用内层的tmp变量。但是,函数f执行后,输出结果为undefined,原因在于变量提升,导致内层的tmp变量覆盖了外层的tmp变量,而temp变量的初始化并不会提升,也就是变量声明了但未初始化,所以temp的值为undefined

第二种场景,用来计数的循环变量泄露为全局变量。

var s = 'hello';
for (var i = 0; i < s.length; i++) {
  console.log(s[i]);
}
console.log(i); // 5

上面代码中,变量i只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。

ES6的块级作用域在一定程度上解决了这些问题。

块级作用域可通过新增命令let和const声明,所声明的变量在指定块的作用域外无法被访问。块级作用域在如下情况被创建:

  1. 在一个函数内部
  2. 在一个代码块(由一对花括号包裹)内部

let 声明的语法与 var 的语法一致。基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中。块级作用域有以下几个特点:

  • 声明变量不会提升到代码块顶部,即不存在变量提升
  • 禁止重复声明同一变量
  • 循环中的绑定块作用域的妙用
3.2.1变量提升

var命令会发生“变量提升”现象,即变量可以在声明之前使用,值为undefined。这种现象多多少少是有些奇怪的,按照一般的逻辑,变量应该在声明语句之后才可以使用。为了纠正这种现象,let命令改变了语法行为,它所声明的变量一定要在声明后使用,否则报错。

// var 的情况
console.log(foo); // 输出undefined
var foo = 2;

// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;

上面代码中,变量foovar命令声明,会发生变量提升,即脚本开始运行时,变量foo已经存在了,但是没有值,因为JS引擎只会将变量的声明进行提升,并不会将变量的初始化进行提升。等同于如下代码:

// var 的情况
var foo;
console.log(foo); // 输出undefined
foo = 2;

所以会输出undefined。变量barlet命令声明,不会发生变量提升。这表示在声明它之前,变量bar是不存在的,这时如果用到它,就会抛出一个错误。

如果有函数和变量同时声明了,哪个才会进行变量提升呢?

console.log(foo); 
var foo = 'abc'; 
function foo(){}

输出结果是function foo(){},也就是函数内容。如果是另外一种形式呢?

console.log(foo);
var foo = 'abc';
var foo = function(){}

输出结果是undefined 对两种结果进行分析说明:

  • 第一种:函数声明。就是上面第一种,function foo(){}这种形式
  • 第二种:函数表达式。就是上面第二种,var foo=function(){}这种形式

第二种形式其实就是var变量的声明定义,因此上面的第二种输出结果为undefined应该就能理解了。 而第一种函数声明的形式,在提升的时候,会被整个提升上去,包括函数定义的部分!因此第一种形式跟下面的这种方式是等价的!

var foo = function(){}
console.log(foo);
var foo ='abc';
  • 函数声明被提升到最顶上;
  • 声明只进行一次,因此后面var foo='abc'的声明会被忽略。
  • 函数声明的优先级优于变量声明,且函数声明会连带定义一起被提升(这里与变量不同)

只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。

var tmp = 123;
if (true) {
  tmp = 'abc'; // ReferenceError
  let tmp;
}

上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错。

ES6 明确规定,如果区块中存在letconst命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。

if (true) {
  // 暂时性死区开始
  tmp = 'abc'; // ReferenceError
  console.log(tmp); // ReferenceError

  let tmp; // 暂时性死区结束
  console.log(tmp); // undefined

  tmp = 123;
  console.log(tmp); // 123
}

上面代码中,在let命令声明变量tmp之前,都属于变量tmp的“死区”。

“暂时性死区”也意味着typeof不再是一个百分之百安全的操作。

typeof x; // ReferenceError
let x;

上面代码中,变量x使用let命令声明,所以在声明之前,都属于x的“死区”,只要用到该变量就会报错。因此,typeof运行时就会抛出一个ReferenceError

作为比较,如果一个变量根本没有被声明,使用typeof反而不会报错。

typeof undeclared_variable // "undefined"

上面代码中,undeclared_variable是一个不存在的变量名,结果返回“undefined”。所以,在没有let之前,typeof运算符是百分之百安全的,永远不会报错。现在这一点不成立了。这样的设计是为了让大家养成良好的编程习惯,变量一定要在声明之后使用,否则就报错。

有些“死区”比较隐蔽,不太容易发现。

function bar(x = y, y = 2) {
  return [x, y];
}
bar(); // 报错

上面代码中,调用bar函数之所以报错(某些实现可能不报错),是因为参数x默认值等于另一个参数y,而此时y还没有声明,属于“死区”。如果y的默认值是x,就不会报错,因为此时x已经声明了。

function bar(x = 2, y = x) {
  return [x, y];
}
bar(); // [2, 2]

另外,下面的代码也会报错,与var的行为不同。

// 不报错
var x = x;
// 报错
let x = x;  // ReferenceError: x is not defined

上面代码报错,也是因为暂时性死区。使用let声明变量时,只要变量在还没有声明完成前使用,就会报错。上面这行就属于这个情况,在变量x的声明语句还没有执行完成前,就去取x的值,导致报错”x 未定义“。

ES6 规定暂时性死区和letconst语句不出现变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。这样的错误在 ES5 是很常见的,现在有了这种规定,避免此类错误就很容易了。

总之,暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

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

function getValue(condition) {
    if (condition) {
        let value = "blue";
        return value;
    } else {
        // value 在此处不可用
        return null;
    }
    // value 在此处不可用
}

块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。

// IIFE 写法
(function () {
  var tmp = ...;
  ...
}());

// 块级作用域写法
{
  let tmp = ...;
  ...
}
3.2.2重复声明

如果一个标识符已经在代码块内部被定义,那么在此代码块内使用同一个标识符进行 let 声明就会导致抛出错误。例如:

// 报错
function func() {
  let a = 10;
  var a = 1;
}

// 报错
function func() {
  let a = 10;
  let a = 1;
}

但如果在嵌套的作用域内使用 let 声明一个同名的新变量,则不会抛出错误。

var count = 30;
if (condition) {
    let count = 40; // 不会抛出错误
}

此外,也不能在函数内部重新声明参数。

function func(arg) {
  let arg;
}
func() // 报错

function func(arg) {
  {
    let arg;
  }
}
func() // 不报错

另外,还有一个需要注意的地方。ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。

// 第一种写法,报错
if (true) let x = 1;

// 第二种写法,不报错
if (true) {
  let x = 1;
}
3.2.3 for循环

开发者可能最希望实现for循环的块级作用域了,因为可以把声明的计数器变量限制在循环内,例如:

for (let i = 0; i < 10; i++) {
  // ...
}
console.log(i); // ReferenceError: i is not defined

上面代码中,计数器i只在for循环体内有效,在循环体外引用就会报错。

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

上面代码中,变量i是var命令声明的,在全局范围内都有效,所以全局只有一个变量i。每一次循环,变量i的值都会发生改变,而循环内被赋给数组a的函数内部的console.log(i),里面的i指向的就是全局的i。也就是说,所有数组a的成员里面的i,指向的都是同一个i,导致运行时输出的是最后一轮的i的值,也就是 10。

如果使用let,声明的变量仅在块级作用域内有效,最后输出的是 6。

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

上面代码中,变量ilet声明的,当前的i在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。你可能会问,如果每一轮循环的变量i都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

再看下面这个例子:

<button>点我打印</button>
<button>点我打印</button>
<button>点我打印</button>

let el = document.querySelectorAll('button')
let i
for (i = 0; i < 3; i++) {
  el[i].addEventListener('click', function (event) {
     console.log(i)
  })
}

依次点击button按钮,会打印出什么呢?

答案是:3,3,3

因为let i 是在全局下声明的,for循环里每一次循环改变的都是全局的i,类似于上面用var声明的那个例子,所以最终输出都是3.

另外,for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部又是一个单独的子作用域。

for (let i = 0; i < 3; i++) {
  let i = 'abc';
  console.log(i);
}
// abc
// abc
// abc

上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域(同一个作用域不可使用 let 重复声明同一个变量)。

4.作用域链

如下代码中,console.log(a)要得到变量a,但是在当前的作用域中没有定义a(可对比一下b)。当前作用域没有定义的变量,会成为 自由变量 。自由变量的值如何得到呢?它会向父级作用域一层一层地向外查找,直到找到全局window对象,也就是全局作用域,如果全局作用域里还没有,就返回undefined。类似于顺着一条链条从里往外一层一层查找变量,这条链条,我们就称之为作用域链。内部环境可以通过作用域链访问所有外部环境,但外部环境不能访问内部环境的任何变量和函数。

var a = 100
function fn() {
    var b = 200
    console.log(a) // 这里的a在这里就是一个自由变量
    console.log(b)
}
fn()

下面再来看一个例子

var x = 10
function fn() {
   console.log(x)
}
function show(f) {
   var x = 20
   (function() {
      f()   //10,而不是20
   })()
}
show(fn)

在fn函数中,取自由变量x的值时,要到哪个作用域中取?——要到创建fn函数的那个作用域中取,无论fn函数将在哪里调用

所以,用这句话描述自由变量的取值过程可能会更加贴切:要到创建这个函数的那个作用域中取值,这里强调的是“创建”,而不是“调用”(有点类似于箭头函数的this指向) ,切记切记——其实这就是所谓的"静态作用域"

var a = 10
function fn() {
  var b = 20
  function bar() {
    console.log(a + b) //30
  }
  return bar
}
var x = fn(),
b = 200
x() //bar()

fn()返回的是bar函数,赋值给x。执行x(),即执行bar函数代码。取b的值时,直接在fn作用域取出。取a的值时,试图在fn作用域取,但是取不到,只能转向创建fn的那个作用域中去查找,结果找到了,所以最后的结果是30

参考