# 浏览器

# 多进程架构

# 单进程浏览器的问题

  1. 不稳定:插件、渲染引擎和浏览器进程混在一起,插件或渲染引擎崩溃导致浏览器崩溃。
  2. 不流畅:插件、渲染模块和JS执行环境运行在一个线程,那么如果JS出现死循环那页面就无法被渲染;同时也存在关闭页面内存无法完全回收的问题,造成内存泄漏。
  3. 不安全:插件可以获取到操作系统的任意资源。

# 多进程架构如何解决问题

  1. 不稳定:浏览器主进程、页面渲染进程、插件进程相互隔离
  2. 不流畅:JS分别运行在不同的页面渲染进程,只影响当前页面的渲染;同时由于进程关闭时完全回收内存,也不存在内存泄漏的问题。
  3. 不安全:对渲染进程和插件进程使用安全沙箱,不允许写数据和读取敏感数据。

# 目前的多进程架构

最新的Chrome浏览器包括:1个浏览器主进程、1个GPU进程、1个网络进程、多个渲染进程、多个插件进程。

  1. 浏览器主进程:界面展示、用户交互、子进程管理
  2. GPU进程:绘制UI界面、3d效果
  3. 网络进程:页面的网络资源加载
  4. 渲染进程:每个Tab页创建一个渲染进程,负责页面渲染、JS执行(V8引擎),渲染进程运行在沙箱环境
  5. 插件进程:运行插件,运行在沙箱环境

# 多进程的问题

  1. 更高的资源占用:每个进程都包含公共基础环境的副本,如JS运行环境
  2. 更复杂的体系架构:模块高耦合性、低扩展性

# 面向服务的架构

