执行上下文,词法环境,作用域链,this究竟是什么??

939 阅读8分钟

引言

之前总结了浏览器的异步处理和EventLoop机制,对于js代码的执行机制有了一定的了解,但是仍存在一定的疑惑,比如:js代码在call stack中调用的时候,压入栈中的执行上下文究竟是什么呢?函数内部的变量从哪里寻找呢?词法环境是什么呢?

执行上下文是什么

之前介绍js执行机制的时候提到过,js代码在执行时会先被压入执行栈,执行完毕之后就会被弹出执行栈,而存放在执行栈中的内容,就是执行上下文。

//示例代码:
var name;
function f2(){
    name ='yzh';
    console.log(name);
}
function f1(){
    f2()
}
f1()

整个代码执行过程:

  • 开始执行代码之前,先创建全局上下文压入栈底
  • 创建词法环境,登记变量声明和函数声明(下面会详细说明词法环境)
  • 运行到f1时,便把f1的执行上下文压入执行栈
  • f1调用f2,便把f2的执行上下文压入执行栈
  • f2调用console.log(name)函数,便把其也要入执行栈
  • console执行完毕之后弹出执行栈
  • f2执行完毕后弹出执行栈
  • f1执行完毕后弹出执行栈
  • 整个js代码执行完毕后,全局执行上下文也会被弹出

从上面可知:function自定义函数,console等js内置函数和整个js文件被看作全局执行上下文可以被压入执行栈,还有两种也可以:

  • module:模块代码
  • eval:放在eval的代码也会

上面这四种能放在执行上下文中的代码有一个统称,就是可执行代码(Executable)

执行上下文组成?

  • 词法环境(Lexical Environment)
  • 变量环境(Variable Environment)
  • this指向(ThisBinding)

变量环境和词法环境的区别是什么呢?

前者用于登记var和function变量声明,后者用来登记let,const,class变量声明。

在ES6之前没有块级作用域,有这两个词法环境作用就是为了既能实现块级作用域,也能实现var的变量与函数声明,具体操作如下:

词法环境是什么

介绍词法环境之前,先看一下V8引擎如何解析js代码

  • V8引擎拿到执行上下文之后,会将代码逐行进行分词和词法分析

    分词(Tokenizing)即将代码字符串拆分为一个个原子符号,词法分析(Lexing)即登记量声明、函数声明、函数声明的形参。

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

  • 引擎生成CPU可以执行的机器码

在词法分析这一步中,就会把变量,函数等登记到词法环境中。

词法环境主要包含以下两方面:

  • 环境记录 (变量登记的地方)

    • 声明式环境记录(用来记录有标识符定义的元素,比如let,const,module,import以及函数声明)
      • 函数环境记录(用于函数作用域)
      • 模块环境记录
    • 对象式环境记录(用于with和global的词法环境,全局函数和全局变量)
  • 对外部词法环境的引用(作用域链的关键,稍后介绍)

词法环境与我们自己写的代码结构相对应,也就是我们自己代码写成什么样子,词法环境就是什么样子。词法环境是在代码定义的时候决定的,跟代码在哪里调用没有关系。也即JavaScript采用的是静态作用域

作用域:是根据名称来查找变量的一套规则。前面说到了词法环境的确定是依据静态作用域,与之相对的动态作用域是在运行时确定的,静态作用域关心的是函数在何处声明,动态作用域关心的是函数从何处调用。js中this的实现就是采用动态作用域,所以this在js中只有执行了才能确定其指向。

举个例子:

var a = 2;
let x = 1;
const y = 5;
function foo() {
    console.log(a); //2
    function bar() {
        var b = 3;
        console.log(a * b);//2*3
    }
    bar();
}
function baz() {
    var a = 10;
    foo();
}
baz();

它的词法环境可以划分为下图:

image.png

使用伪代码模拟上面代码的词法环境:

