人人都能读标准14. 底层算法:函数的创建与执行
Posted 水鱼兄
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了人人都能读标准14. 底层算法:函数的创建与执行相关的知识,希望对你有一定的参考价值。
本文为《人人都能读标准》—— ECMAScript篇的第14篇。我在这个仓库中系统地介绍了标准的阅读规则以及使用方式,并深入剖析了标准对javascript核心原理的描述。
模块化是程序员应对系统复杂性的一个重要手段,而函数就是模块化的基石。在ECMAScript中,每个函数都是一个对象,该对象把一段代码封装在一个词法环境中,并支持这段代码的重复调用。
一个函数的生命周期大概会经历「创建函数」 -「 调用函数」两个阶段。在本节,我会先给你展示,从标准的角度看,这两个阶段的一般过程是怎样的。然后我们会使用这两个阶段的算法找到普通函数与箭头函数的区别。最后,我会讲一种特别的函数 —— ECMAScript内置函数,并解释它与开发者编写的函数在这两个阶段上有什么差异。
创建函数
创建一个函数,有两种方式:一种是使用函数声明语句,另一种是使用函数表达式。 前者会在环境声明实例化的时候被创建,后者则在函数表达式所在的语句实际执行的时候才被创建,如以下代码所示:
function a() // 函数声明语句,环境声明实例化时创建函数对象
let b = function() // 内嵌函数表达式,语句执行时才创建函数对象
在标准的第15章,列出了所有ECMAScript函数的类型,不考虑class与class方法,一共有6种。在这里,你还可以找到不同函数的核心创建算法,我用下表给你总结:
使用声明语句与使用表达式创建函数对象最主要的区别就是对于函数标识符的处理。 我们都知道,函数声明语句的标识符会绑定在对函数进行声明实例化的环境记录器中,而带有标识符的函数表达式,在创建函数前会新建一个声明式环境记录器,单独用来绑定函数标识符,进而使得函数体内可以使用这个标识符,以此获得函数的递归能力。 下图是普通函数声明语句(左侧)与带有标识符的函数表达式(右侧)创建算法的对比,红色框框出的就是函数表达式新建声明式环境记录器并绑定函数标识符的逻辑片段:
因此,下面的代码:
let a = function b()
let c = 1
// ①
b()
a()
执行到位置①时,调用栈如下图的样子,且此时,环境中b === a
。
不管是函数声明语句还是函数表达式,也不管是什么类型的函数,仔细对比它们的创建算法,你会发现这些算法最终都是殊途同归 —— 通过抽象操作OrdinaryFunctionCreate()来完成函数对象的创建(如上面算法图中紫色下划线部分所示)。
OrdinaryFunctionCreate()
是用来创建函数对象的核心抽象操作:它会先基于参数中传入的函数原型创建一个对象,然后初始化这个对象的内部方法和内部插槽。 如下图所示:
ECMAScript一共有4种函数原型:
- %Function.prototype%:普通函数、箭头函数的原型;
- %Generator.prototype%:Generator函数的原型;
- %AsyncGeneratorFunction.prototype%:AsyncGenerator函数的原型;
- %AsyncFunction.prototype%:Async函数、Async箭头函数的原型;
不同类型的函数在创建时,会给OrdinaryFunctionCreate()
传入各自的函数原型(图中黄色标记的functionPrototype),抽象操作OrdinaryObjectCreate会创建函数对象,并把对象的[[Prototype]]
内部插槽设置为传入的functionPrototype。
完成对象的创建后,OrdinaryFunctionCreate()
开始初始化其他的内部插槽和内部方法,一些比较重要的是(红色圈圈出部分):
- (步骤3):使用标准10.2.1定义的逻辑实现
[[Call]]
方法,这也表示使用OrdinaryFunctionCreate()
创建的函数对象会归类为”ECMAScript普通对象“,如我们在13.对象类型提及的一样。 - (步骤5):使用
[[FormalParameters]]
内部插槽记录函数参数; - (步骤6):使用
[[ECMAScriptCode]]
内部插槽记录函数体; - (步骤7、8):判断函数是否处于严格模式,把结果保存在
[[Strict]]
内部插槽; - (步骤9、10、11):设置函数的
[[ThisMode]]
内部插槽,该内部插槽对this值的解析有重要影响。 - (步骤13):使用
[[Environment]]
记录函数的闭包环境,这个内部插槽在10.作用域链早已登场。
关于函数内部插槽的完整列表以及含义,可见这里。
完成OrdinaryFunctionCreate()
的执行后,创建一个函数就只剩下一些收尾工作需要做了。比如设置函数名、把函数变成一个构造器等等,我这里就不再展开具体的细节内容。
接下来,就让我们把注意力放在函数生命周期的第二个阶段 —— 调用函数。
调用函数
函数的调用一般由函数调用表达式触发。而所有的函数,不管什么类型,调用结果都会殊途同归 —— 触发内部方法[[Call]]
。
[[Call]]方法是函数执行的核心逻辑。 我们在以上的创建阶段已经看到,由开发者创建的函数,其[[Call]]
方法的逻辑使用标准10.2.1中的定义:
[[Call]]
方法的关键步骤我已经为你标记出来了:
PrepareForOrdinaryCall() —— 为函数调用作环境准备,此时会:
- 创建新的ECMAScript代码执行上下文;
- 创建函数环境记录器
- 初始化执行上下文的环境组件
- 把执行上下文压入调用栈栈顶;
OrdinaryCallBindThis() —— 在函数环境记录器中绑定this值;
OrdinaryCallEvaluateBody() —— 执行函数体,此时会:
- 进行函数声明实例化(FunctionDeclarationInstantiation());
- 执行对应函数类型的逻辑
函数执行完毕,把执行上下文从栈内弹出。
如果你顺利完成了原理篇的学习,这里大部分内容对你来说已经算非常熟悉了。唯一新鲜的可能是this值的绑定,而this值我需要使用另外一整个大节来进行阐述。
此外,在执行函数体的时候,虽然所有函数都会进行函数声明实例化,但不同类型的函数在实际执行语句时,具体逻辑可能会有所不同:
- 普通函数和箭头函数就是依次执行函数体内的语句;
- Generator函数、AsyncGenerator函数会创建并返回一个generator,此时压根不会执行函数体内的语句;
- Async函数会创建一个promise作为函数的返回值,并依次执行函数体内的语句直到遇上await…
沿着抽象操作OrdinaryCallEvaluateBody() ,你就可以找到具体函数语句的执行细节:
Generator函数的执行细节会在16.生成器中展开,async函数的执行细节会在18.promise中展开。
应用:找出普通函数与箭头函数的区别
有了上面的基础,我们就可以开始做一些特别的事情。举个例子,我们可以找出普通函数与箭头函数的区别。
首先,由于箭头函数只有表达式的写法,所以箭头函数不能像普通函数一样被提升。
其次,在创建函数阶段,对比普通函数的创建算法(下图左侧)以及箭头函数的创建算法(下图右侧),你会发现有两个重要的不同地方:
- 在调用OrdinaryFunctionCreate()的时候,两者传入的ThisMode参数不同。普通函数传入的是
non-lexical-this
,箭头函数传入的是lexical-this
,lexical-this
会使得函数对象的[[ThisMode]]
内部插槽赋值为lexical
。在19.this值解析你会看到,当[[ThisMode]]
为lexical
时,不允许绑定this值。因而,箭头函数没有this值。 - 普通函数会调用MakeConstructor()把对象变成构造器,而箭头函数没有。所以,箭头函数不是构造器,没有prototype属性,在箭头函数头上使用new表达式会报错。
最后,在调用函数阶段,箭头函数和普通函数都会进行函数声明实例化,而此时,[[ThisMode]]
内部插槽又出来发挥作用了:当[[ThisMode]]
为lexical
时,不需要创建函数的arguments对象(argumentsObjectNeeded
为false)。所以,箭头函数没有arguments对象。
基于以上,箭头函数是一种轻量的函数,不能被提升、没有this值、即不是构造器、也没有arguments对象。
ECMAScript的内置函数
上面我们讲的函数,都是由开发者自己创建的函数,在ECMAScript中,这类函数称为ECMAScript函数对象(ECMAScript Function Objects)。除了这类函数,ECMAScript还有另外一类函数,称为内置函数对象(Build-in Function Objects) 。
内置函数即我们在13.对象类型中提到的“内置对象”的一个分支。内置函数由程序的运行环境提供,帮助完成语言的基础功能。一些内置函数对象的例子包括:
- 全局对象上的方法:如
eval()
、isFinite()
、isNaN()
、parseFloat()
、parseInt()
等等; - 各种构造器:如
Array
、Number
、Date
、Map
等等; - Promise提供的
resolve()
、reject()
函数。(注意不是Promise.resolve
、Promise.reject
方法,而是new Promise((resolve, reject) => )
的两个参数)
ECMAScript函数与内置函数的主要区别是:
-
逻辑的制定方不同。内置函数的逻辑由标准规定,而ECMAScript函数对象的逻辑则由程序员提供。
-
由第一点导致:在函数创建阶段,两种函数创建方式不同。内置函数使用抽象操作CreateBuiltinFunction()创建。而ECMAScript函数由上面提到的OrdinaryFunctionCreate()创建。
你可以在创建固有对象的抽象操作CreateIntrinsics()上看到
CreateBuiltinFunction()
的使用。当CreateIntrinsics()启动的时候,所有标准化的内置函数都会通过CreateBuiltinFunction(steps,...)
创建,而这里的step
参数,表示的是函数的逻辑,这个逻辑由标准定义。 -
由第二点导致,在函数执行阶段,两种函数的
[[Call]]
方法逻辑也不同。内置函数对象的[[Call]]内部方法在标准10.3定义,你可以在下图看见,它创建的只是普通执行上下文而不是ECMAScript代码执行上下文(第3步),它不需要创建函数环境记录器,也不需要调整this的值,并且最终执行的是由标准定义的逻辑(第10步)。 -
最后,内置函数的逻辑也不可以直接在程序中查看得的:
new Promise(resolve => console.log(resolve)) // ƒ()[native code]
以上是关于人人都能读标准14. 底层算法:函数的创建与执行的主要内容,如果未能解决你的问题,请参考以下文章