ECMAScript 函数对象之调用

204 阅读17分钟

前言

ECMAScript标准是深入学习JavaScript原理最好的资料,没有其二。

通过增加对ECMAScript语言的理解,理解javascript现象后面的逻辑,提升个人编码能力。

欢迎关注和订阅专栏 重学前端-ECMAScript协议上篇

代码开篇

本文以下列代码为例:

function test(paramA, paramB){
  "use strict"
  var varA =  "varA";
  let letA = "letA";

  function fn(){
    console.log("i am a function");
  }    
  console.log(paramA, paramB, letC, fn);
}
test("paramA","paramB");

知识准备

简单参数 IsSimpleParameterList

是不是简单参数。协议内容罗列了一大堆, 简单归纳就是参数没有如下行为:

  • 参数默认值
  • 解构
  • 剩余参数

在特异对象的章节有涉及到:如果是严格模式或者非简单参数模式,是不会和形参进行联动变化的。

联动变化和不联动变化:

// 不联动变化, 非简单参数
function func(a = 55) {
    a = 99; 
    console.log(arguments[0], a); 
}
func(10);   // 10 99

// 联动变化
function func2(a) {
    a = 99; 
    console.log(arguments[0], a); 
} 
func2(10);  // 99 99

函数严格模式下,参数默认值,解构,剩余参数等行为,均会报错。
原因,函数内部的严格模式,同时适用于函数体和函数参数。 但是,函数执行的时候,是先评估函数参数,然后再执行函数体,这样不合理的地方就是只有从函数体之中,才能知道参数是否应该以严格模式执行,这就已经晚了。

// 默认参数
function fn1(paramA, paramB = a) {
	'use strict'
	// ......code
}

// 解构
function fn2({propertyA, propertyB}) {
	'use strict'
	// ......code
}
// 剩余参数
const fn3 = (...rest) => {
	'use strict'
	// ......code
}

参数包含表达式 ContainsExpression of formals

你要是语法向的协议描述,难搞。 主要是两种行为

  • 有赋值
  • 有计算属性
// 默认值, 有赋值行为
function test(a = 1){
  console.log(a);
}
function test({a = 1}){
  console.log(a);
}
function test(a = (ccc=100)){
  console.log(a);
}

//  计算属性
var a = "a";
function test({ [a]: b }) {
    console.log(b);
}
function test({ [(ccc='a')]:b }){
  console.log(b);
}
test({a:100})

这里就有点有意思的推断了, 还记得简单参数吗

  • 无默认值
  • 无解构
  • 无剩余参数

而参数包含表达式的条件,稍作调整

  • 有赋值
  • 有计算属性 (解构时会出现)

而计算属性什么情况下会出现呢? 解构,对象解构和数组解构,均可能出现。

所以有下面的推断

  • 参数包含表达式 一定 不是简单参数

[[ThisMode]]

定义如何在函数的形式参数和代码体中解释this引用。

协议描述参见 Internal Slots of ECMAScript Function Objects的表格。

[[ThisMode]]lexical当前函数为箭头函,函数没有自己的this。
strict严格模式。完全按照函数调用所提供的方式使用,不会对this进行包装或者改变。
global值为 global 意味着当 null 或者 undefined 为 this 绑定值时会被解析为对全局对象的引用。

不管模式咋样,执行上下文都是通过 ResolveThisBinding 从环境记录中取获取 this的值,更多细节单独的文章老说。

流程回顾

再回顾一下上一节提到的大体流程

接着上个章节,说今天的主角 [[Call]]

[[Call]] 是什么

[[Call]]是协议内部方法,来识别是不是可以被调用,即函数。

在协议内部主要有两个提到

10.2.1 可以认为是开发者编写的函数调用逻辑, 10.3.1 是内置函数的调用。

大家都知道函数也是对象。怎么区别函数和对象的呢,和这个 [[Call]]大有关系。

Function也是Object, 协议内部是怎么区分的呢? 很简单, 如果Object内部方法 [[Call]], 那么就是函数。 协议内部对应有个方法叫做 IsCallable ,通常用来识别是不是可以被调用,即函数。

