//解决文本无法选择选择
var eles = document.getElementsByTagName('*');
for (var i = 0; i < eles.length; i++) {
eles[i].style.userSelect = 'text';
}
完整的 V8 执行 JavaScript 的流程图:
V8 执行一段 JavaScript 代码所经历的主要流程:
初始化基础环境;
解析源码生成 AST 和作用域;
依据 AST 和作用域生成字节码;
解释执行字节码;
监听热点代码;
优化热点代码为二进制的机器代码;
反优化生成的二进制机器代码。
函数
函数即对象
JavaScript 是一门基于对象 (Object-Based) 的语言,可以说 JavaScript 中大部分的内容都是由对象构成的,诸如函数、数组,也可以说 JavaScript 是建立在对象之上的语言。
虽然 JavaScript 是基于对象设计的,但是它却不是一门面向对象的语言 (Object-Oriented Programming Language),因为面向对象语言天生支持封装、继承、多态,但是 JavaScript 并没有直接提供多态的支持,因此要在 JavaScript 中使用多态并不是一件容易的事。
除了对多态支持的不好,JavaScript 实现继承的方式和面向对象的语言实现继承的方式同样存在很大的差异。
面向对象语言是由语言本身对继承做了充分的支持,并提供了大量的关键字,如 public、protected、friend、interface 等,众多的关键字使得面向对象语言的继承变得异常繁琐和复杂,而 JavaScript 中实现继承的方式却非常简单清爽,只是在对象中添加了一个称为原型的属性,把继承的对象通过原型链接起来,就实现了继承,我们把这种继承方式称为基于原型链继承。
其实 JavaScript 中的对象非常简单,每个对象就是由一组组属性和值构成的集合,对象的值可以是任意类型的数据对象属性值的三种类型
函数的本质
在 JavaScript 中,函数是一种特殊的对象,它和对象一样可以拥有属性和值,但是函数和普通对象不同的是,函数可以被调用。
V8 内部是怎么实现函数可调用特性的呢?
其实在 V8 内部,我们会为函数对象添加了两个隐藏属性,具体属性如下图所示:
也就是说,函数除了可以拥有常用类型的属性值之外,还拥有两个隐藏属性,分别是 name 属性和 code 属性。
隐藏 name 属性的值就是函数名称,如果某个函数没有设置函数名,如下面这段函数:
(function (){
var test = 1
console.log(test)
})()
该函数对象的默认的 name 属性值就是 anonymous,表示该函数对象没有被设置名称。另外一个隐藏属性是 code 属性,其值表示函数代码,以字符串的形式存储在内存中。当执行到一个函数调用语句时,V8 便会从函数对象中取出 code 属性值,也就是函数代码,然后再解释执行这段函数代码。
函数是一等公民
在 JavaScript 中,函数可以赋值给一个变量,也可以作为函数的参数,还可以作为函数的返回值。如果某个编程语言的函数可以和它的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民
函数表达式
在 V8 解析 JavaScript 源码的过程中,如果遇到普通的变量声明,那么便会将其提升到作用域中,并给该变量赋值为 undefined,如果遇到的是函数声明,那么 V8 会在内存中为声明生成函数对象,并将该对象提升到作用域中。
函数表达式与函数声明的最主要区别有以下三点:
函数表达式是在表达式语句中使用 function 的,最典型的表达式是“a=b”这种形式,因为函数也是一个对象,我们把“a = function (){}”这种方式称为函数表达式;
在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions);
一个函数表达式可以被用作一个即时调用的函数表达式——IIFE(Immediately Invoked Function Expression)
立即调用的函数表达式(IIFE)
存放在括号里面的函数便是一个函数表达式,它会返回一个函数对象,如果我直接在表达式后面加上调用的括号,这就称立即调用函数表达式(IIFE),比如下面代码:
(function () {
//statements
})()
因为函数立即表达式也是一个表达式,所以 V8 在编译阶段,并不会为该表达式创建函数对象。这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。
在 ES6 之前,JavaScript 中没有私有作用域的概念,如果在多人开发的项目中,你模块中的变量可能覆盖掉别人的变量,所以使用函数立即表达式就可以将我们内部变量封装起来,避免了相互之间的变量污染。
另外,因为函数立即表达式是立即执行的,所以将一个函数立即表达式赋给一个变量时,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。如下所示:
var a = (function () {
return 1
})()
继承
继承就是一个对象可以访问另外一个对象中的属性和方法,比如我有一个 B 对象,该对象继承了 A 对象,那么 B 对象便可以直接访问 A 对象中的属性和方法,你可以参看下图:
观察上图,因为 B 继承了 A,那么 B 可以直接使用 A 中的 color 属性,就像这个属性是 B 自带的一样。
原型链
JavaScript 的每个对象都包含了一个隐藏属性 proto ,我们就把该隐藏属性 proto 称之为该对象的原型 (prototype),proto 指向了内存中的另外一个对象,我们就把 proto 指向的对象称为该对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性。
构造函数
function DogFactory(type,color){
this.type = type
this.color = color
}
var dog = new DogFactory('Dog','Black')
new的过程
var dog = {}
dog.__proto__ = DogFactory.prototype
DogFactory.call(dog,'Dog','Black')
首先,创建了一个空白对象 dog;
然后,将 DogFactory 的 prototype 属性设置为 dog 的原型对象,这就是给 dog 对象设置原型对象的关键一步,我们后面来介绍;
最后,再使用 dog 来调用 DogFactory,这时候 DogFactory 函数中的 this 就指向了对象 dog,然后在 DogFactory 函数中,利用 this 对对象 dog 执行属性填充操作,最终就创建了对象 dog。
每个函数对象中都有一个公开的 prototype 属性,当你将这个函数作为构造函数来创建一个新的对象时,新创建对象的原型对象就指向了该函数的 prototype 属性。当然了,如果你只是正常调用该函数,那么 prototype 属性将不起作用。
现在我们知道了新对象的原型对象指向了构造函数的 prototype 属性,当你通过一个构造函数创建多个对象的时候,这几个对象的原型都指向了该函数的 prototype 属性,如下图所示:
作用域链
作用域就是存放变量和函数的地方,全局环境有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。
var name = '极客时间'
var type = 'global'
function foo(){
var name = 'foo'
console.log(name)
console.log(type)
}
function bar(){
var name = 'bar'
var type = 'function'
foo()
}
bar()
先当 V8 启动时,会创建全局作用域,全局作用域中包括了 this、window 等变量,还有一些全局的 Web API 接口。V8 会先编译顶层代码,在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中,最终的全局作用域如下图所示:全局作用域创建完成之后,V8 便进入了执行状态。前面我们介绍了变量提升,因为变量提升的原因,你可以把上面这段代码分解为如下两个部分:
var name = '极客时间'
var type = 'global'
function foo(){
var name = 'foo'
console.log(name)
console.log(type)
}
function bar(){
var name = 'bar'
var type = 'function'
foo()
}
bar()
第一部分是在编译过程中完成的,此时全局作用中两个变量的值依然是 undefined,然后进入执行阶段;第二部代码就是执行时的顺序,首先全局作用域中的两个变量赋值“极客时间”和“global”,然后就开始执行函数 bar 的调用了。
当 V8 执行 bar 函数的时候,同样需要经历两个阶段:编译和执行。在编译阶段,V8 会为 bar 函数创建函数作用域,最终效果如下所示:
然后进入了 bar 函数的执行阶段。在 bar 函数中,只是简单地调用 foo 函数,因此 V8 又开始执行 foo 函数了。
同样,在编译 foo 函数的过程中,会创建 foo 函数的作用域,最终创建效果如下图所示:
因为 JavaScript 是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。bar 和 foo 函数的外部代码都是全局代码,所以无论你是在 bar 函数中查找变量,还是在 foo 函数中查找变量,其查找顺序都是按照当前函数作用域–> 全局作用域这个路径来的。
另外,我再展开说一些。因为词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以我们也将词法作用域称为静态作用域。
运行时环境
除了浏览器可以作为 V8 的宿主环境,Node.js 也是 V8 的另外一种宿主环境,它提供了不同的宿主对象和宿主的 API,但是整个流程依然是相同的,比如 Node.js 也会提供一套消息循环系统,也会提供一个运行时的主线程。
宿主环境和V8的关系
全局执行上下文和全局作用域
当 V8 开始执行一段可执行代码时,会生成一个执行上下文。V8 用执行上下文来维护执行当前代码所需要的变量声明、this 指向等。
执行上下文中主要包含了三部分,变量环境、词法环境、和 this 关键字。比如在浏览器的环境中,全局执行上下文中就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数,诸如 setTimeout、XMLHttpRequest 等内容。
而词法环境中,则包含了使用 let、const 等变量的内容。
执行上下文所包含的具体内容,你可以参考下图:
全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了。另外,当你执行了一段全局代码时,如果全局代码中有声明的函数或者定义的变量,那么函数对象和声明的变量都会被添加到全局执行上下文中。比如下面这段代码:
var x = 1
function show_x(){
console.log(x)
}
V8 在执行这段代码的过程中,会在全局执行上下文中添加变量 x 和函数 show_x
在这里还有一点需要注意下,全局作用域和全局执行上下文的关系,其实你可以把作用域看成是一个抽象的概念,比如在 ES6 中,同一个全局执行上下文中,都能存在多个作用域,你可以看下面这段代码:
var x = 5
{
let y = 2
const z = 3
}
这段代码在执行时,就会有两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些内容都会保存到全局执行上下文中。具体你可以参看下图:
V8是如何实现闭包的
在第一节我们介绍过 V8 执行 JavaScript 代码,需要经过编译和执行两个阶段,其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字节码,或者是 CPU 直接执行二进制机器代码的阶段。总的流程你可以参看下图:
在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点:
- 首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。
- 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源。
基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。
惰性解析的过程
关于惰性解析,我们可以结合下面这个例子来分析下:
function foo(a,b) {
var d = 100
var f = 10
return d + f + a + b;
}
var a = 1
var c = 4
foo(1, 5)
当把这段代码交给 V8 处理时,V8 会至上而下解析这段代码,在解析过程中首先会遇到 foo 函数,由于这只是一个函数声明语句,V8 在这个阶段只需要将该函数转换为函数对象,如下图所示:
注意,这里只是将该函数声明转换为函数对象,但是并有没有解析和编译函数内部的代码,所以也不会为 foo 函数的内部代码生成抽象语法树。
然后继续往下解析,由于后续的代码都是顶层代码,所以 V8 会为它们生成抽象语法树,最终生成的结果如下所示:
生成顶层代码的抽象语法树
代码解析完成之后,V8 便会按照顺序自上而下执行代码,首先会先执行“a=1”和“c=4”这两个赋值表达式,接下来执行 foo 函数的调用,过程是从 foo 函数对象中取出函数代码,然后和编译顶层代码一样,V8 会先编译 foo 函数的代码,编译时同样需要先将其编译为抽象语法树和字节码,然后再解释执行。
好了,上面就是惰性解析的一个大致过程,看上去是不是很简单,不过在 V8 实现惰性解析的过程中,需要支持 JavaScript 中的闭包特性,这会使得 V8 的解析过程变得异常复杂。
拆解闭包——JavaScript 的三个特性
JavaScript 中的闭包有三个基础特性。
第一,JavaScript 语言允许在函数内部定义新的函数,代码如下所示:
function foo() {
function inner() {
}
inner()
}
这和其他的流行语言有点差异,在其他的大部分语言中,函数只能声明在顶层代码中,而 JavaScript 中之所以可以在函数中声明另外一个函数,主要是因为 JavaScript 中的函数即对象,你可以在函数中声明一个变量,当然你也可以在函数中声明一个函数。
第二,可以在内部函数中访问父函数中定义的变量,代码如下所示:
var d = 20
//inner函数的父函数,词法作用域
function foo() {
var d = 55
//foo的内部函数
function inner() {
return d+2
}
inner()
}
查找变量的时候会沿着词法作用域链的途径来查找。
第三,因为函数是一等公民,所以函数可以作为返回值,我们可以看下面这段代码:
function foo() {
return function inner(a, b) {
const c = a + b
return c
}
}
const f = foo()
观察上面这段代码,我们将 inner 函数作为了 foo 函数的返回值,也就是说,当调用 foo 函数时,最终会返回 inner 函数给调用者,比如上面我们将 inner 函数返回给了全局变量 f,接下来就可以在外部像调用 inner 函数一样调用 f 了。
预解析器如何解决闭包所带来的问题?
在执行 foo 函数的阶段,虽然采取了惰性解析,不会解析和执行 foo 函数中的 inner 函数,但是 V8 还是需要判断 inner 函数是否引用了 foo 函数中的变量,负责处理这个任务的模块叫着预解析器。
V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。
第一,是判断当前函数是不是存在一些语法上的错误,如下面这段代码:
function foo(a, b) {
{/} //语法错误
}
var a = 1
var c = 4
foo(1, 5)
第二,除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。