为了解决这些问题,在 2016 年,Chrome 官方团队使用“面向服务的架构”(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。也就是说 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。

# 使用TCP协议传输文件

# TCP的优点

  1. 传输层协议主要分为UDP和TCP,UDP速度快但不保证完整性,对于浏览器请求这类要求数据完整性的连接,就需要使用TCP协议。TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。

  2. 相对于UDP,TCP有如下特点:

    • 对于数据包丢失,提供重传机制
    • 引入数据包排序机制,用于组装乱序的数据包

# TCP的生命周期

  1. 建立连接:通过三次握手建立连接。
  2. 数据传输:在该阶段,接收端需要对每个数据包进行确认操作,也就是接收端在接收到数据包之后,需要发送确认数据包给发送端。
  3. 断开连接:通过四次挥手断开连接。

# HTTP请求流程

# 浏览器发起HTTP请求流程

  1. 构建请求:构建请求行信息
  2. 查找缓存:查找缓存中是否有要请求的文件,如果存在则拦截请求,返回该资源副本
  3. 查询DNS:通过DNS获取域名对应的IP
  4. 等待TCP队列:同一个域名最多建立6个TCP连接,多余的请求进入队列等待
  5. 建立TCP连接:排队结束后,通过三次握手建立连接
  6. 发送HTTP请求:发送请求行、请求头、请求体信息

# 服务器处理HTTP请求流程

  1. 返回请求:返回响应行、响应头、响应体
  2. 断开TCP连接:返回请求后就断开TCP连接;如果请求头或响应头中有:Connection:Keep-Alive,则保持TCP连接
  3. 重定向:服务器返回301和Location,浏览器将重新发起请求。

# 导航流程

在浏览器里,从输入 URL 到页面展示,这中间发生了什么?用户发出 URL 请求到页面开始解析的这个过程,就叫做导航。

  1. 用户输入:地址栏判断输入的是URL还是搜索内容

    • 如果是搜索内容,是使用默认的搜索引擎合成URL
    • 如果是URL,则进行格式化,比如添加上http协议

    beforeunload:触发beforeunload事件,允许页面执行数据清理或询问是否离开该页面

    标签页上的图标进入加载状态(转圈圈),但页面仍然是之前的页面

  2. URL请求:浏览器进程将URL发给网络进程,网络进程进行缓存查询、DNS解析、TLS连接等发起URL请求

    • 如果返回301,则网络进程读取Location地址重新请求
    • 如果Content-Type是下载类型,则提交给浏览器下载管理器,导航流程结束
    • 如果Content-Type是HTML,则导航流程继续进行
  3. 准备渲染进程:默认情况,每一个页面有一个渲染进程,但如果多个页面处于同一站点,则复用同一个渲染进程。

    • 同一站点:协议和根域名相同,如http://www.domain.com, http://bbs.domain.com
  4. 提交文档:浏览器进程将网络进程接收到的HTML数据提交给渲染进程

    • 浏览器进程向渲染进程发送“提交文档”消息
    • 渲染进程和网络进程建立管道接收数据
    • 数据传输完成后,渲染进程向浏览器进程发送“确认提交”消息
    • 浏览器进程收到“确认提交”消息后更新浏览器界面状态和页面,导航流程结束

# 渲染流程

  1. 构建DOM树:HTML经由解析器解析,生成DOM树

  2. 样式计算:计算DOM树中每个节点的具体样式

    • 将CSS文本转换为计算机可以理解的结构StyleSheets
    • 标准化属性值,比如2em转化为32px,bold转化为700
    • 计算DOM树中每个节点的具体样式,涉及样式的继承和层叠,结果保存在ComputedStyle结构
  3. 布局阶段:计算DOM树中可见元素的几何位置,创建布局树(只包含可见元素)

  4. 分层:创建图层树LayerTree,处理3D板换、z-index等

  5. 图层绘制:每个图层拆解为多个绘制指令,最终输出一份绘制列表

  6. 光栅化:主线程将绘制列表提交给合成线程,合成线程将图层划分为图块,按照视口附近的图块优先生成位图,图块转化为位图就是光栅化,通常会使用GPU加速光栅化,又会涉及到GPU进程的跨进程操作。

  7. 合成和显示:所有图块都被光栅化后,合成线程向浏览器进程提交绘制好的内容,浏览器进程负责存储和显示

# JavaScript执行机制

# 变量提升

  1. 编译阶段:生成执行上下文和可执行代码
    • 执行上下文:将变量保存到变量环境并赋值为undefined,将函数存储在堆中并将函数名保存到变量环境指向堆函数的位置
  2. 执行阶段:JS引擎会从执行上下文中的变量环境查找自定义变量和函数

所以产生变量提升的原因是:JavaScript执行分为编译阶段和执行阶段

# 调用栈

  1. 执行上下文创建:

    • 全局执行上下文:编译全局代码生成全局上下文,全局上下文只有一份;
    • 函数执行上下文:编译函数体内代码生成函数上下文,函数执行结束后,函数上下文会被销毁;
    • eval执行上下文:使用eval函数,eval的代码也会被编译生成上下文。(一般不用)
  2. 调用栈

    JavaScript执行中使用执行上下文栈又称调用栈来管理多个执行上下文

    代码执行前会先将执行上下文压入调用栈,执行结束后会弹出执行上下文

    调用栈是有大小的,当入栈的执行上下文超过一定数量,就会报”栈溢出“的错误

# 作用域

作用域就是变量和函数的可访问范围

  1. 全局作用域:代码中任何位置都可访问
  2. 函数作用域:函数内部定义的变量和函数只能在函数内访问,函数执行完毕后作用域内变量会被销毁
  3. 块状作用域:一对大括号{}内定义的变量外部无法访问,执行结束后变量会被销毁(ES6新增,使用let和const)

执行上下文中存在变量环境和词法环境,var变量和函数放在变量环境,let/const放在词法环境

词法环境是一个栈结构,用于管理嵌套的块作用域

单个执行上下文中变量的查询顺序是词法作用域(栈顶到栈底) -> 变量作用域

变量提升的规则(编译阶段加入执行上下文):

  • var变量创建和初始化被提升,赋值不被提升(undefined)

  • let/const变量创建被提升,初始化和赋值不被提升(访问时会报错:Cannot access 'myname' before initialization)

    这种报错称作:暂时性死区(TDZ)

  • function函数创建,初始化,赋值均被提升

# 作用域链和闭包

  1. 词法作用域:作用域是由代码中函数声明的位置来决定的,是静态作用域,是在编译阶段就创建好的
  2. 作用域链:每个执行上下文的变量环境中存在一个outer变量指向外部的执行上下文,如果当前上下文找不到变量则进入outer指向的上下文进行查找,这个查找的链条称作作用域链
  3. 闭包:在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

# this

  1. this是和执行上下文绑定的,每个执行上下文都有一个this
  2. 全局执行上下文的this指向window,默认调用一个函数,this也是指向window,严格模式下,this为undefined
  3. this绑定:默认绑定(window),调用绑定(a.b,this是a),new绑定(let a = new A(),this是a),强制绑定(bind,call,apply)
  4. 箭头函数:普通函数的this不从外层函数继承(简单说this指向谁完全取决于如何调用),箭头函数不会创建上下文,因而this取决于外层函数。

# V8工作原理

# 内存机制

javascript是一门动态的(无需定义数据类型),弱类型(支持隐式转换)语言。

javascript共有8种数据类型,7种原始类型(null,undefined,number,string,boolean,bigInt,symbol),1种引用类型(object)

  1. 代码空间:存储可执行代码

  2. 栈空间:原始类型保存在栈空间(对于a={}会将对象保存在堆空间然后将返回的地址保存在栈空间)

  3. 堆空间:引用类型保存在堆空间

栈空间指的就是调用栈,为了保证执行上下文切换的效率,栈空间不能太大。

堆空间很大,但分配和回收内存会占用一定的时间。

原始类型采用值传递(完整复制变量值),引用类型采用引用传递(复制引用地址)。

闭包生成的步骤:1. 预扫描内部函数 2.将内部函数引用的变量作为闭包对象属性存储在堆中。

# 垃圾回收

  1. 通常,垃圾数据回收分为手动回收和自动回收两种策略。

  2. 栈空间内存回收:通过向下移动ESP(记录当前执行上下文的指针)来销毁执行上下文,因为会被后面的上下文覆盖。

  3. 堆空间内存回收:垃圾回收器

    • 代际假说:大部分对象存在时间很短,不死的对象会活得更久
    • V8中将堆内存分为新生代和老生代两个区域,新生代存放生存时间很短的对象,老生代存放生存时间很久的对象
    • 副垃圾回收期负责新生代的垃圾回收,主垃圾回收器负责老生代的垃圾回收
  4. 垃圾回收器的工作流程:

    • 标记空间中的活动对象和非活动对象
    • 回收非活动对象的内存
    • 内存整理,整理回收后产生的内存碎片
  5. 副垃圾回收器:使用Scavenge 算法,将新生代空间分为对象区域和空闲区域,新加入的对象放入对象区域,当对象区域快写满时开始执行垃圾回收

    • 对对象区域中对象做标记
    • 垃圾清理时,将存活对象复制到空闲区域(这里就已经完成了内存整理)
    • 将对象区域和空闲区域进行角色互换,原先对象区域变成了空闲区域,空闲区域变成对象区域

    对象复制需要时间成本,为了保证效率,新生代区域空间通常设置的比较小;

    对象晋升策略:新生区中进过两次垃圾清理依然存活的对象会被移动到老生区,解决新生区空间不大导致被占满的问题。

  6. 主垃圾回收器:占用空间大或者存活时间长的对象会被放到老生区,老生区的空间比较大,采用标记-清除算法进行垃圾回收

    • 标记阶段:如果遍历整个调用栈没有找到某个对象的引用,则该对象被标记为垃圾数据;
    • 清除阶段:清除掉垃圾对象,这样会产生内存碎片,优化的算法:标记-整理算法则是在清除阶段通过将活动对象向一端移动实现垃圾数据清理和内存整理。
  7. 全停顿:JavaScript是运行在主线程上的,一旦执行垃圾回收算法,需要将正在执行的JavaScript脚本暂停下来,这种行为叫做全停顿。新生代由于空间小,全停顿影响不大,老生代耗时就比较久。为了解决老生代垃圾回收造成卡顿的问题,使用”增量标记算法“,将标记过程拆分为一个个的子标记过程,同时让标记和JS应用逻辑交替进行,直到标记阶段完成。

# 编译器和解释器

V8在执行过程中既有解释器Ignition,又有编译器TurboFan。

  1. V8执行代码的流程:
    • 将源代码转换为抽象语法树(AST),并生成执行上下文。
      • Babel的工作原理就是先将ES6源码转换为AST,然后再将ES6语法的AST转换为ES5语法的AST,最后利用ES5的AST生成JavaScript源代码。
      • ESLint对JS的检测流程也是先将源码转换为AST,然后再利用AST来检查代码规范化的问题。
      • 生成AST需要经过两个阶段:
        • 分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个token。
        • 解析(parse),又称为语法分析,其作用是将token数据根据语法规则转为AST。如果源码存在语法错误,则会抛出”语法错误“
    • 生成字节码,解释器Ignition根据AST生成字节码并解释执行字节码
      • 字节码是介于AST和机器码之间的一种代码,使用字节码可以减少系统的内存使用。
    • 执行代码
      • 如果字节码第一次执行,则解释器会逐条解释执行
      • 如果一段代码被重复执行多次,则被称为”热点代码“,后台编译器TurboFan会将热点字节码编译为机器码,后续再次执行时只需要执行编译后的机器码,提升代码执行效率。(这种字节码配合解释器和编译器的技术称为即时编译(JIT))
  2. 性能优化:
    • 提升单次脚本的执行速度,避免JS长任务霸占主线程
    • 避免大的内联脚本,因为解析HTML过程中,解析和编译会占用主线程
    • 减小JS文件大小,提升下载速度,降低内存占用

# 事件循环

  1. 浏览器中的事件循环类似一个for循环
  2. 使用消息队列事件进程间任务调度
  3. 使用IO线程统一接收其他进程任务并封装加入消息队列

# setTimeout

  1. 使用单独的延时队列(其实是个hashmap,这里是为了表述方便)处理延时任务
  2. 当处理完消息队列中一个任务后会去检查延时队列中是否有到期的任务需要执行
  3. 可以使用clearTimeout(id)函数取消延时任务,实际上是删除了延时队列中某个id的任务
  4. 如果消息队列 中某个任务执行时间过久,会影响定时器任务的执行
  5. 如果setTimeout存在嵌套调用,则浏览器会将最小间隔时间设置为4ms
  6. 对于未激活的页面(未激活的标签),setTimeout执行的最小间隔为1000ms
  7. setTimeout最大时间间隔为2147483647ms(24.8天)(因为浏览器用32bit设置延时值),超过则归0。
  8. setTimeout中的this是指全局环境,解决方案是箭头函数或者bind

# XMLHttpRequest

XMLHttpRequest提供了从Web服务器获取数据的能力。

  1. 新建XMLHttpRequest请求对象:new XMLHttpRequest()
  2. 注册相关事件回调处理函数:ontimeout,onerror,onreadystatechange()
  3. 打开请求:xhr.open('GET', URL,)
  4. 配置参数:xhr.timeout=3000
  5. 发送请求:xhr.send()
    • 渲染主线程将请求发送给网络进程
    • 网络进程负责资源的下载
    • 网络进程接收到数据后通知渲染进程
    • 渲染进程将回调函数加入消息队列
    • 渲染主线程循环系统执行该任务

涉及到的问题:

  1. 跨域问题:域名和端口不一致导致跨域
  2. HTTPS混合内容问题:HTTPS中通过XHR请求了HTTP资源会被禁止

# 宏任务和微任务

页面上的大部分任务是在主线程上执行的,比如渲染、交互事件、JS、网络请求完成或文件读写完成,这些任务通过事件循环进行调度。浏览器中使用多个消息队列存储和调度这些任务,这些任务就是宏任务。

由于宏任务队列两个任务之间系统可能也会插入很多任务,导致宏任务队列执行的时间间隔无法精确控制,不适合高实时性的需求。

为了满足高实时性的需求,在宏任务之下又产生了微任务:

  1. 每个宏任务都会绑定一个微任务队列(存在于宏任务的执行上下文中)
  2. 微任务的执行时间点(检查点)是在宏任务主函数执行完毕,退出执行上下文之前。
  3. V8不断执行微任务直到微任务队列为空,因而微任务的执行时长和任务数量影响该宏任务的执行时长。
  4. 有两种方式添加微任务:mutationObserver和promise

MutationObserver的介绍:

通常web应用需要监视Dom元素的变化并作出响应,监控方式经历了几代演变:

  1. 早期没有监控DOM的API,使用setTimeout或者setInterval轮询检查,但是间隔过长无法及时响应,间隔过短又导致性能浪费。
  2. MutationEvent解决及时性的问题,一旦DOM变化则立即调用回调函数,但DOM的频繁变动由导致了性能问题。
  3. MutationObserver将同步调用改为异步调用,DOM变化后将回调加入微任务队列,相同DOM的多次变化只会触发一次回调执行,既满足及时性又解决了性能问题。

# Promise

Promise解决的是异步编码风格的问题:

  1. 由于web页面使用事件循环方式执行代码,导致多个任务分散地添加进消息队列,这就导致代码逻辑不连续。
  2. 嵌套回调和任务结果的不确定性导致回调地狱和代码混乱。
  3. Promise消灭了嵌套调用,同时合并多个任务的错误处理。
  4. Promise通过回调函数的延时绑定(先创建promise后绑定回调函数)和返回值穿透(穿透到最外层,所有then函数都会创建promise对象并返回)解决了嵌套回调的问题,promise的错误具有冒泡特性(不被reject或catch捕获就会一直向下传递)合并了多个任务的错误处理。

Promise与微任务:

new Promise(resolve, reject)中执行resolve函数会调用then的onResolve回调,但由于延时绑定的特性,resolve执行时onResolve还没有绑定,所以resolve中使用异步调用onResolve,比如setTimeout(onResolve(value), 0),但是setTimeout的效率比较低,于是Promise又把这个步骤改成了微任务,这就是Promise产生微任务的原因。

# async/await

使用同步方式编写异步代码:

  1. 生成器:可以暂停和恢复执行的函数。

  2. 生成器背后的原理是协程,协程是运行在线程之上的更加轻量的由程序控制(用户态)的存在,协程可以暂停和恢复。

  3. async/await的本质是生成器结合Promise的使用,生成器实现了同步的代码书写方式,Promise作为异步任务的载体,实现了微任务的注册与执行。

# 浏览器页面

# DOM的创建过程

浏览器使用HTML解析器进行DOM创建(边下载边创建):

  1. 分词:将文本字节流分为Tag Token和文本Token
  2. 生成Node,创建DOM:当遇到StartTag如<div>时将其加入Token栈,并在DOM中添加一个HTML节点(默认会产生一个Document空DOM);遇到文本Token时在DOM中添加文本节点;遇到EndTag如</div>时如果栈顶是StartTag则将其弹出;

JavaScript影响DOM创建:

  1. 当遇到<script>标签时,创建script节点加入DOM树然后HTML解析器停止工作,JS引擎开始工作;
  2. 如果<script>中使用src引入外部脚本,则执行前还需要先下载JS文本;浏览器使用"预解析"来优化这一过程,即拿到HTML文本后首先扫描一下所有需要下载的文件并开启预解析线程进行下载。

CSS影响DOM创建:

  1. JS中可能涉及CSS style样式的操作,所以执行JS之前会首先将CSS解析为CSSOM。

  2. 如果引入了外部CSS文件,则还需要进行CSS文件下载。

# 解析白屏

解析白屏即页面首次加载的白屏时间。

从发出URL请求到首次显示页面内容,视觉上经历了三个过程:

  1. 请求发出到数据提交阶段, 此时显示的是上个页面的内容。
  2. 解析白屏:提交数据后渲染进程创建一个空白页面,并等待CSS、JS文件下载,DOM,CSSOM构建完成,然后生成布局树,然后准备首次渲染。
  3. 首次渲染完成后,就进入页面生成阶段,此时页面被一点点绘制出来。

解析白屏阶段要做的事情:HTML解析、CSS下载、JS下载、生成CSSOM、执行JS、生成DOM、生成布局树、绘制页面等

通常影响首次加载白屏时间的瓶颈主要体现在:CSS下载、JS下载、执行JS代码三个部分

优化手段:使用内联CSS、JS或者减小CSS、JS文件大小,使用async/defer异步加载JS文件、使用媒体查询将CSS按用途拆分等

上次更新: 2/13/2025, 3:29:47 AM