一、宏观视角下浏览器
01 打开一个页面四个进程
进程和线程: 一个进程就是一个程序运行的运行实例,当启动一个程序的时候,操作系统为该程序创建一块内存用来存放代码、运行中的数据和一个执行任务的主线程,这样的运行环境叫“进程”。 线程是依附于进程的,进程中使用多线程并行处理能提升效率。 目前浏览器的多进程架构:
- 浏览器进程:界面显示,交互,子进程管理和存储等。
- 渲染进程:解析html css和js,排版引擎blink和js的v8引擎都是运行在该进程中,chrome会为每个tab标签页创建一个渲染进程,出于安全考虑(资源都是网络进程来的,可能不安全),渲染进程都是沙箱模式下运行的
- GPU进程:绘制chrome的UI界面都采用GPU进程。
- 网络进程:网络资源加载
- 其他插件进程等:因插件容易崩溃等问题,单独放在进程中。
Q&A: 即使是多进程的架构,还是会有一些由于页面卡死最终奔溃导致所有页面崩溃的情况? 通常情况下,一个页面是一个进程,但是有一种情况是,同一站点(same site)。chrome的默认策略是,每个标签对应一个渲染进程,但是如果从一个页面打开了新页面,新页面和当前属于同一站点,新页面复用父页面的渲染进程,这个策略叫process-per-site-instance。所以这种情况下如果一个页面崩溃,因为他们使用了同一个渲染进程。
扩展: 浏览器的UA发展史【后续贴链接】 Chrome Tools
02 基于TCP/IP的HTTP
IP协议: IP(Internet Protocol),互联网通信协议 TCP协议: TCP(Transmission Control Protocol,传输控制协议),面向连接的、可靠的,基于字节流的传输层通信协议。 HTTP: 建立在TCP连接基础上的web协议。浏览器的HTTP协议是基于TCP/IP的应用层协议。
客户端发起HTTP请求流程:
- 构建请求: 构建请求行信息,准备发起网络请求
- 查找缓存: 在发起网络请求前,浏览器会先在浏览器缓存中查询是否有要请求的文件
- 准备IP地址和端口:这时候去请求的DNS返回域名IP,这里也会有DNS数据缓存
- 等待TCP队列
- 建立TCP链接
- 发送HTTP请求
服务器端处理HTTP请求流程:
- 返回请求
- 断开链接:通常情况下一旦服务器向客户端返回了请求数据,就要关闭TCP链接,但是如果加这一行可以保持链接。
Connection: Keep-Alive
- 重定向:如果返回301,告诉浏览器需要重定向,地址在响应头的Location字段中。
Q&A:
- 浏览器可以打开多个标签,端口一样吗?如果一样,数据如何知道去哪个标签页 端口一样的,网络进程知道每个TCP链接所对应的标签是哪个,收到数据后,会把数据分给对应标签页的渲染进程。
- 为什么很多网站二次打开速度快?
因为第一次打开缓存了一些数据,比如DNS缓存和页面资源缓存。
- 登录状态保持? 服务器将标识用户身份的id写到cookie中,响应头中返回
Set-Cookie: UID=xxxxxx
浏览器接收到响应头后将这个字段信息保存在本地,再次访问,浏览器读取之前保存的cookie信息发给服务器
扩展 浏览器缓存 HTTP相关1.0 2.0,HTTPS这种
03 从输入URL到页面展示发生了什么
(一)导航阶段
首先是需要各个浏览器进程之间的互相配合:
- 浏览器接收用户输入的URL请求,浏览器将URL转发给网络进程
- 网络进程发起URL请求(查找缓存、建立链接、重定向)
- 网络请求收到响应数据,解析响应头,转发数据给浏览器进程
- 浏览器进程收到响应头数据后,发送提交导航(CommitNavigation)消息到渲染进程
- 渲染进程收到消息后准备接收HTML数据,直接和网络进程建立数据管道通信
- 文档传输完成后,渲染进程最后向浏览器进程确认提交,“已经准备好接收和解析页面数据了”
- 浏览器进程接收到渲染进程的消息后,移除之前旧的文档,更新浏览器进程的页面状态(安全状态、地址栏URL、history等)
其中,用户发出URL请求到页面开始解析的过程叫导航。
(二)渲染阶段
- 构建DOM树:HTML经过解析,输出一个树状结构的DOM
- 样式计算:将css属性标准化,计算出DOM树中每个节点的具体样式(涉及到css的继承和层叠规则)
- 布局阶段:计算出DOM树中可元素的几何位置:(1)创建布局树,额外的构建一颗只有可见元素布局树 (2)布局计算
- 分层:渲染引擎为特定的节点生成专用的图层,并生成一个图层树(Layer Tree)。并不是每一个节点都有单独的图层,拥有层叠上下文属性的元素会被提升为单独的一层,比如(明确定位属性的元素、定义透明度、使用css滤镜等,需要裁剪的地方也会有单独的一层)
- 图层绘制:渲染引擎实现的图层绘制与绘画类似,会把一个图层的绘制拆分出很多绘制指令,再把这些指令组成一个待绘制列表。
- 栅格化(raster):绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操纵是由渲染引擎的合成线程完成的。合成线程将图层划分为图块,按照视口附近的图块优先生成位图。实际上生成位图的操作由栅格化执行的,栅格化就是将图块转成位图,该过程由GPU来加速生成,保存在GPU内存中
- 合成和显示:所有图块被光栅化后,合成线程会生成一个绘制图块的命令“DrawQuad”,然后将该命令教给浏览器主线程,接收合成线程发过来的命令,并将内容绘制到内存中,最终显示到界面上。
重排、重绘和直接合成
- 更改了元素的几何属性,位置、高度等,引起重新布局,重排
- 更改元素的绘制属性 改变元素的背景颜色,布局不会重排,直接进入绘制阶段,重绘
- 直接合成阶段
如果更改一个既不要布局也不要重新绘制的属性,渲染引擎会跳过布局和绘制,只执行后续的合成操作,比如css的tansform【不太懂】,而且也不是在渲染进程的主线程操作,会大大提高绘制效率
Q&A:在优化web性能的方法中,减少重排重绘的手段?
二、浏览器中js的执行机制
1 变量提升
执行以下程序:
showName()
console.log(myName)
var myName = 'xxx'
function showName() {
console.log('函数showName被执行')
}
结果:
变量提升
js中的声明和赋值
var myName = 'xxx'
可以看成:
var myName // 声明部分
myName = 'xxx' // 赋值部分
变量提升,是指在js代码执行的过程中,js引擎把变量的声明部分和函数的声明部分提到代码开头的行为。变量提升后会给变量设置默认值,这个默认值就是undefined. 模拟变量提升:
// 变量提升部分
var myName = undefined;
function showName() { console.log(函数showName被执行)}
// 可执行代码部分
showName()
myName = 'xxx'
js代码的执行流程
实际上变量和函数声明在代码里的位置是不会改变的,而是编译阶段被js引擎存入内存中。
(1)编译阶段
一段代码经过编辑会输入执行上下文(Execution Context)和可执行代码,前者是js执行一段代码时的运行环境,比如调用一个函数就会进入这个函数的执行上下文,确定函数在执行期间用到的this 变量 对象等。
(2)执行阶段
js引擎开始执行可执行代码,按照顺序一行一行执行。
代码中出现相同的变量或函数?
function showName2() { console.log('1111') }
showName2()
function showName2() { console.log('222') }
showName2()
分析代码,首先执行编译阶段,第一个函数体被放到变量环境中,第二个会将第一个覆盖;执行阶段会打印出2个'222' 因此,一段代码如果定义了两个相同名字的函数,最终生效的只有一个。
Q&A:分析代码结果
showName()
var showName = function(){ console.log('aaa')}
function showName() { console.log(1) }
showName()
答案:
// 模拟过程
// 编译部分
var showName
function showName() { console.log(1) }
// 可执行部分
showName() // 打印 1
showName = function(){ console.log('aaa')} // showName被重新赋值
showName() // 打印aaa
2 JS调用栈
在函数调用的时候,会为每个函数确定一个执行上下文,js引擎正是通过栈这种数据结构来管理执行上下文的,在执行上下文创建好后,js引擎会将执行上下文压入栈中。这种用来管理执行上下文的栈就是js的调用栈。
var a = 2;
function add(b, c) { return b + c }
function addAll(b, c) {
var d = 10;
var result = add(b, c);
return a + result + d;
}
addAll(3, 6)
- 每调用一个函数,js为其创建执行上下文,并压入调用栈,然后js引擎开始执行函数代码
- 如果在一个函数A中调用了另一个函数B,则js引擎会为b创建执行上下文,将b函数的执行上下文压入栈顶
- 当前函数执行完毕后,js引擎会将该函数的执行上下文弹出栈
- 当分配的调用栈被占满时,引发“堆栈溢出”的问题
(1) 利用浏览器查看调用栈的信息
(2) 栈溢出(Stack Overflow)
3 块级作用域
作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的声明周期。也就是说,作用域就是变量与函数的可访问范围。 在es6之前,es的作用域只有两种:全局作用域和函数作用域。
ES6引入了let和const关键字,使得js也拥有了块级作用域。
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()
- 通过var声明的变量,在编译阶段会被放到执行上下文的变量环境中
- 通过let const声明的变量,在编译阶段会被放在词法环境中
- 作用域块内部的let声明的变量,会被存放在词法环境单独的区域中
- 当执行到可执行代码的console.log(a)这行代码时,需要在词法环境中查找变量,沿着词法环境的栈从顶向下查询,如果没找到,则去环境变量里找
Q&A
分析词法环境,最终打印结果?
let myname= '极客时间'
{
console.log(myname)
let myname= '极客邦'
}
答案: 【最终打印结果】:VM6277:3 Uncaught ReferenceError: Cannot access 'myname' before initialization 在块作用域内,let声明的变量被提升,但变量只是创建被提升,初始化并没有被提升,在初始化之前使用变量,就会形成一个暂时性死区。
var的创建和初始化被提升,赋值不会被提升。
let的创建被提升,初始化和赋值不会被提升。
function的创建、初始化和赋值均会被提升。
4 作用域链和闭包
function bar() {
console.log(myName)
}
function foo() {
var myName = "极客邦"
bar()
}
var myName = "极客时间"
foo() // 打印极客时间
作用域链
每个执行上下问的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,叫做outer,当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,这个查找链条就是作用域链。
3 V8工作原理
4 浏览器的页面循环系统
消息队列和事件循环
每个渲染进程都有一个主线程,既要处理DOM,又要计算样式处理布局,需要消息队列和事件循环系统来调度。
- 使用单线程处理安排好的任务
- 在线程运行过程中处理新任务,需要事件循环机制
- 处理其他线程发送过来的任务,需要消息队列。是一种数据结构存放要执行的任务,符合队列先进先出的特点。
- 处理其他进程发过来的任务,渲染进程有一个专门的IO线程用来接收其他进程传进来的消息。
消息队列中的任务类型:
输入事件(鼠标滚动、点击)、微任务、文件读写、websocket、js计时器等等; 除此之外,还有一些页面相关的事件,如js执行、解析、DOM、样式计算、布局计算、css动画
安全退出
当页面主线程执行完后,设置一个退出的标志变量,每次执行完任务后判断是否有退出标志。
页面单线程的缺点
- 如何处理高优先级的任务,微任务和宏任务的机制应运而生。通常消息队列中的任务成为宏任务,每个宏任务都包含一个微任务队列;如果执行宏任务的过程,DOM有变化,就将变化添加到微任务列表中,不影响宏任务的执行,宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务。
- 如何解决单个任务执行时间过久问题,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。
实践
图中灰色的就是一个个任务,每个任务下面还有子任务,其中的 Parse HTML 任务,是把 HTML 解析为 DOM 的任务。值得注意的是,在执行 Parse HTML 的时候,如果遇到 JavaScript 脚本,那么会暂停当前的 HTML 解析而去执行 JavaScript 脚本。
setTimeout 和 XMLHttpRequest
浏览器实现setTimeout
要执行一段异步任务,需要先将任务添加到消息队列中,但是定时器设置的回调需要在指定时间内执行,所以不能直接将回调添加到消息队列中。 在chrome中除了正常使用的消息队列外,还维护了需要延迟执行的任务列表,包括定时器和chromium内部一些需要延迟执行的任务,当通过js创建一个定时器时,渲染进程将定时器的回到任务添加到延迟队列中。 源码
使用setTimeout的注意事项:
- 当前执行任务过就会影响定时器任务的执行
- 如果setTimeout存在嵌套调用,那么系统设置最短间隔时间为4毫秒
- 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
- 延时执行时间有最大值,32bit来存储最大毫秒,超过立即执行
- 使用 setTimeout 设置的回调函数中的 this指向全局环境,而不是定义所在的对象, 用箭头函数解决。
var name= 1;
var MyObj = {
name: 2,
showName: function(){
console.log(this.name);
}
}
setTimeout(MyObj.showName,1000) // 1
- 对比于setTimeout,使用 requestAnimationFrame 不需要设置具体的时间,由系统来决定回调函数的执行时间,requestAnimationFrame 里面的回调函数是在页面刷新之前执行,它跟着屏幕的刷新频率走,保证每个刷新间隔只执行一次,内如果页面未激活的话,requestAnimationFrame 也会停止渲染,这样既可以保证页面的流畅性,又能节省主线程执行函数的开销。
XMLHttpRequest
- 跨域问题
- HTTPS混合内容问题
宏任务和微任务
宏任务
- 渲染事件(解析DOM、布局计算、绘制)
- 用户交互事件(鼠标点击、页面滚动缩放)
- js脚本执行事件
- 网络请求完成、文件读写完成
为了协调任务在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部维护延迟队列和普通队列等,主线程采用一个for循环,不断地从任务队列中取出任务执行。我们把这些消息队列中的任务称为宏任务。
微任务
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。微任务的技术有 MutationObserver、Promise 以及以 Promise 为基础开发出来的很多其他的技术。
V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。
微任务如何产生?
第一种是调用MutationObserver监控某个DOM节点,然后通过js改变这个节点,当dom节点发生变化,就会产生dom变化记录的微任务。
第二种是使用Promise,当调用Promise.resolve或Promise.reject时,也会产生微任务
微任务队列何时被执行
当前宏任务的js快执行完成时,js引擎会检查全局执行上下文种的微任务队列,如果在执行微任务种产生了新的微任务,也会将该微任务添加到队列中。
Promise
Promise是如何解决嵌套回调的?
嵌套回调的产生,主要原因是在发起任务请求会带上回调函数,下个任务在回调函数中处理。Promise主要通过以下两步解决嵌套回调。 (1)Promise实现了回调函数的延时绑定。 回调函数的延时绑定在代码上上创建一个promise对象,通过promise对象的构造函数来执行业务逻辑;创建好后,再使用.then设置回调函数;
(2)其次,需要将回调函数onResolve的返回值穿透到最外层。我们会根据onResolve函数的传入值决定创建什么类型的Promise任务,创建好的promise对象需要返回到最外层。
Promise与微任务
Promise之所以需要微任务,是由回调函数延迟绑定技术导致的。new Promise的时候需要执行promise的方法,发生了先执行方法后添加回调的工程,此时需要等待then方法绑定两个回调后才能继续执行方法回调,便可将回调添加到当js调用栈中执行结束后的任务队列中,由于宏任务较多容易阻塞,则采用了微任务。
async和await
aync和awit使用了Generator和Promise两种技术。
生成器 VS 协程
生成器是一个带*号的函数,可以暂停执行和恢复。
function* genDemo() {
console.log('开始执行第一段')
yield 'generator 1'
console.log('开始执行第二段')
yield 'generator 2'
console.log('开始执行第三段')
yield 'generator 3'
console.log('执行结束')
return 'generator 4'
}
console.log('main 0')
let gen = genDemo();
console.log(gen.next().value)
console.log('main 1');
console.log(gen.next().value)
// 打印结果
main 0
开始执行第一段
generator 1
main 1
始执行第二段
generator 2
- 在生成器内部执行一段代码,如果遇到yield关键字,js将返回关键字后面的内容给外部,并暂停该函数的执行
- 外部函数可以通过next方法恢复函数的执行。
协程是比线程更轻量级的存在,协程可以看作是跑在线程上的任务,一个线程上可以存在多个协程,但是线程上同时只能执行一个协程,如果当前启动的是A协程,要启动b协程,a协程就需要将主线程控制权交给b协程,那么体现在a协程暂停执行,b协程恢复执行。如果从a协程启动b协程,我们通常把a协程称为b协程的父协程。
协程切换完全由程序所控制,不像切换线程一样消耗资源。
(1)通过生成器函数创建一个协程gen,并没有立即执行 (2)调用next函数交给gen协程 (3)协程执行时,遇到yield关键字暂停gen协程的执行,并返回信息给父协程 (4)如果协程在执行过程中遇到return关键字,js结束当前协程,return后面内容返回给父协程
async/await
- async是一个异步执行并隐式返回Promise作为结果的函数
- await
async function foo() {
console.log(1);
let a = await 100;
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)
// 0 1 3 100 2
------分割线
console.log(0)
await foo()
console.log(3)
// 0 1 100 2 3
Q&&A
async function foo() {
console.log('foo')
}
async function bar() {
console.log('bar start')
await foo()
console.log('bar end')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
bar();
new Promise(function (resolve) {
console.log('promise executor')
resolve();
}).then(function () {
console.log('promise then')
})
console.log('script end')
分析:
- 首先主协程中初始化异步函数foo和bar,遇到console打印script start
- 解析到setTimeout初始化一个Timer放到延时队列中
- 执行bar,遇到async,控制权交给协程,打印bar start,遇到await,相当于new 一个promise,执行foo,打印foo,创建一个Promise返回给主协程
- 返回的promise添加到微任务队列
- 向下执行new Promise, 输出promise executor,返回一个resolve添加到微任务队列
- 输出script end
- task结束之前检查微任务队列,按顺序执行,bar end, promise then
- 当前任务执行完毕后从任务队列中那拿出计时器任务,打印setTimeout
5 浏览器中的页面
5.1 chrome DevTools
Elements、Console、Sources、NetWork、Performance、Memory、Application、Security、Audits、Layers
网络面板
- 控制器
- 过滤器
- 抓图信息可以用来分析用户等待页面加载时间所看到的内容
- 时间线
- 相信列表
- 下载信息摘要,关注DOMContentLoaded和Load两个事件,以及着两个事件的完成时间。
- DOMContentLoaded 页面以及构建好DOM
- Load 浏览器加载了所有的资源
网络面板中的详细列表
- 列表的属性
- 详细信息
- 三个资源的时间线Timing
优化时间线上的耗时项
- 排队时间过久,域名分片技术或者HTTP2(没有每个域名最多维护6个TCP的限制)
- 第一字节时间过久:服务器生成页面数据时间过久;网速的原因;请求头带了多余的用户信息 3.content download太久,可能是文件过大,减少文件体积、压缩的方法。
5.2 DOM树
DOM树,就是渲染引擎能够理解的内部结构,在渲染引擎中,DOM有三个层面的作用:
- dom是生成页面的基础数据结构
- dom提供给js脚本的接口,通过这套接口,js可以对dom结构进行访问,从而改变节点的样式、文档结构等
- dom是一道安全防护线,不安全的内容会被过滤
DOM树的生成:
渲染引擎内部,有一个HTML解析器(HTML Parser)的模块,负责将HTML字节流转成dom结构。HTML解析器是网络进程加载了多少数据就解析了多少数据。
- 网络进程接收到content-type为text/html类型,为该请求选择或创建一个渲染进程
- 渲染进程准备好后,网络进程和渲染进程建立共享数据的管道,渲染进程将从网络进程中读取到的数据给html parser
- html parser解析html字节流
parse过程:
- 通过分词器将字节流转换成Token。
- 二三阶段是同步进行的将Token解析成DOM节点,并将DOM节点添加到DOM树种。parser维护了一个Token栈结构,用于计算节点间的父子关系
JS影响DOM生成
- 如果在html中间插入一段script标签包裹的js脚本,script标签之前,所有的解析流程还是和上文一样,解析到script标签后,渲染引擎判断这是一段脚本,html parser停止dom解析,执行标签中的这段脚本。脚本执行完成,继续解析完成。
- 如果js代码是引入的,需要先下载这段js代码。所以说js的下载过程会阻塞dom的解析。
- 不过,chrome对此坐了预解析操作的优化。当渲染引擎收到字节流后,会开启预解析线程,用来分析文件中包含的js css文件等,解析到有这种文件,预解析线程会提前下载这些文件
- 如果js代码中出现操作css样式,在执行js之前,还需要等待外部的css文件加载完成,并解析生成cssom对象后才能执行js脚本。无论脚本是否操作css,都会先执行css文件,解析操作再执行js。所以说js脚本是依赖样式表的,这又是一个阻塞。
5.3 渲染流水线(pipeline):css如何影响首次加载时的白屏时间?
渲染引擎无法理解css文件内容,需将其解析成cssom,体现在dom中就是document.styleSheets有两个作用:
- 提供给js操作样式表的能力
- 为布局树合成提供基础的样式信息
影响页面展示的因素以及优化策略
渲染流水线影响到了首次页面的展示速度,从url请求到首次显示页面的内容:
- 请求发出后,到提交阶段,页面展示出来的还是之前的页面内容
- 提交数据之后渲染进程会创建一个空白页面,称为解析空白,并等待css和js文件加载完成,生成dom和cssom,然后合成布局树
- 首次渲染完成后,开始进入页面生成阶段,页面会一点点被绘制出来。
影响首屏的第一个因素是网络或服务器,第二个是白屏时间,包括解析html、下载css,js、生成csssom、执行js、生成布局树、绘制页面一系列操作。
优化策略:
- 尽量减少文件大小
- 将一些不需要再解析html阶段使用的js标记成async或defer。defer异步下载,在html解析后执行;async异步下载,下载后立即执行。
- 对于大的css文件,可以通过媒体查询方式,拆分成多个不同场景下用的
5.4 渲染引擎的分层和合成机制
显示图像