学习笔记 - 你不知道的JavaScript之作用域

时间:2022-03-29 14:43:37

Javascript的作用域


Javascript的词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

学习笔记 - 你不知道的JavaScript之作用域
①包含着整个全局作用域,其中只有一个标识符:foo。
②包含着foo 所创建的作用域,其中有三个标识符:a、bar 和b。
③包含着bar 所创建的作用域,其中只有一个标识符:c。

  • 无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

函数作用域和块作用域

函数中的作用域

function foo(a) {
    var b = 2;
    //一些代码
    function bar() {
        // ...
    }
    //更多代码
    var c = 3;
}

在这个代码片段中,foo(..) 的作用域气泡中包含了标识符a、b、c 和bar。无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处作用域的气泡。

  • 局部作用域一般只在固定的代码片段内可访问到。
function doSomething() {
    var name = "eve";
    function inner(){
        alert(name)
    }
    inner()
}
doSomething();//eve
inner();//ReferenceError

作用

可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来“隐藏”它们。

1)如果所有变量和函数都在全局作用域中,可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确的代码应该是可以阻止对这些变量或函数进行访问的。
看下面的代码:

function doSomething(a) {
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );
}
function doSomethingElse(a) {
    return a - 1;
}
var b;
doSomething( 2 ); // 15

在这个代码片段中,变量b 和函数doSomethingElse(..) 应该是doSomething(..) 内部具体实现的“私有”内容。给予外部作用域对b 和doSomethingElse(..) 的“访问权限”不仅没有必要,而且可能是“危险”的,因为它们可能被有意或无意地以非预期的方式使用,从而导致超出了 doSomething(..) 的适用条件。更“合理”的设计会将这些私有的具体内容隐藏在doSomething(..) 内部,例如:

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }
    var b;
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );
}
doSomething( 2 ); // 15

现在,b 和doSomethingElse(..) 都无法从外部被访问,而只能被doSomething(..) 所控制。

2)可以避免同名标识符之间的冲突。
两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突,冲突会导致变量的值被意外覆盖。
看以下代码:

function foo() {
    function bar(a) {
        i = 3; 
        console.log( a + i );
    }
    for (var i=0; i<10; i++) {
        bar( i * 2 );
    }
}
foo(); // 结果无限循环输出3

由于for循环中的变量声明提升,上面的代码相当于:

