大话Chrome浏览器原理

一、一个页面为什么4个进程?

(1)主要原因

  • 进程中的任何一个线程崩溃都会导致整个进程崩溃
  • 线程之间的数据时共享的,多页面使用多线程有安全性问题。
  • 当一个进程关闭后资源的回收时候操作系统控制的,不易出现内存泄漏
  • 插件的崩溃会导致Chrome的不稳定。
  • 所有模块都在一个进程导致Chrome不流畅

(2)目前Chrome的进程架构

  • 浏览器进程:主要负责用户界面显示、交互、子进程管理、存储。
  • 渲染进程:使用Blink排版引擎和V8引擎渲染出页面,Chrome会为每一个Tab创建一个渲染进程,每个进程运行在沙箱中。
  • GPU进程:初衷是实现CSS 3D、网页绘制和Chrome的UI部分。
  • 网络进程:加载网络资源。
  • 插件进程:负责插件的运行。

(3)当前的Chrome架构带来的问题

  • 消耗资源
  • 体系复杂

(4)未来面向服务的架构(SOA)

构建一个更加内聚、松耦合、易于维护和扩展的系统

大话Chrome浏览器原理

Chrome正在构建操作系统化的Chrome基础服务,在性能强大的设备上使用多进程的方式运行基础服务,当在硬件资源受限的设备上使用Chrome的时候,就使用单进程的方式。

二、TCP协议如何保证页面传送到浏览器?

TCP和UDP协议的传输过程思考,引出QUIC和HTTP3。

三、为什么第二次打开站点会很快?

浏览器发起HTTP请求的流程:

  • 构建请求,首先是构建请求行。
  • 查找缓存,查找浏览器缓存失败才会进行网络请求。
  • 通过DNS准备IP地址和端口。
  • 等待TCP队列,Chrome机制是同一个域名下最多只能建立6个连接,否则就会进入等待TCP队列。
  • 建立TCP连接。
  • 发送HTTP请求。
    • 首先发送请求行,分别是请求方法、请求URI、HTTP协议版本。
    • 如果是POST请求,那么还要发送请求体。
  • 然后服务器返回数据,包括响应头和响应体。
  • 通常情况下会断开TCP连接,如果在HTTP头部加入Connection:Keep-Alive,这样TCP就不会断开,连接可以被复用。

四、从输入URL到页面展示,中间发生了什么?

  • 用户向浏览器输入URL,然后浏览器处理用户输入,判断是合法的URL地址还是搜索关键字,如果是关键字那么使用默认的搜索引擎构建搜索URL。
  • 检查是否有缓存内容,否则浏览器通过进程间的IPC通信向网络进程发送请求信息。
  • 网络进程接收服务器返回的数据,根据状态码判断是否进行重定向等操作。如果是301或302,那么表示需要重定向,此时网络进程会在请求头的Location字段中读取重定向的地址,再次发送网络请求。
  • 通过响应头中的Content-Type判断进行何种操作。如果是下载类型,那么网络进程会把任务交给下载任务管理器。
  • 由于服务器响应数据的时候就已经准备好了渲染进程,那么会将数据提交给渲染进程。Chrome会为每个站点打开一个渲染进程。

五、JavaScript、HTML和CSS如何变成页面?

由于渲染机制的复杂性,所以划分为许多子阶段,每个子阶段都有输入、输出和处理过程,这许多子阶段构成了渲染流水线

(1)构建DOM

将HTML标签转化为DOM树,每个节点对应一个HTML标签。可以通过如下代码从Chrome开发者工具中获取当前页面的DOM树:

document

HTML解析器会随着文档的加载,边加载边解析。HTML解析器维护了一个Token栈结构用于计算父子节点之间的关系。

当解析到JavaScript的时候,DOM解析将会停止,执行代码,因为JavaScript有可能修改DOM结构。

Chrome在解析之前会有预解析线程先下载文档内嵌入的JavaScript下载链接。

在解析JavaScript之前首先要解析CSS文件,CSS文件加载会阻塞JavaScript脚本执行。

(2)样式计算

  • 解析CSS文件。当渲染引擎接收到一个CSS文件的时候,会将CSS文本转换为样式表结构。可以通过如下代码在Chrome开发者工具中获取当前页面的样式表:
