JavaScript的运行原理

137 阅读15分钟

写在前面

跟着coderwhy老师学习前端,不知不觉已经2年多了,但是只是学习知识,没有知识的输出总归觉得还是有所欠缺,最近终于决心把自己的所学整理出来,在掘金上发布。个人所学有限,见解也未必正确,希望看到文章的大大们发现有叙述不当或者错漏的地方不吝赐教,予以指正。在此特别感谢coderwhy老师的悉心教导。 闲话少叙,我们直接进入正题

浏览器的工作原理

大家有没有思考过,JavaScript代码,在浏览器中是如何被执行的呢?

1.当用户在浏览器地址栏输入一个域名,并且访问该域名的时候,域名会经过dns解析,解析成服务器对应的IP地址,也就是说用户最终是通过ip地址找到服务器,然后服务器给用户返回一个html

2.拿到html以后,浏览器对html进行解析。

3.解析的过程中如果遇到link标签引用css文件的话,则去服务器下载.css文件

4.如果遇到script标签引用js文件的话,则去服务器下载.js文件,到此浏览器中就有了需要的js代码了。

以上是简略的浏览器下载并解析静态资源的简略步骤,更详细的步骤我们在后续再进行讨论。

image.png

认识浏览器的内核

我们从服务器下载下来html,css,js文件以后,这些文件是需要进行解析的,而这个解析的过程则是浏览器来帮助我们完成的,这些文件经过浏览器内核的解析之后浏览器上才呈现出用户所看到的界面。 不同的浏览器有不同的内核组成,以下列举一些常见的浏览器内核:

  • Gecko:早期被Netscape和Mozilla Firefox浏览器使用
  • Trident:由微软开发,被IE4~IE11浏览器使用,但是Edge浏览器已经转向Blink
  • Webkit:苹果基于KHTML开发、开源的浏览器内核,用于Safari。Google Chrome之前也在使用
  • Blink:Blink是Webkit的一个分支,由Google开发,目前应用于Google Chrome、Edge、Opera等

事实上,我们经常说的浏览器内核指的是浏览器的排版引擎 (layout engine),也称为浏览器引擎(browser engine)、页面渲染引擎(rendering engine)或样版引擎

浏览器渲染过程

上面我们说到,浏览器在解析html的时候,如果遇到css,会去下载css。如果遇到script标签,会去下载和执行js代码,执行完之后继续对html进行解析(这是script标签写在body标签前面的情况)。

这里可能会有一个疑惑:暂停了对html的解析,转去下载并执行js代码,这个时候html并没解析完成,如果js代码需要对dom进行操作怎么办呢?

所以现在我们的js代码都是写在window.onload方法里面或者是写在body标签之后的,这样就可以保证我们在真正执行js代码对dom进行操作的时候html里的dom元素已经解析完成了。

image.png 我们来对浏览器的渲染过程进行一个稍微详细一点的说明:

1.下载html,并由浏览器内核对html进行解析,解析之后会将html转成一个DOM树

2.当遇到link标签的时候下载并解析css,形成css规则(Style Rules)

3.css规则和DOM树结合生成一个渲染树(RenderTree)

4.通过布局引擎对RenderTree进行一些具体操作,确定元素到底应该渲染到屏幕的什么位置(比如css把某个元素定位到某个位置,但是由于浏览器窗口的大小是全屏的还是缩小了并不确定,元素在屏幕上显示的位置就会不同,所以还需要布局引擎最终确定元素在屏幕的什么位置渲染出来)

5.确定完元素最终的渲染位置之后,浏览器就可以对页面上的元素进行绘制(Painting)了,绘制完成后网页就可以呈现给用户了

那么,JavaScript代码由谁来执行呢?答案是JavaScript引擎

认识JavaScript引擎

为什么需要JavaScript引擎呢?

高级编程语言最终都是需要转成机器指令来执行的,事实上我们编写的JavaScript无论你是交给浏览器还是Node执行,最终都是需要交给CPU来执行的。而CPU只认识自己的指令集,实际上也就是机器语言。所以我们需JavaScript引擎帮助我们将JavaScript代码翻译成CPU指令。

比较常见的JavaScript引擎有哪些呢?

  • SpiderMonkey:第一款JavaScript引擎,由Brendan Eich开发(也就是JavaScript作者)
  • Chakra:微软开发,用于IT浏览器
  • JavaScriptCore:WebKit中的JavaScript引擎,Apple公司开发
  • V8:Google开发的强大JavaScript引擎,也帮助Chrome从众多浏览器中脱颖而出

那么浏览器内核和JavaScript引擎有什么关系呢?

  • 浏览器内核负责HTML解析、布局、渲染等等相关的工作。
  • JavaScript引擎负责解析、执行JavaScript代码

这里我们来介绍一下V8这一款JavaScript引擎