系统内置的函数,程序员编写的函数,以及前面两个章节的 <<Bound Function Exotic Objects>> 提到的绑定函数特异对象,均是如此。

[[Call]] 流程简析

借用上章节的图,[[Call]]对应流程图红色圈出的部分的逻辑

结合流程图,再一起分析流程。

[[Call]] ( thisArgument, argumentsList )

[[Call]] ( thisArgument, argumentsList )

  • 新建执行上下文,环境记录: 词法环境和变量环境,之后会被替换掉。
  • 根据调用函数的[[ThisMode]], 给函数环境记录绑定this的值。
  • 函数调用 ,底层调用 OrdinaryCallEvaluateBody ( F, argumentsList )
  • 恢复上下文
  • 返回结果

PrepareForOrdinaryCall ( F, newTarget )

  • 新建代码执行上下文,并进行一些设置
  • 初始化默认的环境记录,后期会被更改

上下文的词法环境记录和变量环境记录的初始化都是函数环境记录

  • 新上下文入栈,并且作为当前执行山上下文

OrdinaryCallBindThis ( F, calleeContext, thisArgument )

更多的细节在 <<函数的this之路>> 章节。

根据函数的内置属性[[ThisMode]] 和 传入的 thisArgument来设置环境记录的[[ThisValue]], 也就是后面函数调用的 this。

详情可以看带标注的下图

这里是在环境记录上绑定 this 的值, 函数体语句执行时,如果用到this,当前上下文会负责获取 会通过 ResolveThisBinding ( ) 函数调用时this的值,其逻辑为:

  1. 通过 GetThisEnvironment ( ) 获取具有有效 this 的环境记录 env。 这个过程会一直往上层的环境记录查找,当然最后的环境记录就是全局环境记录,其一定有 this
  2. 再通过 环境记录 env.GetThisBinding() 方法获取 this 的值。因环境记录类型不一样,逻辑也会不一样。
    • 函数环境记录 GetThisBinding ( ) 是直接读取内置属性 [[ThisValue]]
    • 全局环境记录 GetThisBinding ( ) 直接读取内置属性 [[GlobalThisValue]], 也就是常说的全局对象。

OrdinaryCallEvaluateBody ( F, argumentsList )

不同类型的函数的函数体走的逻辑是不一样的

函数类型逻辑方法
普通函数EvaluateFunctionBody
箭头函数EvaluateConciseBody
生成器函数EvaluateGeneratorBody
异步生成器函数EvaluateAsyncGeneratorBody
异步函数EvaluateAsyncFunctionBody
异步箭头函数EvaluateAsyncConciseBody

当然,本示例比较属于普通函数,走的是 EvaluateFunctionBody

不管是那种函数的函数体,其底层都会调用 FunctionDeclarationInstantiation ( func, argumentsList ) 进行函数申明实例化,在之后在进行不同的定制化的操作。

EvaluateFunctionBody 逻辑很简单,所谓的字数越少,事情越大,下面每一行都是非常复杂的行为。当然都可以简单的概括总结。

  1. 函数申明实例化
  2. 评估执行函数体的语句

FunctionDeclarationInstantiation ( func, argumentsList )

函数申明实例化,这是重中之重!!!!!!!!!!!!!!!!!!!!!。

整个过程有兴趣的同学可以好好看看,如果看着头疼,没关系, 了解一下下图的内容,就可以跳过本小节,重点是后面的 场景分析

简单说做了如下的操作操作(不表示按照顺序)

其主要作用就是按需创建环境记录,按需创建arguments对象,在环境记录上创建绑定关系,函数实例化等。

  1. 实例化各种内部用的变量

  1. 遍历var申明解析节点,对其中的函数申明节点做处理。

  1. 判断是不是需要创建 arguments对象

  1. 根据严格模式和是否有参数表达式,确定是否新建申明环境记录。

  1. 在环境记录上创建形参的绑定关系

  1. 按需初始化 arguments 对象

  1. 给函数参数对应的绑定关系初始化(赋值)

