浏览器工作原理 学习笔记

学习地址:

浏览器工作原理与实践

浏览器架构演化

进程:一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程
线程:依附于进程的,在进程中使用多线程并行处理能提升运算效率
进程与线程关系:
1.进程中的任意一线程执行出错,都会导致整个进程的崩溃。
2.线程之间共享进程中的数据。
3.当一个进程关闭之后,操作系统会回收进程所占用的内存
4.进程之间的内容相互隔离。
**单进程浏览器:**不稳定(插件和渲染引擎),不流畅(同一时刻只有一个模块运行),不安全(插件和脚本)

早期多进程架构

浏览器工作原理 学习笔记_第1张图片

目前多进程架构浏览器工作原理 学习笔记_第2张图片

缺点:占用资源多(吃内存),架构复杂(各模块耦合性高、扩展性差)

面向服务的架构(Service Oriented Architecture)

各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。

浏览器工作原理 学习笔记_第3张图片

TCP/IP

IP

(Internet Protocol 网际协议)***把数据包送达目的主机(靠IP辨认)
image.png

UDP

(User Datagram Protocol 用户数据包协议): 把数据包送达应用程序(靠端口号辨认目的地)
特点:不能保证数据可靠性,速度快
浏览器工作原理 学习笔记_第4张图片

TCP

(Transmission Control Protocol):把数据完整地送达应用程序(一种面向连接的、可靠的、基于字节流的传输层通信协议)
特点:提供重传机制防止数据包丢失;引入数据包排序机制,确保数据恢复
浏览器工作原理 学习笔记_第5张图片

TCP连接过程

浏览器工作原理 学习笔记_第6张图片
建立连接:三次握手
数据传输:接收端需要对每个数据包进行确认操作
断开连接:四次挥手

HTTP

建立在TCP连接基础之上,一种允许浏览器向服务器获取资源的协议。

浏览器发起HTTP请求

1.构建请求:构建请求行信息

GET /index.html HTTP1.1

2.查找缓存:查看浏览器缓存是否有资源副本
3.准备IP和端口:请求DNS返回域名指向的IP(DNS也有缓存)
浏览器工作原理 学习笔记_第7张图片
4.等待TCP队列:同一域名同时最多只能建立6个TCP连接,超过的连接会处于等待状态,直到前面的请求完成
5.建立TCP连接:
6.发送HTTP请求:
浏览器工作原理 学习笔记_第8张图片

服务器处理HTTP请求

1.返回请求:
浏览器工作原理 学习笔记_第9张图片
2.断开连接:一般服务器返回数据就会关闭TCP连接,除非

Connection:Keep-Alive 

保持 TCP 连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。
3.重定向:

浏览器工作原理 学习笔记_第10张图片

浏览器缓存

浏览器工作原理 学习笔记_第11张图片

登录状态保持

浏览器工作原理 学习笔记_第12张图片

输入URL到页面展示

浏览器工作原理 学习笔记_第13张图片
1.用户输入:生成请求的URL(搜索内容拼接或直接就是URL)
2.URL请求过程:浏览器进程通过进程通信(IPC)把URL给网络进程,网络进程先找缓存,有缓存返回无缓存请求
2.1重定向
2.2响应数据类型处理:Content-Type
浏览器工作原理 学习笔记_第14张图片
浏览器工作原理 学习笔记_第15张图片
3.准备渲染进程:默认每一个页面一个渲染进程,但如果从一个页面打开另一个新页面且二者属于同一站点(根域名和协议都相同)那么新页面复用父页面的渲染进程(即process-per-site-instance)
4.提交文档:此处文档指URL请求的响应体数据

  • 提交文档的消息是浏览器进程发出,渲染进程接收到消息后会和网络进程建立传输数据的管道
  • 文档传输完成后渲染进程会返回“确认提交”的消息给浏览器进程
  • 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态(安全状态、地址栏URL、历史信息等)并更新web页面

浏览器工作原理 学习笔记_第16张图片
5.渲染阶段:渲染进程开始页面解析和子资源的加载

HTML、CSS、JS如何变成页面

浏览器工作原理 学习笔记_第17张图片

渲染流水线

浏览器工作原理 学习笔记_第18张图片

构建DOM树:

浏览器工作原理 学习笔记_第19张图片

样式计算:

1.把CSS转换为浏览器能够理解的结构(styleSheets)
浏览器工作原理 学习笔记_第20张图片
2.转换样式表中的属性值,使其标准化
浏览器工作原理 学习笔记_第21张图片
3.计算出DOM树中每个节点的具体样式(根据继承规则和层叠规则)
浏览器工作原理 学习笔记_第22张图片
浏览器工作原理 学习笔记_第23张图片

布局阶段:

计算DOM树中可见元素的几何位置
1.创建布局树
浏览器工作原理 学习笔记_第24张图片
2.布局计算

分层

:为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree),图层叠加合成最终页面(不是每个节点都包含一个图层,如果一个节点没有对应的层,那该节点姐从属于父节点所在图层)
创建型层的要求:
1)拥有层叠上下文属性的元素会被提升为单独的一层(关于层叠上下文)
浏览器工作原理 学习笔记_第25张图片
2)需要裁剪(clip)的地方也会被创建为图层

<style>
      div {
            width: 200;
            height: 200;
            overflow:auto;
            background: gray;
        } 
style>
<body>
    <div >
        <p> 所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:p>
        <p> 从上图我们可以看到,document 层上有 A 和 B 层,而 B 层之上又有两个图层。这些图层组织在一起也是一颗树状结构。p>
        <p> 图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。p> 
    div>
body>

上面文字显示区域会超出div,产生剪裁,渲染引擎会把剪裁文字内容的一部分用于显示在div区域
渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层
浏览器工作原理 学习笔记_第26张图片
浏览器工作原理 学习笔记_第27张图片

图层绘制

:把一个图层绘制拆分Wie很多小的绘制指令,按顺序组成待绘制列表
浏览器工作原理 学习笔记_第28张图片

栅格化

绘制操作实际由渲染引擎中的合成线程来完成
浏览器工作原理 学习笔记_第29张图片
视口:屏幕上页面的可见区域(ViewPort)
浏览器工作原理 学习笔记_第30张图片
合成线程会将图层分为图块(tile),通常为256X256或512X512。按照视口附近的图块来优先生成位图,实际生成位图的操作由栅格化(将图块转化为位图)来执行。图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的
浏览器工作原理 学习笔记_第31张图片
栅格化都会使用GPU来加速(快速栅格化),生成的位图保存在GPU内存中
浏览器工作原理 学习笔记_第32张图片

合成和显示:

