作用域 + 作用域链【JS深入知识汇点2】

1,090 阅读7分钟

系列文章:

作用域介绍

作用域是什么?

作用域是指程序源代码中定义变量的区域,本质上是一套规则,用于确定在何处以及如何查找变量(标识符)。

作用域类型

主要有两种:

  • 动态作用域:是在代码运行时确定的,关注函数从何处调用。javascript 并不具有动态作用域,但是this机制某种程度上很像动态作用域。
  • 词法作用域:在函数定义时决定了,关注函数在何处声明。有时候可能会有在代码运行时“修改”词法作用域的需求,可以通过以下机制:
    • eval():可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。

      function foo(str, a) {
          eval(str);
          console.log(a, b)
      }
      var b = 3;
      foo("var b = 4", 2); // 2, 4  
      
    • with:通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域。

      function foo(obj) {
          with(obj) {
              a = 2;
          }
      }
      var o1 = {
          a: 3
      };
      var o2 = {
          b: 3
      };
      foo(o1);
      console.log(o1.a)  //2
      foo(o2)
      console.loh(o2.a)  //undefined;
      console.log(a)  
      //2 在o2中,对a进行LHS引用,没有找到,
      //在o2中不会创造a这个属性
      //因为是非严格模式,所以会在全局作用域中创建一个变量 a,并赋值给2
      

      ⚠️注意:这两个机制只在非严格模式下有效,严格模式下会抛出 Reference 错误。还会导致性能下降,引擎无法在编译时对其进行优化,所以会变慢。

JS采用词法作用域,也就是静态作用域。词法环境是一种持有标识符-变量映射的结构。

词法环境的内部有两个组件:

  • 环境记录器,存储变量和函数声明的实际位置
  • 外部环境的引用,意味着可以访问其父级词法环境

作用域的种类

作用域有三种:

  • 全局作用域:生命周期存在于整个程序内,能被程序中任何函数或者方法访问,在js中默认是可以被修改的。没有外部环境引用的词法环境。
  • 局部作用域
    • 函数作用域:函数作用域内,对外是封闭的,从外层的作用域无法直接访问函数内部的作用域。
    • 块级作用域:任何一对花括号中的语句集都属于一个块,在这之中定义的所有变量在代码块外都是不可见的。

作用域链

作用域链是什么?

当查找变量时,会先从当前 EC 的 VO 中查找,如果没找到,就会去父级 EC 的 VO 中查找,一直到全局VO。这样由多个EC的VO构成的链表就是作用域链。

作用域链代码化

function foo() {
    function bar() {}
}
// 函数创建时,各自的[[scope]]
foo.[[scope]] = [globalContext.VO]
bar.[[scope]] = [fooContext.AO, globalContext.VO]

// 函数激活时,会创建执行上下文,先创建 AO 对象,然后将 AO 对象插入到 [[scoped]]属性的链表的底部,组成新链表

foo.[[scopeChain]] = [fooContext.AO, globalContext.VO]
bar.[[scopeChain]] = [barContext.AO, fooContext.AO, globalContext.VO]

如何查找变量?

有以下两种方式:

  • LHS:赋值操作的目标是谁;结果不成功的话,有两种情况:
    • 严格模式下:抛出 Reference 异常。
    • 非严格模式下,自动隐式地创建一个全局变量。
  • RHS:谁是赋值操作的源头;结果不成功会报 Reference 异常。

⚠️注意:只会查找一级标识符,比 如foo.bar.baz,只会试图找到 foo 标识符,找到后,对象属性访问规则后分别接管对 bar、baz 的属性访问。

举🌰:

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

引擎:作用域,我需要为 b 进行 LHS引用,这个你见过吗?
全局作用域:见过见过!刚才编译器声明它来着,给你。
引擎:谢谢大哥,现在我要把2赋值给 b
引擎:作用域啊,还有个事,我要对 foo 进行 RHS 引用,你见过没啊?
全局作用域:见过呀,它是个函数,给你。
引擎:好的,我现在执行一下 foo 
引擎:哥啊,我需要对 a 进行 LHS 引用,这个你见过没?
全局作用域:这个也见过,是编译器把它声明成 foo 的一个形参了,拿去吧。
引擎:太棒了,现在我把3赋值给 a 了
引擎:foo 作用域啊,我要对 console 进行 RHS 引用,你见过没啊?
foo作用域:这我也有,是个内置对象,给你
引擎:你总是那么给力,现在我要看看这里有没有 log(),找到了,是个函数。
引擎:哥,我要对 a 进行 RHS 引用,虽然我记得好像有这个值,但是想让你帮我确认以下。
foo作用域:好,这个值没变过,你拿走吧。
引擎: 哥,我还要对 b 进行 RHS 引用,你找找呗
foo作用域:我没听过啊,你问问我的上级吧:
引擎:foo 的上级作用域兄弟,你见过 b 没啊?
全局作用域:见过 b 啊,等于2,拿走不谢!
引擎:真棒,我现在把 a + b ,也就是5,传递进 log(...)

