精读JS(二) 变量-->环境记录

367 阅读9分钟

前言

萝莉赛高!!!

关于变量,在Javascript核心知识体系中,占比不重,即使有些迷惑行为,也认为Javascript本应就如此,故而被人轻易忽视。所以我把以前常见的问题重新拿起来。

这里就先从两个极为经典的问题开始吧。

变量提升和暂存死区

先来两个示例:

其一:变量提升
  console.log(typeof number);  // undefined
  var number = 1000;
  console.log(number);  // 1000

应用中,var声明的变量能够提前使用虽然是undefined, 我们把这种现象称之为变量提升。事实上,var声明的变量奇怪之处并不只是如此,例如window.number,它返回的是1000

它声明的变量被绑定到了window全局对象上,甚至在Javascript代码中,可以允许重复声明。这又是为什么?

但是,如果我们这样使用:

  console.log(typeof isNaN); // function
  var isNaN = 1000;
  var isNaN;
  console.log(isNaN);     // 1000
  

不知有没有发现, isNaN不仅提升了,甚至有一个值当然这是来自window

总体而言, 如果说这么多的迷惑行为都是Javascript本质特征,说实话很难让人信服。

其二 暂存死区
   var a=100,b=200,c=300,d=400;
   var f = ()=>{
      let sum = a*2+b*3+c*4+d*5;     /// (*)
      let b = 9*sum;
      console.log(b);
      
   }
   f();

显式下面的错误: Uncaught ReferenceError: Cannot access 'b' before initialization, 一个未初始化错误。

如果是有些基础的就能知道, 因为在 (*)处用到了b, 在let声明之前不能动用,把这种现象称之为暂存死区。这又是为什么?

实际上,Javascript对于变量,只有两个限制

  • 未初始化(uninitialized)的变量无法访问
  • let/const这类声明的标识符不允许重复。
  • 这些限制在其他编程语言中也有

更多细节,就与词法环境中的环境记录有关了。

环境记录(EnvironmentRecord)

就如上一篇中说的, 环境记录是用于记录词法环境中的标识符与变量的映射,但是要注意,这里的环境记录可以抽象为一个特殊的对象(如果对ES3版本有所了解的,就是活动对象和变量对象啦),它与一般对象也没什么不同。

只是要特别记住的是,这里的标识符,确切说是标识符字符串名称(IdentifierName), 而变量,则是存储区,当标识符与变量的成立一种绑定关系,就可以通过标识符引用一个变量(存储区)进行更多操作。当前词法环境中所有的标识符都会在环境记录中,反过来说,任何在环境记录中的标识符都可以在当前词法环境直接以标识符形式访问

一个典型例子就是刚刚的isNaN,它绑定在了环境记录中,自然也能直接访问了,而不需要window.isNaNparseFloat、parseIint这类也是一样; 不过这也会引来下一个问题,为什么来自window对象的属性都绑定到了环境记录中了?(不要怪罪到javascript)

这就与环境记录的类别有关系了,如:

  1. DeclarativeEnvironmentRecord(声明式记录):
    • FunctionEnvironmentRecord(函数式环境记录)
    • ModuleEnvironmentRecord(模块式环境记录)
    • 注意:上面两个子类,无特殊情况都可以称之为声明式记录。
  2. ObjectEnvironmentRecord(对象式记录)
  3. GlobalEnvironmentRecord(全局环境记录)

铺垫了这么久,终于讲到正文了……

全局环境记录

它是特别的。

它实际上就是声明式记录和对象式记录终极组合,两者都包含。也是作用域链最最基础最最底层的存在。关于全局环境记录,它的内部属性 [[DeclarativeRecord]]指向声明式记录, 而[[ObjectRecord]]指向对象式记录,还有很重要的集合,就是[[VarNames]]集合,它包含了全局词法环境中使用var声明的所有标识符字符串名称。(这里的[[]]表示内部属性……细细想来也没卵用,以后我就不加了)

现在请记住一点: 全局环境的绑定对象就是window对象

现在开始高能时刻。

对象式环境记录

对象式记录也是用于记录标识符与变量的映射,但是它只记录var声明的标识符 ; 并且它有一个关联的绑定对象(binding object)。

  1. 在词法环境中,会为对象式环境记录中所有的标识符绑定到绑定对象的同名属性上。
    • 例如var number=1000; , 也能够通过window.number形式获取到number的值。
  2. 反过来也可以,会将绑定对象的所有属性名(自然也必须是能做标识符的)绑定到对象式环境记录中的同名标识符上。
    • 例如:window.thousand = 1000; 然后直接以 thousand就能获取到该值(严格模式下报错)
  3. 每个标识符在绑定后都会直接实例化并初始化为undefined ,如果标识符已经绑定了绑定对象上的原有属性上,那么该变量就是对应属性值
    • 比如之前的isNaN在声明前使用时就有值,就是这个原因。
    • 变量提升也是这个原因造成的。
  4. 如果标识符已经存在,那么无视之,所以var可以重复声明。