document.styleSheets
  • 转换CSS属性值,使其标准化。比如2em会被转换为35px,HTML颜色会被转化RGB颜色。
  • 计算出DOM树每个节点的样式。CSS具有继承和层叠规则。这些会在Chrome的Computed标签中显示。

(3)布局节点

  • 创建布局树。由于HTML中还包含了许多不可见的元素,因此还需要创建一棵只包含可见元素的布局树。
  • 然后将可见布局树和Computed CSS合成带有CSS的DOM树。

(4)图层树

渲染引擎还需要为特定的节点生成专门的图层,并生成一棵图层树。可以在Chrome的Layers标签中查看。

并不是每一个节点都会对应一个图层,如果一个节点没有图层,那么这个节点就属于父节点的图层。

  • 拥有层叠上下文属性的HTML元素会提升为一个图层。
  • 需要进行裁剪的HTML元素,比如overflow属性。

(5)图层绘制

渲染引擎会将图层树中的每个图层进行绘制,首先会将每一层的绘制拆分成许多绘制指令,然后绘制指令按照顺序组成待绘制列表

(6)栅格化操作

  • 主线程将待绘制列表准备好后提交给合成线程。通常情况下,一个页面可能很大,但是视口ViewPort是有限大的。因此合成线程会将图层划分为图块,通常是256*256或者512*512

  • 合成线程会将视口附近的图块来优先生成位图,实际生成位图的操作由栅格化线程来执行。
  • 栅格化线程通常情况下会使用GPU来完成,也叫快速栅格化
  • GPU生成的位图保存在GPU的显存中。浏览器中有个viz组件用来接收合成线程的DrawQuad命令,然后浏览器根据该命令将页面内容显示在屏幕上。

(7)3个重要概念

  • 重排:更新元素的几何属性。通过CSS或者JS修改了元素的位置属性,那么就会触发浏览器重新布局,导致需要完整的渲染流水线。
  • 重绘:更新元素的绘制属性。如果更改了页面的颜色属性,那么就会省去布局和分层阶段。
  • 合成:比如使用了CSS的transform属性,那么就会避开重绘和重排。

六、JavaScript是按照顺序执行的吗?

变量提升:JavaScript解析引擎执行代码过程中,将变量的声明部分和函数的声明部分提升到代码开头的行为,变量提升以后会给变量设置默认值,这个默认是就是undefined

变量提升发生在编译阶段,在这个阶段会生成执行上下文可执行代码。在执行上下文中保存了变量环境对象,该对象保存了变量提升的内容。

如果函数或者变量出现了重名,那么变量环境对象将会发生覆盖。

console.log(x);
var x = 10;
var x = 20;

f1();
function f1() {console.log('method: f1');}
function f1() {console.log('method: f1 override');}

f2();
var f2 = function() {console.log('method: f2');}
var f2 = function() {console.log('method: f2 override');}
undefined
method: f1 override
/Users/koils/test.js:9
f2();
^

TypeError: f2 is not a functio

七、为什么JavaScript会出现栈溢出?

在JavaScript中每个函数都有自己的执行上下文,JavaScript使用调用栈来管理这些执行上下文环境。全局执行上下文位于栈底。

调用栈是JavaScript引擎追踪函数执行的一个机制。

栈溢出:栈是有大小的,当入栈数目超过这个大小就会造成栈溢出现象。

八、作用域、作用域链和闭包

ES6中通过引入块级作用域配合let和const来避免变量提升这个设计缺陷。

作用域:是指在程序中定义变量的区域,这个位置决定了变量的生命周期。作用域是变量和函数的可访问范围。在ES6之前只有全局作用域函数作用域,之后支持块级作用域。

变量提升带来的问题:

  • 变量容易在不被察觉的情况下被覆盖掉。
  • 本来应该销毁的变量没有被销毁

JavaScript如何支持块级作用域?通过let声明的变量在编译阶段会被存放到词法环境,因此是使用词法环境来支持的。

在JavaScript的每个执行上下文中都包含一个叫做outer的外部引用,用来指向外部的执行上下文。当进行变量查找的时候在当前作用域找不到就回去outer中查找,直到找到。这个查找链条叫做作用域链

