JS Advance --- 浏览器解析过程 和 预解析

670 阅读10分钟

浏览器内核

不同的浏览器有不同的内核组成:

Gecko: 早期被Netscape和Mozilla Firefox浏览器浏览器使用

Trident: 微软开发,被IE4~IE11浏览器使用,但是Edge浏览器已经转向Blink

Webkit:苹果基于KHTML开发、开源的,用于Safari,Google Chrome之前也在使用

Blink:是Webkit的一个分支,Google开发,目前应用于Google Chrome、Edge、Opera等

事实上,我们经常说的浏览器内核指的是浏览器的排版引擎:

排版引擎(layout engine),也称为浏览器引擎(browser engine)、页面渲染引擎(rendering engine) 或样版引擎

浏览器内核

IJp2kS.png

  1. 当我们输入网址去请求资源的时候,浏览器会将域名转换为ip地址,随后通过ip地址去对应的主机上下载对应的资源,

    一般情况下,我们会去请求的首页资源为index.html

  2. 浏览器会通过HTML Parser将html解析为DOM Tree, 并开始解析,当解析到script标签和link标签,且需要对应的资源的时候,

    浏览器就会发送请求去请求对应的css文件,或js文件

  3. 浏览器会通过CSS Parser将CSS 解析为Style Rules

  4. 浏览器在解析JS代码的时候,会停止HTML和CSS的解析,因为JS可以通过代码的方式去操作HTML和CSS,等到JS解析完毕以后

    浏览器才会继续解析CSS和JS,从而减少不必要的操作

  5. 浏览器继续生成DOM Tree 和 Style Rules后,会将他们进行合并(Attachment)生成最后的Render Tree(渲染树)

  6. 通过Layout对Render Tree具体如何渲染进行布局,如根据浏览器实际的宽度和高度确定样式值中80%所对应的具体距离值

  7. 通过Painting模块,对Render Tree进行实际的绘制,最后将绘制后的内容展示在界面上

JS引擎

JS是一门高级的编程语言,这就意味着我们在使用JS的时候,需要一个工具将JS转换为CPU可以直接识别的机器语言,这个工具就是JS引擎

常见的JS引擎

SpiderMonkey: 第一款JavaScript引擎,由Brendan Eich开发(也就是JavaScript作者)

Chakra: 微软开发,用于IE浏览器

JavaScriptCore: WebKit中的JavaScript引擎,Apple公司开发

V8: Google开发,用于Chrome的引擎

浏览器内核和JS引擎

虽然我们经常说的浏览器内核实际指代的是排版引擎

但是实际上,浏览器内核由两部分组成:

排版引擎: 负责HTML解析、布局、渲染等等相关的工作

JS引擎: 解析、执行JavaScript代码

以Webkit为例,其中的排版引擎为webCore,JS引擎为JSCore

V8

  • V8是用C ++编写的Google开源高性能引擎

  • V8可以解析JavaScript和WebAssembly

  • V8可以运行在Chrome和Node.js上

  • V8是跨平台的,可以运行在Linux,Windows,Mac os等多个平台上

  • V8可以独立运行,也可以嵌入到任何C ++应用程序中

IJvSCH.png

浏览器引擎在解析到JS引擎后,会以数据流的形式,将js代码交给js引擎去进行对应的解析

  1. parse模块: 对JS引擎进行相应的编码解析,语法分析和词法分析,生成名为tokens的对象数组,最后将tokens数组生成对应的AST抽象语法树

  2. Ignitation模块: 解析AST树生成对应的字节码,在解析过程中,会收集对应的信息,如类型信息,将频繁会调用的函数标记为hot函数

  3. V8引擎根据用户所在的平台的CPU架构 (不同平台的CPU架构是不同的,例如:window的CPU架构和mac的CPU架构是不同的)

转换为对应的汇编语言,再转换为对于的机器指令去操作CPU来完成我们对应的功能

  1. 之前所收集到的hot函数,会通过TurboFun模块,将对应的代码在合适的时机直接转换为优化后的机器码,并缓存起来

  2. 因为JS是弱数据类型语言,所以在这个过程中,可能因为参数类型的改变,导致之前缓存起来的函数无法再次使用

    所以V8也提供了对应的Deoptimization操作,将优化后的机器码转换为字节码,在根据情况将再次重新转换为合适的机器码

总结:

Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码:

  • 如果函数没有被调用,那么是不会被转换成AST的

Ignition是一个解释器,会将AST转换成ByteCode(字节码):

  • 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算)
  • 如果函数只调用一次,Ignition会执行解释执行ByteCode

TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码:

  • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能
  • 机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是 number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码
  • 这就是为什么需要TypeScript来对JS进行类型校验的原因之一

IJvBCy.png

Blink将源码交给V8引擎,Stream以数据流的形式获取到源码并且进行编码转换;

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

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

  • Parser就是直接将tokens转成AST树架构
  • PreParser就是我们常说的预解析

生成AST树后,会被Ignition转成字节码(bytecode),之后的过程就是代码的执行过程

预解析