左边是执行前,右边是执行后

  1. 函数如果没有参数表达式的情况,在环境记录上实例化 var申明

  1. 如果有参数表达式,新建环境记录

  1. 按需创建新的函数申明环境。

创建绑定关系(实例化)和初始化是两个操作。 CreateMutableBinding ( N, D ) 只是实例化,其状态为uninitializedInitializeBinding ( N, V ) 初始化,可以设置值,状态转为initialized

  1. 根据词法申明的解析节点,在词法环境记录上创建绑定关系

  1. 设置私有环境记录, 实例化函数对象以及在变量环境记录中创建函数绑定关系。

Evaluation of FunctionStatementList

这个就是每个语句进行解析执行。略过。

函数申明实例化究竟创建了几个环境记录(重点,重点,重点)

函数申明实例化FunctionDeclarationInstantiation ( func, argumentsList ) 过程,有几处新建了申明环境记录,并有多处进行绑定,如果纯从文字上去理解,虽然有些地方有Note, 依然非常,非常,非常难于理解。

PrepareForOrdinaryCall ( F, newTarget ) 的时候创建了一个函数环境记录,本例的函数test是在全局代码下创建的,所以函数环境记录的外部环境是全局环境。

function test(paramA, paramB){
  "use strict"
  var varA =  "varA";
  let letA = "letA";

  function fn(){
    console.log("i am a function");
  }    
  console.log(paramA, paramB, letC, fn);
}
test("paramA","paramB");

此时的环境记录如下:

FunctionDeclarationInstantiation ( func, argumentsList ) 函数申明实例化的过程会根据一定条件新建环境记录:

  1. 步骤20时,如果非严格模式 + 有函数参数表达式
    新建申明环境记录作为上下文的 LexicalEnvironment,[[OuterEnv]]为上下文旧的 LexicalEnvironment

  2. 步骤28,如果有函数参数表达式
    新建申明环境记录作为上下文的 VariableEnvironment, [[OuterEnv]]为上下文旧的 LexicalEnvironment

  3. 步骤30 ,如果非严格模式
    新建申明环境记录作为上下文的 LexicalEnvironment [[OuterEnv]]为上下文旧的 VariableEnvironment

所以呢? 除了函数环境记录本身外, 还可能创建 0 - 3,确切的是0,1或者3 个申明环境记录,至于为什么要创建新的环境记录,挨个进行分析。

场景分析

从协议上看,新建环境记录的情况如下:

  • 非严格模式 + 有参数表达式 环境记录 +1 步骤20
  • 有参数表达式 环境记录 + 1 步骤28
  • 非严格模式 环境记录 +1 步骤30

如果按照 非严格模式 和 有参数表达式 两个条件,实际可以组合四种场景

序号非严格模式有参数表达式实际新建申明环境记录数量
1✔️✔️1 + 1 + 1 = 3
2❌ 即严格模式✔️不存此情况
3✔️1
4❌ 即严格模式0

严格模式只允许简单参数,函数参数如果有表达式(就一定不是简单参数),就一定不是简单参数, 所以不会出现2

步骤20 why? 非严格模式 + 有参数表达式

首先这种场景和函数参数表达式中有 eval 有关。

  • 函数严格模式不允许有 参数表达式,所以只可能出现在非严格模式
  • 无参数表达式就不可能有 eval调用, eval 出现在 参数默认值,计算属性等场景

函数参数表达式出现 eval 的条件就是:非严格模式 + 有参数表达式

比如下面的代码 eval就会产生新的var申明 paramC

function test(paramA = eval("var paramC = 'paramC'"), paramB =  paramC){
  var varA =  "varA";
  let letA = "letA";

  console.log(paramA, paramB, varA, letA);
}
test(); // undefined 'paramC' 'varA' 'letA'

接下来就是 eval 的直接调用了,eval的调用比较复杂,就简单说 非严格模式 下的直接调用, 这种情况下

  • eval 被调用时使用的上下文是 调用eval函数的执行上下文,即当前执行上下文,不会新建执行上下文
  • eval 被调用时 var 申明的变量会绑定到 调用eval函数的上下文的环境记录中