词法作用域:作用域由代码中的函数声明的位置来决定,是静态的作用域,通过这个作用域可以预测代码的执行过程。词法作用域在代码阶段就决定好了,与函数如何调用无关。

闭包:在JavaScript中,根据词法作用域的规则,内部函数总是可以访问其外部函数声明的变量,当通过调用一个外部函数返回一个内部函数的时候,即使该外部函数已经执行结束,但是内部函数引用外部函数变量依然保存在内存中,就把这些变量的集合称为闭包。

闭包的回收:如果闭包会一直被使用,那么可以当做全局变量存在。但是如果使用频率不高,而且占用内存较大,尽量让该闭包作为局部变量。

九、this

(1)全局执行上下文的this

全局执行上下文的this指向window对象

(2)函数执行上下文的this

默认情况下也是指向window对象。设置this指向的方法有三种:

  • call方法,bind方法和apply方法

  • 通过对象调用位置:使用对象调用其内部的一个方法,该方法的this是指向对象本身的。

    var object = {
    fn: function () {console.log(this);}
    };
    object.fn();
  • 通过构造函数:函数中的this属于新对象

    function fn () {this.x = 'HelloWorld';}
    var object = new fn();

(3)this的设计缺陷

  • 嵌套函数中this不会从外层函数中继承,解决方法有

    • 将this体系转化为作用域体系
    function fn () {
      this.x = 1;
      var that = this;
      function fx () {
        that.x = 10;
      }
    }
    • 使用ES6中的箭头函数
    function fn () {
      this.x = 1;
      var fx = () => {this.x = 10;};
    }
  • 普通函数的this指向window对象,这个问题可以通过使用严格模式解决。

十、JavaScript的内存机制

(1)数据存储

  • 原始类型
类型 描述
Boolean 只有true和false两个值
Null 只有一个值null,使用typeof检测时会返回object类型,这是JavaScript的Bug
undefined 一个没有被赋值的默认值,变量提升时也会使用该值
Number 数字类型,64位二进制格式
BigInt 可以用于表示任何精度
String 表示文本数据,不可变
Symbol 唯一且不可修改,通常用于作为Object和Key
Object 一组属性的集合
  • 引用类型

JavaScript的内存空间分为栈空间、堆空间和代码空间。栈空间用于存储执行上下文。在JavaScript的赋值过程中,引用类型只会复制内存地址。

(2)垃圾回收

  • 调用栈中的垃圾回收

    JavaScript引擎通过向下移动ESP来销毁该函数保存在栈中执行的上下文。

  • 堆中的垃圾回收

    • JavaScript使用垃圾回收器收集垃圾。待际假说:大部分对象在内存中存活时间会很短,不死的对象会活的更久。在V8引擎中分为新生代和老年代,新生代通常是1-8MB的内存空间,并且两个区域使用不同的GC机制。

    • 新生代使用Scavenge算法,它将新生代划分为两个区域,一半是对象区域,另一半是空闲区域。当对象区域写满以后就进行GC,首先对对象区域的对象进行标记,然后再清理垃圾,副垃圾收集器将这些没有变成垃圾的对象复制到空闲区域,然后有序的排列,最后将对象区域和空闲区域进行角色翻转
    • JavaScript的主垃圾回收器主要进行老年代的垃圾回收工作,使用标记-清除算法。
    • 当JavaScript的进行GC的时候,会产生StopTheWorld(全停顿)现象。由于老年代受到GC全停顿的影响较大,因此老年代的垃圾回收使用增量-标记算法,使得JavaScript脚本的执行和GC两个线程交替执行

(3)解释编译

在JavaScript的执行引擎V8中,既有解释器(Ignition)也存在编译器(TurboFan)。

  • 首先会从JavaScript代码翻译为AST并生成执行上下文。AST是⾮常重要的⼀种数据结构,在很多项⽬中有着⼴泛的应⽤。其中最著名的⼀个项⽬是Babel。的⼯作原理就是先将ES6源码转换为AST,然后再将ES6语法的AST 转换为ES5语法的AST,最后利⽤ES5的AST⽣成JavaScript源代码。
    • 词法分析,生成Token。
    • 语法分析,解析Token生成AST。
  • 生成字节码。解释器根据AST解释并执行字节码。字节码是介于AST和机器码之间的一种代码,与特定类型的机器无关。
  • 执行代码。多次重复执行的代码会选定为热点代码,由编译器编译为机器代码并保存。解释器Ignition在解释执⾏字节码 的同时,收集代码信息,当它发现某⼀部分代码变热了之后,TurboFan编译器把热点的字节 码转换为机器码,并把转换后的机器码保存起来,以备下次使⽤,这叫做JIT即时编译

