StepByStep前端科普系列(003)-词法环境LexicalEnvironment与闭包

111 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情

Step1: 执行上下文

浏览器并不理解我们在应用中编写的JS代码。代码需要被转换成浏览器和计算机能够理解的格式:机器码。浏览器在读取HTML时,如果遇到了<script> 标签或包含JavaScript代码的属性如onClick,会发送给JavaScript引擎。浏览器的JavaScript引擎会创造一个特殊的环境来处理这些JavaScript代码的转换和执行。这个特殊的环境被称为执行上下文

// 执行上下文代码示例
const ExecutionContextObj = {
    VO: window, // 变量对象
    ScopeChain: {}, // 作用域链
    this: window
};

执行上下文 = 全局执行上下文 + 函数执行上下文

Step2:全局执行上下文

  • Chrome浏览器打开控制台,输入this,控制台会输出该表达式的结果,如下图,输出值为Window对象,这就是全局执行上下文,它是由浏览器创建,可通过this直接访问。

image.png

  • 全局对象window有很多预定义的方法和属性,在全局环境任意处都可直接访问它们。我们用var声明的全局变量也会存储在window全局对象中,可直接访问。

另外,变量前不加修饰符,视为全局变量

function f1(){
  n=999;
}
  f1(); // 执行后,n才赋值。
  alert(window.n); // 输出 999,变量前不加修饰符,视为全局变量

image.png

  • 每一个JavaScript文件只能有一个全局执行上下文。每当 JavaScript 引擎接收到脚本文件时,它首先会创建一个默认的执行上下文,称为全局执行上下文,它是基础/默认的执行上下文,所有 不在函数内部的JavaScript代码 都在这里执行。

Step3:函数执行上下文

每当函数被调用时,JavaScript引擎就会在全局执行上下文内部创建另一种执行上下文,称为函数执行上下文,并在函数执行上下文中评估和执行函数中的代码。因为每个函数调用都创建自己的函数执行上下文,所以在脚本运行期间会有多个函数执行上下文。

  • 函数执行上下文可以有很多个;
  • 每当函数被调用时都会创建一个函数上下文;

Step4:执行栈

JS代码首次运行,都会先创建一个全局执行上下文并压入到执行栈中,之后每当有函数被调用,都会创建一个新的函数执行上下文并压入栈内;当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。整个程序执行完毕,全局执行上下文会被销毁。

Step5:创建执行上下文

  • 绑定 this
  • 创建词法环境组件 LexicalEnvironment
  • 创建变量环境组件 VariableEnvironment
// 创建执行上下文
ExecutionContext = {  
    // 确定this的值
    ThisBinding = <this value>,
    // 创建词法环境组件
    LexicalEnvironment = {},
    // 创建变量环境组件
    VariableEnvironment = {},
};

绑定this

  • 全局执行上下文中,this总是指向全局对象,在浏览器环境下,this指向window对象
  • 函数执行上下文中,如果函数被对象调用,那么this指向该对象,否则,指向全局对象window或者undefined(严格模式)

Step6: 词法环境

V8里JS的编译执行过程

  • V8引擎刚拿到执行上下文的时候,会把代码从上到下一行一行的先做分词/词法分析(Tokenizing/Lexing)。

    分词是指:比如var a = 2;这段代码,会被分词为:var a 2;这样的原子符号(atomic token);

    词法分析是指:登记变量声明、函数声明、函数声明的形参。后续代码执行的时候就知道去哪里拿变量的值和函数了,这个登记的地方就是Lexical Environment(词法环境)

  • 在分词结束以后,会做代码解析,引擎将 token 解析翻译成一个AST(抽象语法树), 在这一步的时候,如果发现语法错误,就会直接报错不会再往下执行。

    var greeting = "Hello";
    console.log(greeting);
    greeting = ."Hi";
    // SyntaxError: unexpected token .
    // 没有打印出 hello,而是先报错,说明JS引擎在真正执行代码之前,会做代码解析。
    
  • 引擎生成CPU可以执行的机器码。

