自虐系列之通过最新规范理解执行上下文

415 阅读15分钟

这是我第一次发分享类文章,瑟瑟发抖,可能会有一些表述的不准确或者有些地方难以理解,请大家指出,我会好好改的。

至于为什么叫做自虐系列,是我想从最底层解读一些大家常见的知识点,因为在学习过程中,我发现去背别人得出的结论很难以记得很深刻,而且遇到一些特殊情况也不能很好地解释,关键还是得看底层实现和定义,可是翻到底层去学习理解往往是一个痛苦的过程,因为资料往往是英文的,而且网上可参考的资料很少,就算有的话也有可能会存在疏漏,毕竟一块知识太大,一两篇博客不可能讲的面面俱到。

至于我分享的文章也是如此,大家应该都要抱有怀疑的态度,有自己的想法,遇到不懂去查对应的规范或者讲的很好的博客。接下来是自虐系列第一篇————执行上下文

执行上下文是JavaScript中很重要的一块知识,也是JavaScript的基础之一,原本我以为我已经够理解执行上下文,但是某天无意中看到一道题,我发现我并未非常理解执行上下文。

题目如下:

function sidEffecting(ary) {
  ary[0] = ary[2];
}
function bar(a, b, c) {
  c = 10;
  sidEffecting(arguments);
  return a + b + c;
}
bar(1, 1, 1);
// 输出:21

输出21是因为arguments对象与函数参数进行绑定,对arguments改动会影响到参数的值。

可是我答错了,我以为会输出12。这反映我并未足够理解arguments,由于arguments对象是在创建执行上下文中创建的, 也反映了我对该部分知识还存在盲区,于是我决定重新再理一遍执行上下文的创建过程。

我之前对于函数的执行上下文的创建过程的认识是这样的:

1、函数的执行上下文创建,并压入调用栈
2、复制函数的[[scope]]内部属性创建作用域链
3、以arguments对象创建变量对象VO,并把函数内声明的变量以及函数提升并添加到变量对象内,
   声明的变量初始化为undefined,函数初始化为声明的函数内容
4、变量对象VO激活为活动对象AO,并把AO添加到作用域链前端
5、确定this的指向
6、执行上下文创建完成

可能有些基础同学还看不太懂上面的过程,没关系,我们来重新梳理一下,不过你得先有一些基础概念,例如执行栈(调用栈)、arguments、函数内的this、执行上下文、作用域链。

但是对于上面的过程还是有些概念没有深入说明,例如augrments怎么创建在什么时候创建、this怎么确定、[[scope]]是什么怎么来的等等。

基础概念补充:

执行上下文:每个函数都有一个执行上下文,在执行函数时创建,用于保存该函数内声明的变量和函数,
            以及保存父级执行上下文的引用,用于到外部查找该函数内不存在的变量和函数。

执行栈:每当JS执行一个函数时,都会为其创建一个执行上下文,由于JS是单线程的,同一个时间只能运行
        一个执行上下文,此时就需要一个栈结构保存执行上下文信息,所以每创建一个新的执行上下文都
        会被压入栈顶,销毁执行上下文时出栈。

arguments对象:函数执行时可以用arguments对象获取到调用该函数时传递的参数。

this:函数执行前给this赋值,指向调用该函数的对象。

作用域链:在一个函数内查找某个变量时,会先从该函数内部查找,找不到就到父级作用域找,一直找到全局作用域,
          找不到就会返回undefined错误

让我们重新来梳理一遍执行上下文

执行上下文类型

  • 全局执行上下文:只有一个,浏览器中全局对象是window对象,this指向这个对象

  • 函数执行上下文:函数被执行时创建,每次执行函数都会创建一个新的执行上下文

  • Eval函数执行上下文:运行在eval函数中代码的执行上下文,已不推荐使用,下面不再做说明

执行栈(调用栈)

执行栈是一个后进先出的栈结构,储存代码执行期间创建的执行上下文。

首次执行js代码时,全局执行上下文会被创建并压入执行栈栈底。

每当有新的函数被调用执行,新的执行上下文会被创建并压入执行栈内。

执行上下文的创建

执行上下文在执行函数时创建,例如我定义了一个函数 function foo(){},在我调用该函数时 foo();foo的执行上下文才会创建

执行上下文的创建分为2个阶段:

  1. 创建阶段

  2. 执行阶段(可以理解为函数执行阶段)

创建阶段(从这里开始是重点)

