浏览器的工作原理
大家有没有深入思考过:JavaScript代码,在浏览器中是如何被执行的?
浏览器的渲染过程
在这个执行过程中,HTML解析的时候遇到了JavaScript标签,应该怎么办呢?
会停止解析HTML,而去加载和执行JavaScript代码;
那么,JavaScript代码由谁来执行呢?
JavaScript引擎
为什么需要JavaScript引擎呢?
高级的编程语言都是需要转成最终的机器指令来执行的; 事实上我们编写的JavaScript无论你交给浏览器或者Node执行,最后都是需要被CPU执行的; 但是CPU只认识自己的指令集,实际上是机器语言,才能被CPU所执行; 所以我们需要JavaScript引擎帮助我们将JavaScript代码翻译成CPU指令来执行;
V8引擎
V8引擎的原理
我们来看一下官方对V8引擎的定义:
V8是用C ++编写的Google开源高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等。
它实现ECMAScript和WebAssembly,并在Windows 7或更高版本,macOS 10.12+和使用x64,IA-32,ARM或MIPS处理器的Linux系统上运行。
V8可以独立运行,也可以嵌入到任何C ++应用程序中
代码被解析,v8引擎内部会帮助我们创建一个对象(GlobalObject -> go)
该对象所有的作用域(scope)都可以访问;
里面会包含Date、Array、String、Number、setTimeout、setInterval等等;
其中还有一个window属性指向自己;
运行代码
js引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈
V8引擎的架构
V8引擎本身的源码非常复杂,大概有超过100w行C++代码,通过了解它的架构,我们可以知道它是如何对JavaScript执行的:
Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;
如果函数没有被调用,那么是不会被转换成AST的;
Parse的V8官方文档:https://v8.dev/blog/scanner
Ignition是一个解释器,会将AST转换成ByteCode(字节码)
同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
如果函数只调用一次,Ignition会执行解释执行ByteCode;
Ignition的V8官方文档:https://v8.dev/blog/ignition-interpreter
TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;
如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
TurboFan的V8官方文档:https://v8.dev/blog/turbofan-jit
V8引擎的解析图(官方)
V8执行的细节
Parser就是直接将tokens转成AST树架构;
PreParser称之为预解析,为什么需要预解析呢?
这是因为并不是所有的JavaScript代码,在一开始时就会被执行。那么对所有的JavaScript代码进行解析,必然会影响网页的运行效率;
所以V8引擎就实现了Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容,而对函数的全量解析是在函数被调用时才会进行;
比如我们在一个函数outer内部定义了另外一个函数inner,那么inner函数就会进行预解析;
那么我们的JavaScript源码是如何被解析(Parse过程)的呢?
Blink将源码交给V8引擎,Stream获取到源码并且进行编码转换;
Scanner会进行词法分析(lexical analysis),词法分析会将代码转换成tokens;
接下来tokens会被转换成AST树,经过Parser和PreParser:
生成AST树后,会被Ignition转成字节码(bytecode),之后的过程就是代码的执行过程(后续会详细分析)。
JavaScript的执行过程
1. 初始化全局对象
js引擎会在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)
该对象 所有的作用域(scope)都可以访问;
里面会包含Date、Array、String、Number、setTimeout、setInterval等等;
其中还有一个window属性指向自己;
2. 执行上下文栈(调用栈)
js引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈。
那么现在它要执行谁呢?执行的是全局的代码块:
全局的代码块为了执行会构建一个 Global Execution Context(GEC);
GEC会 被放入到ECS中 执行;
GEC被放入到ECS中里面包含两部分内容:
这个过程也称之为变量的作用域提升(hoisting)
第一部分:在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值;
第二部分:在代码执行中,对变量赋值,或者执行其他的函数;
3.GEC被放入到ECS中
4. GEC开始执行代码
作用域提升
第二行打印num1 会显示undefined
全局代码执行过程
函数
这里打印name,在foo函数内部找不到name,根据变量的真实查找路径是沿着作用域链来查找的规则,就会在父级作用域里找name,这里foo的父级作用域就是全局,所以打印出来就是why
函数嵌套
在foo函数中再嵌套一个bar函数,在bar函数中打印这个name会怎么样呢?
在代码执行前(预编译),在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值,bar函数这里保存的是内存地址0xb00,指向它所对应的内存空间
执行代码的时候,给AD里面的num:undefined等赋值,变成num:123等
执行第13行代码的时候,这里是调用了bar函数,然后它就会在函数调用栈中创建一个函数执行上下文
打印name的时候,先在AO里面找是否有name,没有-->去上层作用域找,没有-->沿着作用域链继续往上层找,找到了name = 'why',所以最终打印出来是'why'。
函数调用函数执行过程
打印结果:Hello Global
执行过程:首先,预编译 {message : undefined, foo:0xa00, bar:0xb00}; 然后执行代码给各项赋值{message :"Hello Global", foo:0xa00, bar:0xb00};按照顺序,先执行bar(),那么就是先调用了bar() ,然后这里就有个函数执行上下文 ,这里面的VO对象是AO,在这里面var出来的message是储存在AO里面,接下来再调用foo(),又有一个函数执行上下文,这里执行的代码是打印message,那么首先找的是foo()里面有没有message,没找到就沿着作用域链向上查找,找到了父级中有个message,所以打印出来为"Hello Global"
变量环境和记录
其实我们上面的讲解都是基于早期ECMA的版本规范:
在最新的ECMA的版本规范中,对于一些词汇进行了修改:
通过上面的变化我们可以知道,在最新的ECMA标准中,我们前面的变量对象VO已经有另外一个称呼了变量环境VE。
作用域提升面试题
第一题
var n = 100; function foo(){ n = 200 } foo() console.log(n)
输出结果:200
分析: 首先预编译->{n:undefined,foo:0xa00},然后编译时对n赋值,调用foo(),这里foo函数被调用就创建了一个函数执行上下文,这里执行代码n=200,然后n在foo函数对象里面找不到,就会到上层去找,找到父级里面的n,并赋值为200,所以就相当于修改了go里面的值,所以最后打印的时候,n为200。
注意:这里要区分一下,如果是这种情况,输出的n就是100
var n = 100;function foo(){ var n = 200}foo()console.log(n)
第二题
function foo(){ console.log(n) var n= 200 console.log(n)}var n = 100foo()
输出结果:undefined; 200
分析:
第三题
var n = 100 function foo1(){ console.log(n) //2、100 } function foo2(){ var n =200 console.log(n) //1、200 foo1() } foo2() console.log(n) //3、100
输出结果:见注释
第四题
var a= 100 function foo(){ console.log(a) return var a=100 } foo()
输出结果:undefined
分析:
第五题
function foo(){ var a = b = 100 //这里相对于var a = 10 // b = 10 //b这里没有定义,js会默认把他放在全局 }foo()console.log(a)console.log(b)
输出结果:undefined ; 10