// 输出
2
6
// 全局词法环境
GlobalEnvironment = {
    outer: null, //全局环境的外部环境引用为null
    GlobalEnvironmentRecord: {
        //全局this绑定指向全局对象
        [[GlobalThisValue]]: ObjectEnvironmentRecord[[BindingObject]],
        //声明式环境记录,除了全局函数和var,其他声明都绑定在这里
        DeclarativeEnvironmentRecord: {
            x: 1,
            y: 5
        },
        //对象式环境记录,绑定对象为全局对象
        ObjectEnvironmentRecord: {
            a: 2,
            foo:<< function>>,
            baz:<< function>>,
            isNaNl:<< function>>,
            isFinite: << function>>,
            parseInt: << function>>,
            parseFloat: << function>>,
            Array: << construct function>>,
            Object: << construct function>>
            ...
            ...
        }
    }
}
//foo函数词法环境
fooFunctionEnviroment = {
    outer: GlobalEnvironment,//外部词法环境引用指向全局环境
    FunctionEnvironmentRecord: {
        [[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
        bar:<< function>> 
    }
}
//bar函数词法环境
barFunctionEnviroment = {
    outer: fooFunctionEnviroment,//外部词法环境引用指向foo函数词法环境
    FunctionEnvironmentRecord: {
        [[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
        b: 3
    }
}

//baz函数词法环境
bazFunctionEnviroment = {
    outer: GlobalEnvironment,//外部词法环境引用指向全局环境
    FunctionEnvironmentRecord: {
        [[ThisValue]]: GlobalEnvironment,//this绑定指向全局环境
        a: 10
    }
}

我们可以看到词法环境和我们代码的定义一一对应,每个词法环境都有一个outer指向上一层的词法环境,当运行上面代码,函数bar的词法环境里没有变量a,所以就会到它的上一层词法环境(foo函数词法环境)里去找,foo函数词法环境里也没有变量a,就接着去foo函数词法环境的上一层(全局词法环境)去找,在全局词法环境里var a=2,沿着outer一层一层词法环境找变量的值就是作用域链。在沿着作用域链向上找变量的时候,找到第一个就停止往上找,如果到全局词法环境里还是没有找到,因为全局词法环境里的outer是null,没办法再往上找,就会报ReferenceError。

变量提升 函数提升

V8引擎执行代码的大致可以分为三步,先做分词和词法分析,然后解析生成AST,最后生成机器码执行代码。在词法分析的时候会生成词法环境登记变量,对于变量声明和函数声明,词法环境的处理是不一样的。

  • 对于变量声明var a=2; let x=1;,给变量分配内存并初始化为undefined,赋值语句是在第三步生成机器码真正执行代码的时候才执行。
  • 对于函数声明function foo(){...},会在内存里创建函数对象,并且直接初始化为该函数对象。

Step7 : 闭包

有时候我们需要从外部得到函数内部的局部变量,我们可以通过在函数内部再定义一个函数来做到。如下示例:

  • 我们的需求:在f1外部词法环境中,获取f1词法环境中的局部变量 n
  • 因为,f2的父外部词法环境是f1,所以,f2可以获取到 f1中的 n
  • 思路:定义f2,通过f2n 返回,在 f1 中将f2函数作为返回值
  • 在f1的外部词法环境中,调用执行f1即可得到f2函数,执行f2函数,拿到f1的的局部变量

f2就是闭包。闭包就是能够读取其它函数内部变量的函数。进一步的可简单理解为, 闭包是定义在一个函数内部的函数

 function f1(){
    var n=999;
    function f2(){
      return n;
    }
    return f2;
  }
  
 var result=f1();
  console.log(result());

参考文献

博客园-一文看懂执行上下文

LiMei - 词法环境

JavaScript Execution Context – How JS Works Behind The Scenes

深入理解闭包

闭包底层运行机制

阮一峰 闭包

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情