创建执行环境(Execution Context),也就是创建执行上下文,主要做的是这三件事:

  • 创建词法环境:是一个词法环境对象,包含let,const,函数声明

  • 创建变量环境:是一个词法环境对象,只包含var声明的变量

  • this 绑定

概念补充:

词法环境对象:由一个环境记录项和可能为null的outer构成,全局对象的词法环境的outer就是null
环境记录项:记录当前执行上下文声明的变量和函数
outer:指向外层词法环境对象

简单的理解,可以看下面伪代码:

执行上下文 = {
    词法环境: < Environment Object > // 词法环境对象
    变量环境: < Environment Object > // 词法环境对象
    this: Object // 调用该函数的对象
}

再来看看词法环境对象< Environment Object >的伪代码,如下:

EnvironmentObject = {
    outer: 上一级执行上下文, // outer
    environmentRecord: { 当前执行上下文声明的变量和函数 } // 环境记录项
}

结合一下,一个执行上下文的具体结构就像这样:

执行上下文 = {
    词法环境: {
        outer: 父级执行上下文, 
        environmentRecord: { 当前执行上下文let,const声明的变量和函数保存在这里 } // 环境记录项
    },
    变量环境: {
        outer: 父级执行上下文, 
        environmentRecord: { 当前执行上下文 var 声明的变量保存在这里 } // 环境记录项
    },
    this: Object // 调用该函数的对象
}

下面会有详细例子作说明。

变量对象(VO)意思与这里描述的相似,但这是ES3的标准,从ES5开始已改为词法环境、变量环境, 由于本文只关注最新规范,不做ES3规范内容的详细介绍。

词法环境对象

下面就来详细说一说这个词法环境对象

  • 环境记录:储存变量和函数的位置,主要有两种类型。

    • 声明式环境记录(Decarative Environment Record):记录函数定义、变量声明、try catch等等
    • 对象式环境记录(Object Environment Record):记录全局对象Global的属性、with内创建的变量等等
  • 外部词法环境引用 outer:指向父级执行上下文

对于全局执行上下文、函数执行上下文、模块执行上下文还有eval语句的执行上下文,它们创建词法环境对象时,其对象的环境记录类型是不同的,就好像我要创建一个动物Animal的对象,也要给定动物的类型一样。

常见的环境记录:

  • 全局环境记录( Global Environment Records ): 拥有 声明式环境记录 和 对象式环境记录
  • 函数环境记录(function Environment Records):只有声明式环境记录
  • 模块环境记录(Module Environment Records):只有声明式环境记录
  • eval 环境:只有声明式环境记录

简单的理解:全局执行上下文有声明式和对象式两种记录表,而其余的只有一种声明式记录表。

浏览器中只有全局对象拥有 声明式环境记录 和 对象式环境记录,在全局环境下通过 let、const 声明的变量
会储存在声明式记录(Decarative Record)中,而var、function、async、Generator生成器等声明的会存储在
对象记录(Object Record)。而对象记录的内容可以通过 window.xxx 访问获取,声明式环境记录却不可以。

以上原因就可以解释:为什么全局 let、const 声明的变量不能通过 window.xxx 访问,而 var、function、async、
Generator生成器等声明的变量可以。

举个例子

var name = 'ccc';
function say(name) {
    const b = '123';
    function x() {
        const content = name + 'test' + b;
        return content; 
    }
    x();
}
let content = say(name);

首先是创建全局执行上下文,全局环境记录伪代码如下:

GlobalEnvironmentRecord = {
    GlobalThisValue: <Global Object>, // 全局环境this指向自身
    
    EnvironmentRecord: { 
        Type: 'Object', // 对象式环境记录
        outer: < null >, // 全局执行上下文的outer为null
        
        say: < func >,
        name: undefined
    },
    
    EnvironmentRecord: { 
        Type: 'Declarative', // 声明式环境记录
        outer: < null >, // 全局执行上下文的outer为null
        
        content: < uninitialized > // let 创建的变量未进行初始化
    }
}

全局执行上下文创建成功,并压入执行栈底。

然后执行 say(name),创建say函数的执行上下文:

FunctionExecutionContext = {
    ThisBinding: <Global Object>, // this 指向全局对象
    LexicalEnvironment: {    // 词法环境
        EnvironmentRecord: {
            Type: 'Declarative', // 声明式环境记录
            outer: < Global Lexical Environment >, // outer 指向父级执行上下文
            x: < func >,
            b: < uninitialized >, // const 创建的变量未进行初始化
            Arguments: {0: 'ccc', length: 1}
        },
    },
    VariableEnvironment: {   // 变量环境
        EnvironmentRecord: {
            Type: 'Declarative',  // 声明式环境记录
            outer: < Global Lexical Environment >
        }
    }
}