我们来看一下官方对V8引擎的定义:

  1. V8 是用C++编写的Google开源高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等。
  2. 它实现ECMAScript和WebAssembly,并可在Windows 7或更高版本,macOS 10.12+和使用x64,IA-32, ARM或MIPS处理器的Linux系统上运行。
  3. V8可以独立运行,也可以嵌入到任何C++应用程序中。

V8执行的细节

image.png 1.parse模块对js代码进行词法分析、语法分析生成抽象语法树

2.由Ignation解析器将抽象语法树转成字节码(bytecode),字节码是跨平台的与机器无关的

3.当真正需要执行js代码的时候,由Ignition将字节码(bytecode)转成机器指令再交给cpu执行

这里为什么要先把AST转成字节码再转成机器指令呢?

那是因为字节码是跨平台的,如果把AST直接转成机器指令,刚开始的时候我们并不能确定要转成那种机器指令,另外把字节码转成机器指令要比把AST树转成机器指令容易得多。

这里来解释一下TurboFan的作用:TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码

在我们的js代码中如果一个函数被多次调用,那么这个函数就会被标记为热点函数,那么它就会经过TurboFan转换成优化的机器码,到下次执行这个函数的时候,就直接执行TurboFan转换出来的机器码就可以了,这样一来就提高了代码的执行性能。

但是,机器码实际上也有可能会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数,原本是对2个数进行简单的相加操作,之前每次执行函数传入的参数都是number类型,并且由于函数频繁调用执行,TurboFan把该函数编译成了机器码。后来有一次执行的时候传入的参数变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的把机器码转换成字节码。所以从这个角度来说,TypeScript也是能提高代码的执行效率的哦。

JavaScript的执行过程

假如我们有下面一段代码,它在JavaScript引擎中是如何被执行的呢?

image.png

初始化全局对象

js引擎在执行代码之前(解析阶段),会在堆内存中创建一个全局对象:Global Object(GO)

该对象所有的作用域(scope)都可以访问;

里面会包含Date、Array、String、Number、setTimeout、setInterval等等内部对象;

其中还有一个window属性指向自己(Global Object);并把全局变量放入GO中,但是因为代码还没执行,此时全局变量的值是undefined,这个过程也称为变量的作用域提升(hoisting)

image.png

image.png

执行上下文栈(调用栈)

到了执行js代码的阶段,js引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈。(这里就是内存中的栈空间了) 其实是一个函数调用栈,用来执行全局的代码块(全局函数)

image.png

但是,如上图,全局代码并没有放到某个函数里面,为了能够执行全局代码块,js引擎会构建一个全局执行上下文 Global Execution Context(GEC)

全局执行上下文(GEC)会被放入到执行上下文栈(ECS)(栈空间)中

image.png

GEC被放入到ECS中里面包含两部分内容:

第一部分: 在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值;这个过程也称之为变量的作用域提升(hoisting)

第二部分: 在代码执行过程中,对变量赋值,或者执行其他的函数;

认识VO对象(Variable Object)

ECMA文档:每个执行上下文都有一个关联的变量对象。在源代码中声明的变量和函数作为变量对象的属性被添加到VO对象中。对于函数代码,参数也作为变量对象的属性被添加到VO对象中

image.png

当全局代码被执行的时候,全局执行上下文的VO就是GO对象了。

ECMA文档:

image.png

  • 作用域链被创建并初始化为只包含全局对象而不包含其他对象。

  • 变量实例化是使用全局对象(GO)作为变量对象(VO)并使用属性DontDelete来执行的。

  • this值是全局对象

从第二点我们得知,全局执行上下文(GEC)中维护着一个变量对象VO(Variable Object),这个VO指向GO对象。

准备好这个VO之后,就可以执行代码了,全局代码依次执行,开始对GO中的全局变量进行初始化操作

全局代码执行过程(执行前)

image.png

但是如上图代码,如果我们在变量的初始化操作之前打印它,比如在var num2=30之前打印num2,它会是一个undefined,因为num2在代码解析阶段就已经在GO对象中了,这就是变量的作用域提升

全局代码执行过程(执行后)

image.png

函数如何被执行呢?

ECMA文档描述:

image.png

(1)当控制权进入一个函数的执行上下文时,会创建一个称为激活对象的对象并与执行上下文相关联。激活对象被会用一个名为“arguments”的属性进行初始化,其属性为“DontDelete”,这个属性的初始值是下面描述的参数对象。

(2)然后,为了变量实例化的目的,激活对象(AO)被用作变量对象(VO)。

image.png

在解析阶段如果遇到函数定义,将会在GO对象中生成一个对应的属性,如上图就是foo属性,然后又在堆内存中开辟一块空间,创建一个函数对象(FunctionObject),用来存储这个函数,这个函数对象主要存储着两块东西 (1)这个函数的父级作用域(该函数的上一层作用域)在本例中foo函数的上层作用域就是全局作用域, (2)函数的执行体。