十一、消息队列和事件循环

  • Chrome将事件存放到队列,然后使用循环机制将消息取出,然后执行。比如渲染进程专门有一个IO线程用于通过队列接受其他线程传来的任务。
  • 当线程需要安全的退出的时候,由于在进程中设置了退出标志,每次在队列中取出任务执行之前都需要检查标志。
  • 对于高优先级任务的处理,比如监听DOM节点的变化情况,会作为微任务添加到队列中宏任务微任务队列中,当任务执行完成后检查当前任务的微任务队列是否存在微任务,有就取出来执行。
  • 通过PromiseMutationObserver监控某个DOM节点都会产生微任务。

十二、JavaScript面向对象

(1)封装

由于JavaScript没有提供权限访问修饰符,因此可以通过闭包的方式实现私有变量的保护

function Book(name) {
  this.getName = () => {return name;}
  this.setName = (x) => {name = x;}
}

let book = new Book("HelloWorld");
book.setName("JavaScript");
book.getName();

(2)继承

在ES6之前,没有extends关键字,最常见的叫做原型链继承。原型prototype是JavaScript函数中的一个内置属性,指向另外一个对象,被指向的对象的所有的属性和方法都会被当前的实例所继承。

  • 设置prototype的代码需要放到构造器之外。
  • 设置prototype的代码需要放到任何实例化之前。

原型链继承无法解决父类构造方法存在参数的问题,因此可以通过构造继承解决:

function Base1(name) {this.name = name;}

function Base2(age) {this.age = age;}

function Child(name, age) {
  Base1.call(this, name);
  Base2.call(this, age);
}

(3)多态

  • 当创建类的实例的时候,没有使用new关键字,this指的是window对象,否则指向的是当前实例对象。
  • 当类存在return语句的时候,如果返回的是基本数据类型,那么this就会强制指定为当前类对象;如果返回的是引用数据类型,那么会遵循return语句。

十三、setTimeout实现原理

Chrome中使用延迟队列保存Chrome内部的延时任务和setTimeout提交的延时任务。当执行完消息队列中的任务之后就会开始执行延时队列的处理函数,然后延时队列处理函数会根据发起时间和延迟时间计算出到期任务。

使用setTimeout的注意事项:

  • 如果当前任务执行时间过久,会影响到定时器的执行。

  • 如果setTimout存在嵌套,那么系统会设置4ms的间隔时间。

  • 当前页面标签如果没有被激活,那么setTimeout的执行最小时间间隔是1s。目的是优化加载消耗和耗电量。

  • 延迟执行时间有最大值,当延时24.8天setTimeout就会溢出,因为setTimeout使用的是32bit来存储。

  • setTimeout执行的函数this对象指向window,可以通过匿名函数或者bind方法解决:

    setTimeout(function() {}, 1);
    setTimeout(() => {}, 1);
    
    setTimeout(object.func.bind(object), 1);

十四、浏览器缓存

(1)强缓存与协商缓存

在浏览器中分为强缓存和协商缓存。强缓存不需要发送HTTP请求,当检查是否是强缓存的时候在HTTP1.0和HTTP1.1中是不一样的:

  • 早期的HTTP1.0使用的是Expires字段,它指明了过期时间。
  • HTTP1.1使用的是Cache-Control字段,有以下参数:
    • 通过max-age指明缓存存活时间。
    • private表示只有浏览器才能缓存,中间代理服务器无法缓存。
    • no-cache表示跳过强缓存,直接进入协商缓存阶段
    • no-store表示直接不进行缓存
    • s-maxage表示针对代理服务器的缓存时长
    • Expires和Cache-Control同时存在的时候,优先考虑Cache-Control