say的执行上下文被压入执行栈。执行x(),创建执行上下文:

FunctionExecutionContext = {
    ThisBinding: <Global Object>, 
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: 'Declarative',
            content: < uninitialized >, // const 创建的变量未进行初始化
            Arguments: {length: 0}
        },
        outer: < say Lexical Environment > 
    },
    VariableEnvironment: {
        EnvironmentRecord: {
            Type: 'Declarative'
        },
        outer: < say Lexical Environment >
    }
}

x 函数的执行上下文压入执行栈,此时执行栈从栈顶到栈底的顺序为:x FunctionExecutionContext -> say FunctionExecutionContext -> GlobalExecutionContext

创建函数执行上下文的过程中会创建 Arguments 对象,下面我们看看 Arguments 对象规范中如何描述创建过程。

arguments 对象的创建

重新撸了一下规范,先来说说arguments对象的创建:

提取规范中关键逻辑后,中文简化版:

1. 创建一个变量 len 值为 argumentsList 的长度,其中argumentsList是传入函数的实际参数
2. 创建一个新的对象 obj,并初始化这个对象的[[GetOwnProperty]]、[[Get]]、[[Set]]、[[DefineOwnProperty]]、
   [[Delete]],[[Prototype]] 设置为 Object.prototype
3. 设置 index = 0,index < len,开始循环
4. 调用 obj 的 [[DefineOwnProperty]] 方法,将argumentsList的值设置为 obj 对象的值,相当于 
   obj[index++] = argumentsList[index++] 
5. 循环结束
6. 然后设置 obj.length = len,len 的属性像这样:
   { [[Value]]: len, [[Writable]]: true,  [[Enumerable]]: false, [[Configurable]]: true }
7. 设置形参列表为实参列表对应的值,如果实参列表 argumentsList 的长度小于形参长度,形参多余的元素不做处理
8. 如果此时不是处于严格模式,将此前创建的 obj 对象的各个字段与形参绑定起来
9. 如果不是严格模式,设置 obj.callee 为当前函数
10. 如果是严格模式,设置 obj.callee 和 obj.caller 的[[Get]]、[[Set]]为抛错函数
11. 返回 obj

总结:

arguments 创建会以实参进行初始化,并与形参绑定关系,同时也会创建 callee 属性,指向函数自身

this 绑定

看看最新规范 12.3.4.2 关于 this 的内容:

1. If Type(ref) is Reference, then
    a. If IsPropertyReference(ref) is true, then
        i. Let thisValue be GetThisValue(ref).
    b. Else the base of ref is an Environment Record,
        i. Let refEnv be GetBase(ref).
        ii. Let thisValue be refEnv.WithBaseObject().
2. Else Type(ref) is not Reference,
    a. Let thisValue be undefined.

先看看什么是 Reference:

引用类型用来说明 delete、typeof、赋值运算符这些运算符的行为。

一个引用是个已解析的命名绑定。它由三部分组成:基值、引用名称和一个严格引用标志(布尔值)。 
基值是 undefined、Object、Boolean、String、Number、环境记录项中的任意一个。基值是 undefined
表示此引用不可以解析为一个绑定。

引用名称是一个字符串。

简单来说,用伪代码实现如下:

var foo = 1;

// 变量 foo 对应的Reference是:
var fooReference = {
    base: EnvironmentRecord,
    name: 'foo',
    strict: false
};

引用类型是规范定义的,不存在于JavaScript实际代码中,无法被打印出来。

对于函数调用来说,举例foo(),引用上述规范的话,ref其实指的就是 () 左边部分的内容,这里的ref也就是foo,这里会先判断foo 是不是引用类型。

然后再去执行IsPropertyReference,再看看该函数内容:

If either the base value component of V is an Object or HasPrimitiveBase(V) is true, return true; otherwise return false.

HasPrimitiveBase又是什么呢?

