JavaScript的作用域详解

69

作用域

作用域(scope),程序设计概念,通常来说,一段程序代码中所用到的变量并不总是有效/可用的,而限定这个变量的可用性的代码范围就是这个变量的作用域。通俗一点就是我要把我的变量分成一坨一坨保管起来,有些地方只能用这几个变量,有些地方只能用另外几个变量,而这个分开的一坨一坨的区域就是作用域~

那这个作用域什么时候用到的呢?

没错就是编译的时候~
让我们来看看编译的大概流程

  • 词法分析(这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块)
  • 语法分析(这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”)
  • 代码生成(将这棵“树” 转换为可执行代码,将我们写的代码变成机器指令并执行)

比起上面这些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等,但是大体上也是差不多的流程~

那我们要编译var a = 2的话,是‘谁’来执行编译的过程呢?

当当当当~

  • 引擎:负责整个编译运行的全部过程。
  • 编译器:负责词法分析以及代码生成。
  • 作用域:负责收集维护所有声明的标识符,确定当前执行代码对标识符的访问权限。

当我们看到var a = 2的时候,我们认为是一条声明,但是对于引擎来说,这是两个完全不一样的声明,分为下面两部分

  • 1.遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a(严格模式下报错)。
  • 2.接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。

可以看到,编译的时候,编译器和引擎需要询问作用域,所求变量是否存在,然后根据查询结果来进行不同的操作

作用域嵌套

上面我们展示了只有一个作用域,变量的声明和赋值过程。
实际情况中,我们通常需要同时顾及几个作用域。当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止;但是反过来,外层的作用域无法访问内层作用域的变量,如果可以的话那不就全都是全局变量了吗嘿嘿嘿

function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4

当引擎需要变量b的时候,首先在foo的作用域中查找,发现没有b的踪影,于是就跑出来,往上面一层作用域走一走,发现了这个b原来在全局作用域里待着,那可不得一顿引用!如果全局作用域也没有b的话,那就得报错了,告诉写代码的傻子“你猪呢?一天到晚净会写bug!”。

clipboard.png

第一层楼代表当前的执行作用域,也就是你所处的位置。建筑的顶层代表全局作用域。
变量引用都会在当前楼层进行查找,如果没有找到,就会坐电梯前往上一层楼,如果还是没有找到就继续向上,以此类推。一旦抵达顶层(全局作用域),可能找到了你所需的变量,也可能没找到,但无论如何查找过程都将停止

函数作用域

可以看到我们在上面生成两层作用域(一层foo一层全局)的时候用了函数。因为JavaScript的函数可以产生一层函数作用域。
上代码!

function foo(a) {
    var b = a * 2;
    function bar(c) {
        console.log( a, b, c );
    }
    bar( b * 3 );
}
foo( 2 ); // 2, 4, 12

我们来分析一下上面几行代码。这个例子里面包含了三层逐级嵌套的作用域,其中两个函数生成了两层嵌套作用域。

1.包含着整个全局作用域,其中只有一个标识符: foo 。
2.包含着 foo 所创建的作用域,其中有三个标识符: a 、 bar 和 b 。
3.包含着 bar 所创建的作用域,其中只有一个标识符: c 。

由于bar是最内层的作用域,如果在它作用域内的查询不到它需要的值,它会逐级往外查询外层作用域的同名变量。如果查询到了则取用~

块级作用域

尽管函数作用域是最常见的作用域单元,当然也是现行大多数 JavaScript 中最普遍的设计方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可以实现维护起来更加优秀、简洁的代码。(如果你会其他一些语言你就会发现一个花括号不就一个块级作用域了吗)
我们来看看JavaScript中的花括号~

for(var i=0;i<5;i++){console.log(window.i)} //0 1 2 3 4

你惊奇的发现,妈耶,我这个var不等于白var嘛,反正都是全局变量(如果你没在函数内使用的话)。
是的JavaScript就是这么的高端兼灵性~(滑稽)
if的花括号和for是一样的,不做赘述。

那我们怎样整一个独立作用域?然后我又不想一直声明函数
JavaScript有四种方式可以产生块级作用域。

  • with
  • try/catch
  • let
  • const

让我们来介绍一下这四种东西吧~

1.首先是with,算了,垃圾,不讲。好处不多,坏处倒是挺多,有兴趣百度用法~不建议使用
2.然后是try/catch, ES3 规范中规定 try / catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效
try{throw 2}catch(a){console.log(a)};
console.log(a);//Uncaught ReferenceError
3.let,这个是es6引入的新关键字,非常香~看下面可以和上面的var i的循环做对比
for(let j=0;j<5;j++)(console.log(window.j));//undefined *5
4.这个跟let差不多,但是是用来定义常量的。const a = 5;a = 6;//报错

ok~这个很敢单~让我们来学习下一部分

提升