图块光栅化结束,合成线程生成绘制图块的命令——“DrawQuad”,将该命令交给浏览器进程。浏览器进程中viz组件接收命令根据命令将页面内容绘制到内存中,再显示到屏幕上。
浏览器工作原理 学习笔记_第33张图片

相关概念

重排

:更新了元素的几何属性(重排需要更新完整的流水线,开销最大)
浏览器工作原理 学习笔记_第34张图片

重绘

:更新元素的绘制属性(不进行布局和分层,效率比重排高)
浏览器工作原理 学习笔记_第35张图片

直接合成

**:**修改既不布局也不绘制的属性,渲染引擎会跳过布局和绘制只执行后续的合成操作。
浏览器工作原理 学习笔记_第36张图片

JS执行机制

变量提升

JS代码执行中,JS引擎把变量的声明部分和函数的声明部分提升到代码开头的行为。变量提升后,会给变量设置默认值为undefined
浏览器工作原理 学习笔记_第37张图片

JS代码执行流程

实际上变量和函数声明在代码里的位置是不会改变的,而是在编译阶段被JavaScript引擎放入内存中。
浏览器工作原理 学习笔记_第38张图片

1.编译阶段

编译结果:执行上下文(Execution context)和可执行代码
执行上下文是JS执行一段代码时的运行环境。执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容。
浏览器工作原理 学习笔记_第39张图片

2.执行阶段

  • 当执行到 showName 函数时,JavaScript 引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以 JavaScript 引擎便开始执行该函数,并输出“函数 showName 被执行”结果。
  • 接下来打印“myname”信息,JavaScript 引擎继续在变量环境对象中查找该对象,由于变量环境存在 myname 变量,并且其值为 undefined,所以这时候就输出 undefined。
  • 接下来执行第 3 行,把“极客时间”赋给 myname 变量,赋值后变量环境中的 myname 属性值改变为“极客时间”

调用栈

用来管理函数调用关系的一种数据结构

函数调用

即运行一个函数——即,函数名()

var a = 2
function add(){
  var b = 10
  return  a+b
}
add()

在执行到add()之前,JS引擎会创建全局执行上下文,包含声明的函数和变量
浏览器工作原理 学习笔记_第40张图片
首先,从全局执行上下文中,取出add函数代码。其次,对add函数进行编译,并创建该函数的执行上下文和可执行代码。最后,执行代码,输出结果。
浏览器工作原理 学习笔记_第41张图片

浏览器工作原理 学习笔记_第42张图片

JS调用栈

在执行上下文创建好后,JS引擎会将执行上下文压入栈中(执行上下文栈或调用栈)

var a = 2
function add(b,c){
  return b+c
}
function addAll(b,c){
  var d = 10
  result = add(b,c)
  return  a+result+d
}
addAll(3,6)

1.创建全局上下文,并将其压入栈底
浏览器工作原理 学习笔记_第43张图片
执行全局代码
浏览器工作原理 学习笔记_第44张图片
2.调用addAll函数
浏览器工作原理 学习笔记_第45张图片
执行d=10
3.执行到add函数
浏览器工作原理 学习笔记_第46张图片
add函数返回,并出栈
浏览器工作原理 学习笔记_第47张图片
addAll出栈
浏览器工作原理 学习笔记_第48张图片

调用栈的使用

1.利用浏览器查看调用栈的信息:
加断点,call stack
浏览器工作原理 学习笔记_第49张图片
console.trace()
浏览器工作原理 学习笔记_第50张图片
2.栈溢出(Stack Overflow)
当入栈的执行上下文超过一定的数目,JS引擎就会报错,即栈溢出
浏览器工作原理 学习笔记_第51张图片

块级作用域

作用域

程序中定义变量的区域,该位置决定了变量的生命周期。作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

变量提升带来的问题

1.变量容易在不被察觉的情况下被覆盖掉
2.本应该销毁的变量没有被销毁

ES6的解决方案

引入let和const关键字

function letTest() {
  let x = 1;
  if (true) {
    let x = 2;  // 不同的变量
    console.log(x);  // 2
  }
  console.log(x);  // 1
}

JS如何支持块级作用域

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()

1.编译并创建执行上下文
浏览器工作原理 学习笔记_第52张图片

  • 通过var声明的变量,在编译阶段会被存放到变量环境里面
  • 通过let声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中
  • 函数作用域内部,通过let声明的变量并没有被存放到词法环境中

2.继续执行代码
浏览器工作原理 学习笔记_第53张图片
在词法环境内维护一个小型栈结构,栈底是函数最外层的变量。当执行到console.log(a)时,需要在词法环境和变量环境中查找变量a的值,沿词法环境栈顶向下查询,如果在词法环境中的某个块中查找到了就返回,如果没有就在变量环境中查找。
浏览器工作原理 学习笔记_第54张图片
当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出

浏览器工作原理 学习笔记_第55张图片

扩展

报错,因为let在作用域内的声明被提升但是初始化没有被提上去,会形成暂时性死区

let myname= '极客时间'
{
  console.log(myname) 
  let myname= '极客邦'
}

作用域链和闭包

function bar() {
    console.log(myName)
}
function foo() {
    var myName = " 极客邦 "
    bar()
}
var myName = " 极客时间 "
foo()

浏览器工作原理 学习笔记_第56张图片

作用域链

每个执行上下文的变量环境中,都包含了一个外部引用(outer),用来指向外部的执行上下文。当一段代码使用了一个变量时,JS引擎先在当前的执行上下文中查找该变量,如果没有找到,JS引擎会继续在outer所指的执行上下文中查找。
浏览器工作原理 学习笔记_第57张图片
从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链

词法作用域

JS执行过程中,其作用域链是由词法作用域决定的。词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
浏览器工作原理 学习笔记_第58张图片
词法作用域是在代码阶段就决定好的,和函数是怎么调用的没有关系

块级作用域中的变量查找

function bar() {
    var myName = " 极客世界 "
    let test1 = 100
    if (1) {
        let myName = "Chrome 浏览器 "
        console.log(test)
    }
}
function foo() {
    var myName = " 极客邦 "
    let test = 2
    {
        let test = 3
        bar()
    }
}
var myName = " 极客时间 "
let myAge = 10
let test = 1
foo()

浏览器工作原理 学习笔记_第59张图片

闭包