function foo(){
    var i; // js 引擎把变量声明提前了
    function bar(){
        i = 3; // 注意前面没有var,这个i变量赋值给了var i
        console.log(i);
  
  // 执行for循环时,i初始化,此时i=0
  // 然后满足条件(0<10),进入循环
  // 在循环内部,调用bar函数,并把 i*2 作为实参传入(其实传不传参数都一样)
  // 调用bar函数时, bar内部的 i 变量其实时是bar 的上层执行环境即foo内的变量
  // bar函数执行完,会把foo的i变量变为3
   
  // -------------死循环开始了
   
  // i++ 执行完,此时 i = 4
  // 4<10,满足条件,再一次进入循环,
  // 再次调用bar函数,又把i变成了3
   
  // i++ 执行完,此时 i = 4
  // 4<10,满足条件,再一次进入循环,
  // 再次调用bar函数,又把i变成了3
   
  // i++ 执行完,此时 i = 4
  // 4<10,满足条件,再一次进入循环,
  // 再次调用bar函数,又把i变成了3
   
  // .
  // .
  // . 
  for(i = 0;i<10;i++){
     bar(i * 2);
  }
}
foo();

再看一个变体:

function foo(){
  function bar(){
     var i = 3; // 注意,与上面唯一不同是i前面有var
     console.log(i);
  }
   
  // 由于bar中的i变量使用了var声明,那么,bar中的i就是局部变量
  // 所以调用bar之后,改变的是bar内部的i,并不会影响foo中的i
  // for循环能正确执行,for循环会如愿执行10次
  for(var i = 0;i<10;i++){
     bar(i * 2);
  }
}
foo();

存在的一些问题

var a = 2;
function foo() { // <-- 添加这一行
    var a = 3;
    console.log( a ); // 3
} // <-- 以及这一行
foo(); // <-- 以及这一行
console.log( a ); // 2

在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容,但也会导致一些额外的问题,首先,必须声明一个具名函数foo(),意味着foo 这个名称本身“污染”了所在作用域(在这个例子中是全局作用域)。其次,必须显式地通过函数名(foo())调用这个函数才能运行其中的代码。
既能隐藏内部又不污染作用域的解决方案:

var a = 2;
(function foo(){ // <-- 添加这一行
    var a = 3;
    console.log( a ); // 3
})(); // <-- 以及这一行
console.log( a ); // 2

将函数当作函数表达式而不是函数声明来处理。
区分函数声明和表达式最简单的方法是看function 关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
比较一下前面两个代码片段,上面的片段中foo 被绑定在所在作用域中,可以直接通过foo() 来调用它。第二个片段中foo 被绑定在函数表达式自身的函数中而不是所在作用域中。换句话说,(function foo(){ .. }) ()作为函数表达式意味着foo 只能在.. 所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。这种函数叫做立即执行函数表达式(IIFE)

块作用域

  • JavaScript不支持块作用域。
var foo = true;
if (foo) {
    var bar = foo * 2;
    bar = something(bar);
    console.log(bar);
}

bar变量仅在if声明的上下文中使用,当使用var声明时,它写在哪里都是一样的,因为它们最终都会属于外部作用域,这段代码只是伪装出形式上的块作用域,如果要使用这种形式,要确保没在作用域其他地方意外地使用bar只能依靠自觉性。

Javascript的作用域链


JavaScript中的函数运行在它们被定义的作用域里,而不是它们被执行的作用域里。

在一个函数被定义的时候, 会将它定义时的scope chain链接到这个函数对象的[[scope]]属性.
在一个函数对象被调用的时候,会创建一个活动对象,该对象包含了函数的arguments,形参,局部变量和this, 然后将这个活动对象做为此时的作用域链(scope chain)最前端, 并将这个函数对象的[[scope]]加入到scope chain中.

function foo(x, y) {  
  var z = 30;  
  function bar() {} // FD  
  (function baz() {}); // FE  
}  
  
foo(10, 20);

函数foo的上下文中的活动对象为:

学习笔记 - 你不知道的JavaScript之作用域

在函数执行过程中,每遇到一个变量,都会经历一次标识符解析过程,该过程从作用域链头部,也就是活动对象开始搜索,查找同名的标识符,如果找不到,就继续向上一级查找,当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”。

Javascript的预编译


任何JavaScript代码片段在执行前都要进行编译

  • 变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。

对于var a = 2JavaScript会将其看成两个声明:var aa = 2,一个在编译阶段执行,第二个等待执行阶段。

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

等价于

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

详细过程就是:

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

(补充1)查询:

引擎查询共分为两种:LHS查询和RHS查询 。

  1. LHS查询:如果查找的目的是对变量进行赋值,那么就会使用LHS查询。例如a = 2,想要找到一个var a来进行赋值。

  2. RHS查询:如果查找的目的是获取变量的值,那么就会使用LHS查询。例如对于console.log(a),这里a没有赋值,需要查找并获取a的值才能传递给console.log(..)。

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

在这段代码中,执行了四个查询:
1.foo(..)对foo进行了RHS引用;
2.2当作参数传递给foo(..)时,会把2分配给参数a,也就是要对a进行赋值,所以会进行LHS查询。
3.console.log(...)本身需要一个引用才能执行,所以会对console对象进行RHS引用,并检查其是否有一个log的方法。
4.console.log(a)对a进行了RHS引用,并把得到的值传给了console.log(...)

为什么要区分LHS 和RHS?

如果变量未声明,两种查询抛出的错误不一样。

  • RHS查询:
    ①未查找到变量,抛出ReferenceError异常
    ②如果RHS 查询找到了一个变量,但对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用null 或undefined 类型的值中的属性,抛出TypeError异常

  • LHS查询:
    ①(非严格模式)如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎。
    ②(严格模式)不会创建并返回一个全局变量,引擎会抛出同RHS 查询失败时类似的ReferenceError 异常

变量声明提升

  • 变量和函数在内的所有声明都会在任何代码被执行前首先被处理。只有声明本身会被提升,赋值或其他运行逻辑会留在原地,而函数声明会整个一起提升。
foo();
function foo() {
    console.log(a);//undefined
    var a = 2;
}

等价于

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

但函数表达式不会被提升,即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用。这里foo()对undefined值进行函数调用导致非法操作,抛出TypeError异常。

foo();
bar();
var foo = function bar() {...}

等价于

var foo;
foo();//TypeError
bar();//ReferenceError
foo = function() {
    var bar = ...
}

函数声明优先

  • 函数会优先被提升,然后才是变量,函数声明可以覆盖函数声明。
foo(); // 3
function foo() {
    console.log( 1 );
}
var foo = function() {
    console.log( 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 );
};

注意:上面var foo = function() {}的赋值在foo()后面,所以不会影响函数foo()的值,但是如果在foo()前面赋值函数的值就会被普通变量的值覆盖,因此要注意避免重复声明,特别是当普通的var声明和函数声明混合在一起时,参考函数的变量提升问题-segmentfault,看下面的代码:

function foo() {
    console.log( 1 );
}
var foo = function() {
    console.log( 2 );
};//这里覆盖了值3
function foo() {
    console.log( 3 );
}
foo();//2

一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制,因此应该尽可能避免在块内部声明函数

foo(); // "b"(自己试验的结果是TypeError)
var a = true;
if (a) {
    function foo() { console.log("a"); }
}
else {
    function foo() { console.log("b"); }
}

上面书上的结论和我自己试验的不一样,找了一下解释,可以参考一下《你不知道的javascript》-segmentfault