在最开始之前,我们先来学习一下两种报错。

  • ReferenceError 异常
  • TypeError

第一种的出现是因为遍历了所有的作用域都查找不到变量,第二种是找到了这个变量,但是对这个变量的值进行了错误的操作,比如试图对一个非函数类型的值进行函数调用

我们先来看看下面的代码会输出什么

a = 2;
var a;
console.log( a );

你可能会以为,我先给a赋值了2,然后var a又给a赋值了undefined,所以会输出undefined。但是这个输出了2。
我们再来看一题

console.log( a );
var a = 2;

这个时候你可能认为会报ReferenceError异常,因为使用在前,使用的时候a还没有定义,作用域肯定也找不到a,但是这个却输出了undefined。

Why?

为了搞明白这个问题,我们需要回顾一下前面关于编译器的内容。回忆一下,引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。
因此,正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。当你看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个声明: var a; 和 a = 2; 。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。
上面的第一段代码就可以看做

var a;
a = 2;
console.log(a)

第二段代码则可以看成

var a;
console.log(a);//此时a还没赋值,所以是undefined
a = 2;

打个比方,这个过程就好像变量从它们在代码中出现的位置被“移动”到了最上面(变量所在作用域)。这个过程就叫作提升。

我们从上面可以看到变量声明的提升,那么对于函数声明呢?当然是no趴笨啦~

foo();
function foo() {
    console.log( a ); // undefined
    var a = 2;
}

但是,需要注意的是,函数声明会被提升,但是函数表达式却不会。

foo(); // 不是 ReferenceError, 而是 TypeError!
var foo = function bar() {
// ...
};

这个就相当于

var foo;
foo(); // 此时foo肯定是undefined啦,undefined()? 对undefined值进行函数调用显然是错误操作!TypeError!
foo = function bar() {
// ...
};

既然函数声明和变量声明都会被提升,那它们两个哪个提升到更前面呢?

是函数!!函数作为JavaScript的一名大将,确实是有一些牌面。

foo(); // 1
var foo;
function foo() {
    console.log( 1 );
}
foo = function() {
    console.log( 2 );
};

我们可以将上面看成

function foo() {
    console.log( 1 );
}
var foo;//重复声明,可以去掉
foo(); // 1
foo = function() {
    console.log( 2 );
};

注意:后面的声明会覆盖前面的声明。

foo(); // 3
function foo() {
    console.log( 1 );
}
var foo = function() {
    console.log( 2 );
};
foo();//2
function foo() {
    console.log( 3 );
}

相当于


function foo() {
    console.log( 1 );
}
function foo() {
    console.log( 3 );
}
var foo;
foo(); // 3
foo = function() {
    console.log( 2 );
};
foo();//2

闭包

我们刚刚讲那么多,相信大家都已经知道并且深信,作用域只能一层一层往外查询,不能往里走,那我如果要找一个函数里的变量值呢?那可咋整啊?
很简单,我们不能往里走,但是我们可以再给这个函数里面整一层作用域,这样函数里面的子作用域不就可以访问它的变量了吗?
perfect~


function foo() {
    var a = 2;
    function bar() {
        console.log( a ); // 2
    }
    return bar;
}
var baz = foo();执行了foo()就返回了一个bar;现在相当于baz=bar;
baz();//2

这里我们需要获取a的值,我们就在里面写一个函数bar,显然这个bar是有权利访问a的,那我们返回这个有权利访问a的函数不就顶呱呱了吗?

在 foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。
而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收(频繁使用闭包可能导致内存泄漏)。谁在使用这个内部作用域?原来是 bar() 本身在使用。拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。

来点练习题

第一题
var tt = 'aa'; 
function test(){ 
    alert(tt); 
    var tt = 'dd'; 
    alert(tt); 
} 
test();
第二题
var a = 100;
function test(){
    console.log(a);
    a = 10;
    console.log(a);
}
test();
console.log(a);
第三题
var a=10; 
function aaa(){ 
    alert(a);
};            
function bbb(){
    var a=20;
    aaa();
}
bbb();

答案:

  1. undefined dd
  2. 100 10 10
  3. 10

参考文献

《你不知道的JavaScript》

最后

有什么错误或者建议可以在评论区告诉我~谢谢

你可能感兴趣的

laogouD · 2018年09月25日

谢谢分享

回复

Live · 2018年09月26日

4.这个跟let差不多,但是是用来定义常量的。var a = 5;a = 6;//报错
这里的var 应该是const吧

回复

0

是的,这里写错了,谢谢提醒

李大雷 作者 · 2018年09月26日
佩佩鸭 · 2018年09月26日

楼主坐火箭上去吧🚀

回复

曾培森 · 2018年09月26日

怒顶好文!

回复

曾培森 · 2018年09月26日

踩踩 记得回踩

回复

唐伯虎点蚊香 · 2018年09月26日

查漏补缺了,谢谢分享

回复

载入中...