ECMAScript 函数对象之调用

前言

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

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

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

代码开篇

本文以下列代码为例:

javascript 复制代码
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

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

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

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

联动变化和不联动变化:

javascript 复制代码
// 不联动变化, 非简单参数
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

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

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

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

参数包含表达式 ContainsExpression of formals

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

  • 有赋值
  • 有计算属性
javascript 复制代码
// 默认值, 有赋值行为
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是在全局代码下创建的,所以函数环境记录的外部环境是全局环境。

javascript 复制代码
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

javascript 复制代码
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。

javascript 复制代码
function t(a = eval('var c = 8'), b = () => c) {
  console.log(b())
}

t() // 8

环境记录关系如下:


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

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

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

javascript 复制代码
function t(a = eval('var c = 8'), b = c) {
  console.log(b)
}

t() // 8

步骤28 Why? 有参数表达式

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

比如下面的代码:

javascript 复制代码
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 冲突, 新的环境记录就是用于保存词法申明的。

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

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

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

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

javascript 复制代码
function test(){
  let a = 1;    // 顶级
  {
    let a = 1   // 非顶级
  }
}

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

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

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

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

javascript 复制代码
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申明
第二个 申明环境记录 步骤20 paramA,arguments 函数参数的绑定关系
第三个 申明环境记录 步骤28 varA,fn var申明和函数申明
第四个 申明环境记录 步骤30 letA let,const,class等词法申明

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

javascript 复制代码
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 形参 变量申明 函数申明
第二个 申明环境记录 步骤30 letA let/const等词法申明

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

示例代码如下。

javascript 复制代码
  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]]一层一层往外查找。

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

javascript 复制代码
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

相关推荐
Captaincc33 分钟前
为什么MCP火爆技术圈,普通用户却感觉不到?
前端·ai编程
海上彼尚1 小时前
使用Autocannon.js进行HTTP压测
开发语言·javascript·http
阿虎儿1 小时前
MCP
前端
layman05281 小时前
node.js 实战——(fs模块 知识点学习)
javascript·node.js
毕小宝1 小时前
编写一个网页版的音频播放器,AI 加持,So easy!
前端·javascript
万水千山走遍TML1 小时前
JavaScript性能优化
开发语言·前端·javascript·性能优化·js·js性能
Aphasia3111 小时前
react必备JS知识点(一)——判断this指向👆🏻
前端·javascript·react.js
会飞的鱼先生2 小时前
vue3中slot(插槽)的详细使用
前端·javascript·vue.js
小小小小宇2 小时前
一文搞定CSS Grid布局
前端
0xHashlet2 小时前
Dapp实战案例002:从零部署链上计数器合约并实现前端交互
前端