透过V8深入理解JavaScript的函数

函数

什么是函数

在JavaScript中,函数其实也是一个对象,所以函数也可以有自己的属性和值,它与对象不同的地方在于,它可以被执行,执行时会创建函数作用域。

V8如何去执行函数

我们知道,在执行函数的时候,我们只需要在函数名后面,加上一个括号,在括号里传入参数,就可以执行这个函数了,但是,V8是怎么做到执行这个函数的,V8如何找到这个函数的相关代码

既然函数是一个对象,那么它就有自己的属性,除了我们给它添加的属性,函数本身有两个属性,分别是name和code。

name属性存储着函数的名字,而code以字符串的形式存储函数

就像下面的代码

function fn(){
    // ...
}

console.log(fn.name)
// fn

当我们执行代码的时候,V8会去找到这个函数对应的code属性,然后解释执行code属性的值,也就是原函数。

使用栈结构来管理函数调用

我们要知道,V8是使用栈结构来管理函数调用的,所以我们经常可以听到函数调用栈,但为什么使用栈结构来管理函数调用呢?

除了栈结构本身的特点:空间连续和先进后出外,也与函数本身有关。

首先,函数是可以被调用的,我们可以在一个函数里面去调用另外一个函数,当我们调用一个函数的时候,代码执行的控制权将由父函数或者全局作用域转移到子函数,而当函数调用完毕后,会返回给父函数或者全局,这与栈结构的先进后出刚好吻合。

其次,函数具有作用域机制,函数内部的变量,被我们称为局部变量,只要我们不暴露出去,就只能在函数内部被访问到,即使返回,也只是返回一个值或者一个对象的地址,所以一般情况下,当我们一个函数调用完毕时,函数内部的局部变量都该被销毁,而通过使用栈结构,当函数执行完毕出栈时,空间里对应的变量也被清除。

函数是一等公民

什么是一等公民,对一种语言来说,如果它的函数和它的基本类型能完成一样的事情,比如可以赋值给变量,可以做为函数的参数,可以做为函数的返回值,可以做为对象的属性值等,那么就说这种语言的函数是一等公民。

函数成为一等公民,让我们可以方便地去传递函数,但也正是因为函数是一等公民,带来了一些麻烦。当我们在函数内部去引用外部变量的时候,V8需要去判断,这个外部变量是否真的存在,闭包就是一个典型的例子。如下面的代码

function fn(){
    let count = 0
    function add(){
        count++
        console.log(count)
    }
    return add
}
let a = fn()
a()

这里fn内部声明了一个函数add,而这个add函数,调用了它所在的函数作用域里的count变量,所以即使fn已经调用完了,因为add函数内部用到了fn函数作用域里的变量,所以V8不得不去维护这个作用域,不能去销毁这个作用域,这就是函数做为一等公民带来的麻烦。

V8如何去处理闭包

首先,我们说说闭包,为什么JavaScript会有闭包,有以下三点

  1. JavaScript运行在函数内部定义新的函数
  2. 可以在内部函数访问父函数中定义的变量
  3. 函数是一等公民,可以做为函数的返回值

再有一个,我们要明白,JavaScript出于加快初次解析和减少内存浪费的目的,采用了惰性解析:当解析代码的时候,对函数声明只转换成对用的对象,而不解析函数内部的内容,

我们可以看看下面这个闭包

function fn(){
    var fnNum = 1
    return function inner(){
        var innerNum = 2
        return fnNum+innerNum
    }
}
var f = fn()

在这里,inner就是一个典型的闭包,这个闭包带来了一定的问题。当我们调用fn函数时,它会将里面的函数inner返回给变量f,然后当函数执行完毕的时候,fn的执行上下文会被V8销毁,但是,我们可以看到,闭包inner调用了fn内的变量fnNum,这导致了fnNum不能被销毁,但是这样就带来问题了。

我们知道,这段代码是可以实现的,而且执行f()的时候,会返回3,那就意味着fnNum没用被销毁,那么我们怎么做到它不被销毁,同时,上面提到了,V8采用了惰性解析,既然采用了惰性解析,那也就意味着,当我们执行fn()之前,我们是不会去解析inner函数内部的代码的,那我们又怎么知道,fnNum被inner函数使用了呢。

要解决这个问题,我们就需要去判断函数内部是否会用到父函数的变量,V8引入了预解析器去执行这个任务。

引入预解析器后,V8遇到一个函数时,不会直接跳过该函数,而是会对该函数进行以此快速的预解析,预解析有两个工作

  1. 判断当前函数是否存在语法错误
    如果这个函数出现了语法错误,那也没必要继续执行下去了
  2. 检查函数内部是否引用了外部变量,如果引用了外部变量,预解析器就会将栈中的变量复制到堆中,当下次执行到该函数的时候,直接使用堆中的引用,以此解决闭包的问题。

函数表达式

函数表达式是JavaScript中非常重要的基础内容,我们可以使用函数表达式来执行一个函数,同时又不会把实现的逻辑代码暴露出来。

函数表达式说简单,看起来不难,但实际上,还是涉及到挺多东西的。它涉及到一些底层概念,什么是语句,什么是表达式,函数即对象的概念。我们要清楚这些概念,才能更好地去理解函数表达式。

函数声明和函数表达式

我们知道,在JavaScript里面,一般情况下,我们可以有两种方式去声明一个函数

// 函数声明
function fn(){
    // ...
}
// 函数表达式
var fn = function(){
    // ...
}