声明式环境记录:

同样的,声明式环境记录也比较特殊,它只记录非var声明的标识符,例如let、const、function……声明的标识符等等。并且它没有关联的绑定对象

  1. 所有声明的标识符(这里应该包含var声明的标识符,但不建立关联)都位于此处。
  2. 将所有非var声明的标识符实例化,但不初始化,也就是变量处于uninitialized状态。也就是说内存中已经为变量预留出空间,但是还没有和对应的标识符建立绑定关系
  3. 在执行上下文的运行(perform状态)阶段,并执行到声明语句时,才会真正初始化并默认赋值为undefined。
    • 所以你就懂了,let声明的标识符之前无法访问,就是因为还没有建立绑定。
    • 暂存死区的根本原因在此。
  4. 在声明式环境记录中,不允许出现重复的标识符,所以它无法重复。甚至和var声明的标识符冲突。注意,它会在代码加载后的预编译阶段(只能说是运行前,因为JS没有真正的预编译啊……)就已经完成。

(什么是执行上下文?? 下一篇再说……) 例如:

   console.log(temp);  
   var temp = 0;
   let temp;     // (*)

报错信息:Uncaught SyntaxError: Identifier 'temp' has already been declared

当然还有一个要补充的是,严格来讲,所有环境下的标识符都必须在声明式环境记录中有记录(即便是不建立关联)

如:

 'use strict'
 i=10;
//   Uncaught ReferenceError: i is not defined

出入点

一、 有人说可能存在LexNamesVarNames两个集合?

  1. 没有证据证明存在,但是也没有证据能表明它们不存在。
  2. 这时只要能够建立理解就足够,没必要深究。

二、 为什么函数也能提升? 它不是声明式中的么?

  1. 目前唯一最有说服力的说法是:**浏览器为了兼容性考虑,为特意将函数绑定到ObjRec上。
  2. 规范有所允许,但是不影响分析。

三、 为什么觉得怪怪的?这些概念有何用?

  1. 没什么用。
  2. 但是编码时没有那种憋着一股气的感觉了,感觉浑身轻松。

四、 这些对面试有用么?

  1. 看个人,这些都是底层的内容,你把这些底层的理解了,还担心面试么?

五、 你的说法有何凭证?

  1. 大部分来自github上,也有少部分来自规范以及Medium博客。
  2. 其实大部分都是理解的,不过我觉得能解释更多问题,少了一点教条就行了。

实例:

代码:

  function f(a,b){
      var t = 10;
      let sum = 10;
      {
          let sum = a+b;
          var mul = a*b +sum;
      }
       return  mul*t;
  }
  f(20,30) // 6500

词法环境:

FunctionEnv = {
    This:<window>
    outerEnv:<GlobalEnv>,
    ObjRec:{
        t:<10>,
        mul:<650>
    },
    DecRec:{
        sum:<10>
    }
}

BlockEnv={
    This:<window>,
    outerEnv:<FunctionEnv>,
    DecRec:{
        sum:<50>
    }
}

变量环境和词法环境

当将一个词法环境的varlet声明的标识符彻底泾渭分明时,也会将词法环境彻底掰开,一个称之为变量环境(VariableEnvironment),用于封装var声明的标识符,另一个则称之为词法环境(LexicalEnvironment),用于封装非var声明的标识符。

正因为如此, 变量环境只有全局作用域和函数作用域,同理,词法环境则是三种作用域都有(全局、块、函数……)。

这个时候,刚刚的FunctionEnv就要写成:

FunctionEnv = {
    VarEnv:{
        This:<window>
        outerEnv:<GlobalEnv>,
        ObjRec:{
            t:<10>,
            mul:<650>
        }
    },
    LexEnv:{
        This:<window>
        outerEnv:<GlobalEnv>,
        DecRec:{
            sum:<10>
        }
    },
}

上面是一种完整写法,但是没必要的情况的情况下我不会用这种形式

补充:为什么函数属于 DecRec?

看下面的代码:

  function f(a,b){
      let c = 100;
      {
          let c = 300;
          function say(){
             console.log(a+b+c);
             
          }
          say(); // 350
      }
  }
  f(20,30) // 6500

然后得到 say函数的词法环境伪代码:

FunctionSayEnv = {
    VarEnv:{
        This:<window>
        outerEnv:<Function_F_Env>
    },
    LexEnv:{
        This:<window>
        outerEnv:<Block_Env>,
    },
}

LexEnv的外部词法环境是 块级作用域,但是 VarEnv的外部词法环境是 函数作用域。 如果选择VarEnv结果就大不相同。

最后:

上面的内容虽不属于虚构,但是也是极为理论化、也是极为概念的东西,每个人的理解都不相同(说白了,规范就是一种约束,而不代表真正的实现方式),只要结果正确,那就是正确

实际上有很多东西应该归入执行上下文中,但是这样下一篇字数就太多了,所以给分开了。

本文只是小生的一点浅见,若有出入,还请大佬指正(我也是新人,只是喜欢瞎看)。