当强缓存失效之后,浏览器在请求头中携带相应的缓存tag来向服务器发请求,由服务器根据这个tag,来决定是否使用缓存,这就是协商缓存

  • Last-Modified:即最后修改时间。在浏览器第一次给服务器发送请求后,服务器会在响应头中加上这个字段。浏览器接收到后,如果再次请求,会在请求头中携带If-Modified-Since字段,这个字段的值也就是服务器传来的最后修改时间。服务器拿到请求头中的If-Modified-Since的字段后,其实会和这个服务器中Last-Modified对比:
    • 如果请求头中的这个值小于最后修改时间,说明是时候更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。
    • 否则返回304,告诉浏览器直接用缓存。
  • ETag:ETag 是服务器根据当前文件的内容,给文件生成的唯一标识,只要里面的内容有改动,这个值就会变。服务器通过响应头把这个值给浏览器。浏览器接收到ETag的值,会在下次请求时,将这个值作为If-None-Match这个字段的内容,并放到请求头中,然后发给服务器。服务器接收到If-None-Match后,会跟服务器上该资源的ETag进行比对:
    • 如果两者不一样,说明要更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。
    • 否则返回304,告诉浏览器直接用缓存。

在精准度上,ETag优于Last-Modified。优于 ETag 是按照内容给资源上标识,因此能准确感知资源的变化。Last-Modified 能够感知的单位时间是秒,如果文件在 1 秒内改变了多次,那么这时候的 Last-Modified 并没有体现出修改了。

在性能上,Last-Modified优于ETag,也很简单理解,Last-Modified仅仅只是记录一个时间点,而 Etag需要根据文件的具体内容生成哈希值。

(2)Service Worker Cache

Service Worker 借鉴了 Web Worker的 思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问DOM。虽然如此,但它仍然能帮助我们完成很多有用的功能,比如离线缓存、消息推送和网络代理等功能。其中的离线缓存就是 Service Worker Cache。Service Worker 同时也是 PWA 的重要实现机制。

(3)Memory Cache

内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

(4)Disk Cache

存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长。

(5)Push Cache

即推送缓存,这是浏览器缓存的最后一道防线。它是 HTTP/2中的内容,虽然现在应用的并不广泛,但随着 HTTP/2 的推广,它的应用越来越广泛。

十五、浏览器存储

(1)Cookie

Cookie 本质上就是浏览器里面存储的一个很小的文本文件,内部以键值对的方式来存储。向同一个域名下发送请求,都会携带相同的 Cookie,服务器拿到 Cookie 进行解析,便能拿到客户端的状态。

Cookie就是用来做状态存储的。缺陷如下:

  • 容量缺陷。Cookie 的体积上限只有4KB。
  • 性能缺陷。Cookie 紧跟域名,不管域名下面的某一个地址需不需要这个 Cookie ,请求都会携带上完整的 Cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容。
  • 安全缺陷。由于 Cookie 以纯文本的形式在浏览器和服务器中传递。在HttpOnly为 false 的情况下,Cookie 信息能直接通过 JS 脚本来读取。

(2)localStorage

也是针对一个域名,即在同一个域名下,会存储相同的一段localStorage。与Cookie的区别如下:

  • 容量。localStorage 的容量上限为5M。对于一个域名是持久存储的。
  • 只存在客户端,默认不参与与服务端的通信。这样就很好地避免了 Cookie 带来的性能问题和安全问题。
  • 接口封装。通过localStorage暴露在全局,并通过它的 setItem 和 getItem等方法进行操作。

(3)sessionStorage

  • 容量。容量上限也为 5M。
  • 只存在客户端,默认不参与与服务端的通信。
  • 接口封装。

sessionStoragelocalStorage有一个本质的区别,那就是前者只是会话级别的存储,并不是持久化存储。会话结束,也就是页面关闭,这部分sessionStorage就不复存在了。

(4)IndexedDB

IndexedDB是运行在浏览器中的非关系型数据库, 本质上是数据库,理论上这个容量是没有上限的。支持事务和二进制存储。

  • 键值对存储,内部采用对象仓库存储方式。
  • 异步操作,数据库的读写属于IO操作,浏览器提供了异步IO支持。
  • 受到同源策略限制,无法跨域访问数据库。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章