function foo() {
    var myName = " 极客时间 "
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(" 极客邦 ")
bar.getName()
console.log(bar.getName())

浏览器工作原理 学习笔记_第60张图片
根据词法作用域链的规则,内部函数getName和setName总是可以访问他们的外部函数foo中的变量。所以当innerBar对象返回giee全局变量bar时,虽然foo函数已经执行结束,但是getName和setName函数依然可以使用foo函数中的变量myName和test1.
浏览器工作原理 学习笔记_第61张图片
foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。
之所以是
专属
背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包
浏览器工作原理 学习笔记_第62张图片

闭包怎么回收

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。
所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量

this

var bar = {
    myName:"time.geekbang.com",
    printName: function () {
        console.log(myName)
    }    
}
function foo() {
    let myName = " 极客时间 "
    return bar.printName
}
let myName = " 极客邦 "
let _printName = foo()
_printName()
bar.printName()

此处输出“极客邦”,为了输出bar内部的myName,引入了this机制
为了在对象内部的方法中使用对象内部的属性是一个非常普遍的需求

this是什么

浏览器工作原理 学习笔记_第63张图片
this是和执行上下文绑定的,即每一个执行上下文都有一个this

#### 全局上下文中的this
全局执行上下文中的 this 是指向 window 对象的。

函数执行上下文中的this

默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的可以进行设置
1.通过函数的 call 方法设置(还有bind和apply)

let bar = {
  myName : " 极客邦 ",
  test1 : 1
}
function foo(){
  this.myName = " 极客时间 "
}
foo.call(bar)
console.log(bar)
console.log(myName)

浏览器工作原理 学习笔记_第64张图片
2. 通过对象调用方法设置
使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的

var myObj = {
  name : " 极客时间 ", 
  showThis: function(){
    console.log(this)
  }
}
myObj.showThis()

在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window
浏览器工作原理 学习笔记_第65张图片
3. 通过构造函数中设置

function CreateObj(){
  this.name = " 极客时间 "
}
var myObj = new CreateObj()

其实,当执行 new CreateObj() 的时候,JavaScript 引擎做了如下四件事:

  • 首先创建了一个空对象 tempObj;
  • 接着调用 CreateObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 CreateObj 的执行上下文创建时,它的 this 就指向了 tempObj 对象;
  • 然后执行 CreateObj 函数,此时的 CreateObj 函数执行上下文中的 this 指向了 tempObj 对象;
  • 最后返回 tempObj 对象。

this的缺点及解决方案

嵌套函数中的this不会继承外层this

var myObj = {
  name : " 极客时间 ", 
  showThis: function(){
    console.log(this)
    function bar(){console.log(this)}
    bar()
  }
}
myObj.showThis()

函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象

var myObj = {
  name : " 极客时间 ", 
  showThis: function(){
    console.log(this)
    var self = this
    function bar(){
      self.name = " 极客邦 "
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

上面方法的本质是把this体系转换为了作用域体系
也可以使用 ES6 中的箭头函数来解决这个问题

var myObj = {
  name : " 极客时间 ", 
  showThis: function(){
    console.log(this)
    var bar = ()=>{
      this.name = " 极客邦 "
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

普通函数中的 this 默认指向全局对象 window

在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。
可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。

总结

  1. 当函数作为对象的方法调用时,函数中的 this 就是该对象;
  2. 当函数被正常调用时,在严格模式下,this 值是 undefined,非严格模式下 this 指向的是全局对象 window;
  3. 嵌套函数中的 this 不会继承外层函数的 this 值。

V8工作原理

JS中数据如何存储在内存中

JS是什么类型的语言

在使用之前就需要确认其变量数据类型的称为静态语言。相反地,我们把在运行过程中需要检查数据类型的语言称为动态语言。JS为动态语言。
支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言。在这点上,C 和 JavaScript 都是弱类型语言。

浏览器工作原理 学习笔记_第66张图片
弱类型,意味着你不需要告诉 JavaScript 引擎这个或那个变量是什么数据类型,JavaScript 引擎在运行代码的时候自己会计算出来。
动态,意味着你可以使用同一个变量保存不同类型的数据。

JS数据类型

var bar
console.log(typeof bar)  //undefined
bar = 12 
console.log(typeof bar) //number
bar = " 极客时间 "
console.log(typeof bar)//string
bar = true
console.log(typeof bar) //boolean
bar = null
console.log(typeof bar) //object
bar = {name:" 极客时间 "}
console.log(typeof bar) //object

浏览器工作原理 学习笔记_第67张图片
注意:
1.使用 typeof 检测 Null 类型时,返回的是 Object。遗留bug()
2.Object 类型比较特殊,它是由上述 7 种类型组成的一个包含了 key-value 对的数据类型。
3.把前面的 7 种数据类型称为原始类型,把最后一个对象类型称为引用类型

内存空间

浏览器工作原理 学习笔记_第68张图片
JS内存模型
在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间堆空间

栈空间和堆空间

栈空间即调用栈,是来存储执行上下文的。

function foo(){
    var a = " 极客时间 "
    var b = a
    var c = {name:" 极客时间 "}
    var d = c
}
foo()

执行到第三行
浏览器工作原理 学习笔记_第69张图片
第4行,对象类型存放在堆空间,在栈空间只保留了对象的引用地址。
浏览器工作原理 学习笔记_第70张图片
原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的

为什么要区分堆栈

因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率
。所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。
浏览器工作原理 学习笔记_第71张图片
在 JavaScript 中,赋值操作和其他语言有很大的不同,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址
d=c

浏览器工作原理 学习笔记_第72张图片

闭包的内存模型

function foo() {
    var myName = " 极客时间 "
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(" 极客邦 ")
bar.getName()
console.log(bar.getName())
  1. 当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文。
  2. 在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。
  3. 接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 JavaScript 引擎又将 test1 添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了 myName 和 test1 两个变量了。
  4. 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。

执行到return innerBar时
浏览器工作原理 学习笔记_第73张图片
当 foo 函数执行结束之后,返回的 getName 和 setName 方法都引用“clourse(foo)”对象,所以即使 foo 函数退出了,“clourse(foo)”依然被其内部的 getName 和 setName 方法引用。所以在下次调用bar.setName或者bar.getName时,创建的执行上下文中就包含了“clourse(foo)”。
产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中。

垃圾回收

不同语言的垃圾回收策略

手动回收自动回收
C/C++ 就是使用手动回收策略,何时分配内存、何时销毁内存都是由代码控制的

// 在堆中分配内存
char* p =  (char*)malloc(2048);  // 在堆空间中分配 2048 字节的空间,并将分配后的引用地址保存到 p 中
 
 // 使用 p 指向的内存
 {
   //....
 }
 
// 使用结束后,销毁这段内存
free(p);
p = NULL

如果这段数据已经不再需要了,但是又没有主动调用 free 函数来销毁,那么这种情况就被称为内存泄漏
JavaScript、Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的,并不需要手动通过代码来释放。

调用栈中的数据是如何回收的

function foo(){
    var a = 1
    var b = {name:" 极客邦 "}
    function showName(){
      var c = 1
      var d = {name:" 极客时间 "}
    }
    showName()
}
foo()

当执行到第6行时,
浏览器工作原理 学习笔记_第74张图片有一个记录当前执行状态的指针(称为 ESP),指向调用栈中 showName 函数的执行上下文,表示当前正在执行 showName 函数。接着,当 showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP 这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo 函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程
image.png

堆中的数据是如何回收的

当函数执行完ESP指向全局执行上下文
浏览器工作原理 学习笔记_第75张图片

代际假说和分代收集

代际假说(The Generational Hypothesis)

  • 第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
  • 第二个是不死的对象,会活得更久。

在 V8 中会把堆分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象
新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。

垃圾回收器工作流程

1.标记空间中活动对象和非活动对象
2.回收非活动对象所占据的内存
3.内存整理(处理内存碎片)

副垃圾回收器

新生代中用Scavenge 算法来处理。即把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,

浏览器工作原理 学习笔记_第76张图片
新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去
由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小
也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。

主垃圾回收器

老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。主垃圾回收器是采用**标记 - 清除(Mark-Sweep)**的算法进行垃圾回收的。
比如最开始的那段代码,当 showName 函数执行退出之后,这段代码的调用栈和堆空间如下图所示
浏览器工作原理 学习笔记_第77张图片
如果遍历调用栈,是不会找到引用 1003 地址的变量,也就意味着 1003 这块数据为垃圾数据,被标记为红色。由于 1050 这块数据被变量 b 引用了,所以这块数据会被标记为活动对象。这就是大致的标记过程。
接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,你可以理解这个过程是清除掉红色标记数据的过程,可参考下图大致理解下其清除过程:
浏览器工作原理 学习笔记_第78张图片
对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

浏览器工作原理 学习笔记_第79张图片

全停顿

由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行,称全停顿(Stop-The-World)

浏览器工作原理 学习笔记_第80张图片
在 V8 新生代的垃圾回收中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代就不一样了。
为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法

浏览器工作原理 学习笔记_第81张图片
使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

V8是如何执行一段JS的

编译器(Compiler)和解释器(Interpreter)

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。
而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。
浏览器工作原理 学习笔记_第82张图片

  1. 在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。
  2. 在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果。

V8如何执行一段JS

浏览器工作原理 学习笔记_第83张图片
V8 在执行过程中既有解释器 Ignition,又有编译器 TurboFan

1.生成抽象语法树(AST)和执行上下文

高级语言是开发者可以理解的语言,但是让编译器或者解释器来理解就非常困难了。对于编译器或者解释器来说,它们可以理解的就是 AST 了。所以无论使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个 AST。这和渲染引擎将 HTML 格式文件转换为计算机可以理解的 DOM 树的情况类似。

var myName = " 极客时间 "
function foo(){
  return 23;
}
myName = "geektime"
foo()

浏览器工作原理 学习笔记_第84张图片
AST 是非常重要的一种数据结构,在很多项目中有着广泛的应用。其中最著名的一个项目是 Babel。Babel 是一个被广泛使用的代码转码器,可以将 ES6 代码转为 ES5 代码,这意味着你可以现在就用 ES6 编写程序,而不用担心现有环境是否支持 ES6。Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。
除了 Babel 外,还有 ESLint 也使用 AST。ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。
生成AST:
1)分词(tokenize),又称为词法分析:将一行行的源码拆解成一个个 token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串。

浏览器工作原理 学习笔记_第85张图片
2)**解析(parse),又称为语法分析:**将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

2.生成字节码

其实一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。
**浏览器工作原理 学习笔记_第86张图片

3.执行代码

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。
V8 的解释器和编译器的取名也很有意思。解释器 Ignition 是点火器的意思,编译器 TurboFan 是涡轮增压的意思,寓意着代码启动时通过点火器慢慢发动,一旦启动,涡轮增压介入,其执行效率随着执行时间越来越高效率,因为热点代码都被编译器 TurboFan 转换了机器码,直接执行机器码就省去了字节码“翻译”为机器码的过程。
其实字节码配合解释器和编译器是最近一段时间很火的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,我们把这种技术称为即时编译(JIT)。具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。

对于 JavaScript 工作引擎,除了 V8 使用了“字节码 +JIT”技术之外,苹果的 SquirrelFish Extreme 和 Mozilla 的 SpiderMonkey 也都使用了该技术。

浏览器工作原理 学习笔记_第87张图片

JS性能优化

  1. 提升单次脚本的执行速度,避免 JavaScript 的长任务霸占主线程,这样可以使得页面快速响应交互;
  2. 避免大的内联脚本,因为在解析 HTML 的过程中,解析和编译也会占用主线程;
  3. 减少 JavaScript 文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。

页面循环系统

消息队列和事件循环:页面是怎么活起来的

使用单线程处理安排好的任务

void MainThread(){
  int num1 = 1+2; // 任务 1
  int num2 = 20/5; // 任务 2
  int num3 = 7*8; // 任务 3
  print(" 最终计算的值为:%d,%d,%d",num,num2,num3)// 任务 4
}

浏览器工作原理 学习笔记_第88张图片

在线程运行过程中处理新任务

要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制

//GetInput
// 等待用户从键盘输入一个数字,并返回该输入的数字
int GetInput(){
    int input_number = 0;
    cout<<" 请输入一个数:";
    cin>>input_number;
    return input_number;
}
 
// 主线程 (Main Thread)
void MainThread(){
     for(;;){
          int first_num = GetInput()int second_num = GetInput();
          result_num = first_num + second_num;
          print(" 最终计算的值为:%d",result_num)}
}

相较于第一版的线程,这一版的线程做了两点改进。

  • 第一点引入了循环机制,具体实现方式是在线程语句最后添加了一个for 循环语句,线程会一直循环执行。
  • 第二点是引入了事件,可以在线程运行过程中,等待用户输入的数字,等待过程中线程处于暂停状态,一旦接收到用户输入的信息,那么线程会被激活,然后执行相加运算,最后输出结果。

浏览器工作原理 学习笔记_第89张图片

处理其他线程发送过来的任务

浏览器工作原理 学习笔记_第90张图片
那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?一个通用模式是使用消息队列
浏览器工作原理 学习笔记_第91张图片
有了队列之后,就可以继续改造线程模型了,改造方案如下

浏览器工作原理 学习笔记_第92张图片

  1. 添加一个消息队列;
  2. IO 线程中产生的新任务添加进消息队列尾部;
  3. 渲染主线程会循环地从消息队列头部中读取任务,执行任务。

处理其他进程发送过来的任务

浏览器工作原理 学习笔记_第93张图片
渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程

消息队列中的任务类型

内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

如何安全退出

Chrome在确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。如果设置了,那么就直接中断当前的所有任务,退出线程。

TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainThread(){
  for(;;){
    Task task = task_queue.takeTask();
    ProcessTask(task);
    if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
        break; 
  }
}

页面使用单线程的缺点

如何处理高优先级的任务

为了权衡效率和实时性,使用微任务。
通常把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。
等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

如何解决单个任务执行时长过久的问题

因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。

浏览器工作原理 学习笔记_第94张图片
如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉,这当然是极不好的用户体验。针对这种情况,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。

实践:浏览器页面是如何运行的

打开开发者工具,点击“Performance”标签,选择左上角的“start porfiling and load page”来记录整个页面加载过程中的事件执行情况
浏览器工作原理 学习笔记_第95张图片

WebAPI:setTimeout是如何实现的?

要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。
在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
延迟队列

 DelayedIncomingQueue delayed_incoming_queue;

当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间、延迟执行时间,其模拟代码如下

struct DelayTask{
  int64 id;
  CallBackFunction cbf;
  int start_time;
  int delay_time;
};
DelayTask timerTask;
timerTask.cbf = showName;
timerTask.start_time = getCurrentTime(); // 获取当前时间
timerTask.delay_time = 200;// 设置延迟执行时间

创建好回调任务之后,再将该任务添加到延迟执行队列中

delayed_incoming_queue.push(timerTask)

消息循环系统是怎么触发延迟队列的呢?

void ProcessTimerTask(){
  // 从 delayed_incoming_queue 中取出已经到期的定时器任务
  // 依次执行这些任务
}
 
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
  for(;;){
    // 执行消息队列中的任务
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    // 执行延迟队列中的任务
    ProcessDelayTask()
 
    if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
        break; 
  }
}

设置一个定时器,JavaScript 引擎会返回一个定时器的 ID。那通常情况下,当一个定时器的任务还没有被执行的时候,也是可以取消的,具体方法是调用clearTimeout 函数,并传入需要取消的定时器的 ID。其实浏览器内部实现取消定时器的操作也是非常简单的,就是直接从 delayed_incoming_queue 延迟队列中,通过 ID 查找到对应的任务,然后再将其从队列中删除掉就可以了。

使用 setTimeout 的一些注意事项

1. 如果当前任务执行时间过久,会影延迟到期定时器任务的执行

2. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒

也就是说在定时器函数里面嵌套调用定时器,也会延长定时器的执行时间
在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。

3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒

如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。

4. 延时执行时间有最大值

Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,这导致定时器会被立即执行。

5. 使用 setTimeout 设置的回调函数中的 this 不符合直觉

如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象。

var name= 1;
var MyObj = {
  name: 2,
  showName: function(){
    console.log(this.name);
  }
}
setTimeout(MyObj.showName,1000)
// 输出是1

解决方案
1)将MyObj.showName放在匿名函数中执行

// 箭头函数
setTimeout(() => {
    MyObj.showName()
}, 1000);
// 或者 function 函数
setTimeout(function() {
  MyObj.showName();
}, 1000)

2)使用 bind 方法,将 showName 绑定在 MyObj 上面

setTimeout(MyObj.showName.bind(MyObj), 1000)

扩展

setTimeout 设置的回调任务实时性并不是太好,所以很多场景并不适合使用 setTimeout。比如要使用 JavaScript 来实现动画效果,函数 requestAnimationFrame 就是个很好的选择。

WebAPI:XMLHttpRequest是怎么实现的

在 XMLHttpRequest 出现之前,如果服务器数据有更新,依然需要重新刷新整个页面。

回调函数VS系统调用栈

let callback = function(){
    console.log('i am do homework')
}
function doWork(cb) {
    console.log('start do work')
    cb()
    console.log('end do work')
}
doWork(callback)

回调函数 callback 是在主函数 doWork 返回之前执行的,把这个回调过程称为同步回调

let callback = function(){
    console.log('i am do homework')
}
function doWork(cb) {
    console.log('start do work')
    setTimeout(cb,1000)   
    console.log('end do work')
}
doWork(callback)

使用了 setTimeout 函数让 callback 在 doWork 函数执行结束后,又延时了 1 秒再执行,这次 callback 并没有在主函数 doWork 内部被调用,把这种回调函数在主函数外部执行的过程称为异步回调
消息队列和主线程循环机制保证了页面有条不紊地运行
循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈。这个系统调用栈类似于 JavaScript 的调用栈,只不过系统调用栈是 Chromium 的开发语言 C++ 来维护的,其完整的调用栈信息你可以通过 chrome://tracing/ 来抓取。当然,你也可以通过 Performance 来抓取它核心的调用信息
浏览器工作原理 学习笔记_第96张图片
异步回调是指回调函数在主函数之外执行,一般有两种方式:

  • 第一种是把异步函数做成一个任务,添加到信息队列尾部;
  • 第二种是把异步函数添加到微任务队列中,这样就可以在当前任务的末尾处执行微任务了。

XMLHttpRequest 运作机制

浏览器工作原理 学习笔记_第97张图片

 function GetWebData(URL){
    /**
     * 1: 新建 XMLHttpRequest 请求对象
     */
    let xhr = new XMLHttpRequest()
 
    /**
     * 2: 注册相关事件回调处理函数 
     */
    xhr.onreadystatechange = function () {
        switch(xhr.readyState){
          case 0: // 请求未初始化
            console.log(" 请求未初始化 ")
            break;
          case 1://OPENED
            console.log("OPENED")
            break;
          case 2://HEADERS_RECEIVED
            console.log("HEADERS_RECEIVED")
            break;
          case 3://LOADING  
            console.log("LOADING")
            break;
          case 4://DONE
            if(this.status == 200||this.status == 304){
                console.log(this.responseText);
                }
            console.log("DONE")
            break;
        }
    }
 
    xhr.ontimeout = function(e) { console.log('ontimeout') }
    xhr.onerror = function(e) { console.log('onerror') }
 
    /**
     * 3: 打开请求
     */
    xhr.open('Get', URL, true);// 创建一个 Get 请求, 采用异步
 
 
    /**
     * 4: 配置参数
     */
    xhr.timeout = 3000 // 设置 xhr 请求的超时时间
    xhr.responseType = "text" // 设置响应返回的数据格式
    xhr.setRequestHeader("X_TEST","time.geekbang")
 
    /**
     * 5: 发送请求
     */
    xhr.send();
}

执行流程
1.创建 XMLHttpRequest 对象
2.为 xhr 对象注册回调函数
3.配置基础的请求信息
4.发起请求

XMLHttpRequest 使用过程中的“坑”

1.跨域问题

默认情况下,跨域请求是不被允许的

2.HTTPS 混合内容的问题

HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。
通过 HTML 文件加载的混合资源,虽然给出警告,但大部分类型还是能加载的。而使用 XMLHttpRequest 请求时,浏览器认为这种请求可能是攻击者发起的,会阻止此类危险的请求。

宏任务和微任务

不过随着浏览器的应用领域越来越广泛,消息队列中这种粗时间颗粒度的任务已经不能胜任部分领域的需求,所以又出现了一种新的技术——微任务微任务可以在实时性和效率之间做一个有效的权衡

宏任务

页面中的大部分任务都是在主线程上执行的,这些任务包括了:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件。

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。把这些消息队列中的任务称为宏任务
WHATWG 规范定义的大致流程:

  • 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
  • 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
  • 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
  • 最后统计执行完成的时长等信息。

宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了。宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如监听 DOM 变化的需求。

微任务

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以无法通过 JavaScript 直接访问的。

微任务产生方式

第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务

微任务队列是何时被执行

通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点
如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

image.png
浏览器工作原理 学习笔记_第98张图片
特点:

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  • 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

监听 DOM 变化方法演变

早期页面并没有提供对监听的支持,所以那时要观察 DOM 是否变化,唯一能做的就是轮询检测,比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效。
直到 2000 年的时候引入了 Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。采用 Mutation Event 解决了实时性的问题,因为 DOM 一旦发生变化,就会立即调用 JavaScript 接口。但也正是这种实时性造成了严重的性能问题,因为每次 DOM 变动,渲染引擎都会去调用 JavaScript,这样会产生较大的性能开销。
为了解决了 Mutation Event 由于同步调用 JavaScript 而造成的性能问题,从 DOM4 开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。
首先,MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。
MutationObserver 采用了“异步 + 微任务”的策略。

  • 通过异步操作解决了同步操作的性能问题
  • 通过微任务解决了实时性的问题

Promise

异步编程的问题:代码逻辑不连续

浏览器工作原理 学习笔记_第99张图片
Web 页面的单线程架构决定了异步回调,而异步回调影响到了编码方式,回调会导致代码的逻辑不连贯、不线性,非常不符合人的直觉。

封装异步代码,让处理流程变得线性

浏览器工作原理 学习笔记_第100张图片

//makeRequest 用来构造 request 对象
function makeRequest(request_url) {
    let request = {
        method: 'Get',
        url: request_url,
        headers: '',
        body: '',
        credentials: false,
        sync: true,
        responseType: 'text',
        referrer: ''
    }
    return request
}

将所有的请求细节封装进 XFetch 函数

//[in] request,请求信息,请求头,延时值,返回类型等
//[out] resolve, 执行成功,回调该函数
//[out] reject  执行失败,回调该函数
function XFetch(request, resolve, reject) {
    let xhr = new XMLHttpRequest()
    xhr.ontimeout = function (e) { reject(e) }
    xhr.onerror = function (e) { reject(e) }
    xhr.onreadystatechange = function () {
        if (xhr.status = 200)
            resolve(xhr.response)
    }
    xhr.open(request.method, URL, request.sync);
    xhr.timeout = request.timeout;
    xhr.responseType = request.responseType;
    // 补充其他请求信息
    //...
    xhr.send();
}

XFetch 函数需要一个 request 作为输入,然后还需要两个回调函数 resolve 和 reject,当请求成功时回调 resolve 函数,当请求出现问题时回调 reject 函数

XFetch(makeRequest('https://time.geekbang.org'),
    function resolve(data) {
        console.log(data)
    }, function reject(e) {
        console.log(e)
    })

新的问题:回调地狱

原因

  • 第一是嵌套调用,下面的任务依赖上个任务的请求结果,并在上个任务的回调函数内部执行新的业务逻辑,这样当嵌套层次多了之后,代码的可读性就变得非常差了。
  • 第二是任务的不确定性,执行每个任务都有两种可能的结果(成功或者失败),所以体现在代码中就需要对每个任务的执行结果做两次判断,这种对每个任务都要进行一次额外的错误处理的方式,明显增加了代码的混乱程度。

解决方案

  • 第一是消灭嵌套调用
  • 第二是合并多个任务的错误处理

Promise:消灭嵌套调用和多次错误处理

用 Promise 来重构 XFetch

function XFetch(request) {
  function executor(resolve, reject) {
      let xhr = new XMLHttpRequest()
      xhr.open('GET', request.url, true)
      xhr.ontimeout = function (e) { reject(e) }
      xhr.onerror = function (e) { reject(e) }
      xhr.onreadystatechange = function () {
          if (this.readyState === 4) {
              if (this.status === 200) {
                  resolve(this.responseText, this)
              } else {
                  let error = {
                      code: this.status,
                      response: this.response
                  }
                  reject(error, this)
              }
          }
      }
      xhr.send()
  }
  return new Promise(executor)
}

利用 XFetch 来构造请求流程

var x1 = XFetch(makeRequest('https://time.geekbang.org/?category'))
var x2 = x1.then(value => {
    console.log(value)
    return XFetch(makeRequest('https://www.geekbang.org/column'))
})
var x3 = x2.then(value => {
    console.log(value)
    return XFetch(makeRequest('https://time.geekbang.org'))
})
x3.catch(error => {
    console.log(error)
})

Promise是怎么解决的

**Promise 实现了回调函数的延时绑定,**回调函数的延时绑定在代码上体现就是先创建 Promise 对象 x1,通过 Promise 的构造函数 executor 来执行业务逻辑;创建好 Promise 对象 x1 之后,再使用 x1.then 来设置回调函数。

// 创建 Promise 对象 x1,并在 executor 函数中执行业务逻辑
function executor(resolve, reject){
    resolve(100)
}
let x1 = new Promise(executor)
 
 
//x1 延迟绑定回调函数 onResolve
function onResolve(value){
    console.log(value)
}
x1.then(onResolve)

其次,需要将回调函数 onResolve 的返回值穿透到最外层。根据 onResolve 函数的传入值来决定创建什么类型的 Promise 任务,创建好的 Promise 对象需要返回到最外层,这样就可以摆脱嵌套循环了。

浏览器工作原理 学习笔记_第101张图片
Promise 通过回调函数延迟绑定回调函数返回值穿透的技术,解决了循环嵌套。

Promise异常处理

function executor(resolve, reject) {
    let rand = Math.random();
    console.log(1)
    console.log(rand)
    if (rand > 0.5)
        resolve()
    else
        reject()
}
var p0 = new Promise(executor);
 
var p1 = p0.then((value) => {
    console.log("succeed-1")
    return new Promise(executor)
})
 
var p3 = p1.then((value) => {
    console.log("succeed-2")
    return new Promise(executor)
})
 
var p4 = p3.then((value) => {
    console.log("succeed-3")
    return new Promise(executor)
})
 
p4.catch((error) => {
    console.log("error")
})
console.log(2)

Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被 onReject 函数处理或 catch 语句捕获为止。具备了这样“冒泡”的特性后,就不需要在每个 Promise 对象中单独捕获异常了。

Promise 与微任务

由于 Promise 采用了回调函数延迟绑定技术,所以在执行 resolve 函数的时候,回调函数还没有绑定,那么只能推迟回调函数的执行

function Bromise(executor) {
    var onResolve_ = null
    var onReject_ = null
     // 模拟实现 resolve 和 then,暂不支持 rejcet
    this.then = function (onResolve, onReject) {
        onResolve_ = onResolve
    };
    function resolve(value) {
          setTimeout(()=>{
            onResolve_(value)
          },0)
    }
    executor(resolve, null);
}
function executor(resolve, reject) {
    resolve(100)
}
// 将 Promise 改成我们自己的 Bromsie
let demo = new Bromise(executor)
 
function onResolve(value){
    console.log(value)
}
demo.then(onResolve)

使用定时器的效率并不是太高,所以 Promise 又把这个定时器改造成了微任务了,这样既可以让 onResolve_ 延时被调用,又提升了代码的执行效率。

async/await

fetch('https://www.geekbang.org')
      .then((response) => {
          console.log(response)
          return fetch('https://www.geekbang.org/test')
      }).then((response) => {
          console.log(response)
      }).catch((error) => {
          console.log(error)
      })

从这段 Promise 代码可以看出来,使用 promise.then 也是相当复杂,虽然整个请求流程已经线性化了,但是代码里面包含了大量的 then 函数,使得代码依然不是太容易阅读。基于这个原因,ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰

async function foo(){
  try{
    let response1 = await fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = await fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
  }catch(err) {
       console.error(err)
  }
}
foo()

生成器 (Generator)VS 协程(Coroutine)

生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的

function* genDemo() {
    console.log(" 开始执行第一段 ")
    yield 'generator 2'
 
    console.log(" 开始执行第二段 ")
    yield 'generator 2'
 
    console.log(" 开始执行第三段 ")
    yield 'generator 2'
 
    console.log(" 执行结束 ")
    return 'generator 2'
}
 
console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')

生成器函数的具体使用方式:

  1. 在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
  2. 外部函数可以通过 next 方法恢复函数的执行。

协程是一种比线程更加轻量级的存在。可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,把 A 协程称为 B 协程的父协程
正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

浏览器工作原理 学习笔记_第102张图片

协程的四点规则:

  1. 通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行。
  2. 要让 gen 协程执行,需要通过调用 gen.next。
  3. 当协程正在执行的时候,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。
  4. 如果协程在执行期间,遇到了 return 关键字,那么 JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程。

父协程和 gen 协程是如何切换调用栈

浏览器工作原理 学习笔记_第103张图片
在 JavaScript 中,生成器就是协程的一种实现方式

//foo 函数
function* foo() {
    let response1 = yield fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = yield fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
}
 
// 执行 foo 函数的代码
let gen = foo()
function getGenPromise(gen) {
    return gen.next().value
}
getGenPromise(gen).then((response) => {
    console.log('response1')
    console.log(response)
    return getGenPromise(gen)
}).then((response) => {
    console.log('response2')
    console.log(response)
})

foo 函数是一个生成器函数,在 foo 函数里面实现了用同步代码形式来实现异步操作;

  • 首先执行的是let gen = foo(),创建了 gen 协程。
  • 然后在父协程中通过执行 gen.next 把主线程的控制权交给 gen 协程。
  • gen 协程获取到主线程的控制权后,就调用 fetch 函数创建了一个 Promise 对象 response1,然后通过 yield 暂停 gen 协程的执行,并将 response1 返回给父协程。
  • 父协程恢复执行后,调用 response1.then 方法等待请求结果。
  • 等通过 fetch 发起的请求完成之后,会调用 then 中的回调函数,then 中的回调函数拿到结果之后,通过调用 gen.next 放弃主线程的控制权,将控制权交 gen 协程继续执行下个请求。

以上就是协程和 Promise 相互配合执行的一个大致流程。不过通常,我们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器(可参考著名的 co 框架)

function* foo() {
    let response1 = yield fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = yield fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
}
co(foo());

通过使用生成器配合执行器,就能实现使用同步的方式写出异步代码了,这样也大大加强了代码的可读性。

async/await

async

async 是一个通过异步执行隐式返回 Promise 作为结果的函数。

async function foo() {
    return 2
}
console.log(foo())  // Promise {: 2}

调用 async 声明的 foo 函数返回了一个 Promise 对象,状态是 resolved

await

async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)

浏览器工作原理 学习笔记_第104张图片

浏览器中的页面

Chrome开发者工具:利用网络面板做性能分析

Chrome 开发者工具

浏览器工作原理 学习笔记_第105张图片

网络面板

浏览器工作原理 学习笔记_第106张图片

控制器

浏览器工作原理 学习笔记_第107张图片

过滤器

起过滤功能,有时候一个页面有太多内容在详细列表区域中展示了,而你可能只想查看 JavaScript 文件或者 CSS 文件,这时候就可以通过过滤器模块来筛选你想要的文件类型。.

抓图信息

抓图信息区域,可以用来分析用户等待页面加载时间内所看到的内容,分析用户实际的体验情况。比如,如果页面加载 1 秒多之后屏幕截图还是白屏状态,这时候就需要分析是网络还是代码的问题了。(勾选面板上的“Capture screenshots”即可启用屏幕截图。)

时间线

时间线,主要用来展示 HTTP、HTTPS、WebSocket 加载的状态和时间的一个关系,用于直观感受页面的加载过程。如果是多条竖线堆叠在一起,那说明这些资源被同时被加载。

详细列表

这个区域是最重要的,它详细记录了每个资源从发起请求到完成请求这中间所有过程的状态,以及最终请求完成的数据信息。通过该列表,你就能很容易地去诊断一些网络问题。

下载信息概要

下载信息概要中,要重点关注下 DOMContentLoaded 和 Load 两个事件,以及这两个事件的完成时间。

  • DOMContentLoaded,这个事件发生后,说明页面已经构建好 DOM 了,这意味着构建 DOM 所需要的 HTML 文件、JavaScript 文件、CSS 文件都已经下载完成了。
  • Load,说明浏览器已经加载了所有的资源(图像、样式表等)

网络面板中的详细列表

列表的属性

列表的属性比较多,比如 Name、Status、Type、Initiator 等等。可以按照列表的属性来给列表排序,默认情况下,列表是按请求发起的时间来排序的,最早发起请求的资源在顶部。当然也可以按照返回状态码、请求类型、请求时长、内容大小等基础属性排序,只需点击相应属性即可。

浏览器工作原理 学习笔记_第108张图片

详细信息

如果选中详细列表中的一项,右边就会出现该项的详细信息

浏览器工作原理 学习笔记_第109张图片

单个资源的时间线

浏览器工作原理 学习笔记_第110张图片
时间线面板
浏览器工作原理 学习笔记_第111张图片
**Queuing,**当浏览器发起一个请求的时候,会有很多原因导致该请求不能被立即执行,而是需要排队等待。

  • 首先,页面中的资源是有优先级的,比如 CSS、HTML、JavaScript 等都是页面中的核心文件,所以优先级最高;而图片、视频、音频这类资源就不是核心资源,优先级就比较低。通常当后者遇到前者时,就需要“让路”,进入待排队状态。
  • 其次,浏览器会为每个域名最多维护 6 个 TCP 连接,如果发起一个 HTTP 请求时,这 6 个 TCP 连接都处于忙碌状态,那么这个请求就会处于排队状态。
  • 最后,网络进程在为数据分配磁盘空间时,新的 HTTP 请求也需要短暂地等待磁盘分配结束。

等待排队完成之后,就要进入发起连接的状态了。不过在发起连接之前,还有一些原因可能导致连接过程被推迟,这个推迟就表现在面板中的Stalled上,它表示停滞的意思。如果使用了代理服务器,还会增加一个Proxy Negotiation阶段,也就是代理协商阶段,它表示代理服务器连接协商所用的时间。接下来,就到了Initial connection/SSL 阶段了,也就是和服务器建立连接的阶段,这包括了建立 TCP 连接所花费的时间;不过如果你使用了 HTTPS 协议,那么还需要一个额外的 SSL 握手时间,这个过程主要是用来协商一些加密信息的。和服务器建立好连接之后,网络进程会准备请求数据,并将其发送给网络,这就是Request sent 阶段。通常这个阶段非常快,因为只需要把浏览器缓冲区的数据发送出去就结束了,并不需要判断服务器是否接收到了,所以这个时间通常不到 1 毫秒。数据发送出去了,接下来就是等待接收服务器第一个字节的数据,这个阶段称为 Waiting (TTFB),通常也称为“第一字节时间”。 TTFB 是反映服务端响应速度的重要指标,对服务器来说,TTFB 时间越短,就说明服务器响应越快。接收到第一个字节之后,进入陆续接收完整数据的阶段,也就是Content Download 阶段,这意味着从第一字节时间到接收到全部响应数据所用的时间。

优化时间线上耗时项

1. 排队(Queuing)时间过久

排队时间过久,大概率是由浏览器为每个域名最多维护 6 个连接导致的。那么基于这个原因,你就可以让 1 个站点下面的资源放在多个域名下面,比如放到 3 个域名下面,这样就可以同时支持 18 个连接了,这种方案称为域名分片技术。除了域名分片技术外,还建议把站点升级到 HTTP2,因为 HTTP2 已经没有每个域名最多维护 6 个 TCP 连接的限制了。

2. 第一字节时间(TTFB)时间过久

  • 服务器生成页面数据的时间过久。对于动态网页来说,服务器收到用户打开一个页面的请求时,首先要从数据库中读取该页面需要的数据,然后把这些数据传入到模板中,模板渲染后,再返回给用户。服务器在处理这个数据的过程中,可能某个环节会出问题。(去提高服务器的处理速度,比如通过增加各种缓存的技术)
  • 网络的原因。比如使用了低带宽的服务器,或者本来用的是电信的服务器,可联通的网络用户要来访问你的服务器,这样也会拖慢网速。(使用 CDN 来缓存一些静态文件)
  • 发送请求头时带上了多余的用户信息。比如一些不必要的 Cookie 信息,服务器接收到这些 Cookie 信息之后可能需要对每一项都做处理,这样就加大了服务器的处理时长。(发送请求时就去尽可能地减少一些不必要的 Cookie 数据信息)

3. Content Download 时间过久

如果单个请求的 Content Download 花费了大量时间,有可能是字节数太多的原因导致的。这时候就需要减少文件大小,比如压缩、去掉源码中不必要的注释等方法。

DOM树:JavaScript是如何影响DOM树构建的?

什么是 DOM

DOM 提供了对 HTML 文档结构化的表述。在渲染引擎中,DOM 有三个层面的作用

  • 从页面的视角来看,DOM 是生成页面的基础数据结构。
  • 从 JavaScript 脚本视角来看,DOM 提供给 JavaScript 脚本操作的接口,通过这套接口,JavaScript 可以对 DOM 结构进行访问,从而改变文档的结构、样式和内容。
  • 从安全视角来看,DOM 是一道安全防护线,一些不安全的内容在 DOM 解析阶段就被拒之门外了。

DOM 树如何生成

在渲染引擎内部,有一个叫HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据
网络进程接收到响应头之后,会根据响应头中的 content-type 字段来判断文件的类型,比如 content-type 的值是“text/html”,那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据“喂”给 HTML 解析器。

浏览器工作原理 学习笔记_第112张图片
第一个阶段,通过分词器将字节流转换为 Token
**image.png

至于后续的第二个和第三个阶段是同步进行的,需要将 Token 解析为 DOM 节点,并将 DOM 节点添加到 DOM 树中。
HTML 解析器维护了一个Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:

  • 如果压入到栈中的是StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
  • 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
  • 如果分词器解析出来的是EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。

HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底。然后经过分词器解析出来的第一个 StartTag html Token 会被压入到栈中,并创建一个 html 的 DOM 节点,添加到 document 上

浏览器工作原理 学习笔记_第113张图片
然后按照同样的流程解析出来 StartTag body 和 StartTag div,其 Token 栈和 DOM 的状态如下图所示:

浏览器工作原理 学习笔记_第114张图片
接下来解析出来的是第一个 div 的文本 Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点
浏览器工作原理 学习笔记_第115张图片
再接下来,分词器解析出来第一个 EndTag div,这时候 HTML 解析器会去判断当前栈顶的元素是否是 StartTag div,如果是则从栈顶弹出 StartTag div
浏览器工作原理 学习笔记_第116张图片
按照同样的规则,一路解析,最终结果如下图所示

浏览器工作原理 学习笔记_第117张图片

JavaScript 是如何影响 DOM 生成的

<html>
<body>
    <div>1</div>
    <script>
    let div1 = document.getElementsByTagName('div')[0]
    div1.innerText = 'time.geekbang'
    </script>
    <div>test</div>
</body>
</html>

解析到

你可能感兴趣的:(前端基础)