关于浏览器工作原理和 JS 引擎

160 阅读8分钟

1. 从输入 URL 到页面展示 发生了什么?

​ 总体分为以下过程:

  • DNS 域名解析:将域名解析成 IP 地址
  • TCP 连接:TCP 三次握手
  • 发送 HTTP 请求
  • 服务器处理请求并返回 HTTP 报文
  • 浏览器解析渲染页面
  • 断开连接:TCP 四次挥手

2. 浏览器工作原理

在浏览器中输入查找内容,浏览器是怎样将页面加载出来的?

大致流程如下:

  • 首先,用户输入服务器地址,与服务器建立连接
  • 服务器返回对应的静态资源(index.html)
  • 然后浏览器拿到 index.html 后进行解析
  • 当解析时遇到 css 或 js 文件,就向服务器请求并下载对应的 css 和 js 文件
  • 最后浏览器对页面进行渲染,执行 js 代码

3. 浏览器渲染过程

image-20220325160129577.png

  1. HTML Parser 将 HTML解析转换成 DOM 树

  2. CSS Parser 将 样式表转换成 CSS 规则树

  3. 合并 DOM 树和 CSS 规则树,生成 render(渲染) 树

  4. 布局 render 树(Layout)

    通过渲染树中渲染对象的信息,计算出每一个渲染对象的位置和尺寸

  5. 绘制 render 树(painting),进行 Display 展示

注意图中顶部的紫色 DOM 三角形,实际上是 js 对 DOM 的相关操作。

4. 一个强大的 JavaScript 引擎 — V8 引擎

在解析 HTML 的过程中,遇到了 JavaScript 标签,该怎么办呢?

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

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

  • JavaScript 引擎

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

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

(1)V8 引擎的架构

image-20220325165859884.png

V8 的底层架构主要有三个核心模块(Parse、Ignition、TurboFan)

1. Parse(解析):

该过程主要是对 JavaScript 源代码进行词法分析语法分析

词法分析:对代码中的每一个词每一个符号进行解析,最终生成很多 tokens

例如:对 const name = "curry"

// 首先对const进行解析,因为const为一个关键字,所以类型会被记为一个关键词,值为const
tokens: [
  { type: 'keyword', value: 'const' }
]

// 接着对name进行解析,因为name为一个标识符,所以类型会被记为一个标识符,值为name
tokens: [
  { type: 'keyword', value: 'const' },
  { type: 'identifier', value: 'name' }
]

// 以此类推...

语法分析:在词法分析的基础上,拿到 tokens 中的一个个对象,根据不同类型,再进一步分析具体语法,最终生成 AST 抽象语法树

可以详细查看通过 Parse 转换后的 AST 的工具:astexplorer.net/

2. Ignition

一个解析器,可以将 AST 转换成 ByteCode(字节码)

3. TurboFan

一个编译器,可以将字节码编译为 CPU 认识的机器码

(2)V8 引擎的执行过程

image-20220325171051967.png

  • Blink 内核将 JS 源码交给 V8 引擎
  • Stream 获取到 JS 源码进行编码转换
  • Scanner 进行词法分析,将代码转换成 tokens
  • Parser 和 PreParser
    • Parser :直接解析,将 tokens 转成 AST 树
    • PreParser:预解析,对不必要的函数进行预解析,也就是只解析暂时需要的内容,而在函数被调用时才进行函数的全量解析
  • 生成 AST 树后,会被 Ignition 转成字节码,之后就是代码的执行过程

5. JavaScript 的执行过程

假如要执行如下代码:

var title = "hello"
console.log(num1)
var num1 = 20
var num2 = 30
var result = num1 + num2
console.log(result)

(1)首先,代码被解析,V8 引擎内部会帮助我们创建一个全局对象:Global Object(GO)

  • GO 可以访问所有的作用域

  • 里面会包含 Date、Array、String、setTimeout等等(所以我们可以直接 new Date() )

  • GO 还有一个window 属性指向自己(所以window.window.window还是指向 GO自己)

用伪代码表示为:

var globalObject = {
    String: 类,
    Date: 类
    setTimeout: 函数,
    ...
    window: globalObject,
    title: undefined,
    num1: undefined,
    num2: undefined,
    result: undefined
}

window指向自己,window.window.window