If Type(V's base value component) is Boolean, String, Symbol, or Number, return true; otherwise return false.

结合以上两个函数,IsPropertyReference的作用就是判断该引用的base值是否为 Object、Boolean、String、Symbol 或 Number,是就返回 true。

对应上述例子,如果foo是引用类型,且IsPropertyReference条件成立,则 this 为 fooReference.base

再来看看后面的判断条件:

b. Else the base of ref is an Environment Record,
        i. Let refEnv be GetBase(ref).
        ii. Let thisValue be refEnv.WithBaseObject().

如果 base 是一个环境记录,调用WithBaseObject,举个例子:

function abc() {    
    function ggg() {}
    ggg();
}
abc();

上述代码中ggg()abc()的 ggg 和 abc 对应引用的 gggReference.base、abcReference.base 都为环境记录。

WithBaseObject规范说明返回的只会是 undefined,所以 this 为 undefined

剩下的最后一条判断:

2. Else Type(ref) is not Reference,
    a. Let thisValue be undefined.

如果 foo 不是引用类型, this 为 undefined

然后把 this 值传递到创建执行上下文的函数,进入创建执行上下文阶段,为该执行上下文绑定 this 值,创建执行上下文时会判断:如果处于非严格模式,如果this为null或者undefined,将this绑定为全局对象。

拓展练习:

var value = 1;

var foo = {
  value: 2,
  bar: function () {
    return this.value;
  }
}

//示例1
console.log(foo.bar()); // 2
//示例2
console.log((foo.bar)()); // 2
//示例3
console.log((foo.bar = foo.bar)()); // 1
//示例4
console.log((false || foo.bar)()); // 1
//示例5
console.log((foo.bar, foo.bar)()); // 1

这里需要对操作符有一些了解,像这里出现了() = || , 等操作符,后三个会对这里的表达式进行 [[GetValue]]取值操作,得到具体的值进行计算。

示例二的原因:() 组操作符没有对 foo.bar 进行 [[GetValue]] 计算,返回的是其引用本身,所以还是引用。

后面三个示例都是由于操作符调用了[[GetValue]],最终的结果已经不是 Reference ,所以 this 初始化为 undefined,由于是非严格模式,this 绑定为全局对象。

[[scope]]内部属性

还得看文档...... o(╥﹏╥)o

在最新规范文档里面,[[Scope]] 去掉了,多出来了 [[Environment]],意思稍有不同。

详细来说,函数(非箭头函数)F创建后,会为该函数创建一个函数环境记录(function Environment Records), 每个环境记录(Environment Records)都会有其[[OuterEnv]]内部属性,该属性会在环境记录创建时确定下来, 而 函数环境记录(function Environment Records) 创建时[[OuterEnv]]指向了F.[[Environment]]

那就变成了这样(伪代码):

// 创建函数 F
var F = createFunctionObject();

// 将 F 的 [[Environment]] 指向当前正在执行的上下文
F.[[Environment]] = runningExecutionContext;

// 创建一个函数环境记录 env
var env = NewFunctionEnvironmentRecords();

// 将 env.[[OuterEnv]] 指向 F.[[Environment]],也就是 env.[[OuterEnv]] 指向了当前正在执行的上下文
env.[[OuterEnv]] = F.[[Environment]];

// F 的词法环境赋值为 env
F.calleeContext.LexicalEnvironment = env;

// F 的变量环境赋值为 env
F.calleeContext.VariableEnvironment = env;

由上面可以看出,在函数创建时已经确定了外部的执行上下文,并保存到函数内部,这个过程跟 ES3 中描述的 作用域链 [[Scope]] 是类似的,可以理解为描述改变了。

最后等到函数执行时,将函数内声明的变量函数还有创建出来的Arguments对象放到词法环境和变量环境中。

简单说说箭头函数

从上面我们知道了普通函数的创建执行上下文需要绑定 this 和 创建 Arguments 对象,但是在箭头函数的执行上下文创建过程中并没有这两步操作

所以箭头函数的 this 需要通过词法环境往 outer 去找,找到外层词法环境的 this,而函数的词法环境的 outer 往往是在函数创建时已经确定,所以箭头函数执行时的 this 并不是执行时外部的 this ,而是函数定义时外部词法环境的 this。

同时函数对象拥有[[Constructor]] [[Call]]两个内部属性,分别对应构造器和函数的执行,箭头函数是不具备[[Constructor]] 属性的,因此无法当做构造函数,也就无法用 new 创建对象,也就没有prototype属性。

最后

终于松了一口气,终于梳理完了,读规范的过程很痛苦,但是经过这次梳理,我已经摸到了读规范的套路, 后面就会越来越顺利了,还是得坚持,不能放弃鸭~

附上我自己的github:github.com/jjaimm/fron…

参考资料