01.深入JavaScript运行原理

775 阅读8分钟

浏览器的工作原理

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

图片.png

浏览器的渲染过程

  • 在这个执行过程中,HTML解析的时候遇到了JavaScript标签,应该怎么办呢?

    会停止解析HTML,而去加载和执行JavaScript代码;

图片.png

  • 那么,JavaScript代码由谁来执行呢?

    JavaScript引擎

  • 为什么需要JavaScript引擎呢?

    高级的编程语言都是需要转成最终的机器指令来执行的;

    事实上我们编写的JavaScript无论你交给浏览器或者Node执行,最后都是需要被CPU执行的;

    但是CPU只认识自己的指令集,实际上是机器语言,才能被CPU所执行;

    所以我们需要JavaScript引擎帮助我们将JavaScript代码翻译成CPU指令来执行;

V8引擎

  • V8引擎的原理

我们来看一下官方对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 ++应用程序中

图片.png

代码被解析,v8引擎内部会帮助我们创建一个对象(GlobalObject -> go)

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

  2. 里面会包含Date、Array、String、Number、setTimeout、setInterval等等;

  3. 其中还有一个window属性指向自己;

图片.png 运行代码

js引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈

  • V8引擎的架构

  1. V8引擎本身的源码非常复杂,大概有超过100w行C++代码,通过了解它的架构,我们可以知道它是如何对JavaScript执行的:
  2. Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;
    • 如果函数没有被调用,那么是不会被转换成AST的;
    • Parse的V8官方文档:v8.dev/blog/scanne…
  3. Ignition是一个解释器,会将AST转换成ByteCode(字节码)
    • 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
    • 如果函数只调用一次,Ignition会执行解释执行ByteCode;
    • Ignition的V8官方文档:v8.dev/blog/igniti…
  4. TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;
    • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
    • 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
    • TurboFan的V8官方文档:v8.dev/blog/turbof…
  • V8引擎的解析图(官方)

图片.png

  • V8执行的细节

    • 那么我们的JavaScript源码是如何被解析(Parse过程)的呢?

    • Blink将源码交给V8引擎,Stream获取到源码并且进行编码转换;

    • Scanner会进行词法分析(lexical analysis),词法分析会将代码转换成tokens;

    • 接下来tokens会被转换成AST树,经过Parser和PreParser:

      • Parser就是直接将tokens转成AST树架构;
      • PreParser称之为预解析,为什么需要预解析呢?
        • 这是因为并不是所有的JavaScript代码,在一开始时就会被执行。那么对所有的JavaScript代码进行解析,必然会影响网页的运行效率;
        • 所以V8引擎就实现了Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容,而对函数的全量解析是在函数被调用时才会进行;
        • 比如我们在一个函数outer内部定义了另外一个函数inner,那么inner函数就会进行预解析;
    • 生成AST树后,会被Ignition转成字节码(bytecode),之后的过程就是代码的执行过程(后续会详细分析)。

JavaScript的执行过程

1. 初始化全局对象

  • js引擎会在执行代码之前,会在堆内存中创建一个全局对象:Global Object(GO)
    • 该对象 所有的作用域(scope)都可以访问;
    • 里面会包含Date、Array、String、Number、setTimeout、setInterval等等;
    • 其中还有一个window属性指向自己;

图片.png

2. 执行上下文栈(调用栈)

  • js引擎内部有一个执行上下文栈(Execution Context Stack,简称ECS),它是用于执行代码的调用栈。
  • 那么现在它要执行谁呢?执行的是全局的代码块:
    • 全局的代码块为了执行会构建一个 Global Execution Context(GEC);
    • GEC会 被放入到ECS中 执行;
  • GEC被放入到ECS中里面包含两部分内容:
    • 第一部分:在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值;
      • 这个过程也称之为变量的作用域提升(hoisting)
    • 第二部分:在代码执行中,对变量赋值,或者执行其他的函数;

3.GEC被放入到ECS中

图片.png

4. GEC开始执行代码

图片.png

作用域提升

第二行打印num1 会显示undefined

图片.png

全局代码执行过程

  • 函数

这里打印name,在foo函数内部找不到name,根据变量的真实查找路径是沿着作用域链来查找的规则,就会在父级作用域里找name,这里foo的父级作用域就是全局,所以打印出来就是why

图片.png

  • 函数嵌套

在foo函数中再嵌套一个bar函数,在bar函数中打印这个name会怎么样呢?

  1. 在代码执行前(预编译),在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值,bar函数这里保存的是内存地址0xb00,指向它所对应的内存空间
  2. 执行代码的时候,给AD里面的num:undefined等赋值,变成num:123等
  3. 执行第13行代码的时候,这里是调用了bar函数,然后它就会在函数调用栈中创建一个函数执行上下文
  4. 打印name的时候,先在AO里面找是否有name,没有-->去上层作用域找,没有-->沿着作用域链继续往上层找,找到了name = 'why',所以最终打印出来是'why'。

图片.png

函数调用函数执行过程

打印结果: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" 图片.png

变量环境和记录

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

图片.png

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

图片.png

  • 通过上面的变化我们可以知道,在最新的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。

图片.png 注意:这里要区分一下,如果是这种情况,输出的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 = 100
foo()

输出结果:undefined; 200

分析:

图片.png 第三题

        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

分析:

图片.png

第五题

function foo(){
    var a = b = 100
    //这里相对于var a = 10
    //         b = 10 
    //b这里没有定义,js会默认把他放在全局
 }
foo()
console.log(a)
console.log(b)

输出结果:undefined ; 10