调用 eval的上下文,就是test函数对应的上下文,在函数申明初始化之前,环境记录和词法环境记录都指向函数环境, 关系图如下:

所以 eval生成var申明的绑定关系就会保存到了 函数环境记录。

步骤20也有注释, 需要一个独立的上下文来保存形参上因为eval直接调用而产生的绑定关系,并且这个环境应该在形参环境所在环境的外部。

在步骤28的时候,

  • 你会知道形参也会独立保存到一个新的环境记录,
  • 而形参环境记录其实又可能从 形参eval产生变量申明和函数申明所以在环境记录 中查找绑定关系的。

例如如下函数:b在调用的时候,会从形参eval产生的环境记录中去查找c。所以才会输出结果为8。

function t(a = eval('var c = 8'), b = () => c) {
  console.log(b())
}

t() // 8

环境记录关系如下:


所以,简单一句话: 利用函数环境记录保存eval创建的var申明,新建一个申明环境记录去保存 函数环境记录原来该保存的申明。

是什么时候建立对应的绑定关系呢?实际是在后面的步骤25.56。

有兴趣的同学,可以研究一下:

function t(a = eval('var c = 8'), b = c) {
  console.log(b)
}

t() // 8

步骤28 Why? 有参数表达式

需要一个单独的环境记录,来确保由形式参数列表中的表达式创建的闭包(函数),不能访问函数体中声明。

比如下面的代码:

function foo1(a, b1 = () => c ) {
  var c = 1
  console.log(b())
}
foo(2) // ReferenceError: c is not defined


function foo2(a, b2 = () => a ) {
  var c = 1
  console.log(b())
}
foo(2) // 2
  • 闭包(函数)b1 是不能访问 函数 foo1 函数体内的变量申明 c
  • 闭包(函数)b2 是可以访问 函数 foo2 的函数参数 a

为了实现这种隔离, 如果 foo1 函数体内的变量申明 和 foo1 函数的参数 保存在同一个环境,显然是不行的,因此这里才需要一个环境记录来达到这种隔离的目的。

新建一个申明环境记录来单独保存函数形参数的绑定关系,当 b1 执行时,从形参所在的环境记录去查找绑定关系,而不是从 foo1 函数体内部变量申明保存的环境去查找。

小结: 步骤28创建的申明环境记录被用于保存形参的绑定关系,新建一个申明环境记录、

这一步之后的环境记录关系图如下:

步骤30 why ? 非严格模式

非严格模式的函数用一个单独的环境记录来保存顶级词法申明,因此 eval的直接调用可以确定 eval 代码引入的任何 var 作用域声明是否与预先存在的顶级词法作用域声明冲突。

举个例子, eval产生的 var 申明 a 与函数顶级词法申明 a 冲突, 新的环境记录就是用于保存词法申明的。

function test(){
    let a = 'letA'
    eval(`var a = 'varA'`)
}
test();  // Uncaught SyntaxError: Identifier 'a' has already been declared

这里需要了解的是,词法环境记录和变量环境记录可能是同一个环境记录,比如:

  • 比如全局顶层代码执行时
  • 严格模式 + 无函数参数表达式的函数执行时,后面的场景4会提到。

所以是因为要用单独的环境记录来保存顶级词法申明,注意顶级,因为函数内部可以有多级词法申明, 下面的两个a属于不同level的词法申明。

function test(){
  let a = 1;    // 顶级
  {
    let a = 1   // 非顶级
  }
}

所以这里新建的的申明环境记录就是用于保存函数顶级词法申明,而步骤28创建申明环境的用于函数内部 var明和函数申明。

新建申明环境记录之后,关系结构如下:

下面的场景示例,不考虑参数表达式中有eval的情况。

场景1: 非严格模式 + 有函数参数表达式 (新建3个环境记录)

