几个概念

函数

计算机语言中的函数是一段可以重复执行的代码,同时可能包含输入输出以及局部数据。 在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.hsrc/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为例。

所有的frame继承结构如下:

其存储结构如下:

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的处理也是如此。