为什么需要预解析

  • 预解析又被称之为变量提升
  • 并不是所有的JavaScript代码,在一开始时就会被执行,对于不需要立即解析内容,js引擎会对他们仅仅只是进行简单的预编译操作,仅仅只是解析其名称和参数。
  • V8引擎实现了Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容(如函数名,参数信息等),而对函数的全量解析是在函数被调用时才会进行
  • 预解析过程是在js代码转换为AST的时候进行的,也就是在parse模块工作的时候(编译阶段)被执行
function foo(num1, num2) {
  // 这里的add函数是不需要一上来就被解析为AST的
  // 只有当foo实际被调用的时候,才需要解析其中的代码和函数
  function add(num1, num2) {
    console.log(num1 + num2)
  }

  add()
}

foo(20, 30)

预解析过程

变量的预解析

初始化全局对象

  1. js引擎会在执行代码之前,会在堆内存中创建一个全局对象
  2. 这个全局对象名为Global Object,简称为GO
  3. 该对象在所有的作用域(scope)都可以访问
  4. 该对象中包含了Date、Array、String、Number、setTimeout、setInterval等全局对象和全局方法
  5. 还有一个window属性指向自己
  6. GO会在GEC(全局执行上下文)被创建之前就已经被创建,是最早被创建的内容
// 伪代码如下
var globalObject =  {
  setTimeout: ()=>{},
  setInterval: ()=>{},
  Math: {},
  Date: {},
  window: this
  ....
}
// 这就是为什么下面三行代码输出的都是window对象
console.log(window)
console.log(window.window)
console.log(window.window.window)

创建执行上下文栈

  • 执行上下文栈 (Execution Context Stack,简称为ECS或ECStack)

  • ECS本质上就是平时说的代码的执行上下文栈

创建全局执行上下文

  1. 当我们执行全局代码的时候,就会创建Global Execution Context,简称为GEC

  2. GEC在解析的时候,会被加入到ECS中运行, 也就是进行入栈操作

2.1 在GEC对象中加入一个值为对象的属性VO(variable object), 对于GEC的VO,其值就是GO

IJwOty.png

2.2 在代码执行之前,将全局变量和全局函数加载到GlobalObject中,但是并不赋值

var num1 = 20
var num2 = 30

var result = num1 + num2

// 上述代码 转换后的GO对象的伪代码如下

var globalObject = {
 /*
   此处省略 存在的 全局的属性和方法
 */
  num1: undefined,
  num2: undefined,
  result: undefined
}
// 这也就是为什么在变量未定义前可以访问后面才定义的变量
// 且获取到的值是undefined的原因
// 而这一步的过程,我们称之为预解析
// 注意: 只有使用var定义的变量才会进行预解析
console.log(num1)

var num1 = 20
var num2 = 30

var result = num1 + num2

IJwxjk.png

2.3 逐行执行代码

// 最后形成的GO的伪代码如下:
var globalObject = {
 /*
    此处省略 存在的 全局的属性和方法
 */
  num1: 20,
  num2: 30,
  result: 50
}
var num1 = 20
var num2 = 30

var result = num1 + num2

// 这也就是为什么可以在定义且赋值后
// 可以使用或访问对应的变量的值
console.log(result) // => 50

IJwZ0r.png

函数的预解析
// 示例代码
sum(10, 20)

function sum(num1, num2) {
  console.log(num1 + num2)
}

sum(20, 30)

编译阶段

和变量的预解析一样,在编译阶段会创建GO, GEStack等,只不过,函数会被视为特殊的数据类型,会在内存中单独开辟一个新的内存空间

ItLi58.png

  1. 函数在GO中值存储的是函数名和函数体所对应的地址,而实际存储函数的是另一个地址空间,此时我们就说sum指向(引用)了0x100这块内存空间

  2. 在函数体中会存储两部分内容

    • 当前需要存储函数的函数体
    • [[ scope ]] 这是一个内部私有属性,存储的是当前函数的父级作用域,在这里sum函数的父级作用域就是GO对象

执行阶段

// 因为函数体被提前声明好了,所以sum函数可以被正常调用
sum(10, 20)

function sum(num1, num2) {
  console.log(num1 + num2)
}

sum(20, 30)

函数被调用了,浏览器才会去解析器内部的代码,所以在此时会和GEC一样,在ECStack中压入一个FEC(函数执行上下文),并开始进行和GEC中一样的操作,只不过此时的VO不在指向GO,而是指向一个新的对象,称之为AO(当前活跃对象),之后的操作和之前一致

AO对象在函数被解析,并创建函数执行上下文前就已经被解析创建完毕

ItP5Nu.png

函数执行完毕

函数在执行完毕以后,会将函数执行上下文和其对应的AO对象,弹出栈,所以此时的内存如下:

ItLi58.png

如果此时,再次执行sum函数的时候,会创建新的FEC和其对应的AO对象,在重新进行对应的流程

所以在本例中,执行了两次sum函数,所以会创建两个完全独立的FEC和对应的AO

PS:

在ES5即以前的js版本中,在执行上下文中会存在一个VO,类型为对象,所有的属性和方法会挂载到VO对象上

但是在新的ES版本中,VO对象改名为了VE(variable Envirment),类型也不再局限为对象,可以是map,set等结构来进行实现

而其中的每一个存储的属性值,每一个键值对被称之为ER(Envirment Record)