// 全局词法环境
GlobalEnvironment = {
    outer: null, //全局环境的外部环境引用为null
    GlobalEnvironmentRecord: {
        //全局this绑定指向全局对象
        [[GlobalThisValue]]: ObjectEnvironmentRecord[[BindingObject]],
        //声明式环境记录,除了全局函数和var,其他声明都绑定在这里
        DeclarativeEnvironmentRecord: {
            x: 1,
            y: 5
        },
        //对象式环境记录,绑定对象为全局对象
        ObjectEnvironmentRecord: {
            a: 2,
            foo:<< function>>,
            baz:<< 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就是每层作用域之间的连接,当一个函数在其词法环境中没有找到要使用的变量时,就会根据outer到上一层的词法环境中寻找,直到到全局词法环境,outer为null,找不到就报错。

值得注意的是:outer和this指向不是一个东西,需要区分(虽说两者都有指向的意思...)

变量提升 vs 函数提升

所谓变量和函数提升,即V8引擎在进行词法分析时,会先将变量声明,然后赋值为undefined保存在其所属的词法环境中,对于函数,会在内存中创建函数对象,并且直接初始化为该函数对象。

举个例子:

var v='Hello World';
(function(){
    alert(v);
})()

这个输出结果为Hello World没有什么问题

var v='Hello World';
(function(){
    alert(v);
    var v='I love you';
})()

这个会输出undefined

为什么呢?

我一开始认为其会输出Hello World,就是忽略了变量提升,认为虽然函数体内的v没有被赋值,但是全局变量v被赋值了,所以会输出Hello World

但其实函数体内的v由于变量提升,会被赋值为undefined,而函数在寻找变量v时,会先在自己的词法环境中寻找,于是就找到了值为undefined的v,故输出undefined

this

ECMAScript规范: 严格模式时,函数内的this绑定严格指向传入的thisArgument。非严格模式时,若传入的thisArgument不为undefined或null时,函数内的this绑定指向传入的thisArgument;为undefined或null时,函数内的this绑定指向全局的this。

而thisArgument是在调用时才能确定的。

下面针对不同的情况,说明一下传入的thisArgument:

  • 普通函数调用,thisArgument就是undefined
  • 对象函数调用(也即作为对象的方法调用),thisArgument就是该对象
  • 构造函数调用,thisArgument就是构造出的实例化对象
  • call,apply,bind调用,显示的传递thisArgument
    • call:
    • apply
    • bind
  • 箭头函数调用

箭头函数调用时不会绑定this,他会去词法环境链上寻找this,所以箭头函数的this取决于它定义的位置,也即箭头函数会跟包裹着它的作用域共享一个作用域。但是箭头函数的this指向不能视作静态的,因为其共享的作用域的this指向是动态的。

所以确定箭头函数this指向需要两步:

  • 找到其共享的作用域
  • 确定该作用域的this指向
//所以对于箭头函数使用call显示绑定thisArgument没有用
//test1.js
var a=10;
const test =()=>{
    console.log(this.a)
}
test() //10
test().call({a:20}) //10
function testArrow(){
    var a=20;
    test();
}
testArrow() //10

//test2.js
var a =10;
function testArrow(){ 
    var test =()=>{
        console.log(this.a)
    };
    test();
}
testArrow() //10

//test3.js
var a =10;
let obj ={
    a:20,
    testArrow:testArrow
}
function testArrow(){ 
    var test =()=>{
        console.log(this.a)
    };
    test();
}
obj.testArrow() //20

  • 回调函数调用
window.a = 10
let obj = {
    a: 20,
    foo: function () {
        console.log(this.a)
    }
}
setTimeout(obj.foo, 0) //10

作为回调函数时,传递的是函数体,而不是函数名(引用类型...),obj.foo已经执行完成被弹出执行栈,此时执行栈中只有全局上下文,所以setTimeout函数体执行的时候this为全局对象。

避免这种情况可以采用两种方法:

  • 使用bind绑定this
  • 使用箭头函数
window.a = 10
function foo() {
  return () => {
      console.log(this.a) //该箭头函数与foo函数的作用域绑定
  }
}
const arrowFn = foo.call({a: 20})
arrowFn() // 20
setTimeout(arrowFn, 0) //20

后记

理清楚了这些概念之后,以后再看一些文档碰到这些名词就没那么懵逼了

当然,可能对于某些概念的理解仍然存在问题,欢迎各位指正。