(2)然后运行代码

  1. 为了执行代码,v8引擎内部会有一个执行上下文栈(Execution Context Stack,简称 ECStack),也是函数调用栈,它是用于执行代码的调用栈

  2. 为了全局代码能够正常执行,首先需要创建一个**全局执行上下文 **(Global Execution Context,简称GEC),一般只有一个,在全局代码需要被执行时才会创建

  3. 然后全局执行上下文会被放入执行上下文栈中执行,包含两个部分:

    • 在代码执行前,会将全局定义的变量,函数等加入到 GlobalOject 中,但是并不会赋值(也称为变量的作用域提升

    VO:变量对象

    img
    • 开始依次执行代码:

    title = "hello" // 赋值

    console.log(num1) // undefined, 不会报错

    num1= 20 // 赋值

    num2......

全局代码执行过程—函数

疑问:为什么在函数定义之前也可以执行这个函数?

全局中,函数的执行过程:

首先根据函数体创建一个函数执行上下文,并且压入到执行上下文栈中(EC Stack)

注意:每个函数执行都会在栈中创建函数执行上下文,执行完之后就出栈

在初始化 GO 的时候,函数的 AO 也是会被初始化的

比如说 ,全局中,有function foo() {},一开始初始化GO的时候从上到下执行到 foo

会在内存中开辟一块内存空间,存储函数foo,其中包含:

  • [[scope]]:parent scope(实际上是函数的上一层作用域)

    foo.[[scope]] = [
    	globalContext.VO
    ]
    
  • 函数的执行体(代码块)

例如:执行以下代码

var name = "why"

foo(123)
function foo(num) {
  console.log(m)
  var m = 10
  var n = 20
}

image.png

补充:以下name打印的是什么

var name = "why"

function foo() {
  var name = "foo"
  console.log(name)
}

打印的是"foo",console.log(name)执行的时候,会在foo的函数执行上下文中的AO查找name,找到的是"foo"

实际上,查找变量的时候,是沿着作用域链来查找的

作用域链

作用域链由 当前执行上下文的VO(变量对象,在函数中就是 AO 对象)和 parent scope (父级 VO)组成,查找时会一层层查找

看一个例子:

var message = "Hello Global"

function foo() {
  console.log(message) // Hello Global
}

function bar() {
  var message = "Hello Bar"
  foo()
}

bar()

比如说这里,当执行 foo 函数的时候, foo 的 AO 中没有message,就会去它的父级 VO 中查找。一定要注意!当第一次全局代码初始化的时候, foo函数对象会保存在内存中,其中包括 [parent scope]: GO,函数体: 代码块

当foo函数执行的时候,会找到保存的代码块,创建foo函数执行上下文,其中包括三部分:

  • 第一部分:在解析函数成为 AST 树结构的时候,会创建一个 AO(Activation)

    其中包含形参、arguments、函数定义、指向函数对象或定义的变量

  • 第二部分:就是作用域链[AO+VO]

  • 第三部分:this 绑定的值

当foo在自己的AO找不到message的时候,去父级 VO 找,这个父级 VO 就是一开始初始化的时候保存的 [parent scope]:GO,所以message为 "Hello Global"

简单描述一下这个过程:

// 全局执行上下文
// 初始化
{VO: GO: {window; message:undefined; foo: 地址1; bar: 地址2;}}
其中
地址1(foo函数对象):{[parent scope]: GO;函数体: 代码块}
地址2(bar函数对象):{[parent scope]: GO;函数体: 代码块}

// 执行代码
{VO: GO: {window; message:"Hello Global"; foo: 地址1; bar: 地址2;}}
// bar()
bar函数执行,创建一个函数执行上下文,
	其中包括 {
        VO: AO:{message: undefined}
        scope chain: AO + parent scope(这里是GO)
        this
    }
	// 然后开始执行 bar的代码
	message:"Hello Bar" (赋值)
	foo()
	  foo函数执行,创建一个foo的函数执行上下文
	  其中包括:{
          VO: AO: {} 
          scope chain: AO + parent scope(这里是GO)
          this
      }
      
	  // 然后开始执行 foo的代码
	  console.log(message)
	  发现自己的AO没有message,会向上找,即从自己保存的父级VO中查找,找到GO中的message为 "Hello Global"

JS执行上下文文章推荐:github.com/mqyqingfeng…

写的挺清晰详细的

6. 变量环境和记录

早起的ECMA版本规范:

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

image-20220704004343272.png 也就是前面的变量对象VO改为变量环境 VE,因为规范不在限制一定要是一个对象了

7. 几道常见的作用域提升面试题:

  1. var n = 100
    function foo() {
      n = 200
    }
    foo()
    console.log(n) // 200
    // foo的VO中找不到n,因此向父级VO中找,所以赋值给了全局的n
    
  2. function foo() {
      console.log(n) // undefined
      var n = 200
      console.log(n) // 200
    }
    
    var n = 100
    foo()
    
  3. var a = 100
    
    function foo() {
      console.log(a) // undefined
      return
      var a = 200
    }
    
    foo()
    
  4. function foo() {
      m = 100
    }
    
    foo()
    console.log(m) // 100
    // 严格来说这是一种语法错误
    // js引擎的处理:会把m放到
    
  5. function foo() {
      var a = b = 10
      // => 转成下面的两行代码
      // var a = 10
      // b = 10
    }
    
    foo()
    
    //console.log(a) // 报错 a is not defined(因为当 foo函数执行完之后,foo的函数执行上下文就会弹出栈(没啦!哪里还会有a呢))
    console.log(b) // 10