几个概念
函数
计算机语言中的函数是一段可以重复执行的代码,同时可能包含输入输出以及局部数据。
在js中:
函数 = 代码段 + 环境
环境包括了它的参数,局部数据,以及引用的非局部数据。
由于js中全局对象的存在,并且每个js函数都会引用全局对象,所以从这个角度可以认为js中一切函数皆为闭包,实际上,正如其他语言中有一个main函数,v8会将所有的js代码用一个函数包裹起来作为main函数。
在使用静态作用域的语言中,函数局部数据的生命周期取决于它的作用域,且当它们的大小已知的话,通常会被分配到活动记录(调用栈)中,然而有些数据会在函数执行完以后依然存在(例如闭包),或者它们大小在编译时未知,它们会被分配到堆区,堆区的数据无法跟随函数介绍而自动释放,它们需要手动释放或者使用GC管理。
活动记录
活动记录又称(栈帧),它是调用栈上的基本单元。当执行某个函数时,会生成一个栈帧,其中包含了:入参,局部数据,返回值,返回地址,保存的机器状态及寄存器数据。 一般来说,调用者在调用另一个函数时,需要在被调用的函数栈帧上填入参数的值,返回地址(即调用者的位置),调用者的栈帧位置,而被调用者需要负责保存之前函数所用到的寄存器和机器数据,用于返回后恢复。
访问链
使用静态作用域(词法作用域)的语言,在编译阶段即可确定下来各个作用域的嵌套关系,因此,当一个函数使用了它外部作用域的名字,就需要向上层回溯,这就是访问链的意义。 每个函数被调用时,会生成一个活动记录(栈帧),其中就包含了访问链指针,它指向离函数最近的静态作用域所在的活动记录,如此会形成类似链表的结构,用于向上溯源查找非局部数据。 除了用访问链实现,还有一种名为display的实现方式,这里不做展开。
结构
JS中function也是object,在v8的实现里,js-function继承自js-object,故function中包含了object的全部属性。
v8的function的定义在src/objects/js-function.h
和src/objects/js-function.cc
,共有三种类型:
- JSFunction
- JSBoundFunction
- JSWrappedFunction
它们的继承关系如下
- JSObject
- JSFunctionOrBoundFunctionOrWrappedFunction
- JSBoundFunction
- JSWrappedFunction
- JSFunction
JSFunction
指常规的函数
function qux (name, age) {
return name + '-' + age;
}
其结构如下
它有三个特殊属性SharedFunctionInfo,Context,Feedback。
JSBoundFunction
指的是使用bind
后所生成的新函数。
function originFn(address) {return `${this.name} - ${this.age} - ${address}`; };
let originObj = {name: 'lily', aeg: 12 };
let foobar = originFn.bind(originObj, 'beijing');
foobar结构如下:
它有三个特别属性:原函数,绑定的对象,bind方法的传参。
JSWrappedFunction
js的新特性,参见 https://tc39.es/proposal-shadowrealm/#sec-wrappedfunctioncreate 这里不展开。
运行时
scope
v8中scope共有8种:
- class scope
- function scope
- eval scope
- with scope
- catch scope
- module scope
- script scope
- block scope
由于scope可以嵌套,在解析过程中,v8会构建一颗scope树。
方法是在scope中设置三个指针
- outer_scope_ 最近的父级scope
- inner_scope_ 子级作用域
- sibling_ 兄弟作用域
在解析过程中,如果遇到子函数,则为子函数新建一个子scope,同时调用当前scope的AddInnerScope(this)
方法。
该方法执行了如下操作:
- 将子scope的
sibling_
指向当前scope的inner_scope_
。 - 当前scope的
inner_scope_
指向子scope - 子scope的
outer_scope_
指向当前scope
可以看出如果有多个子scope,那么当前scope的inner_scope_
会按顺序不断改变。同时后来的子scope的sibling_
会指向前一个inner_scope_
,如此形成了一个完整的scope树。
scope中可能包含很多变量,因此作用域一般会关联一个符号表,用于记录该scope上的所有变量名以及它们的值,且支持高效的插入和查找。
v8定义了一个名为VariableMap
的结构,它是一个hashmap,同时继承了Zone
,支持快速分配一段有大量小分块的内存区域,这些小块上的数据不能单独销毁,只能作为一个整体统一销毁。
frame
函数在运行时依赖活动记录(即栈帧),在v8中活动记录定义在src/execution/frames.h和src/execution/frames.cc
,具体到各个平台,其结构可能会有差异,v8分别对其进行了处理,这里以x64为例。
其存储结构如下:
constant pool 类似于JVM中的constant pool,它扮演着类似符号表的角色,保存了用户定义的字面量(字符串,数组,对象等)以及调用的内置函数(Date, Array, console等)。
通过打印bytecode可以查看constant pool内容:
function foobar() {
let a = 'a string'
console.log(a);
}
foobar();
可以看到console
,log
,a string
都放入了constant pool,bytecode中的LdaConstant [0]
,代表读取constant pool中的第一个元素。
通常在函数执行过程中,调用者称为caller,被调用者称为calle,当caller调用calle时,其执行流程可按下述流程描述,一般而言会有四个阶段post-call,prolog,epiloge,post-return。(此规则称为Calling convention)
pre-call
- 在caller的frame尾部保存入参的值以及该函数所绑定的对象(即this指向,没有绑定则默认为全局环境对象)
- 将calle的基础frame入栈
- 在calle的frame上设置返回地址,以及frame ptr(指向caller的frame)
- 跳转到calle的prolog代码
prolog
- 在callee的frame上保存caller的栈指针以及相关寄存器
- 在callee的frame上保存callee的context
- 在callee的frame上保存其function入口地址
- 在callee的frame上保存callee的的入参数量
此过程的代码可以在
src/codegen/x64/macro-assembler-x64.cc
中查看
void TurboAssembler::Prologue() { ASM_CODE_COMMENT(this); pushq(rbp); // Caller's frame pointer. movq(rbp, rsp); Push(kContextRegister); // Callee's context. Push(kJSFunctionRegister); // Callee's JS function. Push(kJavaScriptCallArgCountRegister); // Actual argument count. }
Epiloge
- 保存返回值
- 恢复寄存器状态
- 释放局部数据
- 加载返回地址并返回
Post-return
如果callee的返回值在frame上,需要拷贝返回值
清空callee的frame
context
js中每个函数都有一个环境,可能是全局环境,函数作用域或者是某个对象。因此有种说法是js的函数都是闭包。
v8在src/objects/contexts.h和src/objects/contexts.cc
中定义了context来表示这种环境。
context中包含如下三个字段:
- scope_info 与当前context关联的scope
- previous 前一个context
- extension 额外的数据
通过previous,context将会组成一个类似链表的结构,在运行时,context链与stack的构建同时发生,frame与context一一对应。
context的类型如下:
- functioin context
- catch context
- debug
- await
- block
- module
- eval
- script
全局定义一个函数:
function foobar(name) {
let a = 'a string';
return a + name;
}
其context内容如下: 由于是全局域,它的previous是0,extension指向了全局对象,此外还包含了微任务队列。
关于闭包
v8对闭包的处理用到了context。
function outer(name) {
let t = name + ':';
function inner(age) {
t = t + age;
return t;
};
return inner;
}
let fn = outer('nike');
打印fn的context:
此时它的previous指向了前一个全局的context,因为outer函数的context也是全局context。
注意其中有一个2: 0x21280010d161 <String[11]: "undefined11">
,这是被inner引用的t的值。
执行fn(12)
后:
此时再定一个函数let other = outer('lucy')
,其context如下:
other和fn中的string并不相同,这说明在创建闭包时,outer中的t发生的是传值调用。而如果t是一个对象,将会是引用调用(具体情况不在展开)。
this的处理
在调用常规函数时,入参中会额外附带一个名为receiver
的参数,它将作为此函数中this的指向。
base::SmallVector<Address, 32> argv(argc + 1);
argv[0] = receiver->ptr();
for (int i = 0; i < argc; ++i) {
argv[i + 1] = args[i]->ptr();
}
可以看到v8重新构造了一个入参数组,第一个元素是receiver。而如果调用了构造函数,v8传入的将是空值TheHoleValue
。
在非严格模式下,receiver会被转为object。
function foobar() {
%DebugPrint(this);
}
let str = 'hello';
%DebugPrint(str);
foobar.call(str);
分别打印foobar内this和str: 可以看到call调用后,str会被转成一个新的包装对象作为this值,对于apply,bind的处理也是如此。