然后把这个空间的内存地址赋值给GO对象的foo属性。(按照上面的叙述可以知道,我们在解析阶段就已经确定了函数的作用域和函数的执行体,由于解析阶段已经有在GO中存储了foo函数,所以在代码真正执行的时候,发现函数的调用在函数的定义之前是不会报错的)

注:通过函数表达式定义函数的情况不太一样:

image.png

到了执行阶段,代码执行到函数的调用,本例中是foo(),则会去全局作用域的VO,也就是GO对象中查找foo,查找完发现是函数的内存地址则会在ECStatck(执行上下文栈)中创建一个函数执行上下文FEC(Functional Execution Context)因为每个执行上下文都会关联一个VO,函数执行上下文(FEC)中也关联着一个变量对象VO,由ECMA文档的第二点可以知道,这个VO对象指向一个活跃对象AO(Activation Object)注:参考下图

image.png AO对象是函数真正执行之前被创建的,创建完这个AO对象后把函数中所有需要用到的变量保存在AO中,如上例就是num,m和n了,但是由于函数体里的代码还没开始执行,所以此时的变量num,m和n是undefined,当真正开始执行函数以后,首先由实参把num赋值成123,然后第5行中要打印m,就在函数执行上下文的VO也就是AO中查找m,此时m还未赋值,就打印出来一个undefined了,接着执行第6和第7行代码把m和n分别赋值成10和20

image.png 当函数执行完,函数执行上下文就会弹出ECStack,并且进行销毁,当然此时的AO对象也会进行销毁

image.png 当然如果再一次调用函数,则重新创建函数执行上下文(FEC)重新执行函数

作用域链

image.png

  • 每个执行上下文都与一个作用域链相关联。作用域链是在评估标识符(查找变量)时被搜索的对象的列表。当控制进入一个执行上下文时,会创建一个作用域链,并根据代码的类型用一组初始对象填充它。在一个执行上下文中执行期间,该执行上下文的作用域仅受with语句和catch子句的影响。

当我们在函数中需要用到某个变量的时候,会沿着作用域链查找这个变量 image.png 什么是作用域链呢?我们在函数执行上下文中除了有VO之外,还维护着一个作用域链(scope chain),作用域链是由当前的VO+父级作用域(Parent Scope)组成的(由于父级作用域(parent scope)在函数的解析阶段就已经确定了,所以这里可以很容易的在函数对象(function object)中得到父级作用域)

如下图bar函数的父级作用域是foo函数,而foo函数的父级作用域就是全局了。因为在js中(es5)只有函数才会产生作用域。所以foo函数的作用域链就是foo的AO+全局作用域GO,而bar函数的作用域链是bar的AO+foo的AO+全局作用域GO

image.png

1.当在foo函数中打印m的时候,由于在foo函数的作用域中可以找到变量m,所以在此打印的变量m就只会是函数内部定义的变量m而不会是foo函数外部的变量m,由于在foo函数刚开始执行的时候已经把变量m和n放到foo函数对应的AO里了,但是此时还没对m进行赋值,所以第8行打印出来m的值是一个undefined

2.在bar函数中打印age(13行),由于在bar函数的作用域中找不到age,所以沿着作用域链往父级作用域找,找到foo函数,这里也找不到age变量,此时再往父级作用域找,找到全局作用域,此时找到了age变量,所以13行打印出来age的值是100

3.在bar函数中打印n(14行),由于在bar的作用域中找不到n,所以沿着作用域链往父级作用域找,找到foo函数,这里已经能找到n了,就不会再往父级作用域中找了,所以14行打印出来n的值是20

4.在bar函数中打印height(15行),此时沿着作用域链一直向上找到全局作用域都不能找到变量height,所以15行中打印height的值会报Uncaught ReferenceError的错误

变量环境和记录

其实我们上面的讲解都是基于早期ECMA的版本规范:

image.png

每一个执行上下文会被关联到一个变量对象(variable object ,VO),在源代码中的变量和函数声明会被作为属性添加到VO中。对于函数来说,参数也会被添加到VO中

如下图,全局执行上下文GEC关联到的VO就是GO了,在全局中的变量、函数的声明,如下图就是name变量和foo函数的声明会被作为属性添加到VO,只不过全局执行上下文里的VO就是GO了,所以是把name属性和foo属性添加到了GO里面

函数执行上下文关联到的VO就是AO了。在函数中对参数num变量m和n的声明就会作为属性添加到AO里面

image.png

在最新的ECMA的版本规范中,对于一些词汇进行了修改:

image.png 通过上面的变化我们可以知道,在最新的ECMA标准中,我们前面的变量对象VO已经有另外一个称呼:变量环境(VE)