function test(paramA = eval('var evalC = "evalC"')){
  var varA =  "varA";
  let letA = "letA";

  function fn(){
    console.log("i am a function");
  }

  console.log(paramA, evalC, varA, letA, fn);
}

test();
// undefined 'evalC' 'varA' 'letA' ƒ fn(){
//    console.log("i am a function");
//  }
  1. 步骤20时,如果非严格模式 + 有函数参数表达式,到步骤28之前环境记录关系如下:

  1. 步骤28,如果有函数参数表达式,30步骤之前环境记录关系如下:

  1. 步骤30 ,如果非严格模式, 到函数申明实例化完毕

小结四个环境记录的作用, 从函数申明环境环境倒推

序号类型创建时机本示例保存的申明保存的申明绑定关系
第一个函数申明环境函数准备调用evalC参数表达式 eval创建的var申明
第二个申明环境记录步骤20paramA,arguments函数参数的绑定关系
第三个申明环境记录步骤28varA,fnvar申明和函数申明
第四个申明环境记录步骤30letAlet,const,class等词法申明

场景3: 非严格模式 + 无函数参数表达式 (新建1个环境记录)

function test(paramA, paramB){
  var varA =  "varA";
  let letA = "letA";

  function fn(){
    console.log("i am a function");
  }

  console.log(paramA, paramB, letC, fn);
}
test("paramA","paramB");

(步骤30 ,如果非严格模式) , 到函数申明实例化完毕

小结2个环境记录的作用, 从函数申明环境环境倒推

序号类型创建时机本示例保存的申明保存的申明绑定关系
第一个函数申明环境函数准备调用paramA, paramB
arguments
varA
fn
形参
变量申明
函数申明
第二个申明环境记录步骤30letAlet/const等词法申明

场景4: 严格模式 + 无函数参数表达式 (新建0个环境记录)

示例代码如下。

  function test(paramA, paramB){
    "use strict"
    var varA =  "varA";
    let letA = "letA";

    function fn(){
      console.log("i am a function");
    }    
    console.log(paramA, paramB, letC, fn);
  }
  test("paramA","paramB");

步骤20,步骤28, 步骤30均没有走,没有新建环境记录。

小结1个环境记录的作用, 从函数申明环境环境倒推

序号类型创建时机本示例保存的申明保存的申明绑定关系
第一个函数申明环境函数准备调用paramA, paramB
arguments
varA
fn
letA

形参
变量申明
函数申明
let/const等词法申明

小结

  1. 严格模式 + 参数表达式 这两个条件额外的环境记录的创建
    1. 非严格模式 + 有参数表达式 3
    2. 非严格模式 + 无参数表达式 1
    3. 严格模式 + 无参数表达式 0
  2. 函数申明环境记录,会 根据 严格模式 + 参数表达式 组合模式,保存不同形式的申明绑定关系。
  3. 执行上下文词法环境和变量环境 有可能相等

函数新建三个环境记录示例

上下文通过 ResolveBinding ( name [ , env ] ) 来查找标志符的并生成引用记录,绝大多情况都是没有传 env参数时(解构,剩余参数等情况会传递), 就默认会从执行上下文的词语环境记录开始查找。 之后就会通过环境记录的 [[outerEnv]]一层一层往外查找。

如下示例标志符的查找是从执行上下文的词语环境开始的。

function test(paramA = eval('var evalC = "evalC"')){
  var varA =  "varA";
  let letA = "letA";

  function fn(){
    console.log("i am a function");
  }

  console.log(paramA, evalC, varA, letA, fn);
}

test();

其执行上下文和环境记录的关系图如下:

console.log(paramA, evalC, varA, letA, fn) 调整为 console.log(evalC, test.name);这里就牵涉两个标志符的查找。

实际上画出了关系图,查找嘛,三岁小孩子都会。

  • evalC

  • test

引用

步骤20, eval

eval declaration instantiation when calling context is evaluating formal parameter initializers

Normative: Eliminate extra environment for eval in parameter initializers #1046

步骤28,

Where are arguments positioned in the lexical environment?

How to check if a variable is an ES6 class declaration?

FunctionDeclarationInstantiation