上面的两行代码,虽然最终都会得到一个函数fn,但是还是存在不同的。

哪里不同,其实从名字就能看出来了,函数声明和函数表达式,既然有声明,那就可能会涉及到JavaScript非人的设计,变量提升。V8在执行JavaScript代码的时候,会先创建全局作用域,然后对代码进行编译,而在编译期间,会把声明内容放到全局作用域中,像下面的这段代码

fn()
function fn(){
    console.log('fn')
}

V8处理时会变为

// 编译阶段
function fn(){
    console.log('fn')
}

// 执行阶段
fn()

而对于函数表达式,如下代码

fn()
var fn = function(){
    console.log('fn')
}

V8处理时会是

// 编译阶段
var fn = undefined

// 执行阶段
fn()
fn = function(){
    console.log('fn')
}

所以这段代码会报错,因为在fn执行的时候,fn还不是方法。

实际上,这就涉及到了表达式和语句的区别了,表达式在编译阶段是不会执行的。

我们可以简单地区分,表达式,是JavaScript中的一个短语,JavaScript编译执行它会返回一个结果,而语句,是一个完整的句子,它可以单独执行。就像下面的函数表达式

(function(){
    //...
})()

我们如果不加括号,那么function(){}是会报错的,因为编译器看到开头是function,将其看做一个函数声明语句,而函数声明语句function后面要加函数名,但是,放到括号里后,它就被看成一个表达式了,这个括号内通过JavaScript编译器“计算”得到一个函数,然后使用()调用。

我们能看到,函数表达式和函数声明的在创建一个函数时的区别,函数声明可以让声明的函数在编译阶段就放到作用域中,也即是说可以在声明前就调用这个方法了,而函数表达式,可以做为一个匿名函数使用,可以不污染环境。

像上面举例用的的函数表达式

(function(){
    //...
})()

这是一个立即执行的函数表达式(IIFE),使用这样的函数调用可以让我们不用去污染环境变量。

V8实现回调函数

回调函数,说到底只是一个函数而已,我们可以在很多个场景见到回调函数,一个简单的定时器setTimeout或者setInterval,一个ajax异步回调,又或者一个forEach方法的回调函数参数,甚至于回调函数的滥用,让我们听到了回调地狱。

具体来说,就我们上面举到的例子其实可以看到了,回调函数分为同步回调和异步回调,同步回调是直接在执行函数内部执行的,而异步回调函数,往往会在另一个任务里面执行,像定时器,或者是ajax异步回调,它们的回调函数和本身所在的函数执行,是在两个宏任务的

对于同步回调函数,我们很容易理解,它在函数内部执行完后就执行,但是异步回调可能就不是很好理解了,这涉及到V8的事件循环机制和消息队列等。而这些又与V8的线程模型相关,所以我们要先分析一下V8的线程模型

线程架构

首先我们要明白的是,JavaScript在浏览器中,是在UI线程里面执行的,这与早期浏览器只有UI线程有关。

所谓UI线程,是指运行窗口的线程,当你运行一个窗口时,无论该页面是Windows上的窗口系统,还是Android或者iOS上的窗口系统,它们都需要处理各种事件,诸如有触发绘制页面的事件,有鼠标点击、拖拽、放大缩小的事件,有资源下载、文件读写的事件,等等。

我们知道,上面说到的事件,往往会在同一段时间内发生,可能上一个事件没有处理完,下一个事件就来了,我们要如何控制这些事件的执行,才能保证CPU正常运行和事件有序执行。

针对这种情况,浏览器实现了一个消息队列,每当我们产生一个事件的时候,浏览器就将这个事件推入到队列中,然后依次把事件从队列中推出,因为队列的特点是先进先出,所以事件的执行,是按照事件触发的顺序,我们把UI线程每次从消息队列中取出事件,执行事件的过程称为一个任务。

异步回调函数的调用时机

对于定时器来说,会有另一个队列来存放定时器里面的回调函数,当我们执行一个定时器时,会将这个回调函数推入这个队列,然后又一个任务调度器来控制,当时间到了的时候,任务调度器就会将这个回调函数从队列中取出,压入到上面说的消息队列里面。

而对于像XHR这种和网络相关的异步回调,又存在了不同的处理。因为我们的XHR请求会去请求服务端的资源,而这个资源请求花费的时间,相对于我们一个普通的事件来说是很长的,如果只是获取一些数据还好,如果是下载一整个文件,那时间就不是以ms为单位了,甚至会以h为单位了。

而就像上面说的,JavaScript运行在UI线程上,如果我们在这里还是由UI线程来处理这个请求事件的话,那无疑会对页面造成严重的阻塞,为了解决这个问题,浏览器借助了网络线程的力量,由网络线程来处理数据的获取

当我们在UI线程,也就是JavaScript发起一个XHR任务时

  • V8会分析出这是一个下载任务,主线程会将该任务交给网络线程去执行
  • 网络线程接到任务后,会和服务端建立连接,并不断接受服务端发来的数据
  • 网络线程在接受数据的过程中,每接收一次数据,都会将此次接收数据的大小,字节,存放在内存中的位置,封装在一个事件里压入消息队列中
  • UI线程不间断地执行消息队列里的任务,当发现下载事件的任务状态变为完成时,就将回调函数压入消息队列中
  • 回调函数前面的任务都被执行完,回调函数出队列,被执行,UI线程回调显示数据获取完成
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值