浏览器工作原理
参考:《浏览器工作原理与实践》— 李兵
一、浏览器的多进程架构
(一)进程 、线程、协程
参考文档: https://blog.csdn.net/ThinPikachu/article/details/121325198
进程
【进程】:一个进程就是一个程序的运行实例,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放进程运行所需的所有状态信息,包含代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程的上下文。
-
进程是资源分配的最小单位,也就是说是程序运行的基本单位;
-
进程之间的内容相互隔离,不同进程通过IPC通信
-
一个进程关闭,操作系统则会回收为该进程分配的内存空间;
-
多个进程共享CPU,轮流使用CPU
-
每个进程都独占一块内存,有独立的代码和数据空间(进程的上下文),进程之间切换是通过操作系统调度的,上下文切换的开销较大
-- 操作系统对把CPU控制权在不同进程之间交换执行,即保存当前进程的上下文,恢复新进程的上下文,然后将CPU控制权转移到新进程,新进程就会从上次停止的地方开始。
线程
【线程】: 线程是CPU调度和执行的最小单位,线程依附于进程,是程序的实际执行者。
-
线程是不能单独存在的,它是由进程来启动和管理的
-
线程之间共享进程中的公共数据,线程之间切换的开销小,因此也被称为轻量级的进程
-
每个线程有各自的调用栈和线程本地存储
-
一个线程崩溃了,则所在的进程也会终止
协程
【协程】:运行在线程之上,协程并没有增加线程数量,只是在线程的基础之上通过分时复用
的方式运行多个协程。
-
一个线程可以拥有多个协程但是它们是串行执行的,当一个协程运行时,其他协程必须挂起
-
协程是一种用户态的轻量级线程,协程的调度不是被操作系统,而是完全由程序控制
(二)浏览器的多进程架构
早期的浏览器是单进程,浏览器的所有功能模块都是运行在同一个进程里,带来一系列问题,如:
- 不稳定:因为是单进程,一个任务崩溃,整个浏览器就崩溃了
- 不流畅,计算机只能一个任务一个任务处理,会出现任务阻塞情况
- 进程阻塞所带来的 CPU 时间浪费
为了解决这些问题,现在的浏览器操作系统实行了多进程架构,但是也带来了一些问题,如:
- 进程拥有太多的资源,系统线程会占用非常多的内存空间
- 进程之间切换、线程之间切换都是通过CPU调度的,占用了大量的系统时间
浏览器的架构是多进程的,Chrome 打开一个页面需要启动多少进程呢?
Chrome 的任务管理器窗口.pngChrome 浏览器的架构是多进程的,它主要包含以下几个进程:
-
浏览器主进程(Browser process): 是浏览器的顶层进程,主要负责页面显示、用户交互、管理子进程、提供存储等功能;
-
渲染进程(Renderer Process):核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页;
-
GPU进程(GPU Process) :负责图形处理
-
网络进程(Network Process):负责网络资源的下载
-
插件进程Plugin Process:负责网页插件
(三)浏览器的渲染进程
参考文档:https://zhuanlan.zhihu.com/p/398901440
浏览器的渲染进程也是浏览器内核的,在前端开发过程中,我们主要和渲染进程打交道,它主要包含以下几个线程:
1. GUI渲染线程
用于解析html、css,构建DOM树LayoutTree布局树(渲染树),布局和绘制生成最终渲染页面,传递给浏览器主进程进行展示。
2. Javascript引擎线程
也就是我们常说的js内核,也称JS引擎(例如V8引擎),用于解析、运行javascript代码;
Javascript引擎线程是单线程的。
·注意:GUI渲染线程和Javascript引擎线程是互斥的,不能同时执行,因此JS的执行会阻塞页面解析和渲染。
3. 事件触发线程
用于监听和触发事件。
注意:事件触发线程归属于渲染进程,不受JS引擎线程控制
。
如何理解事件触发线程 和 Javascript引擎线程的关系 ?
-
Javascript引擎线程执行事件绑定代码,用户执行交互操作后,触发事件;
-
事件触发线程监听到事件触发之后,把事件处理函数里面的代码交给Javascript引擎线程来执行;
-
但是由于Javascript引擎是单线程的,事件处理函数里面的代码会被添加到待处理任务队列的末尾,也就是等Javascript引擎线程空闲以后才会执行。
4. 定时触发器线程
就是经常使用的setInterval与setTimeout所使用的线程。
5. 异步http请求线程
负责发送发送http请求。
6. 合成线程
负责浏览器页面中各个图层的绘制。
7. IO线程:处理和其他进程进行通信
浏览器的渲染进程.jpg二、浏览器的渲染流程
按照渲染的时间顺序,可以把渲染流程分为以下几个阶段:
第一步,解析HTML,浏览器收到HTML页面后,GUI渲染线程开始解析HTML页面
-
HTML解析器开始解析HTML,生成DOM Tree并输出,保存在浏览器内存中;
-- 同时开启一个预解析线程,用来分析 HTML 文件中包含的Javascript、 CSS 、Img等需要下载的资源,通知网络进程提前加载这些资源
DOM 树的构建过程.png
- 解析遇到CSS(style、行内、link),CSS解析器开始对CSS进行解析,生成CSSOM,计算出 DOM 节点中每个元素的具体样式;包含以下3个过程:
- 样式计算,生成stylesheet
- 转换样式中的属性值,如color: red; => color: rgb(255, 0, 0)
- 计算出DOM每个节点的具体样式
- 遇到 <script> ,GUI渲染线程会停止解析剩余的 HTML ,等待Javascript 资源加载,Javascript引擎线程执行完脚本之后,再继续解析HTML页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script>
alert('哈哈哈,我阻挡了HTML页面的继续解析。。。。')
</script>
<style>
div {
width: 100px;
height: 100px;
background: rosybrown;
}
</style>
</head>
<body>
<div></div>
</body>
</html>
上面代码的执行结果.png
css解析和HTML解析并行,不会阻塞HTML解析,但是会阻塞页面渲染
javascript 会阻塞HTML解析和页面渲染
第二步,生成布局树, 根据DOM 树和计算后的CSS样式和生成布局树LayoutTree(也叫渲染树);
有 DOM了 树和 计算后的CSS样式,还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息,那么接下来就需要计算出 DOM 树中可见元素
的几何位置,这个计算过程叫做【布局】。
注意:所有不可见的元素会被忽略,如head标签 , display:none的元素,script标签等;
第三步,布局计算,计算出布局树的每个节点在浏览器屏幕上的具体坐标位置
- 渲染引擎计算出布局树中各元素的几何位置,并将计算结果保存在布局树中,
- 布局阶段的输出就是我们常说的盒子模型,它会精确地捕获每个元素在屏幕内的确切位置与大小
第四步,分层,生成图层树LayerTree
如果页面中有复杂的效果,如使用了3D 变换、页面滚动、 z-index(z 轴排序)等情况下,渲染引擎还需要为这些特定的元素节点生成专用图层,并生成一棵对应的图层树(LayerTree);
并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。
渲染引擎会为哪特定的节点创建新的图层呢?
- 拥有层叠上下文属性的元素会被提升为单独的一层;
-- 定位元素
-- 使用了z-index的元素
-- 定义透明属性的元素
-- 使用 CSS 滤镜的元素
- 需要剪裁(clip)的地方也会被创建为图层,及内容超出了容器元素时需要创建一个新的图层来放置。
渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面。
第五步, 绘制图层
染引擎会对图层树中的每个图层进行绘制,生成待【绘制列表】,交给合成线程
第六步,栅格化,生成位图
【视口】:屏幕的可是窗口成为视口(viewport);
页面可能很大,视口相对较小,为了节省开销,合成线程会将图层划分为图块(tile),然后合成线程会按照视口附近的图块来优先生成位图
-- 实际生成位图的操作是由栅格化来执行的;
-- 所谓【栅格化】,是指使用 GPU 进程将图块转换为位图的过程;
GPU进程根据不同图块生成位图,还给合成线程
第七步,合成
所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
第八步,显示界面
浏览器进程收到合成线程发过来的 DrawQuad 命令后,其内部组件viz 的组件会根据DrawQuad 命令生成页面并保存在内存中,最后再将页面显示在屏幕上。
浏览器渲染流程图.png渲染流程中的特殊情况:
1. 重排(回流):
指修改了元素几何属性,如位置、尺寸、内容、结构等变化,引发元素几何位置变化,浏览器需要重新计算样式、构建布局树,开始之后的一系列子阶段,这个过程就叫重排。
重排需要更新完整的渲染流水线,所以开销也是最大的。
触发重排的情况:(Javascript操作DOM,引发不同渲染流水线重新工作)
- 添加或删除可见的DOM元素
- 元素位置改变
- 元素尺寸改变
- 元素内容改变
- 改变字体大小会引发回流
- 页面渲染器初始化
- 浏览器窗口大小发生改变
- 当获取一些属性时,浏览器为了获得正确的值也会触发回流,这样使得浏览器优化无效,包括
(1) offset(Top/Left/Width/Height)
(2) scroll(Top/Left/Width/Height)
(3) cilent(Top/Left/Width/Height)
(4) width,height
(5) 调用了getComputedStyle()或者IE的currentStyle
2. 重绘:
指修改了元素的外观样式,不会引起几何位置变化,直接入绘制阶段,生成绘制列表,然后执行之后的一系列子阶段,这个过程就叫重绘。如背景颜色、边框颜色,文字颜色等
重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。重排必然带来重绘,但是重绘未必带来重排
3. 直接合成:
指更改一个既不要布局也不要绘制的属性,直接分割图块阶段,然后交给浏览器主进程并不线上显示,这个过程叫做直接合成。
如 transform:translate(100px, 100px)
相对于重绘和重排,直接合成能大大提升效率
减少重排(回流)、重绘, 方法:
- 多次dom 操作合成一次,批量操作,例如 createDocumentFragment,vue框架虚拟DOM和diff算法
- 使用 class 操作样式,而不是频繁操作 style
- 处理动画时,使用will-change和transform 做优化
-- 使用will-change实现动画时,渲染引擎会将该元素单独实现一个图层,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。这也是 CSS 动画比 JavaScript 动画高效的原因;
.box {
will-change: transform, opacity;
}
三、JavaScript执行机制
(一)JavaScript代码执行流程
第一步,代码编译:JavaScript 引擎对代码进行编译,并保存在内存中
编译结果为两部分:执行上下文、可执行代码
showName();//函数showName被执行
console.log(myname);//undefined
var myname = '小白'
function showName() {
console.log('我是小白');
}
编译时的执行上下文如下:(变量环境部分)
{
showName: xxx, //showName 函数在堆内存的引用地址
myname: undefined
}
可执行上下文如下:
showName();
console.log(myname);//undefined
myname = '小白'
- 执行上下文:是 JavaScript 执行一段代码时的运行环境
每个执行上下文包含以下几个部分:- 变量环境
- 词法环境
- 外部环境,即当前执行上下文中变量的外部引用,用来指向外部的执行上下文,也称为 outer
- this,this的指向在于当前函数的调用方式
-直接调用指向全局对象window (严格模式下则是undefined)
-通过对象调用,this指向该对象
-通过apply、call、bind等方法调用则指向第一个参数对象
-箭头函数中的this指向外层函数的this(解析箭头函数不会创建执行上下文)
let userInfo = {
userName: "小白",
age: 18,
sayHello: function () {
setTimeout(function () {
console.log(`${this.userName},你好`) //undefined
}, 100)
}
}
userInfo.sayHello()
修改一个函数this指向的方法:
- 缓存外部的this, 如 var _this = this;
- 使用箭头函数
- 使用app、call、bind改变this指向
第二步,执行可执行代码
问题:
-
var变量提升
编译时变量声明提升,并初始化值为undefind, -
函数声明提升
- 同时声明了多个相同名字的函数,后声明的会覆盖前面声明的函数
- 函数声明的优先级高于变量提升,变量名和函数声明的名字相同时,采用函数名
解决: 引入let、const、块级作用域
(二)函数执行(调用)过程
- 执行上下文栈:
用来管理执行上下文,后进先出
-
全局执行上下文:执行全局代码生成一个全局执行上下文,仅有一个,伴随页面的整个生存周期
-
函数执行上下文:执行每个函数会生成一个函数执行上下文,可以有多个, 当函数执行结束,该函数的执行上下文会被销毁
一段代码解析完成,即执行上下文创建完成,就立即执行可执行代码
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)
第一步,解析全局代码,创建全局执行上下文,压入调用栈,并全局的执行可执行代码
执行上下文栈.png
第二步,执行到addAll调用时,生成addAll函数的执行上下文,压入上下文,并执执行addAll函数内部的可执行代码
执行上下文栈.png
第三步,执行到add 函数调用,生成add 函数的执行上下文,压入调用栈
执行上下文栈.png
执行add 函数内部的可执行代码,return 结果,然后add函数执行上下文销毁,弹出调用栈
第四部,执行addAll后续可执行代码,return 结果,addAll函数上下文销毁,弹出调用栈,最后只剩下全局执行上下文,伴随页面整个生命周期
问题: 栈溢出(递归函数)
(三)作用域、作用域链、闭包
1. 作用域:是指变量和函数可以被访问的范围
-
全局作用域:代码中任何地方都能被访问,即全局执行上下文中的变量和函数能在任何地方被访问,生命周期伴随着页面的生命周期。
-
函数作用域:函数内部定义的变量或函数只能在函数内部被访问,函数执行结束之后,函数内部定义的变量会随着函数执行上下文一起销毁(闭包除外)
-
块级作用域 { }
var 、 let、const的区别:
-
var:
-- 在javascript解析时, 声明和初始化提升,声明之前访问不报错,值为undefined;
-- 存放在执行上下文中的变量环境中
-- 可以多次声明同一个变量,后一个值会覆盖之前的值;
-- 不支持块级作用域 -
let :
-- 用来声明一个变量,在解析时,声明会提升,但是初始化不会提升,声明之前访问报错;
-- 存放在执行上下中的词法环境中
-- 同一作用域内不能多次声明;
-- 支持块级作用域 -
const :
-- 用来声明一个常量,不能再次修改
--声明会提升,但是初始化不会提升,声明之前访问报错;
-- 存放在执行上下中的词法环境中
-- 同一作用域内不能多次声明;
-- 支持块级作用域
function foo(){
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a); //1
console.log(b); //3
}
console.log(b) ;//2
console.log(c); //4
console.log(d); //报错:d is not defined
}
foo()
2. 作用域链:变量查找沿着各作用域一层层向外部引用指向的执行上下文查找,形成一个链条,即作用域链条
函数的作用域由词法作用域决定
词法作用域:是指作用域是函数声明的位置来决定的,和函数怎么调用无关
3. 闭包:
当函数执行完毕时,函数体内的定义的变量会随着函数执行上下文立即销毁,但是当外部函数包含内部函数,且内部函数使用了外部函数中定义的变量,这些变量就不会销毁,仍然保存在内存,这些变量和内部函数就形成了闭包
闭包的形成条件:
- 外部函数里有内部函数
- 内部函数中使用了外部函数中定义的变量
function foo() {
var myName = "小白";
var age = 18;
function sayHello(){
console.log (`你好,我的名字是:${myName},今年${age}`)
}
return sayHello;
}
let hello = foo();
hello()
// myName和age就是foo函数的闭包
-
闭包形成原因:
Javascript在代码编译阶段,遇到内部函数 时,JavaScript 引擎会对内部函数做一次快速的词法扫描,
发现该内部函数引用了外部函数定义的变量,于是在堆空间创建换一个“closure”的对象,用来保存内部函数使用的变量,这个closure对象就是闭包 -
闭包何时回收?
- 引用闭包的函数是全局变量时,闭包则会一直保存在内存中,直到页面关闭
- 引用闭包的内部函是局部变量时,内部函数执行结束后,内部函数就会立即销毁,下次JavaScript 引擎的执行垃圾回收时,判断不再使用,则销毁闭包,回收内存
问题:内存泄露( 该回收的内存未被及时回收 )
(四)Javascrip的垃圾回收机制
1. Javascript的内存机制
-
栈内存: 存储基本类型数据(调用栈,执行上下文栈)
变量是引用类型时,存储的是引用类型的引用地址(编号) -
堆内存:存储引用类型数据
-
代码空间:存储可执行代码
2. Javascript的垃圾回收机制
数据被使用之后,不再需要了,就称为垃圾数据,垃圾数据要及时销毁,释放内存空间,否则会内存泄漏。
- 手动回收,如设置变量为null
- 自动回收
全局变量会常驻到老生代内存中是直到整个进程结束后才会释放,建议少使用,释放使用delete即可。
非全局变量在该作用域摧毁时会自动释放,主动释放建议赋值undefined。
(1)栈内存回收
当Javascript代码执行时,记录当前执行状态的指针(称为 ESP),指向当前执行上下文的指针,当前函数代码之前完毕,指针下移指向下一个要执行的函数执行上下文,当前执行上下文弹出调用栈进行销毁,这个过程就是该函数栈内存回收的过程
function foo(){
var a = 1
var b = {name:"极客邦"}
function showName(){
var c = 2
var d = {name:"极客时间"}
}
showName()
}
foo()
调用栈.png
(2)堆内存回收
垃圾回收器:
- 主垃圾回收器: 负责回收生存时间长的垃圾数据(老生代垃圾数据)
- 副垃圾回收器:负责回收生存时间短的垃圾数据(新生代垃圾数据)
第一步,标记堆内存中活动对象和非活动对象
- 活动对象:还在使用的数据
- 非活动对象:垃圾数据
第二步,回收非活动数据所占据的内存
在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象
第三步,做内存整理
(五)浏览器的事件循环机制
每个渲染进程都有一个主线程,处理以下事件:
- 渲染事件(如解析 DOM、计算布局、绘制)
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
- JavaScript 脚本执行事件
- 网络请求完成、文件读写完成事件
要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是消息队列和事件循环系统,浏览器页面是由消息队列和事件循环系统来驱动的。
1. 任务队列:是一种数据结构,用来放要执行的任务,先进先出
同步任务:直接进入主线程执行的任务,只有前一个任务执行完毕,才能执行后一个任务
异步任务:以回调函数实现,先在其他的任务队列中排队,等待同步任务执行完成,该任务才会进入主线程执行,分为宏任务、微任务
宏任务队列:宏任务执行队列,回调函数里要执行的任务
微任务队列:JavaScript 执行一段脚本,V8 引擎会首先创建一个全局执行上下文,同时也会创建一个专为V8 引擎内部使用的微任务队列
(1)宏任务:宿主环境即浏览器分配的任务
在 Chrome 中除了正常使用的消息队列之外,还有一个延迟队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。
延时队列中的任务包含JS通过定时器设置的回调函数、还有一些浏览器内部的延时回调函数,它们属于宏任务;
正常的消息队列中的任务也属于宏任务。
宏任务 主要有以下几种:
1.渲染事件(如解析 DOM、计算布局、绘制)
- javascript脚本执行
- setInterval、setTimeout
-- setTimeout回调函数的真正执行时间>=设定时间,原因是受消息队列中其他任务执行时间的影响 - DOM事件
-
XMLHttpRequest
XMLHttpRequest工作流程.png
XMLHttpRequest 发起请求,是由浏览器的其他进程或者线程去执行,然后再将执行结果利用 IPC 的方式通知渲染进程,之后渲染进程再将对应的消息添加到消息队列中。
function GetWebData(URL){
/**
* 1:新建XMLHttpRequest请求对象
*/
let xhr = new XMLHttpRequest()
/**
* 2:为 xhr 对象注册相关事件回调处理函数,监听网络请求过程中的各种情况
*/
xhr.onreadystatechange = function () {
switch(xhr.readyState){
case 0: //请求未初始化
console.log("请求未初始化")
break;
case 1://已调用xhr.send(),正在发送请求
console.log("OPENED")
break;
case 2://xhr.send()已经执行完成,已经接收到全部的响应资源
console.log("HEADERS_RECEIVED")
break;
case 3://正在解析响应资源
console.log("LOADING")
break;
case 4://资源解析完成,客户端可以直接使用了
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();
}
(2)微任务:JavaScript 引擎发起的任务,执行时机为当前宏任务结束之前
当 JavaScript 执行一段脚本的时候,V8 引擎在创建全局执行上下文的同时,也会在内部创建一个微任务队列。
这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。
不过这个微任务队列是给 V8 引擎内部使用的,无法通过 JavaScript 直接访问。
每个宏任务都关联了一个微任务队列。
Javascript脚本执行时本身就也是一个宏任务,宏任务中又包含同步任务、微任务、宏任务。
console.log(1);
setTimeout(()=>{
console.log(3);
Promise.resolve(4).then((data) => {
console.log(data)
})
setTimeout(() =>{
console.log(5)
},0)
}, 0)
Promise.resolve(2).then((data) => {
console.log(data)
})
//执行结果:1, 2, 3,5
微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列
微任务早于宏任务执行,如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中
微任务的执行时长会影响到当前宏任务的时长
微任务主要有:
- MotutaionObserver,监听 DOM 变化的一套方法,是Mutation Events的替代品;
MutationObserver 核心是采用了“异步 + 微任务”的策略。
const div1 = document.getElementById('div1');
setTimeout(function() {
console.log('setTimeout', div1.childNodes)
})
// 创建MutationObserver实例
const observer = new MutationObserver(function(mutationsList) {
console.log('mutations', mutationsList);
for(let mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('A child node has been added.');
}
}
})
observer.observe(div1, {
childList: true, // 监听节点变化
})
for(var i=0; i<3; i++) {
const p = document.createElement('p');
const textNode1 = '我是新建的';
p.innerHTML = textNode1;
div1.append(p);
}
Promise.resolve().then(function() {
console.log('Promise', div1.childNodes);
})
执行结果.png
MutationObserver 和 Mutation Events 的区别?
-
Mutation Events使用的是同步回调,且当 DOM 有变动时就会立刻触发相应的事件,即每次DOM 变化都会触发同步回调,造成了严重的性能问题
-
MutationObserver将响应函数改成异步调用,多次 DOM 变化后,一次触发异步调用,
-- 在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务
,并将微任务添加进当前的微任务队列
-- 渲染事件本身还是宏任务
- Promise主要是为了解决回调地狱问题,消灭嵌套调用和多次错误处理
(1) Promise的三种状态
pending(待执行状态)、fulfilled(执行成功状态)、rejected(执行失败状态)
(2)执行过状态不可逆,不会再变
要么pending ->fulfilled
要么pending -> rejected
(3)Promise实现原理:
- 采用.then的方式延时绑定回调函数
- 回调函数返回值穿透,then回调函数中的返回值,可以穿透到最外层
- 错误“冒泡”,会一直向后传递,通过链式调用then、catch,不论在那一层出错,都会“冒泡”至catch
-- 就不需要在每个 Promise 对象中单独捕获异常了
function executor(resolve, reject) {
let rand = Math.random();
console.log(rand);
if (rand > 0.5)
resolve()
else
reject()
}
var p1 = new Promise(executor); // 同步任务
var p2 = p1.then((value) => {
console.log("succeed p1")
const p2 = new Promise(executor);
return p2; // p1中的返回值穿透到最外层
})
p2.then((value) => {
console.log("succeed p2");
})
//错误冒泡,最后一个Promise做错误处理即可
p2.catch((error) => {
console.log("error")
})
Promise 中为什么要引入微任务?
由于promise采用.then延时绑定回调机制,而new Promise时又需要直接执行promise中的方法,即发生了先执行方法后添加回调的过程,此时需等待promise中的方法执行完成后,才能继续执行.then绑定的回调函数,由于宏任务较多容易堵塞,则采用了微任务
(4)Promise.resolve(value):返回一个以给定值解析后的Promise对象
Promise.resolve(value)方法的参数分成四种情况:
-- 参数是一个 Promise对象的实例 ,直接返回这个实例
-- 参数是一个thenable对象(即带有then方法),Promise.resolve()返回的是一个执行then方法之后的Promise对象,状态根据then方法执行之后判断
let thenable = {
then: function(resolve, reject) {
resolve(200)
}
}
let p1 = Promise.resolve(thenable); //200,因为p1已经是fulfilled状态,因此直接then,可以获取到返回值
p1.then((data) => {
console.log(data)
})
-- 参数是一个普通值或对象,则直接返回一个状态为fulfilled的 Promise 对象,值为参数本身
-- 参数为空,直接返回一个fulfilled状态的 Promise 对象,值为undefined
(5)链式调用时,
then回调函数执行成功,返回的是一个fulfilled状态的promise,会进入后面的then
then执行失败,返回的是一个rejected的promise,会进入后面的catch
catch回调函数执行成功,返回的也是一个fulfilled状态的promise,进入后面的then
catch执行失败,返回的是一个rejected的promise,进入后面的catch
Promise 的缺点:采用链式回调方式,充满大量的then函数,语义化方面存在缺陷
- async/await, 使用await,通过同步代码实现了异步访问资源回调的能力
async function foo() {
console.log(1);
let a = await 100;
console.log(a); // 100
console.log(2);
}
console.log(0);
foo();
console.log(3);
//执行顺序:0,1,3,100,2
生成器函数:是一个带星号函数,是可以暂停执行和恢复执行的
-- 在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
-- 外部函数可以通过 next 方法恢复函数的执行。
执行器:执行生成器函数的函数,则成为执行器
协程: 是一种比线程更加轻量级的存在,
- 协程不是被操作系统内核所管理,而是由程序所控制(也就是在用户态执行);这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
- 一个线程上可以存在多个协程,但是同时只能执行一个协程,因此要在协程之间进行切换
- 如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程
function* genDemo() {
console.log("开始执行第一段");
yield 'generator 1';// 遇到yield 关键字,JavaScript 引擎会暂停该函数的执行,并将关键字后面的内容返回给外部,外部函数可以通过next()恢复继续执行
console.log("开始执行第二段");
yield 'generator 2;
}
console.log('main 0')
let gen = genDemo(); //创建了一个gen协程,但是并没有执行
console.log(gen.next().value) ; //generator 1
console.log('main 1')
console.log(gen.next().value); //generator 2
console.log('main 2')
async/await的原理:
- 使用了Promise
- 在Promise基础配合生成器函数和协程,以同步代码编程的风格来实现异步回调
//foo函数
function* foo() {
let response1 = yield fetch('/some/url1') //fetch基于Promise实现
console.log('response1')
console.log(response1)
let response2 = yield fetch('/some/url1')
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)
})
直接使用async时, 返回结果是一个fulfilled状态的Promise对象;
使用await,await后为异步, 会默认创建一个 Promise 对象
- await 后若为函数时,会立即执行函数,再生成一个 Promise 对象
await之后的代码相当于then函数里的代码
;
如果需要做异常捕获,需要try...catch
(async function () {
const p1 = Promise.resolve(100)
const res = await p1
console.log(res) // 100
})()
(async function () {
const p2 = Promise.reject('some err')
const res = await p2
console.log(res) // 不会执行
})()
(async function () {
const p2 = Promise.reject('some err')
try {
const res = await p2
console.log(res)
} catch (ex) {
console.error(ex) // catch会执行
}
})()
2. 事件循环机制
参考文档https://www.jianshu.com/p/12b9f73c5a4f/,这个应该是讲的比较详细的
栈的概念理解(3种):
-
一段代码的运行环境,后进先出(执行上下文栈)
-
存放数据的一种内存区域,栈空间、堆空间
栈空间是有结构的,每个区块按照一定次序存放
堆空间没有结构的,数据可以任意存放。
栈空间的寻址速度要快于堆空间 -
可执行代码执行方式,执行栈(调用栈),先进先出
事件循环执行过程:
setTimeout(function () {
console.log('timeout1');
},0)
new Promise(function (resolve) {
console.log('promise1');
resolve(100)
}).then(() => {
console.log('then1');
})
new Promise(function (resolve) {
console.log('promise2');
resolve(200)
}).then(() => {
console.log('then2');
})
setTimeout(function () {
console.log('timeout2');
},0)
console.log('global1');
- 第一步,开始执行Javascript脚本,进入宏任务队列,因为只有一个script(整体代码)任务,直接开始执行
- 第二步,遇到setTimeout,setTimeout为一个宏任务,异步处理,等待时机成熟(100ms之后),timeout1加入宏任务队列
- 第三步,遇到Promise,Promise本身是同步任务,promise1, resolve(100),立即放到执行栈( 按顺序执行 ),
then才是回调异步函数,异步处理,处理完成后,then1加入微任务队列(异步任务执行加入微任务队中的时间是任务异步处理完成的时间顺序,不是在代码中的上下位置顺序) - 第四部,遇到第二个Promise,promise2, resolve(200),立即加入执行栈,
then2加入微任务队列 - 第五步,遇到第二个setTimeout,timeout2加入宏任务队列
- 至此,调用栈为空,Javascript宏任务中的同步任务函数全部执行完毕
- 第六步,然后去微任务队列查看可执行的微任务,then1加入执行栈执行,执行完成,执行栈为空,再去微任务队列查看可执行的微任务,加入执行栈执行,反复循环,直到微任务队列为空
- 第七部,查看宏任务队列可执行宏任务,timeout2执行完成时间早于timeout,因此先进入执行栈执行,反复循环,直到宏任务任务队列为空
- 任务全部执行完毕,调用栈为空
四、浏览器中的页面
页面的生命周期:
- 加载阶段
- 更新阶段(交互阶段)
- 销毁阶段
(一)页面优化:
从页面的生命周期方向思考:
1. 加载阶段:如何让页面渲染快?
关键资源(核心资源):阻塞页面首次渲染的资源称为页面的关键资源,HTML、CSS、Javascript
- 减少关键资源个数,减少请求次数
- 减小关键资源大小,提高资源加载速度
- 传输关键资源需要多少个 RTT(Round Trip Time)
--TCP协议传输资源时,是将资源分成一个个数据包(一般为14KB 左右),来回多次进行传输
--RTT ,是指客户端开始发送数据开始,到收到服务器端接收确认信息所经历的时间
具体优化方法:
(1)压缩HTML文件,移除 不必要注释
(2)合并并压缩CSS 、JavaScript等文件 ,script 标签加上 async 或 defer属性
(3)避免使用table布局
(4)缓存(第二次请求命中缓存则直接读取缓存)
2. 更新阶段(交互阶段):页面再次渲染速度如何更快?
目标是减少页面渲染过程的重排、重绘
具体优化方法:
(1)避免强制同步布局(即避免使用JavaScript 强制将计算样式和布局操作)
(1)减少DOM操作,将多次操作DOM合并为一次,如插入元素节点
(2)减少逐项更改样式,最好一次性更改style,或者将样式定义为class并一次性更新
(3)前端框架Vue、React(虚拟DOM和Diff算法等)
(3)避免多次读取offset等属性,使用变量做缓存
(4)防抖、节流
(5)做动画效果时,使用will-change和transform 做优化
(二)虚拟DOM及算法
- 多次
1. 页面加载阶段:
- 首次加载时,先创建虚拟DOM树,
- 再根据虚拟DOM树创建真实的DOM树,然后继续一系列渲染流水线工作
2. 页面加载阶段:
- 如果数据发生了改变,再创建一棵新的虚拟DOM树
- 两棵虚拟DOM树对比,计算出最少变化
- 把所有变化记录一次性更新到真实DOM树上,然后继续一系列渲染流水线工作
五、浏览器安全
同源策略:协议、域名、端口三者都相同则称为同源
1. XSS 攻击:跨站脚本攻击(Cross Site Scripting)
XSS 攻击是指黑客往 HTML 文件中或者 DOM 中注入恶意 JavaScript 脚本,在用户浏览页面用户实施攻击的一种手段
(1)风险:
-
窃取用户Cookie信息
-- 通过document.cookie获取用户Cookie 信息,发送到恶意服务器
-- 恶意服务器拿到用户的 Cookie 信息之后,就可以模拟用户的登录,进行转账等操作 -
监听用户行为
-- 通过addEventListener来监听键盘事件,获取用户账号、密码、信用卡等信息, 发送到恶意服务器
-- 恶意服务器拿拿到这些信息,又可以做很多违法的事情 -
修改 DOM 伪造假的登录窗口,用来欺骗用户输入用户名和密码等信息
-
生成广告等影响用户体验
(2)解决方法:
- 对输入脚本进行过滤或转码
如:<script> --><script>
-
响应头Set-Cookie加使用限制
-- httpOnly,通知浏览器此 Cookie 只能通过浏览器 HTTP 协议传输,浏览器的 JS 引擎就会禁用 document.cookie ;
-- SameSite=Strict,限制此Cookie不能随着跳转链接跨站发送 -
充分利用 CSP
-- 限制加载其他域下的资源文件,
-- 禁止向第三方域提交数据;
-- 禁止执行未授权的脚本;
2. CSRF攻击,跨站请求伪造(Cross Site Request Forgery)
目的是利用服务器的漏洞和用户的登录状态来实施攻击
解决方法:
-- 尽量使用post,避免低级的 CSRF 攻击
-- 增加验证,如使用验证码、短信验证码,验证是否是用户主动发起的
-- 服务器验证Referer,即请求的来源站点与当前发起的请求的站点是否一致
-- 使用Token验证
服务器第一次返回时生成一个Token,客户端存储起来
再次请求客户端带着对应的Token,进行验证