难题解析:

Q1:

var a = 1
function fn1(){
    function fn2(){
        console.log(a)
    }
    function fn3(){
        var a = 4
        fn2()
    }
    var a = 2
    return fn3
}
fn1()() //2

结果是 2,因为:console.log 执行时,需要对 a 进行 RHS,在 fn3 中找 a 没找到,就去它的父级词法作用域中找,也就是fn1,就找到了 a = 2

Q2:

function Foo() {
    getName = function () {
        console.log(1);
    };
    return this;
};
Foo.getName = function () {
    console.log(2);
};
Foo.prototype.getName = function () {
    console.log(3);
};
var getName = function () {
    console.log(4);
};
function getName() {
    console.log(5);
} 

Foo.getName(); //2
getName(); // 4
Foo().getName(); //1
getName(); // 1
new Foo.getName(); // 2
new Foo().getName(); // 3
new new Foo().getName(); //3

Q3:

let x = 1;
function A(y){
   let x = 2;
   function B(z){
       console.log(x+y+z);
   }
   return B;
}
let C = A(2);
C(3); //7 
// 因为:在 B 里找 z 是3,找 y 没找到,去父级找,是2,x 找是2

Q4: 点的优先级大于等号的优先级

var a = {n: 1};
var b = a;
// 虽然赋值应该从右到左,但 . 的优先级比 = 高,
// 所以先执行 a.x = undefined;
// a = {n: 2},a的引用改变,指向新对象
// 之后执行 a.x = {n: 2}的时候,并不会重新解析a,
// 而是沿用最初解析a.x时候的a,就对象变成{x: {n: 2}, n: 1}

a.x = a = {n: 2};

console.log(a.x) // undefined	
console.log(b.x) // {n: 2}

Q5:

var obj = {
    '2': 3,
    '3': 4,
    'length': 2,
    'splice': Array.prototype.splice,
    'push': Array.prototype.push
}
// 1.调用 push 时,会在调用对象的key = length的地方做赋值,不管前面key是否有值
// 2.push方法如果对象有length属性,length属性会+1并且返回
// 3.对象如果同时满足:
// 1.有 splice 方法 2. length属性为正整数 => 输出会转换为类数组
obj.push(1)
obj.push(2)
console.log(obj) 
// [2: 1, 3: 4, length: 4, push: f, splice: f]

Q6:

var b = 10;
// IIFE 的函数是函数表达式,而不是函数声明
// 函数表达式的函数名只在函数内部有效,并且是常量绑定
// 如果对一个常量进行赋值,在 strict 模式赋值,非 strict 模式静默失败
(function b() {
  b = 20;
  console.log(b) // function () {...}
  console.log(window.b); // 10
})()

Q7: 简单改造下面的代码,使之分别打印 10 和 20。

var b = 10;
(function b(){
    b = 20;
    console.log(b); 
})();
// 输出10:
var b = 10;
(function b(b) {
  window.b = 20; 
  console.log(b) // 10
})(b)

var b = 10;
(function b(b) {
  b.b = 20; 
  console.log(b) // 10
})(b)

// 输出20:
var b = 10;
(function b() {
  var b = 20; 
  console.log(b) // 20
})()

var b = 10;
(function (){
  b = 20;
  console.log(b);
})();

Q8:

var a = 10;
(function () {
    console.log(a) //undefined
    a = 5
    console.log(window.a) // 10
    var a = 20;
    console.log(a) // 20
})()

Q9:

function Foo() {
    Foo.a = function() {
      console.log(1)
    }
    this.a = function() {
      console.log(2)
    }
}
Foo.prototype.a = function() {
    console.log(3)
}
Foo.a = function() {
    console.log(4)
}
Foo.a(); // 4
let obj = new Foo();
// 有直接方法a,不需要访问原型链
obj.a(); // 2
// Foo方法里替换了全局 Foo 上的 a 方法
Foo.a(); // 1

Q10: var 没有块作用域,声明会提升

var name = 'Tom';
(function() {
 if (typeof name == 'undefined') {
     var name = 'Jack';
     console.log('Goodbye ' + name);
 } else {
     console.log('Hello ' + name);
 }
})();
// Goodbye Jack

var name = 'Tom';
(function() {
 if (typeof name == 'undefined') {
     let name = 'Jack';
     console.log('Goodbye ' + name);
 } else {
     console.log('Hello ' + name);
 }
})();
// Hello Tom

var name = 'Tom';
(function() {
    if (typeof name == 'undefined') {
        name = 'Jack';
        console.log('Goodbye ' + name);
    } else {
        console.log('Hello ' + name);
    }
})();
// Hello Tom