BFC
块级格式化上下文,是一个独立的渲染区域,让处于
BFC
内部的元素与外部的元素相互隔离,使内外元素的定位不会相互影响。
IE下为
Layout
,可通过zoom:1
触发
触发条件:
- 根元素
position: absolute/fixed
display: inline-block / table
float
元素ovevflow !== visible
规则:
- 属于同一个
BFC
的两个相邻Box
垂直排列 - 属于同一个
BFC
的两个相邻Box
的margin
会发生重叠 BFC
中子元素的margin box
的左边, 与包含块 (BFC)border box
的左边相接触 (子元素absolute
除外)BFC
的区域不会与float
的元素区域重叠- 计算
BFC
的高度时,浮动子元素也参与计算 - 文字层不会被浮动层覆盖,环绕于周围
应用:
- 阻止
margin
重叠 - 可以包含浮动元素 —— 清除内部浮动(清除浮动的原理是两个
div
都位于同一个BFC
区域之中) - 自适应两栏布局
- 可以阻止元素被浮动元素覆盖
----------@----------
层叠上下文
元素提升为一个比较特殊的图层,在三维空间中 (z轴) 高出普通元素一等。
触发条件
- 根层叠上下文(
html
) position
css3
属性flex
transform
opacity
filter
will-change
webkit-overflow-scrolling
层叠等级:层叠上下文在z轴上的排序
- 在同一层叠上下文中,层叠等级才有意义
z-index
的优先级最高
----------@----------
左右居中方案
- 行内元素:
text-align: center
- 定宽块状元素: 左右
margin
值为auto
- 不定宽块状元素:
table
布局,position + transform
/* 方案1 */
.wrap {
text-align: center
}
.center {
display: inline;
/* or */
/* display: inline-block; */
}
/* 方案2 */
.center {
width: 100px;
margin: 0 auto;
}
/* 方案2 */
.wrap {
position: relative;
}
.center {
position: absulote;
left: 50%;
transform: translateX(-50%);
}
----------@----------
上下垂直居中方案
- 定高:
margin
,position + margin
(负值) - 不定高:
position
+transform
,flex
,IFC + vertical-align:middle
/* 定高方案1 */
.center {
height: 100px;
margin: 50px 0;
}
/* 定高方案2 */
.center {
height: 100px;
position: absolute;
top: 50%;
margin-top: -25px;
}
/* 不定高方案1 */
.center {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
/* 不定高方案2 */
.wrap {
display: flex;
align-items: center;
}
.center {
width: 100%;
}
/* 不定高方案3 */
/* 设置 inline-block 则会在外层产生 IFC,高度设为 100% 撑开 wrap 的高度 */
.wrap::before {
content: '';
height: 100%;
display: inline-block;
vertical-align: middle;
}
.wrap {
text-align: center;
}
.center {
display: inline-block;
vertical-align: middle;
}
----------@----------
选择器权重计算方式
!important > 内联样式 = 外联样式 > ID选择器 > 类选择器 = 伪类选择器 = 属性选择器 > 元素选择器 = 伪元素选择器 > 通配选择器 = 后代选择器 = 兄弟选择器
- 属性后面加
!import
会覆盖页面内任何位置定义的元素样式 - 作为
style
属性写在元素内的样式 id
选择器- 类选择器
- 标签选择器
- 通配符选择器(
*
) - 浏览器自定义或继承
同一级别:后写的会覆盖先写的
css选择器的解析原则:选择器定位DOM元素是从右往左的方向,这样可以尽早的过滤掉一些不必要的样式规则和元素
----------@----------
清除浮动
- 在浮动元素后面添加
clear:both
的空div
元素
<div class="container">
<div class="left"></div>
<div class="right"></div>
<div style="clear:both"></div>
</div>
- 给父元素添加
overflow:hidden
或者auto
样式,触发BFC
<div class="container">
<div class="left"></div>
<div class="right"></div>
</div>
.container{
width: 300px;
background-color: #aaa;
overflow:hidden;
zoom:1; /*IE6*/
}
- 使用伪元素,也是在元素末尾添加一个点并带有
clear: both
属性的元素实现的。
<div class="container clearfix">
<div class="left"></div>
<div class="right"></div>
</div>
.clearfix{
zoom: 1; /*IE6*/
}
.clearfix:after{
content: ".";
height: 0;
clear: both;
display: block;
visibility: hidden;
}
推荐使用第三种方法,不会在页面新增div,文档结构更加清晰
----------@----------
左边定宽,右边自适应方案
float + margin,float + calc
/* 方案1 */
.left {
width: 120px;
float: left;
}
.right {
margin-left: 120px;
}
/* 方案2 */
.left {
width: 120px;
float: left;
}
.right {
width: calc(100% - 120px);
float: left;
}
----------@----------
左右两边定宽,中间自适应
float,
float + calc
, 圣杯布局(设置BFC,margin负值法),flex
.wrap {
width: 100%;
height: 200px;
}
.wrap > div {
height: 100%;
}
/* 方案1 */
.left {
width: 120px;
float: left;
}
.right {
float: right;
width: 120px;
}
.center {
margin: 0 120px;
}
/* 方案2 */
.left {
width: 120px;
float: left;
}
.right {
float: right;
width: 120px;
}
.center {
width: calc(100% - 240px);
margin-left: 120px;
}
/* 方案3 */
.wrap {
display: flex;
}
.left {
width: 120px;
}
.right {
width: 120px;
}
.center {
flex: 1;
}
----------@----------
CSS动画和过渡
animation / keyframes
animation-name
: 动画名称,对应@keyframes
animation-duration
: 间隔animation-timing-function
: 曲线animation-delay
: 延迟animation-iteration-count
: 次数infinite
: 循环动画
animation-direction
: 方向alternate
: 反向播放
animation-fill-mode
: 静止模式forwards
: 停止时,保留最后一帧backwards
: 停止时,回到第一帧both
: 同时运用forwards / backwards
- 常用钩子:
animationend
动画属性: 尽量使用动画属性进行动画,能拥有较好的性能表现
translate
scale
rotate
skew
opacity
color
transform
- 位移属性
translate( x , y )
- 旋转属性
rotate()
- 缩放属性
scale()
- 倾斜属性
skew()
transition
transition-property
(过渡的属性的名称)。transition-duration
(定义过渡效果花费的时间,默认是0
)。transition-timing-function:linear(匀速) ease
(慢速开始,然后变快,然后慢速结束)(规定过渡效果的时间曲线,最常用的是这两个)。transition-delay
(规定过渡效果何时开始。默认是 0)
般情况下,我们都是写一起的,比如:
transition: width 2s ease 1s
关键帧动画animation
一个关键帧动画,最少包含两部分,animation 属性及属性值(动画的名称和运行方式运行时间等)。@keyframes(规定动画的具体实现过程)
animation 属性可以拆分为
animation-name
规定@keyframes 动画的名称。animation-duration
规定动画完成一个周期所花费的秒或毫秒。默认是0
。animation-timing-function
规定动画的速度曲线。默认是 “ease”,常用的还有linear
,同transtion
。animation-delay
规定动画何时开始。默认是 0。animation-iteration-count
规定动画被播放的次数。默认是 1,但我们一般用infinite
,一直播放
而
@keyframes
的使用方法,可以是from->to
(等同于0%和100%),也可以是从0%->100%
之间任意个的分层设置。我们通过下面一个稍微复杂点的demo
来看一下,基本上用到了上面说到的大部分知识
eg:
@keyframes mymove
{
from {top:0px;}
to {top:200px;}
}
/* 等同于: */
@keyframes mymove
{
0% {top:0px;}
25% {top:200px;}
50% {top:100px;}
75% {top:200px;}
100% {top:0px;}
}
用css3动画使一个图片旋转
#loader {
display: block;
position: relative;
-webkit-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
-ms-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
-ms-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes spin {
0% {
-webkit-transform: rotate(0deg);
-ms-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
-ms-transform: rotate(360deg);
transform: rotate(360deg);
}
}
----------@----------
CSS3的新特性
transition
:过渡transform
: 旋转、缩放、移动或倾斜animation
: 动画gradient
: 渐变box-shadow
: 阴影border-radius
: 圆角word-break
:normal|break-all|keep-all
; 文字换行(默认规则|单词也可以换行|只在半角空格或连字符换行)text-overflow
: 文字超出部分处理text-shadow
: 水平阴影,垂直阴影,模糊的距离,以及阴影的颜色。box-sizing
:content-box|border-box
盒模型- 媒体查询
@media screen and (max-width: 960px) {}
还有打印print
----------@----------
列举几个css中可继承和不可继承的元素
- 不可继承的:
display、margin、border、padding、background、height、min-height、max-height、width、min-width、max-width、overflow、position、left、right、top、bottom、z-index、float、clear、table-layout、vertical-align
- 所有元素可继承:
visibility
和cursor
。 - 内联元素可继承:
letter-spacing、word-spacing、white-space、line-height、color、font、font-family、font-size、font-style、font-variant、font-weight、text-decoration、text-transform、direction
。 - 终端块状元素可继承:
text-indent和text-align
。 - 列表元素可继承:
list-style、list-style-type、list-style-position、list-style-imag
e`。
transition和animation的区别
Animation
和transition
大部分属性是相同的,他们都是随时间改变元素的属性值,他们的主要区别是transition
需要触发一个事件才能改变属性,而animation
不需要触发任何事件的情况下才会随时间改变属性值,并且transition
为2帧,从from .... to
,而animation
可以一帧一帧的
----------@----------
浏览器
浏览器架构
单进程浏览器时代
单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript运行环境、渲染引擎和页面等。其实早在2007年之前,市面上浏览器都是单进程的
- 缺点
- 不稳定:一个插件的意外崩溃会引起整个浏览器的崩溃
- 不流畅:所有页面的渲染模块、JavaScript执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行
- 不安全:可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题
- 以上这些就是当时浏览器的特点,
不稳定,不流畅,而且不安全
多进程浏览器时代
- 由于
进程是相互隔离的
,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题 - JavaScript也是运行在渲染进程中的,所以即使JavaScript阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面,因为其他页面的脚本是运行在它们自己的渲染进程中的
- Chrome把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。
最新的Chrome浏览器包括:
1个浏览器(Browser)主进程
、1个 GPU 进程
、1个网络(NetWork)进程
、多个渲染进程
和多个插件进程
- 浏览器进程 。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
- 渲染进程 。核心任务是将
HTML、CSS
和 JavaScript 转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。 - GPU进程 。其实,Chrome刚开始发布的时候是没有GPU进程的。而GPU的使用初衷是为了实现3D CSS的效果,只是随后网页、Chrome的UI界面都选择采用GPU来绘制,这使得GPU成为浏览器普遍的需求。最后,Chrome在其多进程架构上也引入了GPU进程。
- 网络进程 。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
- 插件进程 。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响
----------@----------
JavaScript单线程模型
JavaScript语言的一大特点就是单线程,也就是说,同一时间只能做一件事,前面的任务没做完,后面的任务只能等着。
1. 为什么JavaScript是单线程的呢?
- 这主要与JavaScript用途有关。它的主要用途是与用户互动,以及操作DOM。如果JavaScript是多线程的,会带来很多复杂的问题,假如 JavaScript有A和B两个线程,A线程在DOM节点上添加了内容,B线程删除了这个节点,应该是哪个为准呢? 所以,为了避免复杂性,所以设计成了单线程。
- 虽然 HTML5 提出了Web Worker标准。Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。但是子线程完全受主线程控制,且不得操作DOM。所以这个并没有改变JavaScript单线程的本质。一般使用 Web Worker 的场景是代码中有很多计算密集型或高延迟的任务,可以考虑分配给 Worker 线程。
- 但是使用的时候一定要注意,worker 线程是为了让你的程序跑的更快,但是如果 worker 线程和主线程之间通信的时间大于了你不使用worker线程的时间,结果就得不偿失了。
2. 浏览器内核中线程之间的关系
- GUI渲染线程和JS引擎线程互斥
- js是可以操作DOM的,如果在修改这些元素的同时渲染页面(js线程和ui线程同时运行),那么渲染线程前后获得的元素数据可能就不一致了。
- JS阻塞页面加载
- js如果执行时间过长就会阻塞页面
3. 浏览器是多进程的优点
- 默认新开 一个 tab 页面 新建 一个进程,所以单个 tab 页面崩溃不会影响到整个浏览器。
- 第三方插件崩溃也不会影响到整个浏览器。
- 多进程可以充分利用现代 CPU 多核的优势。
- 方便使用沙盒模型隔离插件等进程,提高浏览器的稳定性。
4. 进程和线程又是什么呢
进程(process)和线程(thread)是操作系统的基本概念。
- 进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。
- 线程是 CPU 调度的最小单位(是建立在进程基础上的一次程序运行单位)。
由于每个进程至少要做一件事,所以一个进程至少有一个线程。系统会给每个进程分配独立的内存,因此进程有它独立的资源。同一进程内的各个线程之间共享该进程的内存空间(包括代码段,数据集,堆等)。
进程可以理解为一个工厂不不同车间,相互独立。线程是车间里的工人,可以自己做自己的事情,也可以相互配合做同一件事情。
5. 任务队列
- 单线程就意味着,所有任务都要排队执行,前一个任务结束,才会执行后一个任务。
- 如果一个任务需要执行,但此时JavaScript引擎正在执行其他任务,那么这个任务就需要放到一个队列中进行等待。等到线程空闲时,就可以从这个队列中取出最早加入的任务进行执行(类似于我们去银行排队办理业务,单线程相当于说这家银行只有一个服务窗口,一次只能为一个人服务,后面到的就需要排队,而任务队列就是排队区,先到的就优先服务)
注意: 如果当前线程空闲,并且队列为空,那每次加入队列的函数将立即执行。
为什么会有任务队列? 由于 JS 是单线程的,同步执行任务会造成浏览器的阻塞,所以我们将 JS 分成一个又一个的任务,通过不停的循环来执行事件队列中的任务。
----------@----------
Chrome 打开一个页面需要启动多少进程?分别有哪些进程?
打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。
浏览器进程
:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。渲染进程
:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。GPU 进程
:其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。网络进程
:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。插件进程
:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
----------@----------
渲染机制
1. 浏览器如何渲染网页
概述:浏览器渲染一共有五步
- 处理
HTML
并构建DOM
树。 - 处理
CSS
构建CSSOM
树。 - 将
DOM
与CSSOM
合并成一个渲染树。 - 根据渲染树来布局,计算每个节点的位置。
- 调用
GPU
绘制,合成图层,显示在屏幕上
第四步和第五步是最耗时的部分,这两步合起来,就是我们通常所说的渲染
具体如下图过程如下图所示
渲染
- 网页生成的时候,至少会渲染一次
- 在用户访问的过程中,还会不断重新渲染
重新渲染需要重复之前的第四步(重新生成布局)+第五步(重新绘制)或者只有第五个步(重新绘制)
- 在构建
CSSOM
树时,会阻塞渲染,直至CSSOM
树构建完成。并且构建CSSOM
树是一个十分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的CSS
选择器,执行速度越慢 - 当
HTML
解析到script
标签时,会暂停构建DOM
,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载JS
文件。并且CSS
也会影响JS
的执行,只有当解析完样式表才会执行JS
,所以也可以认为这种情况下,CSS
也会暂停构建DOM
2. 浏览器渲染五个阶段
2.1 第一步:解析HTML标签,构建DOM树
在这个阶段,引擎开始解析
html
,解析出来的结果会成为一棵dom
树dom
的目的至少有2
个
- 作为下个阶段渲染树状图的输入
- 成为网页和脚本的交互界面。(最常用的就是
getElementById
等等)
当解析器到达script标签的时候,发生下面四件事情
html
解析器停止解析,- 如果是外部脚本,就从外部网络获取脚本代码
- 将控制权交给
js
引擎,执行js
代码 - 恢复
html
解析器的控制权
由此可以得到第一个结论1
- 由于
<script>
标签是阻塞解析的,将脚本放在网页尾部会加速代码渲染。 defer
和async
属性也能有助于加载外部脚本。defer
使得脚本会在dom
完整构建之后执行;async
标签使得脚本只有在完全available
才执行,并且是以非阻塞的方式进行的
2.2 第二步:解析CSS标签,构建CSSOM树
- 我们已经看到
html
解析器碰到脚本后会做的事情,接下来我们看下html
解析器碰到样式表会发生的情况 js
会阻塞解析,因为它会修改文档(document
)。css
不会修改文档的结构,如果这样的话,似乎看起来css
样式不会阻塞浏览器html
解析。但是事实上css
样式表是阻塞的。阻塞是指当cssom
树建立好之后才会进行下一步的解析渲染
通过以下手段可以减轻cssom带来的影响
- 将
script
脚本放在页面底部 - 尽可能快的加载
css
样式表 - 将样式表按照
media type
和media query
区分,这样有助于我们将css
资源标记成非阻塞渲染的资源。 - 非阻塞的资源还是会被浏览器下载,只是优先级较低
2.3 第三步:把DOM和CSSOM组合成渲染树(render tree)
2.4 第四步:在渲染树的基础上进行布局,计算每个节点的几何结构
布局(
layout
):定位坐标和大小,是否换行,各种position
,overflow
,z-index
属性
2.5 调用 GPU 绘制,合成图层,显示在屏幕上
将渲染树的各个节点绘制到屏幕上,这一步被称为绘制
painting
3. 渲染优化相关
3.1 Load 和 DOMContentLoaded 区别
Load
事件触发代表页面中的DOM
,CSS
,JS
,图片已经全部加载完毕。DOMContentLoaded
事件触发代表初始的HTML
被完全加载和解析,不需要等待CSS
,JS
,图片加载
3.2 图层
一般来说,可以把普通文档流看成一个图层。特定的属性可以生成一个新的图层。不同的图层渲染互不影响,所以对于某些频繁需要渲染的建议单独生成一个新图层,提高性能。但也不能生成过多的图层,会引起反作用。
通过以下几个常用属性可以生成新图层
3D
变换:translate3d
、translateZ
will-change
video
、iframe
标签- 通过动画实现的
opacity
动画转换 position: fixed
3.3 重绘(Repaint)和回流(Reflow)
重绘和回流是渲染步骤中的一小节,但是这两个步骤对于性能影响很大
- 重绘是当节点需要更改外观而不会影响布局的,比如改变
color
就叫称为重绘 - 回流是布局或者几何属性需要改变就称为回流。
回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变深层次的节点很可能导致父节点的一系列回流
以下几个动作可能会导致性能问题
- 改变
window
大小 - 改变字体
- 添加或删除样式
- 文字改变
- 定位或者浮动
- 盒模型
很多人不知道的是,重绘和回流其实和 Event loop 有关
- 当
Event loop
执行完Microtasks
后,会判断document
是否需要更新。因为浏览器是60Hz
的刷新率,每16ms
才会更新一次。 - 然后判断是否有
resize
或者scroll
,有的话会去触发事件,所以resize
和scroll
事件也是至少16ms
才会触发一次,并且自带节流功能。 - 判断是否触发了
media query
- 更新动画并且发送事件
- 判断是否有全屏操作事件
- 执行
requestAnimationFrame
回调 - 执行
IntersectionObserver
回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好 - 更新界面
- 以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行
requestIdleCallback
回调
常见的引起重绘的属性
color
border-style
visibility
background
text-decoration
background-image
background-position
background-repeat
outline-color
outline
outline-style
border-radius
outline-width
box-shadow
background-size
3.4 常见引起回流属性和方法
任何会改变元素几何信息(元素的位置和尺寸大小)的操作,都会触发重排,下面列一些栗子
- 添加或者删除可见的
DOM
元素; - 元素尺寸改变——边距、填充、边框、宽度和高度
- 内容变化,比如用户在
input
框中输入文字 - 浏览器窗口尺寸改变——
resize
事件发生时 - 计算
offsetWidth
和offsetHeight
属性 - 设置
style
属性的值
回流影响的范围
由于浏览器渲染界面是基于流失布局模型的,所以触发重排时会对周围DOM重新排列,影响的范围有两种
- 全局范围:从根节点
html
开始对整个渲染树进行重新布局。 - 局部范围:对渲染树的某部分或某一个渲染对象进行重新布局
全局范围回流
<body>
<div class="hello">
<h4>hello</h4>
<p><strong>Name:</strong>BDing</p>
<h5>male</h5>
<ol>
<li>coding</li>
<li>loving</li>
</ol>
</div>
</body>
当
p
节点上发生reflow
时,hello
和body
也会重新渲染,甚至h5
和ol
都会收到影响
局部范围回流
用局部布局来解释这种现象:把一个
dom
的宽高之类的几何信息定死,然后在dom
内部触发重排,就只会重新渲染该dom
内部的元素,而不会影响到外界
3.5 减少重绘和回流
使用
translate
替代top
<div class="test"></div>
<style>
.test {
position: absolute;
top: 10px;
width: 100px;
height: 100px;
background: red;
}
</style>
<script>
setTimeout(() => {
// 引起回流
document.querySelector('.test').style.top = '100px'
}, 1000)
</script>
- 使用
visibility
替换display: none
,因为前者只会引起重绘,后者会引发回流(改变了布局) - 把
DOM
离线后修改,比如:先把DOM
给display:none
(有一次Reflow)
,然后你修改100
次,然后再把它显示出来 - 不要把
DOM
结点的属性值放在一个循环里当成循环里的变量
for(let i = 0; i < 1000; i++) {
// 获取 offsetTop 会导致回流,因为需要去获取正确的值
console.log(document.querySelector('.test').style.offsetTop)
}
- 不要使用
table
布局,可能很小的一个小改动会造成整个table
的重新布局 - 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用
requestAnimationFrame
CSS
选择符从右往左匹配查找,避免DOM
深度过深- 将频繁运行的动画变为图层,图层能够阻止该节点回流影响别的元素。比如对于
video
标签,浏览器会自动将该节点变为图层。
----------@----------
缓存机制
1. 首先得明确 http 缓存的好处
- 减少了冗余的数据传输,减少网费
- 减少服务器端的压力
Web
缓存能够减少延迟与网络阻塞,进而减少显示某个资源所用的时间- 加快客户端加载网页的速度
2. 常见 http 缓存的类型
- 私有缓存(一般为本地浏览器缓存)
- 代理缓存
3. 然后谈谈本地缓存
本地缓存是指浏览器请求资源时命中了浏览器本地的缓存资源,浏览器并不会发送真正的请求给服务器了。它的执行过程是
- 第一次浏览器发送请求给服务器时,此时浏览器还没有本地缓存副本,服务器返回资源给浏览器,响应码是
200 OK
,浏览器收到资源后,把资源和对应的响应头一起缓存下来 - 第二次浏览器准备发送请求给服务器时候,浏览器会先检查上一次服务端返回的响应头信息中的
Cache-Control
,它的值是一个相对值,单位为秒,表示资源在客户端缓存的最大有效期,过期时间为第一次请求的时间减去Cache-Control
的值,过期时间跟当前的请求时间比较,如果本地缓存资源没过期,那么命中缓存,不再请求服务器 - 如果没有命中,浏览器就会把请求发送给服务器,进入缓存协商阶段。
与本地缓存相关的头有:
Cache-Control
、Expires
,Cache-Control
有多个可选值代表不同的意义,而Expires
就是一个日期格式的绝对值。
3.1 Cache-Control
Cache-Control
是HTPP
缓存策略中最重要的头,它是HTTP/1.1
中出现的,它由如下几个值
no-cache
:不使用本地缓存。需要使用缓存协商,先与服务器确认返回的响应是否被更改,如果之前的响应中存在ETag
,那么请求的时候会与服务端验证,如果资源未被更改,则可以避免重新下载no-store
:直接禁止游览器缓存数据,每次用户请求该资源,都会向服务器发送一个请求,每次都会下载完整的资源public
:可以被所有的用户缓存,包括终端用户和CDN
等中间代理服务器。private
:只能被终端用户的浏览器缓存,不允许CDN
等中继缓存服务器对其缓存。max-age
:从当前请求开始,允许获取的响应被重用的最长时间(秒)。must-revalidate
,当缓存过期时,需要去服务端校验缓存的有效性。
Cache-Control: public, max-age=1000
注意,虽然你可能在其他资料中看到可以使用 meta 标签来设置缓存,比如像下面的形式:
<meta http-equiv="expires" content="Wed, 20 Jun 2021 22:33:00 GMT"
但在 HTML5 规范中,并不支持这种方式,所以尽量不要使用 meta 标签来设置缓存
。
3.2 Expires
Expires
是HTTP/1.0
出现的头信息,同样是用于决定本地缓存策略的头,它是一个绝对时间,时间格式是如Mon, 10 Jun 2015 21:31:12 GMT
,只要发送请求时间是在Expires
之前,那么本地缓存始终有效,否则就会去服务器发送请求获取新的资源。如果同时出现Cache-Control:max-age
和Expires
,那么max-age
优先级更高。他们可以这样组合使用
Cache-Control: public
Expires: Wed, Jan 10 2018 00:27:04 GMT
3.3 所谓的缓存协商
当第一次请求时服务器返回的响应头中存在以下情况时
- 没有
Cache-Control
和Expires
Cache-Control
和Expires
过期了Cache-Control
的属性设置为no-cache
时
那么浏览器第二次请求时就会与服务器进行协商,询问浏览器中的缓存资源是不是旧版本,需不需要更新,此时,服务器就会做出判断,如果缓存和服务端资源的最新版本是一致的,那么就无需再次下载该资源,服务端直接返回
304 Not Modified
状态码,如果服务器发现浏览器中的缓存已经是旧版本了,那么服务器就会把最新资源的完整内容返回给浏览器,状态码就是200 Ok
,那么服务端是根据什么来判断浏览器的缓存是不是最新的呢?其实是根据HTTP
的另外两组头信息,分别是:Last-Modified/If-Modified-Since
与ETag/If-None-Match
。
Last-Modified 与 If-Modified-Since
具体工作流程如下:
- 浏览器第一次请求资源时,服务器会把资源的最新修改时间
Last-Modified:Thu, 29 Dec 2011 18:23:55 GMT
放在响应头中返回给浏览器 - 第二次请求时,浏览器就会把上一次服务器返回的修改时间放在请求头
If-Modified-Since:Thu, 29 Dec 2011 18:23:55
发送给服务器,服务器就会拿这个时间跟服务器上的资源的最新修改时间进行对比 - 服务端再次收到请求,根据请求头
If-Modified-Since
的值,判断相关资源是否有变化,如果没有,则返回304 Not Modified
,并且不返回资源内容,浏览器使用资源缓存值;否则正常返回资源内容,且更新Last-Modified
响应头内容。
如果两者相等或者大于服务器上的最新修改时间,那么表示浏览器的缓存是有效的,此时缓存会命中,服务器就不再返回内容给浏览器了,同时
Last-Modified
头也不会返回,因为资源没被修改,返回了也没什么意义。如果没命中缓存则最新修改的资源连同Last-Modified
头一起返回
这种方式虽然能判断缓存是否失效,但也存在两个问题:
- 精度问题 ,
Last-Modified
的时间精度为秒,如果在1
秒内发生修改,那么缓存判断可能会失效; - 准度问题 ,考虑这样一种情况,如果一个文件被修改,然后又被还原,内容并没有发生变化,在这种情况下,浏览器的缓存还可以继续使用,但因为修改时间发生变化,也会重新返回重复的内容。
Expires: Fri, Jan 12 2018 00:27:04 GMT
Last-Modified: Wed, Jan 10 2018 00:27:04 GMT
这组头信息是基于资源的修改时间来判断资源有没有更新,另一种方式就是根据资源的内容来判断,就是接下来要讨论的
ETag
与If-None-Match
ETag与If-None-Match
为了解决精度问题和准度问题
,HTTP 提供了另一种不依赖于修改时间,而依赖于文件哈希值的精确判断缓存的方式,那就是响应头部字段 ETag 和请求头部字段 If-None-Match。
ETag/If-None-Match
与Last-Modified/If-Modified-Since
的流程其实是类似的,唯一的区别是它基于资源的内容的摘要信息(比如MD5 hash
)来判断
浏览器发送第二次请求时,会把第一次的响应头信息
ETag
的值放在If-None-Match
的请求头中发送到服务器,与最新的资源的摘要信息对比,如果相等,取浏览器缓存,否则内容有更新,最新的资源连同最新的摘要信息返回。用ETag
的好处是如果因为某种原因到时资源的修改时间没改变,那么用ETag
就能区分资源是不是有被更新。
具体工作流程如下:
- 浏览器第一次请求资源,服务端在返响应头中加入
Etag
字段,Etag
字段值为该资源的哈希值 - 当浏览器再次跟服务端请求这个资源时,在请求头上加上
If-None-Match
,值为之前响应头部字段ETag
的值; - 服务端再次收到请求,将请求头
If-None-Match
字段的值和响应资源的哈希值进行比对,如果两个值相同,则说明资源没有变化,返回304 Not Modified
;否则就正常返回资源内容,无论是否发生变化,都会将计算出的哈希值放入响应头部的ETag
字段中
这种缓存比较的方式也会存在一些问题,具体表现在以下两个方面。
- 计算成本 。生成哈希值相对于读取文件修改时间而言是一个开销比较大的操作,尤其是对于大文件而言。如果要精确计算则需读取完整的文件内容,如果从性能方面考虑,只读取文件部分内容,又容易判断出错。
- 计算误差 。HTTP 并没有规定哈希值的计算方法,所以不同服务端可能会采用不同的哈希值计算方式。这样带来的问题是,同一个资源,在两台服务端产生的 Etag 可能是不相同的,所以对于使用服务器集群来处理请求的网站来说,使用 Etag 的缓存命中率会有所降低。
需要注意的是,
强制缓存的优先级高于协商缓存
,在协商缓存中,Etag 优先级比 Last-Modified
高
Cache-Control: public, max-age=31536000
ETag: "15f0fff99ed5aae4edffdd6496d7131f"
If-None-Match: "15f0fff99ed5aae4edffdd6496d7131f"
缓存位置
浏览器缓存的位置的话,可以分为四种,优先级从高到低排列分别👇
Service Worker
Memory Cache
Disk Cache
Push Cache
Service Worker
这个应用场景比如PWA,它借鉴了Web Worker思路,由于它脱离了浏览器的窗体,因此无法直接访问DOM。它能完成的功能比如:
离线缓存
、消息推送
和网络代理
,其中离线缓存
就是 Service Worker Cache 。
Memory Cache
指的是内存缓存,从效率上讲它是最快的,从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。
Disk Cache
存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,优势在于存储容量和存储时长。
Disk Cache VS Memory Cache
两者对比,主要的策略👇
- 内容使用率高的话,文件优先进入磁盘
- 比较大的JS,CSS文件会直接放入磁盘,反之放入内存。
Push Cache
推送缓存,这算是浏览器中最后一道防线吧,它是
HTTP/2
的内容
浏览器缓存总结
浏览器缓存分为强缓存和协商缓存。当客户端请求某个资源时,获取缓存的流程如下
- 先根据这个资源的一些 http header 判断它是否命中强缓存,先检查
Cache-Control
,如果命中,则直接从本地获取缓存资源,不会发请求到服务器; - 当强缓存没有命中时,客户端会发送请求到服务器,服务器通过另一些request header验证这个资源是否命中协商缓存,称为http再验证,如果命中,服务器将请求返回,但不返回资源,而是返回304告诉客户端直接从缓存中获取,客户端收到返回后就会从缓存中获取资源;(服务器通过请求头中的
If-Modified-Since
或者If-None-Match
字段检查资源是否更新) - 强缓存和协商缓存共同之处在于,如果命中缓存,服务器都不会返回资源; 区别是,强缓存不对发送请求到服务器,但协商缓存会。
- 当协商缓存也没命中时,服务器就会将资源发送回客户端。
- 当 ctrl+f5 强制刷新网页时,直接从服务器加载,跳过强缓存和协商缓存;
- 当 f5刷新网页时,跳过强缓存,但是会检查协商缓存;
强缓存
- Expires(该字段是 http1.0 时的规范,值为一个绝对时间的 GMT 格式的时间字符串,代表缓存资源的过期时间)
- Cache-Control:max-age(该字段是 http1.1的规范,强缓存利用其 max-age 值来判断缓存资源的最大生命周期,它的值单位为秒)
协商缓
- Last-Modified(值为资源最后更新时间,随服务器response返回,即使文件改回去,日期也会变化)
- If-Modified-Since(通过比较两个时间来判断资源在两次请求期间是否有过修改,如果没有修改,则命中协商缓存)
- ETag(表示资源内容的唯一标识,随服务器response返回,仅根据文件内容是否变化判断)
- If-None-Match(服务器通过比较请求头部的If-None-Match与当前资源的ETag是否一致来判断资源是否在两次请求之间有过修改,如果没有修改,则命中协商缓存)
----------@----------
浏览器存储
我们经常需要对业务中的一些数据进行存储,通常可以分为 短暂性存储 和 持久性储存。
- 短暂性的时候,我们只需要将数据存在内存中,只在运行时可用
- 持久性存储,可以分为 浏览器端 与 服务器端
- 浏览器:
cookie
: 通常用于存储用户身份,登录状态等http
中自动携带, 体积上限为4K
, 可自行设置过期时间
localStorage / sessionStorage
: 长久储存/窗口关闭删除, 体积限制为4~5M
indexDB
- 服务器:
- 分布式缓存
redis
- 数据库
- 分布式缓存
- 浏览器:
cookie和localSrorage、session、indexDB 的区别
特性 | cookie | localStorage | sessionStorage | indexDB |
---|---|---|---|---|
数据生命周期 | 一般由服务器生成,可以设置过期时间 | 除非被清理,否则一直存在 | 页面关闭就清理 | 除非被清理,否则一直存在 |
数据存储大小 | 4K | 5M | 5M | 无限 |
与服务端通信 | 每次都会携带在 header 中,对于请求性能影响 | 不参与 | 不参与 | 不参与 |
从上表可以看到,
cookie
已经不建议用于存储。如果没有大量数据存储需求的话,可以使用localStorage
和sessionStorage
。对于不怎么改变的数据尽量使用localStorage
存储,否则可以用sessionStorage
存储。
对于 cookie
,我们还需要注意安全性
属性 | 作用 |
---|---|
value | 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识 |
http-only | 不能通过 JS 访问 Cookie ,减少 XSS 攻击 |
secure | 只能在协议为 HTTPS 的请求中携带 |
same-site | 规定浏览器不能在跨域请求中携带 Cookie ,减少 CSRF 攻击 |
Name
,即该Cookie
的名称。Cookie
一旦创建,名称便不可更改。Value
,即该Cookie
的值。如果值为Unicode
字符,需要为字符编码。如果值为二进制数据,则需要使用BASE64
编码。Max Age
,即该Cookie
失效的时间,单位秒,也常和Expires
一起使用,通过它可以计算出其有效时间。Max Age
如果为正数,则该Cookie
在Max Age
秒之后失效。如果为负数,则关闭浏览器时Cookie
即失效,浏览器也不会以任何形式保存该Cookie
。Path
,即该Cookie
的使用路径。如果设置为/path/
,则只有路径为/path/
的页面可以访问该Cookie
。如果设置为/
,则本域名下的所有页面都可以访问该Cookie
。Domain
,即可以访问该Cookie
的域名。例如如果设置为.zhihu.com
,则所有以zhihu.com
,结尾的域名都可以访问该Cookie
。Size
字段,即此Cookie
的大小。Http
字段,即Cookie
的httponly
属性。若此属性为true
,则只有在HTTP Headers
中会带有此 Cookie 的信息,而不能通过document.cookie
来访问此 Cookie。Secure
,即该Cookie
是否仅被使用安全协议传输。安全协议。安全协议有HTTPS、SSL
等,在网络上传输数据之前先将数据加密。默认为false
。
----------@----------
跨域方案
很多种方法,但万变不离其宗,都是为了搞定同源策略。重用的有
jsonp
、iframe
、cors
、img
、HTML5 postMessage
等等。其中用到html
标签进行跨域的原理就是html
不受同源策略影响。但只是接受Get
的请求方式,这个得清楚。
延伸1:img iframe script 来发送跨域请求有什么优缺点?
1. iframe
- 优点:跨域完毕之后
DOM
操作和互相之间的JavaScript
调用都是没有问题的 - 缺点:1.若结果要以
URL
参数传递,这就意味着在结果数据量很大的时候需要分割传递,巨烦。2.还有一个是iframe
本身带来的,母页面和iframe
本身的交互本身就有安全性限制。
2. script
- 优点:可以直接返回
json
格式的数据,方便处理 - 缺点:只接受
GET
请求方式
3. 图片ping
- 优点:可以访问任何
url
,一般用来进行点击追踪,做页面分析常用的方法 - 缺点:不能访问响应文本,只能监听是否响应
延伸2:配合 webpack 进行反向代理?
webpack
在 devServer
选项里面提供了一个 proxy
的参数供开发人员进行反向代理
'/api': {
target: 'http://www.example.com', // your target host
changeOrigin: true, // needed for virtual hosted sites
pathRewrite: {
'^/api': '' // rewrite path
}
},
然后再配合
http-proxy-middleware
插件对api
请求地址进行代理
const express = require('express');
const proxy = require('http-proxy-middleware');
// proxy api requests
const exampleProxy = proxy(options); // 这里的 options 就是 webpack 里面的 proxy 选项对应的每个选项
// mount `exampleProxy` in web server
const app = express();
app.use('/api', exampleProxy);
app.listen(3000);
然后再用
nginx
把允许跨域的源地址添加到报头里面即可
说到
nginx
,可以再谈谈CORS
配置,大致如下
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Headers' 'DNT, X-Mx-ReqToken, Keep-Alive, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 200;
}
}
----------@----------
XSS 和 CSRF
1. XSS
涉及面试题:什么是
XSS
攻击?如何防范XSS
攻击?什么是CSP
?
XSS
简单点来说,就是攻击者想尽一切办法将可以执行的代码注入到网页中。XSS
可以分为多种类型,但是总体上我认为分为两类:持久型和非持久型。- 持久型也就是攻击的代码被服务端写入进数据库中,这种攻击危害性很大,因为如果网站访问量很大的话,就会导致大量正常访问页面的用户都受到攻击。
举个例子,对于评论功能来说,就得防范持久型
XSS
攻击,因为我可以在评论中输入以下内容
- 这种情况如果前后端没有做好防御的话,这段评论就会被存储到数据库中,这样每个打开该页面的用户都会被攻击到。
- 非持久型相比于前者危害就小的多了,一般通过修改
URL
参数的方式加入攻击代码,诱导用户访问链接从而进行攻击。
举个例子,如果页面需要从
URL
中获取某些参数作为内容的话,不经过过滤就会导致攻击代码被执行
<!-- http://www.domain.com?name=<script>alert(1)</script> -->
<div>{{name}}</div>
但是对于这种攻击方式来说,如果用户使用
Chrome
这类浏览器的话,浏览器就能自动帮助用户防御攻击。但是我们不能因此就不防御此类攻击了,因为我不能确保用户都使用了该类浏览器。
对于
XSS
攻击来说,通常有两种方式可以用来防御。
- 转义字符
首先,对于用户的输入应该是永远不信任的。最普遍的做法就是转义输入输出的内容,对于引号、尖括号、斜杠进行转义
function escape(str) {
str = str.replace(/&/g, '&')
str = str.replace(/</g, '<')
str = str.replace(/>/g, '>')
str = str.replace(/"/g, '&quto;')
str = str.replace(/'/g, ''')
str = str.replace(/`/g, '`')
str = str.replace(/\//g, '/')
return str
}
通过转义可以将攻击代码
<script>alert(1)</script>
变成
// -> <script>alert(1)</script>
escape('<script>alert(1)</script>')
但是对于显示富文本来说,显然不能通过上面的办法来转义所有字符,因为这样会把需要的格式也过滤掉。对于这种情况,通常采用白名单过滤的办法,当然也可以通过黑名单过滤,但是考虑到需要过滤的标签和标签属性实在太多,更加推荐使用白名单的方式
const xss = require('xss')
let html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>')
// -> <h1>XSS Demo</h1><script>alert("xss");</script>
console.log(html)
以上示例使用了
js-xss
来实现,可以看到在输出中保留了h1
标签且过滤了script
标签
- CSP
CSP
本质上就是建立白名单,开发者明确告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截是由浏览器自己实现的。我们可以通过这种方式来尽量减少XSS
攻击。
通常可以通过两种方式来开启 CSP :
- 设置
HTTP Header
中的Content-Security-Policy
- 设置
meta
标签的方式<meta http-equiv="Content-Security-Policy">
这里以设置 HTTP Header
来举例
只允许加载本站资源
Content-Security-Policy: default-src ‘self’
只允许加载 HTTPS 协议图片
Content-Security-Policy: img-src https://*
允许加载任何来源框架
Content-Security-Policy: child-src 'none'
当然可以设置的属性远不止这些,你可以通过查阅 文档 (opens new window)的方式来学习,这里就不过多赘述其他的属性了。
对于这种方式来说,只要开发者配置了正确的规则,那么即使网站存在漏洞,攻击者也不能执行它的攻击代码,并且
CSP
的兼容性也不错。
2 CSRF
跨站请求伪造(英语:
Cross-site request forgery
),也被称为one-click attack
或者session riding
,通常缩写为CSRF
或者XSRF
, 是一种挟制用户在当前已登录的Web
应用程序上执行非本意的操作的攻击方法
CSRF
就是利用用户的登录态发起恶意请求
如何攻击
假设网站中有一个通过 Get 请求提交用户评论的接口,那么攻击者就可以在钓鱼网站中加入一个图片,图片的地址就是评论接口
<img src="http://www.domain.com/xxx?comment='attack'"/>
res.setHeader('Set-Cookie', `username=poetry2;sameSite = strict;path=/;httpOnly;expires=${getCookirExpires()}`)
在B网站,危险网站向A网站发起请求
<!DOCTYPE html>
<html>
<body>
<!-- 利用img自动发送请求 -->
<img src="http://localhost:8000/api/user/login" />
</body>
</html>
会带上A网站的cookie
// 在A网站下发cookie的时候,加上sameSite=strict,这样B网站在发送A网站请求,不会自动带上A网站的cookie,保证了安全
// NAME=VALUE 赋予Cookie的名称及对应值
// expires=DATE Cookie 的有效期
// path=PATH 赋予Cookie的名称及对应值
// domain=域名 作为 Cookie 适用对象的域名 (若不指定则默认为创建 Cookie 的服务器的域名) (一般不指定)
// Secure 仅在 HTTPS 安全通信时才会发送 Cookie
// HttpOnly 加以限制,使 Cookie 不能被 JavaScript 脚本访问
// SameSite Lax|Strict|None 它允许您声明该Cookie是否仅限于第一方或者同一站点上下文
res.setHeader('Set-Cookie', `username=poetry;sameSite=strict;path=/;httpOnly;expires=${getCookirExpires()}`)
如何防御
Get
请求不对数据进行修改- 不让第三方网站访问到用户
Cookie
- 阻止第三方网站请求接口
- 请求时附带验证信息,比如验证码或者
token
SameSite Cookies
: 只能当前域名的网站发出的http请求,携带这个Cookie
。当然,由于这是新的cookie属性,在兼容性上肯定会有问题
CSRF攻击,仅仅是利用了http携带cookie的特性进行攻击的,但是攻击站点还是无法得到被攻击站点的cookie。这个和XSS不同,XSS是直接通过拿到Cookie等信息进行攻击的
在CSRF攻击中,就Cookie相关的特性:
- http请求,会自动携带Cookie。
- 携带的cookie,还是http请求所在域名的cookie。
3 密码安全
加盐
对于密码存储来说,必然是不能明文存储在数据库中的,否则一旦数据库泄露,会对用户造成很大的损失。并且不建议只对密码单纯通过加密算法加密,因为存在彩虹表的关系
- 通常需要对密码加盐,然后进行几次不同加密算法的加密
// 加盐也就是给原密码添加字符串,增加原密码长度
sha256(sha1(md5(salt + password + salt)))
但是加盐并不能阻止别人盗取账号,只能确保即使数据库泄露,也不会暴露用户的真实密码。一旦攻击者得到了用户的账号,可以通过暴力破解的方式破解密码。对于这种情况,通常使用验证码增加延时或者限制尝试次数的方式。并且一旦用户输入了错误的密码,也不能直接提示用户输错密码,而应该提示账号或密码错误
前端加密
虽然前端加密对于安全防护来说意义不大,但是在遇到中间人攻击的情况下,可以避免明文密码被第三方获取
4. 总结
XSS
:跨站脚本攻击,是一种网站应用程序的安全漏洞攻击,是代码注入的一种。常见方式是将恶意代码注入合法代码里隐藏起来,再诱发恶意代码,从而进行各种各样的非法活动
防范:记住一点 “所有用户输入都是不可信的”,所以得做输入过滤和转义
CSRF
:跨站请求伪造,也称XSRF
,是一种挟制用户在当前已登录的Web
应用程序上执行非本意的操作的攻击方法。与XSS
相比,XSS
利用的是用户对指定网站的信任,CSRF
利用的是网站对用户网页浏览器的信任。
防范:用户操作验证(验证码),额外验证机制(
token
使用)等
----------@----------
Service Worker
Service workers
本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步API
浏览器对 ServiceWorker 做了很多限制
- 在
ServiceWorker
中无法直接访问DOM
,但可以通过postMessage
接口发送的消息来与其控制的页面进行通信 ServiceWorker
只能在本地环境下或HTTPS
网站中使用ServiceWorker
有作用域的限制,一个ServiceWorker
脚本只能作用于当前路径及其子路径;
目前该技术通常用来做缓存文件,提高首屏速度
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register("sw.js")
.then(function(registration) {
console.log("service worker 注册成功");
})
.catch(function(err) {
console.log("servcie worker 注册失败");
});
}
// sw.js
// 监听 `install` 事件,回调中缓存所需文件
self.addEventListener("install", e => {
e.waitUntil(
caches.open("my-cache").then(function(cache) {
return cache.addAll(["./index.html", "./index.js"]);
})
);
});
// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener("fetch", e => {
e.respondWith(
caches.match(e.request).then(function(response) {
if (response) {
return response;
}
console.log("fetch source");
})
);
});
打开页面,可以在开发者工具中的
Application
看到Service Worker
已经启动了
在 Cache 中也可以发现我们所需的文件已被缓存
当我们重新刷新页面可以发现我们缓存的数据是从
Service
Worker
中读取的
----------@----------
DOM 节点操作
(1)创建新节点
createDocumentFragment() //创建一个DOM片段
createElement() //创建一个具体的元素
createTextNode() //创建一个文本节点
(2)添加、移除、替换、插入
appendChild(node)
removeChild(node)
replaceChild(new,old)
insertBefore(new,old)
(3)查找
getElementById();
getElementsByName();
getElementsByTagName();
getElementsByClassName();
querySelector();
querySelectorAll();
(4)属性操作
getAttribute(key);
setAttribute(key, value);
hasAttribute(key);
removeAttribute(key);
----------@----------
掌握页面的加载过程
网页加载流程
- 当我们打开网址的时候,浏览器会从服务器中获取到 HTML 内容
- 浏览器获取到 HTML 内容后,就开始从上到下解析 HTML 的元素
<head>
元素内容会先被解析,此时浏览器还没开始渲染页面
- 我们看到
<head>
元素里有用于描述页面元数据的<meta>
元素,还有一些<link>
元素涉及外部资源(如图片、CSS 样式
等),此时浏览器会去获取这些外部资源。除此之外,我们还能看到<head>
元素中还包含着不少的<script>
元素,这些<script>
元素通过src
属性指向外部资源
- 我们看到
- 当浏览器解析到这里时(步骤 3),会暂停解析并下载 JavaScript 脚本
- 当 JavaScript 脚本下载完成后,浏览器的控制权转交给 JavaScript 引擎。当脚本执行完成后,控制权会交回给渲染引擎,渲染引擎继续往下解析
HTML
页面 - 此时
<body>
元素内容开始被解析,浏览器开始渲染页面
- 在这个过程中,我们看到
<head>
中放置的<script>
元素会阻塞页面的渲染过程:把 JavaScript 放在<head>
里,意味着必须把所有 JavaScript 代码都下载、解析和解释完成后,才能开始渲染页面
。- 如果外部脚本加载时间很长(比如一直无法完成下载),就会造成网页长时间失去响应,浏览器就会呈现“假死”状态,用户体验会变得很糟糕
- 因此,对于对性能要求较高、需要快速将内容呈现给用户的网页,常常会将 JavaScript 脚本放在
<body>
的最后面。这样可以避免资源阻塞,页面得以迅速展示
。我们还可以使用defer/async/preload
等属性来标记<script>
标签,来控制 JavaScript 的加载顺序
延迟加载的方式有哪些
js 的加载、解析和执行会阻塞页面的渲染过程,因此我们希望 js 脚本能够尽可能的延迟加载,提高页面的渲染速度。
几种方式是:
- 将 js 脚本放在文档的底部,来使 js 脚本尽可能的在最后来加载执行
- 给 js 脚本添加
defer
属性,这个属性会让脚本的加载与文档的解析同步解析,然后在文档解析完成后再执行这个脚本文件,这样的话就能使页面的渲染不被阻塞。多个设置了defer
属性的脚本按规范来说最后是顺序执行的,但是在一些浏览器中可能不是这样 - 给 js 脚本添加
async
属性,这个属性会使脚本异步加载,不会阻塞页面的解析过程,但是当脚本加载完成后立即执行 js脚本,这个时候如果文档没有解析完成的话同样会阻塞。多个async
属性的脚本的执行顺序是不可预测的,一般不会按照代码的顺序依次执行 - 动态创建
DOM
标签的方式,我们可以对文档的加载事件进行监听,当文档加载完成后再动态的创建script
标签来引入 js 脚本
怎么判断页面是否加载完成
Load
事件触发代表页面中的DOM
,CSS
,JS
,图片已经全部加载完毕。DOMContentLoaded
事件触发代表初始的HTML
被完全加载和解析,不需要等待CSS
,JS
,图片加载
----------@----------
从输入URL到页面展示过程
1. DNS域名解析
- 根 DNS 服务器 :返回顶级域 DNS 服务器的 IP 地址
- 顶级域 DNS 服务器:返回权威 DNS 服务器的 IP 地址
- 权威 DNS 服务器 :返回相应主机的 IP 地址
DNS的域名查找,在客户端和浏览器,本地DNS之间的查询方式是递归查询;在本地DNS服务器与根域及其子域之间的查询方式是迭代查询;
在客户端输入 URL 后,会有一个递归查找的过程,从浏览器缓存中查找->本地的hosts文件查找->找本地DNS解析器缓存查找->本地DNS服务器查找
,这个过程中任何一步找到了都会结束查找流程。
如果本地DNS服务器无法查询到,则根据本地DNS服务器设置的转发器进行查询。若未用转发模式,则迭代查找过程如下图:
结合起来的过程,可以用一个图表示:
在查找过程中,有以下优化点:
- DNS存在着多级缓存,从离浏览器的距离排序的话,有以下几种:
浏览器缓存,系统缓存,路由器缓存,IPS服务器缓存,根域名服务器缓存,顶级域名服务器缓存,主域名服务器缓存
。 - 在域名和 IP 的映射过程中,给了应用基于域名做负载均衡的机会,可以是简单的负载均衡,也可以根据地址和运营商做全局的负载均衡。
2. 建立TCP连接
首先,判断是不是https的,如果是,则HTTPS其实是HTTP + SSL / TLS 两部分组成,也就是在HTTP上又加了一层处理加密信息的模块。服务端和客户端的信息传输都会通过TLS进行加密,所以传输的数据都是加密后的数据
进行三次握手,建立TCP连接。
- 第一次握手:建立连接。客户端发送连接请求报文段
- 第二次握手:服务器收到SYN报文段。服务器收到客户端的SYN报文段,需要对这个SYN报文段进行确认
- 第三次握手:客户端收到服务器的SYN+ACK报文段,向服务器发送ACK报文段
SSL握手过程
- 第一阶段 建立安全能力 包括协议版本 会话Id 密码构件 压缩方法和初始随机数
- 第二阶段 服务器发送证书 密钥交换数据和证书请求,最后发送请求-相应阶段的结束信号
- 第三阶段 如果有证书请求客户端发送此证书 之后客户端发送密钥交换数据 也可以发送证书验证消息
- 第四阶段 变更密码构件和结束握手协议
完成了之后,客户端和服务器端就可以开始传送数据
发送HTTP请求,服务器处理请求,返回响应结果
TCP连接建立后,浏览器就可以利用
HTTP/HTTPS
协议向服务器发送请求了。服务器接受到请求,就解析请求头,如果头部有缓存相关信息如if-none-match与if-modified-since
,则验证缓存是否有效,若有效则返回状态码为304
,若无效则重新返回资源,状态码为200
这里有发生的一个过程是HTTP缓存,是一个常考的考点,大致过程如图:
3. 关闭TCP连接
4. 浏览器渲染
按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、栅格化和显示。如图:
- 渲染进程将 HTML 内容转换为能够读懂DOM 树结构。
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的
styleSheets
,计算出DOM
节点的样式。 - 创建布局树,并计算元素的布局信息。
- 对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。合成线程将图层分图块,并栅格化将图块转换成位图。
- 合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上。
构建 DOM 树
- 转码(Bytes -> Characters)—— 读取接收到的 HTML 二进制数据,按指定编码格式将字节转换为 HTML 字符串
- Tokens 化(Characters -> Tokens)—— 解析 HTML,将 HTML 字符串转换为结构清晰的 Tokens,每个 Token 都有特殊的含义同时有自己的一套规则
- 构建 Nodes(Tokens -> Nodes)—— 每个 Node 都添加特定的属性(或属性访问器),通过指针能够确定 Node 的父、子、兄弟关系和所属 treeScope(例如:iframe 的 treeScope 与外层页面的 treeScope 不同)
- 构建 DOM 树(Nodes -> DOM Tree)—— 最重要的工作是建立起每个结点的父子兄弟关系
样式计算
渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
CSS 样式来源主要有 3 种,分别是通过 link 引用的外部 CSS 文件、style标签内的 CSS、元素的 style 属性内嵌的 CSS。
页面布局
布局过程,即
排除 script、meta 等功能化、非视觉节点
,排除display: none
的节点,计算元素的位置信息,确定元素的位置,构建一棵只包含可见元素布局树。如图:
其中,这个过程需要注意的是回流和重绘
生成分层树
页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)
栅格化
合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图
通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
显示
最后,合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上,渲染过程完成。
----------@----------
渲染引擎什么情况下才会为特定的节点创建新的图层
层叠上下文
是HTML元素的三维概念,这些HTML元素在一条假想的相对于面向(电脑屏幕的)视窗或者网页的用户的z轴上延伸,HTML元素依据其自身属性按照优先级顺序占用层叠上下文的空间。
- 拥有层叠上下文属性的元素会被提升为单独的一层。
拥有层叠上下文属性:
-
根元素 (HTML),
-
z-index 值不为 "auto"的 绝对/相对定位元素,
-
position,固定(fixed) / 沾滞(sticky)定位(沾滞定位适配所有移动设备上的浏览器,但老的桌面浏览器不支持)
-
z-index值不为 "auto"的 flex 子项 (flex item),即:父元素 display: flex|inline-flex,
-
z-index值不为"auto"的grid子项,即:父元素display:grid
-
opacity 属性值小于 1 的元素(参考 the specification for opacity),
-
transform 属性值不为 "none"的元素,
-
mix-blend-mode 属性值不为 "normal"的元素,
-
filter值不为"none"的元素,
-
perspective值不为"none"的元素,
-
clip-path值不为"none"的元素
-
mask / mask-image / mask-border不为"none"的元素
-
isolation 属性被设置为 "isolate"的元素
-
在 will-change 中指定了任意CSS属性(参考 这篇文章)
-
-webkit-overflow-scrolling 属性被设置 "touch"的元素
-
contain属性值为"layout",“paint”,或者综合值比如"strict",“content”
- 需要剪裁(clip)的地方也会被创建为图层。
这里的剪裁指的是,假如我们把 div 的大小限定为 200 * 200 像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域。出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。
----------@----------
定时器与requestAnimationFrame、requestIdleCallback
1. setTimeout
setTimeout的运行机制:执行该语句时,是立即把当前定时器代码推入事件队列,当定时器在事件列表中满足设置的时间值时将传入的函数加入任务队列,之后的执行就交给任务队列负责。但是如果此时任务队列不为空,则需等待,所以执行定时器内代码的时间可能会大于设置的时间
setTimeout(() => {
console.log(1);
}, 0)
console.log(2);
输出 2, 1;
setTimeout
的第二个参数表示在执行代码前等待的毫秒数。上面代码中,设置为0,表面意思为 执行代码前等待的毫秒数为0,即立即执行。但实际上的运行结果我们也看到了,并不是表面上看起来的样子,千万不要被欺骗了。
实际上,上面的代码并不是立即执行的,这是因为setTimeout
有一个最小执行时间,HTML5标准规定了setTimeout()
的第二个参数的最小值(最短间隔)不得低于4毫秒
。 当指定的时间低于该时间时,浏览器会用最小允许的时间作为setTimeout
的时间间隔,也就是说即使我们把setTimeout
的延迟时间设置为0,实际上可能为 4毫秒后才事件推入任务队列
。
定时器代码在被推送到任务队列前,会先被推入到事件列表中,当定时器在事件列表中满足设置的时间值时会被推到任务队列,但是如果此时任务队列不为空,则需等待,所以执行定时器内代码的时间可能会大于设置的时间
setTimeout(() => {
console.log(111);
}, 100);
上面代码表示100ms
后执行console.log(111)
,但实际上实行的时间肯定是大于100ms后的, 100ms 只是表示 100ms 后将任务加入到"任务队列"中,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()
指定的时间执行。
2. setTimeout 和 setInterval区别
setTimeout
: 指定延期后调用函数,每次setTimeout
计时到后就会去执行,然后执行一段时间后才继续setTimeout
,中间就多了误差,(误差多少与代码的执行时间有关)。setInterval
:以指定周期调用函数,而setInterval
则是每次都精确的隔一段时间推入一个事件(但是,事件的执行时间不一定就不准确,还有可能是这个事件还没执行完毕,下一个事件就来了).
btn.onclick = function(){
setTimeout(function(){
console.log(1);
},250);
}
击该按钮后,首先将
onclick
事件处理程序加入队列。该程序执行后才设置定时器,再有250ms
后,指定的代码才被添加到队列中等待执行。 如果上面代码中的onclick
事件处理程序执行了300ms
,那么定时器的代码至少要在定时器设置之后的300ms
后才会被执行。队列中所有的代码都要等到javascript进程空闲之后才能执行,而不管它们是如何添加到队列中的。
如图所示,尽管在255ms
处添加了定时器代码,但这时候还不能执行,因为onclick
事件处理程序仍在运行。定时器代码最早能执行的时机是在300ms
处,即onclick
事件处理程序结束之后。
3. setInterval存在的一些问题:
JavaScript中使用 setInterval
开启轮询。定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次,而之间没有任何停顿。而javascript引擎对这个问题的解决是:当使用setInterval()
时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。
但是,这样会导致两个问题:
- 某些间隔被跳过;
- 多个定时器的代码执行之间的间隔可能比预期的小
假设,某个onclick
事件处理程序使用setInterval()
设置了200ms
间隔的定时器。如果事件处理程序花了300ms
多一点时间完成,同时定时器代码也花了差不多的时间,就会同时出现跳过某间隔的情况
例子中的第一个定时器是在205ms
处添加到队列中的,但是直到过了300ms
处才能执行。当执行这个定时器代码时,在405ms处又给队列添加了另一个副本。在下一个间隔,即605ms处,第一个定时器代码仍在运行,同时在队列中已经有了一个定时器代码的实例。结果是,在这个时间点上的定时器代码不会被添加到队列中
使用setTimeout
构造轮询能保证每次轮询的间隔。
setTimeout(function () {
console.log('我被调用了');
setTimeout(arguments.callee, 100);
}, 100);
callee
是arguments
对象的一个属性。它可以用于引用该函数的函数体内当前正在执行的函数。在严格模式下,第5版 ECMAScript (ES5) 禁止使用arguments.callee()
。当一个函数必须调用自身的时候, 避免使用arguments.callee()
, 通过要么给函数表达式一个名字,要么使用一个函数声明.
setTimeout(function fn(){
console.log('我被调用了');
setTimeout(fn, 100);
},100);
这个模式链式调用了setTimeout()
,每次函数执行的时候都会创建一个新的定时器。第二个setTimeout()
调用当前执行的函数,并为其设置另外一个定时器。这样做的好处是,在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔。而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行。
4. requestAnimationFrame
4.1 60fps
与设备刷新率
目前大多数设备的屏幕刷新率为60次/秒
,如果在页面中有一个动画或者渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。
卡顿:其中每个帧的预算时间仅比16毫秒
多一点(1秒/ 60 = 16.6毫秒
)。但实际上,浏览器有整理工作要做,因此您的所有工作是需要在10毫秒
内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。此现象通常称为卡顿,会对用户体验产生负面影响。
跳帧: 假如动画切换在 16ms, 32ms, 48ms时分别切换,跳帧就是假如到了32ms,其他任务还未执行完成,没有去执行动画切帧,等到开始进行动画的切帧,已经到了该执行48ms的切帧。就好比你玩游戏的时候卡了,过了一会,你再看画面,它不会停留你卡的地方,或者这时你的角色已经挂掉了。必须在下一帧开始之前就已经绘制完毕;
Chrome devtool 查看实时 FPS, 打开 More tools => Rendering, 勾选 FPS meter
4.2 requestAnimationFrame
实现动画
requestAnimationFrame
是浏览器用于定时循环操作的一个接口,类似于setTimeout,主要用途是按帧对网页进行重绘。
在 requestAnimationFrame
之前,主要借助 setTimeout/ setInterval
来编写 JS 动画,而动画的关键在于动画帧之间的时间间隔设置,这个时间间隔的设置有讲究,一方面要足够小,这样动画帧之间才有连贯性,动画效果才显得平滑流畅;另一方面要足够大,确保浏览器有足够的时间及时完成渲染。
显示器有固定的刷新频率(60Hz或75Hz),也就是说,每秒最多只能重绘60次或75次,requestAnimationFrame
的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。此外,使用这个API,一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了CPU、GPU和电力。
requestAnimationFrame
是在主线程上完成。这意味着,如果主线程非常繁忙,requestAnimationFrame
的动画效果会大打折扣。
requestAnimationFrame
使用一个回调函数作为参数。这个回调函数会在浏览器重绘之前调用。
requestID = window.requestAnimationFrame(callback);
window.requestAnimFrame = (function(){
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function( callback ){
window.setTimeout(callback, 1000 / 60);
};
})();
上面的代码按照1秒钟60次(大约每16.7毫秒一次),来模拟requestAnimationFrame
。
5. requestIdleCallback()
MDN上的解释:
requestIdleCallback()
方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。
requestAnimationFrame
会在每次屏幕刷新的时候被调用,而requestIdleCallback
则会在每次屏幕刷新时,判断当前帧是否还有多余的时间,如果有,则会调用requestAnimationFrame
的回调函数,
图片中是两个连续的执行帧,大致可以理解为两个帧的持续时间大概为16.67,图中黄色部分就是空闲时间。所以,requestIdleCallback
中的回调函数仅会在每次屏幕刷新并且有空闲时间时才会被调用.
利用这个特性,我们可以在动画执行的期间,利用每帧的空闲时间来进行数据发送的操作,或者一些优先级比较低的操作,此时不会使影响到动画的性能,或者和requestAnimationFrame
搭配,可以实现一些页面性能方面的的优化,
react 的
fiber
架构也是基于requestIdleCallback
实现的, 并且在不支持的浏览器中提供了polyfill
总结
- 从
单线程模型和任务队列
出发理解setTimeout(fn, 0)
,并不是立即执行。 - JS 动画, 用
requestAnimationFrame
会比setInterval
效果更好 requestIdleCallback()
常用来切割长任务,利用空闲时间执行,避免主线程长时间阻塞
----------@----------
Vue 响应式原理
Vue 的响应式原理是核心是通过 ES5 的保护对象的
Object.defindeProperty
中的访问器属性中的 get 和 set 方法,data 中声明的属性都被添加了访问器属性,当读取 data 中的数据时自动调用 get 方法,当修改 data 中的数据时,自动调用 set 方法,检测到数据的变化,会通知观察者 Wacher,观察者 Wacher自动触发重新render 当前组件(子组件不会重新渲染),生成新的虚拟 DOM 树,Vue 框架会遍历并对比新虚拟 DOM 树和旧虚拟 DOM 树中每个节点的差别,并记录下来,最后,加载操作,将所有记录的不同点,局部修改到真实 DOM树上。
- 虚拟DOM (Virtaul DOM): 用 js 对象模拟的,保存当前视图内所有 DOM 节点对象基本描述属性和节点间关系的树结构。用 js 对象,描述每个节点,及其父子关系,形成虚拟 DOM 对象树结构。
- 因为只要在
data
中声明的基本数据类型的数据,基本不存在数据不响应问题,所以重点介绍数组和对象在vue
中的数据响应问题,vue可以检测对象属性的修改,但无法监听数组的所有变动及对象的新增和删除,只能使用数组变异方法及$set
方法。
可以看到,
arrayMethods
首先继承了Array
,然后对数组中所有能改变数组自身的方法,如push
、pop
等这些方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的 3 个方法push
、unshift
、splice
方法做了判断,获取到插入的值,然后把新添加的值变成一个响应式对象,并且再调用ob.dep.notify()
手动触发依赖通知,这就很好地解释了用vm.items.splice
(newLength
) 方法可以检测到变化
总结:Vue 采用数据劫持结合发布—订阅模式的方法,通过
Object.defineProperty()
来劫持各个属性的 setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。
Observer
遍历数据对象,给所有属性加上setter
和getter
,监听数据的变化compile
解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图
Watcher
订阅者是Observer
和Compile
之间通信的桥梁,主要做的事情
- 在自身实例化时往属性订阅器 (
dep
) 里面添加自己 - 待属性变动
dep.notice()
通知时,调用自身的update()
方法,并触发Compile
中绑定的回调
Object.defineProperty() ,那么它的用法是什么,以及优缺点是什么呢?
- 可以检测对象中数据发生的修改
- 对于复杂的对象,层级很深的话,是不友好的,需要经行深度监听,这样子就需要递归到底,这也是它的缺点。
- 对于一个对象中,如果你新增加属性,删除属性,**Object.defineProperty()**是不能观测到的,那么应该如何解决呢?可以通过
Vue.set()
和Vue.delete()
来实现。
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello'
}
// 模拟 Vue 的实例
let vm = {}
// 数据劫持:当访问或者设置 vm 中的成员的时候,做一些干预操作
Object.defineProperty(vm, 'msg', {
// 可枚举(可遍历)
enumerable: true,
// 可配置(可以使用 delete 删除,可以通过 defineProperty 重新定义)
configurable: true,
// 当获取值的时候执行
get () {
console.log('get: ', data.msg)
return data.msg
},
// 当设置值的时候执行
set (newValue) {
console.log('set: ', newValue)
if (newValue === data.msg) {
return
}
data.msg = newValue
// 数据更改,更新 DOM 的值
document.querySelector('#app').textContent = data.msg
}
})
// 测试
vm.msg = 'Hello World'
console.log(vm.msg)
Vue3.x响应式数据原理
Vue3.x
改用Proxy
替代Object.defineProperty
。因为Proxy
可以直接监听对象和数组
的变化,并且有多达13种拦截方法。并且作为新标准将受到浏览器厂商重点持续的性能优化。
Proxy
只会代理对象的第一层,那么Vue3
又是怎样处理这个问题的呢?
判断当前
Reflect.get的
返回值是否为Object
,如果是则再通过reactive
方法做代理, 这样就实现了深度观测。
监测数组的时候可能触发多次get/set,那么如何防止触发多次呢?
我们可以判断
key
是否为当前被代理对象target
自身属性,也可以判断旧值与新值是否相等,只有满足以上两个条件之一时,才有可能执行trigger
// 模拟 Vue 中的 data 选项
let data = {
msg: 'hello',
count: 0
}
// 模拟 Vue 实例
let vm = new Proxy(data, {
// 当访问 vm 的成员会执行
get (target, key) {
console.log('get, key: ', key, target[key])
return target[key]
},
// 当设置 vm 的成员会执行
set (target, key, newValue) {
console.log('set, key: ', key, newValue)
if (target[key] === newValue) {
return
}
target[key] = newValue
document.querySelector('#app').textContent = target[key]
}
})
// 测试
vm.msg = 'Hello World'
console.log(vm.msg)
Proxy 相比于 defineProperty 的优势
- 数组变化也能监听到
- 不需要深度遍历监听
Proxy
是ES6
中新增的功能,可以用来自定义对象中的操作
let p = new Proxy(target, handler);
// `target` 代表需要添加代理的对象
// `handler` 用来自定义对象中的操作
// 可以很方便的使用 Proxy 来实现一个数据绑定和监听
let onWatch = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver) {
getLogger(target, property)
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
setBind(value);
return Reflect.set(target, property, value);
}
};
return new Proxy(obj, handler);
};
let obj = { a: 1 }
let value
let p = onWatch(obj, (v) => {
value = v
}, (target, property) => {
console.log(`Get '${property}' = ${target[property]}`);
})
p.a = 2 // bind `value` to `2`
p.a // -> Get 'a' = 2
总结
- Vue
- 记录传入的选项,设置
$data/$el
- 把
data
的成员注入到Vue
实例 - 负责调用
Observer
实现数据响应式处理(数据劫持) - 负责调用
Compiler
编译指令/插值表达式等
- 记录传入的选项,设置
Observer
- 数据劫持
- 负责把
data
中的成员转换成getter/setter
- 负责把多层属性转换成
getter/setter
- 如果给属性赋值为新对象,把新对象的成员设置为
getter/setter
- 负责把
- 添加
Dep
和Watcher
的依赖关系 - 数据变化发送通知
- 数据劫持
Compiler
- 负责编译模板,解析指令/插值表达式
- 负责页面的首次渲染过程
- 当数据变化后重新渲染
Dep
- 收集依赖,添加订阅者(
watcher
) - 通知所有订阅者
- 收集依赖,添加订阅者(
Watcher
- 自身实例化的时候往
dep
对象中添加自己 - 当数据变化
dep
通知所有的Watcher
实例更新视图
- 自身实例化的时候往
----------@----------
发布订阅模式和观察者模式
1. 发布/订阅模式
- 发布/订阅模式
- 订阅者
- 发布者
- 信号中心
我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信 号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执 行。这就叫做"发布/订阅模式"(publish-subscribe pattern)
Vue 的自定义事件
let vm = new Vue()
vm.$on('dataChange', () => { console.log('dataChange')})
vm.$on('dataChange', () => {
console.log('dataChange1')
})
vm.$emit('dataChange')
兄弟组件通信过程
// eventBus.js
// 事件中心
let eventHub = new Vue()
// ComponentA.vue
// 发布者
addTodo: function () {
// 发布消息(事件)
eventHub.$emit('add-todo', { text: this.newTodoText })
this.newTodoText = ''
}
// ComponentB.vue
// 订阅者
created: function () {
// 订阅消息(事件)
eventHub.$on('add-todo', this.addTodo)
}
模拟 Vue 自定义事件的实现
class EventEmitter {
constructor(){
// { eventType: [ handler1, handler2 ] }
this.subs = {}
}
// 订阅通知
$on(eventType, fn) {
this.subs[eventType] = this.subs[eventType] || []
this.subs[eventType].push(fn)
}
// 发布通知
$emit(eventType) {
if(this.subs[eventType]) {
this.subs[eventType].forEach(v=>v())
}
}
}
// 测试
var bus = new EventEmitter()
// 注册事件
bus.$on('click', function () {
console.log('click')
})
bus.$on('click', function () {
console.log('click1')
})
// 触发事件
bus.$emit('click')
2. 观察者模式
- 观察者(订阅者) –
Watcher
update()
:当事件发生时,具体要做的事情
- 目标(发布者) –
Dep
subs
数组:存储所有的观察者addSub()
:添加观察者notify()
:当事件发生,调用所有观察者的update()
方法
- 没有事件中心
// 目标(发布者)
// Dependency
class Dep {
constructor () {
// 存储所有的观察者
this.subs = []
}
// 添加观察者
addSub (sub) {
if (sub && sub.update) {
this.subs.push(sub)
}
}
// 通知所有观察者
notify () {
this.subs.forEach(sub => sub.update())
}
}
// 观察者(订阅者)
class Watcher {
update () {
console.log('update')
}
}
// 测试
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
3. 总结
- 观察者模式是由具体目标调度,比如当事件触发,
Dep
就会去调用观察者的方法,所以观察者模 式的订阅者与发布者之间是存在依赖的 - 发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在
----------@----------
为什么使用 Virtual DOM
- 手动操作
DOM
比较麻烦,还需要考虑浏览器兼容性问题,虽然有jQuery
等库简化DOM
操作,但是随着项目的复杂 DOM 操作复杂提升 - 为了简化
DOM
的复杂操作于是出现了各种MVVM
框架,MVVM
框架解决了视图和状态的同步问题 - 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是
Virtual DOM
出现了 Virtual DOM
的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述DOM
,Virtual DOM
内部将弄清楚如何有效(diff
)的更新DOM
- 虚拟
DOM
可以维护程序的状态,跟踪上一次的状态 - 通过比较前后两次状态的差异更新真实
DOM
虚拟 DOM 的作用
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 除了渲染
DOM
以外,还可以实现SSR(Nuxt.js/Next.js)
、原生应用(Weex/React Native
)、小程序(mpvue/uni-app
)等
----------@----------
VDOM:三个 part
- 虚拟节点类,将真实
DOM
节点用js
对象的形式进行展示,并提供render
方法,将虚拟节点渲染成真实DOM
- 节点
diff
比较:对虚拟节点进行js
层面的计算,并将不同的操作都记录到patch
对象 re-render
:解析patch
对象,进行re-render
补充1��VDOM 的必要性?
- 创建真实DOM的代价高 :真实的
DOM
节点node
实现的属性很多,而vnode
仅仅实现一些必要的属性,相比起来,创建一个vnode
的成本比较低。 - 触发多次浏览器重绘及回流 :使用
vnode
,相当于加了一个缓冲,让一次数据变动所带来的所有node
变化,先在vnode
中进行修改,然后diff
之后对所有产生差异的节点集中一次对DOM tree
进行修改,以减少浏览器的重绘及回流。
补充2:vue 为什么采用 vdom?
引入
Virtual DOM
在性能方面的考量仅仅是一方面。
- 性能受场景的影响是非常大的,不同的场景可能造成不同实现方案之间成倍的性能差距,所以依赖细粒度绑定及
Virtual DOM
哪个的性能更好还真不是一个容易下定论的问题。 Vue
之所以引入了Virtual DOM
,更重要的原因是为了解耦HTML
依赖,这带来两个非常重要的好处是:
- 不再依赖
HTML
解析器进行模版解析,可以进行更多的AOT
工作提高运行时效率:通过模版AOT
编译,Vue
的运行时体积可以进一步压缩,运行时效率可以进一步提升;- 可以渲染到
DOM
以外的平台,实现SSR
、同构渲染这些高级特性,Weex
等框架应用的就是这一特性。
综上,
Virtual DOM
在性能上的收益并不是最主要的,更重要的是它使得Vue
具备了现代框架应有的高级特性。
----------@----------
vue 和 react技术选型
相同点:
- 数据驱动页面,提供响应式的试图组件
- 都有virtual DOM,组件化的开发,通过props参数进行父子之间组件传递数据,都实现了webComponents规范
- 数据流动单向,都支持服务器的渲染SSR
- 都有支持native的方法,react有React native, vue有wexx
不同点:
- 数据绑定:Vue实现了双向的数据绑定,react数据流动是单向的
- 数据渲染:大规模的数据渲染,react更快
- 使用场景:React配合Redux架构适合大规模多人协作复杂项目,Vue适合小快的项目
- 开发风格:react推荐做法jsx + inline style把html和css都写在js了
vue是采用webpack +vue-loader单文件组件格式,html, js, css同一个文件
----------@----------
nextTick
nextTick
可以让我们在下次DOM
更新循环结束之后执行延迟回调,用于获得更新后的DOM
nextTick
主要使用了宏任务和微任务。根据执行环境分别尝试采用
Promise
MutationObserver
setImmediate
- 如果以上都不行则采用
setTimeout
定义了一个异步方法,多次调用
nextTick
会将方法存入队列中,通过这个异步方法清空当前队列
----------@----------
生命周期
init
initLifecycle/Event
,往vm上挂载各种属性callHook: beforeCreated
: 实例刚创建initInjection/initState
: 初始化注入和data
响应性created: 创建完成,属性已经绑定, 但还未生成真实
dom`- 进行元素的挂载:
$el / vm.$mount()
- 是否有
template
: 解析成render function
*.vue
文件:vue-loader
会将<template>
编译成render function
beforeMount
: 模板编译/挂载之前- 执行
render function
,生成真实的dom
,并替换到dom tree
中 mounted
: 组件已挂载
update
- 执行
diff
算法,比对改变是否需要触发UI
更新 flushScheduleQueue
watcher.before
: 触发beforeUpdate
钩子 -watcher.run()
: 执行watcher
中的notify
,通知所有依赖项更新UI- 触发
updated
钩子: 组件已更新 actived / deactivated(keep-alive)
: 不销毁,缓存,组件激活与失活destroy
beforeDestroy
: 销毁开始- 销毁自身且递归销毁子组件以及事件监听
remove()
: 删除节点watcher.teardown()
: 清空依赖vm.$off()
: 解绑监听
destroyed
: 完成后触发钩子
Vue2 | Vue3 |
---|---|
beforeCreate | ❌setup (替代) |
created | ❌setup (替代) |
beforeMount | onBeforeMount |
mounted | onMounted |
beforeUpdate | onBeforeUpdate |
updated | nUpdated |
beforeDestroy | onBeforeUnmount |
destroyed | onUnmounted |
errorCaptured | onErrorCaptured |
- | 🎉onRenderTracked |
- | 🎉onRenderTriggered |
上面是vue的声明周期的简单梳理,接下来我们直接以代码的形式来完成vue的初始化
new Vue({})
// 初始化Vue实例
function _init() {
// 挂载属性
initLifeCycle(vm)
// 初始化事件系统,钩子函数等
initEvent(vm)
// 编译slot、vnode
initRender(vm)
// 触发钩子
callHook(vm, 'beforeCreate')
// 添加inject功能
initInjection(vm)
// 完成数据响应性 props/data/watch/computed/methods
initState(vm)
// 添加 provide 功能
initProvide(vm)
// 触发钩子
callHook(vm, 'created')
// 挂载节点
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
// 挂载节点实现
function mountComponent(vm) {
// 获取 render function
if (!this.options.render) {
// template to render
// Vue.compile = compileToFunctions
let { render } = compileToFunctions()
this.options.render = render
}
// 触发钩子
callHook('beforeMounte')
// 初始化观察者
// render 渲染 vdom,
vdom = vm.render()
// update: 根据 diff 出的 patchs 挂载成真实的 dom
vm._update(vdom)
// 触发钩子
callHook(vm, 'mounted')
}
// 更新节点实现
funtion queueWatcher(watcher) {
nextTick(flushScheduleQueue)
}
// 清空队列
function flushScheduleQueue() {
// 遍历队列中所有修改
for(){
// beforeUpdate
watcher.before()
// 依赖局部更新节点
watcher.update()
callHook('updated')
}
}
// 销毁实例实现
Vue.prototype.$destory = function() {
// 触发钩子
callHook(vm, 'beforeDestory')
// 自身及子节点
remove()
// 删除依赖
watcher.teardown()
// 删除监听
vm.$off()
// 触发钩子
callHook(vm, 'destoryed')
}
----------@----------
vue-router
mode
hash
history
跳转
this.$router.push()
<router-link to=""></router-link>
占位
<router-view></router-view>
vue-router源码实现
- 作为一个插件存在:实现
VueRouter
类和install
方法 - 实现两个全局组件:
router-view
用于显示匹配组件内容,router-link
用于跳转 - 监控
url
变化:监听hashchange
或popstate
事件 - 响应最新
url
:创建一个响应式的属性current
,当它改变时获取对应组件并显示
// 我们的插件:
// 1.实现一个Router类并挂载期实例
// 2.实现两个全局组件router-link和router-view
let Vue;
class VueRouter {
// 核心任务:
// 1.监听url变化
constructor(options) {
this.$options = options;
// 缓存path和route映射关系
// 这样找组件更快
this.routeMap = {}
this.$options.routes.forEach(route => {
this.routeMap[route.path] = route
})
// 数据响应式
// 定义一个响应式的current,则如果他变了,那么使用它的组件会rerender
Vue.util.defineReactive(this, 'current', '')
// 请确保onHashChange中this指向当前实例
window.addEventListener('hashchange', this.onHashChange.bind(this))
window.addEventListener('load', this.onHashChange.bind(this))
}
onHashChange() {
// console.log(window.location.hash);
this.current = window.location.hash.slice(1) || '/'
}
}
// 插件需要实现install方法
// 接收一个参数,Vue构造函数,主要用于数据响应式
VueRouter.install = function (_Vue) {
// 保存Vue构造函数在VueRouter中使用
Vue = _Vue
// 任务1:使用混入来做router挂载这件事情
Vue.mixin({
beforeCreate() {
// 只有根实例才有router选项
if (this.$options.router) {
Vue.prototype.$router = this.$options.router
}
}
})
// 任务2:实现两个全局组件
// router-link: 生成一个a标签,在url后面添 // <router-link to="/about">aaa</router-link>
Vue.component('router-link', {
props: {
to: {
type: String,
required: true
},
},
render(h) {
// h(tag, props, children)
return h('a',
{ attrs: { href: '#' + this.to } },
this.$slots.default
)
// 使用jsx
// return <a href={'#'+this.to}>{this.$slots.default}</a>
}
})
Vue.component('router-view', {
render(h) {
// 根据current获取组件并render
// current怎么获取?
// console.log('render',this.$router.current);
// 获取要渲染的组件
let component = null
const { routeMap, current } = this.$router
if (routeMap[current]) {
component = routeMap[current].component
}
return h(component)
}
})
}
export default VueRouter
----------@----------
vuex
Vuex 集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以可预测的方式发生变化
核心概念
-
state
: 状态中心 -
mutations
: 更改状态 -
actions
: 异步更改状态 -
getters
: 获取状态 -
modules
: 将state
分成多个modules
,便于管理
- 状态 - state
state保存应用状态
export default new Vuex.Store({ state: { counter:0 },})
- 状态变更 - mutations
mutations
用于修改状态,store.js
export default new Vuex.Store({
mutations:
{
add(state) {
state.counter++
}
}
})
- 派生状态 - getters
从state派生出新状态,类似计算属性
export default new Vuex.Store({
getters:
{
doubleCounter(state) { // 计算剩余数量 return state.counter * 2;
}
}
})
- 动作 - actions
加业务逻辑,类似于controller
export default new Vuex.Store({
actions:
{
add({
commit
}) {
setTimeout(() = >{}
}
})
测试代码:
<p @click="$store.commit('add')">counter: {{$store.state.counter}}</p>
<p @click="$store.dispatch('add')">async counter: {{$store.state.counter}}</p>
<p>double:{{$store.getters.doubleCounter}}</p>
vuex原理解析
- 实现一个插件:声明
Store
类,挂载$store
Store
具体实现:- 创建响应式的
state
,保存mutations
、actions
和getters
- 实现
commit
根据用户传入type
执行对应mutation
- 实现
dispatch
根据用户传入type
执行对应action
,同时传递上下文 - 实现
getters
,按照getters
定义对state
做派生
- 创建响应式的
// 目标1:实现Store类,管理state(响应式的),commit方法和dispatch方法
// 目标2:封装一个插件,使用更容易使用
let Vue;
class Store {
constructor(options) {
// 定义响应式的state
// this.$store.state.xx
// 借鸡生蛋
this._vm = new Vue({
data: {
$$state: options.state
}
})
this._mutations = options.mutations
this._actions = options.actions
// 绑定this指向
this.commit = this.commit.bind(this)
this.dispatch = this.dispatch.bind(this)
}
// 只读
get state() {
return this._vm._data.$$state
}
set state(val) {
console.error('不能直接赋值呀,请换别的方式!!天王盖地虎!!');
}
// 实现commit方法,可以修改state
commit(type, payload) {
// 拿出mutations中的处理函数执行它
const entry = this._mutations[type]
if (!entry) {
console.error('未知mutaion类型');
return
}
entry(this.state, payload)
}
dispatch(type, payload) {
const entry = this._actions[type]
if (!entry) {
console.error('未知action类型');
return
}
// 上下文可以传递当前store实例进去即可
entry(this, payload)
}
}
function install(_Vue){
Vue = _Vue
// 混入store实例
Vue.mixin({
beforeCreate() {
if (this.$options.store) {
Vue.prototype.$store = this.$options.store
}
}
})
}
// { Store, install }相当于Vuex
// 它必须实现install方法
export default { Store, install }
----------@----------
vue3带来的新特性/亮点
1. 压缩包体积更小
当前最小化并被压缩的 Vue 运行时大小约为 20kB(2.6.10 版为 22.8kB)。Vue 3.0捆绑包的大小大约会减少一半,即只有10kB!
2. Object.defineProperty -> Proxy
Object.defineProperty
是一个相对比较昂贵的操作,因为它直接操作对象的属性,颗粒度比较小。将它替换为es6的Proxy
,在目标对象之上架了一层拦截,代理的是对象而不是对象的属性。这样可以将原本对对象属性的操作变为对整个对象的操作,颗粒度变大。- javascript引擎在解析的时候希望对象的结构越稳定越好,如果对象一直在变,可优化性降低,proxy不需要对原始对象做太多操作。
3. Virtual DOM 重构
vdom的本质是一个抽象层,用javascript描述界面渲染成什么样子。react用jsx,没办法检测出可以优化的动态代码,所以做时间分片,vue中足够快的话可以不用时间分片
- 传统vdom的性能瓶颈:
- 虽然 Vue 能够保证触发更新的组件最小化,但在单个组件内部依然需要遍历该组件的整个 vdom 树。
- 传统 vdom 的性能跟模版大小正相关,跟动态节点的数量无关。在一些组件整个模版内只有少量动态节点的情况下,这些遍历都是性能的浪费。
- JSX 和手写的 render function 是完全动态的,过度的灵活性导致运行时可以用于优化的信息不足
- 那为什么不直接抛弃vdom呢?
- 高级场景下手写
render function
获得更强的表达力 - 生成的代码更简洁
- 兼容2.x
- 高级场景下手写
vue的特点是底层为Virtual DOM,上层包含有大量静态信息的模版。为了兼容手写 render function,最大化利用模版静态信息,
vue3.0采用了动静结合的解决方案
,将vdom的操作颗粒度变小,每次触发更新不再以组件为单位进行遍历,主要更改如下
- 将模版基于动态节点指令切割为嵌套的区块
- 每个区块内部的节点结构是固定的
- 每个区块只需要以一个
Array
追踪自身包含的动态节点
vue3.0将 vdom 更新性能由与模版整体大小相关提升为与动态内容的数量相关
Vue 3.0 动静结合的 Dom diff
- Vue3.0 提出动静结合的 DOM diff 思想,动静结合的 DOM diff其实是在预编译阶段进行了优化。之所以能够做到预编译优化,是因为 Vue core 可以静态分析 template,在解析模版时,整个 parse 的过程是利用正则表达式顺序解析模板,当解析到开始标签、闭合标签和文本的时候都会分别执行对应的回调函数,来达到构造 AST 树的目的。
- 借助预编译过程,Vue 可以做到的预编译优化就很强大了。比如在预编译时标记出模版中可能变化的组件节点,再次进行渲染前 diff 时就可以跳过“永远不会变化的节点”,而只需要对比“可能会变化的动态节点”。这也就是动静结合的 DOM diff 将 diff 成本与模版大小正相关优化到与动态节点正相关的理论依据。
4. Performance
vue3在性能方面比vue2快了2倍。
- 重写了虚拟DOM的实现
- 运行时编译
- update性能提高
- SSR速度提高
5. Tree-shaking support
vue3中的核心api都支持了tree-shaking,这些api都是通过包引入的方式而不是直接在实例化时就注入,只会对使用到的功能或特性进行打包(按需打包),这意味着更多的功能和更小的体积。
6. Composition API
vue2中,我们一般会采用mixin来复用逻辑代码,用倒是挺好用的,不过也存在一些问题:例如代码来源不清晰、方法属性等冲突。基于此在vue3中引入了Composition API(组合API),使用纯函数分隔复用代码。和React中的
hooks
的概念很相似
- 更好的逻辑复用和代码组织
- 更好的类型推导
<template>
<div>X: {{ x }}</div>
<div>Y: {{ y }}</div>
</template>
<script>
import { defineComponent, onMounted, onUnmounted, ref } from "vue";
const useMouseMove = () => {
const x = ref(0);
const y = ref(0);
function move(e) {
x.value = e.clientX;
y.value = e.clientY;
}
onMounted(() => {
window.addEventListener("mousemove", move);
});
onUnmounted(() => {
window.removeEventListener("mousemove", move);
});
return { x, y };
};
export default defineComponent({
setup() {
const { x, y } = useMouseMove();
return { x, y };
}
});
</script>
7. 新增的三个组件Fragment、Teleport、Suspense
Fragment
在书写vue2时,由于组件必须只有一个根节点,很多时候会添加一些没有意义的节点用于包裹。Fragment组件就是用于解决这个问题的(这和React中的Fragment组件是一样的)。
这意味着现在可以这样写组件了。
/* App.vue */
<template>
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>
</template>
<script>
export default {};
</script>
或者这样
// app.js
import { defineComponent, h, Fragment } from 'vue';
export default defineComponent({
render() {
return h(Fragment, {}, [
h('header', {}, ['...']),
h('main', {}, ['...']),
h('footer', {}, ['...']),
]);
}
});
Teleport
Teleport其实就是React中的Portal。Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。
一个 portal 的典型用例是当父组件有 overflow: hidden 或 z-index 样式时,但你需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。
/* App.vue */
<template>
<div>123</div>
<Teleport to="#container">
Teleport
</Teleport>
</template>
<script>
import { defineComponent } from "vue";
export default defineComponent({
setup() {}
});
</script>
/* index.html */
<div id="app"></div>
<div id="container"></div>
Suspense
同样的,这和React中的Supense是一样的。
Suspense
让你的组件在渲染之前进行“等待”,并在等待时显示 fallback 的内容
// App.vue
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
Loading...
</template>
</Suspense>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import AsyncComponent from './AsyncComponent.vue';
export default defineComponent({
name: "App",
components: {
AsyncComponent
}
});
</script>
// AsyncComponent.vue
<template>
<div>Async Component</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
const sleep = () => {
return new Promise(resolve => setTimeout(resolve, 1000));
};
export default defineComponent({
async setup() {
await sleep();
}
});
</script>
8. Better TypeScript support
在vue2中使用过TypesScript的童鞋应该有过体会,写起来实在是有点难受。vue3则是使用ts进行了重写,开发者使用vue3时拥有更好的类型支持和更好的编写体验。
----------@----------
Compositon api
Composition API
也叫组合式API,是Vue3.x的新特性。
通过创建 Vue 组件,我们可以将接口的可重复部分及其功能提取到可重用的代码段中。仅此一项就可以使我们的应用程序在可维护性和灵活性方面走得更远。然而,我们的经验已经证明,光靠这一点可能是不够的,尤其是当你的应用程序变得非常大的时候——想想几百个组件。在处理如此大的应用程序时,共享和重用代码变得尤为重要
- Vue2.0中,随着功能的增加,组件变得越来越复杂,越来越难维护,而难以维护的根本原因是Vue的API设计迫使开发者使用
watch,computed,methods
选项组织代码,而不是实际的业务逻辑。 - 另外Vue2.0缺少一种较为简洁的低成本的机制来完成逻辑复用,虽然可以
minxis
完成逻辑复用,但是当mixin
变多的时候,会使得难以找到对应的data、computed
或者method
来源于哪个mixin
,使得类型推断难以进行。 - 所以
Composition API
的出现,主要是也是为了解决Option API带来的问题,第一个是代码组织问题,Compostion API
可以让开发者根据业务逻辑组织自己的代码,让代码具备更好的可读性和可扩展性,也就是说当下一个开发者接触这一段不是他自己写的代码时,他可以更好的利用代码的组织反推出实际的业务逻辑,或者根据业务逻辑更好的理解代码。 - 第二个是实现代码的逻辑提取与复用,当然
mixin
也可以实现逻辑提取与复用,但是像前面所说的,多个mixin
作用在同一个组件时,很难看出property
是来源于哪个mixin
,来源不清楚,另外,多个mixin
的property
存在变量命名冲突的风险。而Composition API
刚好解决了这两个问题。
通俗的讲:
没有Composition API
之前vue相关业务的代码需要配置到option的特定的区域,中小型项目是没有问题的,但是在大型项目中会导致后期的维护性比较复杂,同时代码可复用性不高。Vue3.x中的composition-api就是为了解决这个问题而生的
compositon api提供了以下几个函数:
setup
ref
reactive
watchEffect
watch
computed
toRefs
- 生命周期的
hooks
都说Composition API与React Hook很像,说说区别
从React Hook的实现角度看,React Hook是根据useState调用的顺序来确定下一次重渲染时的state是来源于哪个useState,所以出现了以下限制
- 不能在循环、条件、嵌套函数中调用Hook
- 必须确保总是在你的React函数的顶层调用Hook
useEffect、useMemo
等函数必须手动确定依赖关系
而Composition API是基于Vue的响应式系统实现的,与React Hook的相比
- 声明在
setup
函数内,一次组件实例化只调用一次setup
,而React Hook每次重渲染都需要调用Hook,使得React的GC比Vue更有压力,性能也相对于Vue来说也较慢 Compositon API
的调用不需要顾虑调用顺序,也可以在循环、条件、嵌套函数中使用- 响应式系统自动实现了依赖收集,进而组件的部分的性能优化由Vue内部自己完成,而
React Hook
需要手动传入依赖,而且必须必须保证依赖的顺序,让useEffect
、useMemo
等函数正确的捕获依赖变量,否则会由于依赖不正确使得组件性能下降。
虽然
Compositon API
看起来比React Hook
好用,但是其设计思想也是借鉴React Hook
的。
----------@----------
computed 的实现原理
computed
本质是一个惰性求值的观察者computed watcher
。其内部通过this.dirty
属性标记计算属性是否需要重新求值。
- 当 computed 的依赖状态发生改变时,就会通知这个惰性的 watcher,
computed watcher
通过this.dep.subs.length
判断有没有订阅者, - 有的话,会重新计算,然后对比新旧值,如果变化了,会重新渲染。 (Vue 想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性
最终计算的值
发生变化时才会触发渲染 watcher
重新渲染,本质上是一种优化。) - 没有的话,仅仅把
this.dirty = true
(当计算属性依赖于其他数据时,属性并不会立即重新计算,只有之后其他地方需要读取属性的时候,它才会真正计算,即具备 lazy(懒计算)特性。)
----------@----------
watch 的理解
watch
没有缓存性,更多的是观察的作用,可以监听某些数据执行回调。当我们需要深度监听对象中
的属性时,可以打开deep:true选项,这样便会对对象中的每一项进行监听。这样会带来性能问题,优化的话可以使用字符串形式监听
注意:Watcher : 观察者对象 , 实例分为
渲染 watcher
(render watcher),计算属性 watcher
(computed watcher),侦听器 watcher
(user watcher)三种
----------@----------
vue 渲染过程
- 调用
compile
函数,生成 render 函数字符串 ,编译过程如下:- parse 使用大量的正则表达式对template字符串进行解析,将标签、指令、属性等转化为抽象语法树AST。
模板 -> AST (最消耗性能)
- optimize 遍历AST,找到其中的一些静态节点并进行标记,方便在页面重渲染的时候进行diff比较时,直接跳过这一些静态节点,
优化runtime的性能
- generate 将最终的AST转化为render函数字符串
- parse 使用大量的正则表达式对template字符串进行解析,将标签、指令、属性等转化为抽象语法树AST。
- 调用
new Watcher
函数,监听数据的变化,当数据发生变化时,Render 函数执行生成 vnode 对象 - 调用
patch
方法,对比新旧 vnode 对象,通过 DOM diff 算法,添加、修改、删除真正的 DOM 元素
----------@----------
说一说keep-alive实现原理
keep-alive
组件接受三个属性参数:
include、
exclude、
max
include
指定需要缓存的组件name
集合,参数格式支持String, RegExp, Array。
当为字符串的时候,多个组件名称以逗号隔开。exclude
指定不需要缓存的组件name
集合,参数格式和include一样。max
指定最多可缓存组件的数量,超过数量删除第一个。参数格式支持String、Number。
原理
keep-alive
实例会缓存对应组件的VNode
,如果命中缓存,直接从缓存对象返回对应VNode
LRU(Least recently used)
算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。(墨菲定律:越担心的事情越会发生)
----------@----------
为什么访问data属性不需要带data
vue中访问属性代理
this.data.xxx
转换this.xxx
的实现
/** 将 某一个对象的属性 访问 映射到 对象的某一个属性成员上 */
function proxy( target, prop, key ) {
Object.defineProperty( target, key, {
enumerable: true,
configurable: true,
get () {
return target[ prop ][ key ];
},
set ( newVal ) {
target[ prop ][ key ] = newVal;
}
} );
}
----------@----------
template预编译是什么
对于 Vue 组件来说,模板编译只会在组件实例化的时候编译一次,生成渲染函数之后在也不会进行编译。因此,编译对组件的 runtime 是一种性能损耗。
而模板编译的目的仅仅是将template转化为render function,这个过程,正好可以在项目构建的过程中完成,这样可以让实际组件在 runtime 时直接跳过模板渲染,进而提升性能,这个在项目构建的编译template的过程,就是预编译。
----------@----------
介绍一下Vue中的Diff算法
在新老虚拟DOM对比时
- 首先,对比节点本身,判断是否为同一节点,如果不为相同节点,则删除该节点重新创建节点进行替换
- 如果为相同节点,进行patchVnode,判断如何对该节点的子节点进行处理,先判断一方有子节点一方没有子节点的情况(如果新的children没有子节点,将旧的子节点移除)
- 比较如果都有子节点,则进行updateChildren,判断如何对这些新老节点的子节点进行操作(diff核心)。 匹配时,找到相同的子节点,递归比较子节点
在diff中,只对同层的子节点进行比较,放弃跨级的节点比较,使得时间复杂从O(n^3)降低值O(n),也就是说,只有当新旧children都为多个子节点时才需要用核心的Diff算法进行同层级比较。
----------@----------
说说Vue2.0和Vue3.0有什么区别
- 重构响应式系统,使用
Proxy
替换Object.defineProperty
,使用Proxy
优势:- 可直接监听数组类型的数据变化
- 监听的目标为对象本身,不需要像
Object.defineProperty
一样遍历每个属性,有一定的性能提升 - 可拦截
apply、ownKeys、has
等13种方法,而Object.defineProperty
不行 - 直接实现对象属性的新增/删除
- 新增
Composition API
,更好的逻辑复用和代码组织 - 重构
Virtual DOM
- 模板编译时的优化,将一些静态节点编译成常量
slot
优化,将slot
编译为lazy
函数,将slot
的渲染的决定权交给子组件- 模板中内联事件的提取并重用(原本每次渲染都重新生成内联函数)
- 代码结构调整,更便于Tree shaking,使得体积更小
- 使用Typescript替换Flow
----------@----------
对虚拟DOM的理解
虚拟dom从来不是用来和直接操作dom对比的
,它们俩最终殊途同归。虚拟dom只不过是局部更新的一个环节而已
,整个环节的对比对象是全量更新。虚拟dom对于state=UI的意义是,虚拟dom使diff成为可能(理论上也可以直接用dom对象diff,但是太臃肿),促进了新的开发思想,又不至于性能太差。但是性能再好也不可能好过直接操作dom,人脑连diff都省了。还有一个很重要的意义是,对视图抽象,为跨平台助力
其实我最终希望你明白的事情只有一件:虚拟 DOM 的价值不在性能,而在别处
。因此想要从性能角度来把握虚拟 DOM 的优势,无异于南辕北辙。偏偏在面试场景下,10 个人里面有 9 个都走这条歧路,最后9个人里面自然没有一个能自圆其说,实在让人惋惜。
----------@----------
谈谈你对React的理解
React 是一个网页 UI 框架,通过组件化的方式解决视图层开发复用的问题,本质是一个组件化框架。
- 它的核心设计思路有三点,分别是
声明式、组件化与 通用性
。 - 声明式的优势在于直观与组合。
- 组件化的优势在于视图的拆分与模块复用,可以更容易做到高内聚低耦合。
- 通用性在于一次学习,随处编写。比如 React Native,React 360 等, 这里主要靠虚拟 DOM 来保证实现。
- 这使得 React 的适用范围变得足够广,无论是 Web、Native、VR,甚至 Shell 应用都可以进行开发。这也是 React 的优势。
- 但作为一个视图层的框架,React 的劣势也十分明显。它并没有提供完整的一揽子解决方 案,在开发大型前端应用时,需要向社区寻找并整合解决方案。虽然一定程度上促进了社区的繁荣,但也为开发者在技术选型和学习适用上造成了一定的成本。
- 承接在优势后,可以再谈一下自己对于 React 优化的看法、对虚拟 DOM 的看法
----------@----------
如何避免React生命周期中的坑
16.3版本
>=16.4版本
在线查看 :https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram(opens new window)
- 避免生命周期中的坑需要做好两件事:不在恰当的时候调用了不该调用的代码;在需要调用时,不要忘了调用。
- 那么主要有这么 7 种情况容易造成生命周期的坑
getDerivedStateFromProps
容易编写反模式代码,使受控组件与非受控组件区分模糊componentWillMount
在 React 中已被标记弃用,不推荐使用,主要原因是新的异步渲染架构会导致它被多次调用
。所以网络请求及事件绑定代码应移至componentDidMount
中。componentWillReceiveProps
同样被标记弃用,被getDerivedStateFromProps
所取代,主要原因是性能问题shouldComponentUpdate
通过返回true
或者false
来确定是否需要触发新的渲染。主要用于性能优化componentWillUpdate
同样是由于新的异步渲染机制,而被标记废弃,不推荐使用,原先的逻辑可结合getSnapshotBeforeUpdate
与componentDidUpdate
改造使用。- 如果在
componentWillUnmount
函数中忘记解除事件绑定,取消定时器等清理操作,容易引发 bug - 如果没有添加错误边界处理,当渲染发生异常时,用户将会看到一个无法操作的白屏,所以一定要添加
“React 的请求应该放在哪里,为什么?” 这也是经常会被追问的问题。你可以这样回答。
对于异步请求,应该放在 componentDidMount
中去操作。从时间顺序来看,除了 componentDidMount
还可以有以下选择:
- constructor:可以放,但从设计上而言不推荐。constructor 主要用于初始化 state 与函数绑定,并不承载业务逻辑。而且随着类属性的流行,constructor 已经很少使用了
- componentWillMount:已被标记废弃,在新的异步渲染架构下会触发多次渲染,容易引发 Bug,不利于未来 React 升级后的代码维护。
- 所以React 的请求放在
componentDidMount 里是最好的选择
。
透过现象看本质:React 16 缘何两次求变?
Fiber 架构简析
Fiber 是 React 16 对 React 核心算法的一次重写。你只需要 get 到这一个点:
Fiber 会使原本同步的渲染过程变成异步的
。
在 React 16 之前,每当我们触发一次组件的更新,React 都会构建一棵新的虚拟 DOM 树,通过与上一次的虚拟 DOM 树进行 diff,实现对 DOM 的定向更新。这个过程,是一个递归的过程。下面这张图形象地展示了这个过程的特征:
如图所示,同步渲染的递归调用栈是非常深的,只有最底层的调用返回了,整个渲染过程才会开始逐层返回
。这个漫长且不可打断的更新过程,将会带来用户体验层面的巨大风险:同步渲染一旦开始,便会牢牢抓住主线程不放,直到递归彻底完成
。在这个过程中,浏览器没有办法处理任何渲染之外的事情,会进入一种无法处理用户交互的状态。因此若渲染时间稍微长一点,页面就会面临卡顿甚至卡死的风险。
而 React 16 引入的 Fiber 架构,恰好能够解决掉这个风险:Fiber 会将一个大的更新任务拆解为许多个小任务
。每当执行完一个小任务时,渲染线程都会把主线程交回去
,看看有没有优先级更高的工作要处理,确保不会出现其他任务被“饿死”的情况,进而避免同步渲染带来的卡顿。在这个过程中,渲染线程不再“一去不回头”,而是可以被打断的,这就是所谓的“异步渲染”,它的执行过程如下图所示:
换个角度看生命周期工作流
Fiber 架构的重要特征就是可以被打断的异步渲染模式。但这个“打断”是有原则的,根据“能否被打断”这一标准,React 16 的生命周期被划分为了 render 和 commit 两个阶段
,而 commit 阶段又被细分为了 pre-commit 和 commit
。每个阶段所涵盖的生命周期如下图所示:
我们先来看下三个阶段各自有哪些特征
render 阶段
:纯净且没有副作用,可能会被 React 暂停、终止或重新启动。pre-commit 阶段
:可以读取 DOM。commit 阶段
:可以使用 DOM,运行副作用,安排更新。
总的来说,render 阶段在执行过程中允许被打断,而 commit 阶段则总是同步执行的。
为什么这样设计呢?简单来说,
由于 render 阶段的操作对用户来说其实是“不可见”的,所以就算打断再重启,对用户来说也是零感知
。而commit 阶段的操作则涉及真实 DOM 的渲染
,所以这个过程必须用同步渲染来求稳
。
----------@----------
React Fiber架构
最主要的思想就是将任务拆分 。
- DOM需要渲染时暂停,空闲时恢复。
window.requestIdleCallback
- React内部实现的机制
React 追求的是 “快速响应”,那么,“快速响应“的制约因素都有什么呢
CPU
的瓶颈:当项目变得庞大、组件数量繁多、遇到大计算量的操作或者设备性能不足使得页面掉帧,导致卡顿。IO
的瓶颈:发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。
fiber
架构主要就是用来解决CPU
和网络的问题,这两个问题一直也是最影响前端开发体验的地方,一个会造成卡顿,一个会造成白屏。为此 react 为前端引入了两个新概念:Time Slicing时间分片
和Suspense
。
1. React 都做过哪些优化
- React渲染页面的两个阶段
- 调度阶段(reconciliation):在这个阶段 React 会更新数据生成新的
Virtual DOM
,然后通过Diff
算法,快速找出需要更新的元素,放到更新队列中去,得到新的更新队列。 - 渲染阶段(commit):这个阶段 React 会遍历更新队列,将其所有的变更一次性更新到DOM上
- 调度阶段(reconciliation):在这个阶段 React 会更新数据生成新的
- React 15 架构
- React15架构可以分为两层
- Reconciler(协调器)—— 负责找出变化的组件;
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上;
- React15架构可以分为两层
- 在React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,递归更新时间超过了16ms,用户交互就会卡顿。
- 为了解决这个问题,React16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。
- React 16 架构
- 为了解决同步更新长时间占用线程导致页面卡顿的问题,也为了探索运行时优化的更多可能,React开始重构并一直持续至今。重构的目标是实现Concurrent Mode(并发模式)。
- 从v15到v16,React团队花了两年时间将源码架构中的Stack Reconciler重构为Fiber Reconciler
React16架构可以分为三层
:- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler;
- Reconciler(协调器)—— 负责找出变化的组件:更新工作从递归变成了可以中断的循环过程。Reconciler内部采用了Fiber的架构;
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上。
- React 17 优化
- 使用Lane来管理任务的优先级。Lane用二进制位表示任务的优先级,方便优先级的计算(位运算),不同优先级占用不同位置的“赛道”,而且存在批的概念,优先级越低,“赛道”越多。高优先级打断低优先级,新建的任务需要赋予什么优先级等问题都是Lane所要解决的问题。
- Concurrent Mode的目的是实现一套可中断/恢复的更新机制。其由两部分组成:
- 一套协程架构:Fiber Reconciler
- 基于协程架构的启发式更新算法:控制协程架构工作方式的算法
2. 浏览器一帧都会干些什么以及requestIdleCallback的启示
我们都知道,页面的内容都是一帧一帧绘制出来的,浏览器刷新率代表浏览器一秒绘制多少帧。原则上说 1s 内绘制的帧数也多,画面表现就也细腻。目前浏览器大多是 60Hz(60帧/s),每一帧耗时也就是在 16.6ms 左右。那么在这一帧的(16.6ms) 过程中浏览器又干了些什么呢
通过上面这张图可以清楚的知道,浏览器一帧会经过下面这几个过程:
- 接受输入事件
- 执行事件回调
- 开始一帧
- 执行 RAF (RequestAnimationFrame)
- 页面布局,样式计算
- 绘制渲染
- 执行 RIC (RequestIdelCallback)
第七步的 RIC 事件不是每一帧结束都会执行,只有在一帧的 16.6ms 中做完了前面 6 件事儿且还有剩余时间,才会执行。如果一帧执行结束后还有时间执行 RIC 事件,那么下一帧需要在事件执行结束才能继续渲染,所以 RIC 执行不要超过 30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时。
requestIdleCallback 的启示
:我们以浏览器是否有剩余时间作微任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
requestIdleCallback((deadline) => {
// deadline 有两个参数
// timeRemaining(): 当前帧还剩下多少时间
// didTimeout: 是否超时
// 另外 requestIdleCallback 后如果跟上第二个参数 {timeout: ...} 则会强制浏览器在当前帧执行完后执行。
if (deadline.timeRemaining() > 0) {
// TODO
} else {
requestIdleCallback(otherTasks);
}
});
// 用法示例
var tasksNum = 10000
requestIdleCallback(unImportWork)
function unImportWork(deadline) {
while (deadline.timeRemaining() && tasksNum > 0) {
console.log(`执行了${10000 - tasksNum + 1}个任务`)
tasksNum--
}
if (tasksNum > 0) { // 在未来的帧中继续执行
requestIdleCallback(unImportWork)
}
}
其实部分浏览器已经实现了这个API,这就是requestIdleCallback。但是由于以下因素,Facebook 抛弃了
requestIdleCallback
的原生 API:
- 浏览器兼容性;
- 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的
requestIdleCallback
触发的频率会变得很低。
基于以上原因,在React中实现了功能更完备的
requestIdleCallbackpolyfill
,这就是Scheduler
。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置
3. React Fiber是什么
React Fiber
是对核心算法的一次重新实现。React Fiber
把更新过程碎片化,把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会
- 在
React Fiber
中,一次更新过程会分成多个分片完成,所以完全有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来 - 因为一个更新过程可能被打断,所以
React Fiber
一个更新过程被分为两个阶段(Phase
):第一个阶段Reconciliation Phase
和第二阶段Commit Phase
- 在第一阶段
Reconciliation Phase
,React Fiber
会找出需要更新哪些DOM
,这个阶段是可以被打断的;但是到了第二阶段Commit Phase
,那就一鼓作气把DOM
更新完,绝不会被打断 - 这两个阶段大部分工作都是
React Fiber
做,和我们相关的也就是生命周期函数
React Fiber
改变了之前react
的组件渲染机制,新的架构使原来同步渲染的组件现在可以异步化,可中途中断渲染,执行更高优先级的任务。释放浏览器主线程
关键特性
- 增量渲染(把渲染任务拆分成块,匀到多帧)
- 更新时能够暂停,终止,复用渲染任务
- 给不同类型的更新赋予优先级
- 并发方面新的基础能力
增量渲染用来解决掉帧的问题,渲染任务拆分之后,每次只做一小段,做完一段就把时间控制权交还给主线程,而不像之前长时间占用
4. 组件的渲染顺序
假如有A,B,C,D组件,层级结构为:
我们知道组件的生命周期为:
挂载阶段 :
constructor()
componentWillMount()
render()
componentDidMount()
更新阶段为 :
componentWillReceiveProps()
shouldComponentUpdate()
componentWillUpdate()
render()
componentDidUpdate
那么在挂载阶段,
A,B,C,D
的生命周期渲染顺序是如何的呢?
那么在挂载阶段,A,B,C,D的生命周期渲染顺序是如何的呢?
以
render()
函数为分界线。从顶层组件开始,一直往下,直至最底层子组件。然后再往上
组件update
阶段同理
前面是react16
以前的组建渲染方式。这就存在一个问题
如果这是一个很大,层级很深的组件,
react
渲染它需要几十甚至几百毫秒,在这期间,react
会一直占用浏览器主线程,任何其他的操作(包括用户的点击,鼠标移动等操作)都无法执行
Fiber架构就是为了解决这个问题
看一下fiber架构 组建的渲染顺序
加入
fiber
的react
将组件更新分为两个时期
这两个时期以render为分界
render
前的生命周期为phase1
,render
后的生命周期为phase2
phase1
的生命周期是可以被打断的,每隔一段时间它会跳出当前渲染进程,去确定是否有其他更重要的任务。此过程,React
在workingProgressTree
(并不是真实的virtualDomTree
)上复用current
上的Fiber
数据结构来一步地(通过requestIdleCallback
)来构建新的 tree,标记处需要更新的节点,放入队列中phase2
的生命周期是不可被打断的,React
将其所有的变更一次性更新到DOM
上
这里最重要的是phase1这是时期所做的事。因此我们需要具体了解phase1的机制
- 如果不被打断,那么
phase1
执行完会直接进入render
函数,构建真实的virtualDomTree
- 如果组件再
phase1
过程中被打断,即当前组件只渲染到一半(也许是在willMount
,也许是willUpdate
~反正是在render之前的生命周期),那么react
会怎么干呢?react
会放弃当前组件所有干到一半的事情,去做更高优先级更重要的任务(当然,也可能是用户鼠标移动,或者其他react监听之外的任务),当所有高优先级任务执行完之后,react
通过callback
回到之前渲染到一半的组件,从头开始渲染。(看起来放弃已经渲染完的生命周期,会有点不合理,反而会增加渲染时长,但是react
确实是这么干的)
所有phase1的生命周期函数都可能被执行多次,因为可能会被打断重来
这样的话,就和
react16
版本之前有很大区别了,因为可能会被执行多次,那么我们最好就得保证phase1
的生命周期每一次执行的结果都是一样的,否则就会有问题,因此,最好都是纯函数
- 如果高优先级的任务一直存在,那么低优先级的任务则永远无法进行,组件永远无法继续渲染。这个问题facebook目前好像还没解决
- 所以,facebook在
react16
增加fiber
结构,其实并不是为了减少组件的渲染时间,事实上也并不会减少,最重要的是现在可以使得一些更高优先级的任务,如用户的操作能够优先执行,提高用户的体验,至少用户不会感觉到卡顿
5 React Fiber架构总结
React Fiber如何性能优化
- 更新的两个阶段
- 调度算法阶段-执行diff算法,纯js计算
- Commit阶段-将diff结果渲染dom
- 可能会有性能问题
- JS是单线程的,且和DOM渲染公用一个线程
- 当组件足够复杂,组件更新时计算和渲染压力都大
- 同时再有DOM操作需求(动画、鼠标拖拽等),将卡顿
- 解决方案fiber
- 将调度算法阶段阶段任务拆分(Commit无法拆分)
- DOM需要渲染时暂停,空闲时恢复
- 分散执行: 任务分割后,就可以把小任务单元分散到浏览器的空闲期间去排队执行,而实现的关键是两个新API:
requestIdleCallback
与requestAnimationFrame
- 低优先级的任务交给
requestIdleCallback
处理,这是个浏览器提供的事件循环空闲期的回调函数,需要pollyfill
,而且拥有deadline
参数,限制执行事件,以继续切分任务; - 高优先级的任务交给
requestAnimationFrame
处理;
- 低优先级的任务交给
React 的核心流程可以分为两个部分:
reconciliation
(调度算法,也可称为render
)- 更新
state
与props
; - 调用生命周期钩子;
- 生成
virtual dom
- 这里应该称为
Fiber Tree
更为符合;
- 这里应该称为
- 通过新旧 vdom 进行 diff 算法,获取 vdom change
- 确定是否需要重新渲染
- 更新
commit
- 如需要,则操作
dom
节点更新
- 如需要,则操作
要了解 Fiber,我们首先来看为什么需要它
- 问题 : 随着应用变得越来越庞大,整个更新渲染的过程开始变得吃力,大量的组件渲染会导致主进程长时间被占用,导致一些动画或高频操作出现卡顿和掉帧的情况。而关键点,便是 同步阻塞。在之前的调度算法中,React 需要实例化每个类组件,生成一颗组件树,使用 同步递归 的方式进行遍历渲染,而这个过程最大的问题就是无法 暂停和恢复。
- 解决方案: 解决同步阻塞的方法,通常有两种: 异步 与 任务分割。而 React Fiber 便是为了实现任务分割而诞生的
- 简述
- 在
React V16
将调度算法进行了重构, 将之前的stack reconciler
重构成新版的 fiberreconciler
,变成了具有链表和指针的 单链表树遍历算法。通过指针映射,每个单元都记录着遍历当下的上一步与下一步,从而使遍历变得可以被暂停和重启 - 这里我理解为是一种 任务分割调度算法,主要是 将原先同步更新渲染的任务分割成一个个独立的 小任务单位,根据不同的优先级,将小任务分散到浏览器的空闲时间执行,充分利用主进程的事件循环机制
- 在
- 核心
Fiber
这里可以具象为一个 数据结构
class Fiber {
constructor(instance) {
this.instance = instance
// 指向第一个 child 节点
this.child = child
// 指向父节点
this.return = parent
// 指向第一个兄弟节点
this.sibling = previous
}
}
- 链表树遍历算法 : 通过 节点保存与映射,便能够随时地进行 停止和重启,这样便能达到实现任务分割的基本前提
- 首先通过不断遍历子节点,到树末尾;
- 开始通过
sibling
遍历兄弟节点; - return 返回父节点,继续执行2;
- 直到 root 节点后,跳出遍历;
- 任务分割 ,React 中的渲染更新可以分成两个阶段
- reconciliation 阶段 : vdom 的数据对比,是个适合拆分的阶段,比如对比一部分树后,先暂停执行个动画调用,待完成后再回来继续比对
- Commit 阶段 : 将 change list 更新到 dom 上,并不适合拆分,才能保持数据与 UI 的同步。否则可能由于阻塞 UI 更新,而导致数据更新和 UI 不一致的情况
- 分散执行: 任务分割后,就可以把小任务单元分散到浏览器的空闲期间去排队执行,而实现的关键是两个新API:
requestIdleCallback
与requestAnimationFrame
- 低优先级的任务交给
requestIdleCallback
处理,这是个浏览器提供的事件循环空闲期的回调函数,需要pollyfill
,而且拥有deadline
参数,限制执行事件,以继续切分任务; - 高优先级的任务交给
requestAnimationFrame
处理;
- 低优先级的任务交给
// 类似于这样的方式
requestIdleCallback((deadline) => {
// 当有空闲时间时,我们执行一个组件渲染;
// 把任务塞到一个个碎片时间中去;
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
nextComponent = performWork(nextComponent);
}
});
- 优先级策略: 文本框输入 > 本次调度结束需完成的任务 > 动画过渡 > 交互反馈 > 数据更新 > 不会显示但以防将来会显示的任务
- Fiber 其实可以算是一种编程思想,在其它语言中也有许多应用(Ruby Fiber)。
- 核心思想是 任务拆分和协同,主动把执行权交给主线程,使主线程有时间空挡处理其他高优先级任务。
- 当遇到进程阻塞的问题时,任务分割、异步调用 和 缓存策略 是三个显著的解决思路。
----------@----------
createElement过程
React.createElement(): 根据指定的第一个参数创建一个React元素
React.createElement(
type,
[props],
[...children]
)
- 第一个参数是必填,传入的是似HTML标签名称,eg: ul, li
- 第二个参数是选填,表示的是属性,eg: className
- 第三个参数是选填, 子节点,eg: 要显示的文本内容
//写法一:
var child1 = React.createElement('li', null, 'one');
var child2 = React.createElement('li', null, 'two');
var content = React.createElement('ul', { className: 'teststyle' }, child1, child2); // 第三个参数可以分开也可以写成一个数组
ReactDOM.render(
content,
document.getElementById('example')
);
//写法二:
var child1 = React.createElement('li', null, 'one');
var child2 = React.createElement('li', null, 'two');
var content = React.createElement('ul', { className: 'teststyle' }, [child1, child2]);
ReactDOM.render(
content,
document.getElementById('example')
);
----------@----------
调和阶段 setState内部干了什么
- 当调用 setState 时,React会做的第一件事情是将传递给 setState 的对象合并到组件的当前状态
- 这将启动一个称为和解(
reconciliation
)的过程。和解(reconciliation
)的最终目标是以最有效的方式,根据这个新的状态来更新UI
。 为此,React
将构建一个新的React
元素树(您可以将其视为UI
的对象表示) - 一旦有了这个树,为了弄清 UI 如何响应新的状态而改变,React 会将这个新树与上一个元素树相比较( diff )
通过这样做, React 将会知道发生的确切变化,并且通过了解发生什么变化,只需在绝对必要的情况下进行更新即可最小化 UI 的占用空间
----------@----------
setState
在了解setState之前,我们先来简单了解下 React 一个包装结构: Transaction:
事务 (Transaction)
是 React 中的一个调用结构,用于包装一个方法,结构为: initialize - perform(method) - close。通过事务,可以统一管理一个方法的开始与结束;处于事务流中,表示进程正在执行一些操作
- setState: React 中用于修改状态,更新视图。它具有以下特点:
异步与同步: setState并不是单纯的异步或同步,这其实与调用时的环境相关:
- 在合成事件 和 生命周期钩子 (除 componentDidUpdate) 中,setState是"异步"的;
- 原因: 因为在setState的实现中,有一个判断: 当更新策略正在事务流的执行中时,该组件更新会被推入dirtyComponents队列中等待执行;否则,开始执行batchedUpdates队列更新;
- 在生命周期钩子调用中,更新策略都处于更新之前,组件仍处于事务流中,而componentDidUpdate是在更新之后,此时组件已经不在事务流中了,因此则会同步执行;
- 在合成事件中,React 是基于 事务流完成的事件委托机制 实现,也是处于事务流中;
- 问题: 无法在setState后马上从this.state上获取更新后的值。
- 解决: 如果需要马上同步去获取新值,setState其实是可以传入第二个参数的。setState(updater, callback),在回调中即可获取最新值;
- 原因: 因为在setState的实现中,有一个判断: 当更新策略正在事务流的执行中时,该组件更新会被推入dirtyComponents队列中等待执行;否则,开始执行batchedUpdates队列更新;
- 在 原生事件 和 setTimeout 中,setState是同步的,可以马上获取更新后的值;
- 原因: 原生事件是浏览器本身的实现,与事务流无关,自然是同步;而setTimeout是放置于定时器线程中延后执行,此时事务流已结束,因此也是同步;
- 批量更新 : 在 合成事件 和 生命周期钩子 中,setState更新队列时,存储的是 合并状态(Object.assign)。因此前面设置的 key 值会被后面所覆盖,最终只会执行一次更新;
- 函数式 : 由于 Fiber 及 合并 的问题,官方推荐可以传入 函数 的形式。setState(fn),在fn中返回新的state对象即可,例如this.setState((state, props) => newState);
- 使用函数式,可以用于避免setState的批量更新的逻辑,传入的函数将会被 顺序调用;
注意事项:
- setState 合并,在 合成事件 和 生命周期钩子 中多次连续调用会被优化为一次;
- 当组件已被销毁,如果再次调用setState,React 会报错警告,通常有两种解决办法
- 将数据挂载到外部,通过 props 传入,如放到 Redux 或 父级中;
- 在组件内部维护一个状态量 (isUnmounted),componentWillUnmount中标记为 true,在setState前进行判断;
总结
setState 并非真异步,只是看上去像异步。在源码中,通过
isBatchingUpdates
来判断
setState
是先存进state
队列还是直接更新,如果值为 true 则执行异步操作,为 false 则直接更新。- 那么什么情况下
isBatchingUpdates
会为true
呢?在 React 可以控制的地方,就为 true,比如在 React 生命周期事件和合成事件中,都会走合并操作,延迟更新的策略。 - 但在 React 无法控制的地方,比如原生事件,具体就是在
addEventListener
、setTimeout
、setInterval
等事件中,就只能同步更新。
一般认为,
做异步设计是为了性能优化、减少渲染次数
,React 团队还补充了两点。
- 保持内部一致性。如果将 state 改为同步更新,那尽管 state 的更新是同步的,但是 props不是。
- 启用并发更新,完成异步渲染。
setState
只有在 React 自身的合成事件和钩子函数中是异步的,在原生事件和 setTimeout 中都是同步的setState
的异步并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数中没法立马拿到更新后的值,形成了所谓的异步。当然可以通过 setState 的第二个参数中的 callback 拿到更新后的结果setState
的批量更新优化也是建立在异步(合成事件、钩子函数)之上的,在原生事件和 setTimeout 中不会批量更新,在异步中如果对同一个值进行多次 setState,setState 的批量更新策略会对其进行覆盖,去最后一次的执行,如果是同时 setState 多个不同的值,在更新时会对其进行合并批量更新
- 合成事件中是异步
- 钩子函数中的是异步
- 原生事件中是同步
- setTimeout中是同步
这是一道经常会出现的 React setState 笔试题:下面的代码输出什么呢?
class Test extends React.Component {
state = {
count: 0
};
componentDidMount() {
this.setState({count: this.state.count + 1});
console.log(this.state.count);
this.setState({count: this.state.count + 1});
console.log(this.state.count);
setTimeout(() => {
this.setState({count: this.state.count + 1});
console.log(this.state.count);
this.setState({count: this.state.count + 1});
console.log(this.state.count);
}, 0);
}
render() {
return null;
}
};
我们可以进行如下的分析:
- 首先第一次和第二次的
console.log
,都在 React 的生命周期事件中,所以是异步的处理方式,则输出都为0
; - 而在
setTimeout
中的console.log
处于原生事件中,所以会同步的处理再输出结果,但需要注意,虽然count
在前面经过了两次的this.state.count + 1
,但是每次获取的this.state.count
都是初始化时的值,也就是0
; - 所以此时
count
是1
,那么后续在setTimeout
中的输出则是2
和3
。
所以完整答案是 0,0,2,3
同步场景
异步场景中的案例使我们建立了这样一个认知:setState 是异步的,但下面这个案例又会颠覆你的认知。如果我们将 setState 放在 setTimeout 事件中,那情况就完全不同了。
class Test extends Component {
state = {
count: 0
}
componentDidMount(){
this.setState({ count: this.state.count + 1 });
console.log(this.state.count);
setTimeout(() => {
this.setState({ count: this.state.count + 1 });
console.log("setTimeout: " + this.state.count);
}, 0);
}
render(){
...
}
}
那这时输出的应该是什么呢?如果你认为是 0,0,那么又错了。
正确的结果是 0,2
。因为 setState
并不是真正的异步函数,它实际上是通过队列延迟执行操作实现的,通过 isBatchingUpdates 来判断 setState 是先存进 state 队列还是直接更新。值为 true 则执行异步操作,false 则直接同步更新
接下来这个案例的答案是什么呢
class Test extends Component {
state = {
count: 0
}
componentDidMount(){
this.setState({
count: this.state.count + 1
}, () => {
console.log(this.state.count)
})
this.setState({
count: this.state.count + 1
}, () => {
console.log(this.state.count)
})
}
render(){
...
}
}
如果你觉得答案是 1,2
,那肯定就错了。这种迷惑性极强的考题在面试中非常常见,因为它反直觉。
如果重新仔细思考,你会发现当前拿到的 this.state.count
的值并没有变化,都是 0
,所以输出结果应该是 1,1
。
当然,也可以在 setState
函数中获取修改后的 state
值进行修改。
class Test extends Component {
state = {
count: 0
}
componentDidMount(){
this.setState(
preState=> ({
count:preState.count + 1
}),()=>{
console.log(this.state.count)
})
this.setState(
preState=>({
count:preState.count + 1
}),()=>{
console.log(this.state.count)
})
}
render(){
...
}
}
这些通通是异步的回调,如果你以为输出结果是 1,2,那就又错了,实际上是 2,2
。
为什么会这样呢?当调用 setState
函数时,就会把当前的操作放入队列中
。React 根据队列内容,合并 state 数据,完成后再逐一执行回调,根据结果更新虚拟 DOM,触发渲染。所以回调时,state 已经合并计算完成了
,输出的结果就是 2,2
了。
----------@----------
setState原理分析
1. setState异步更新
- 我们都知道,
React
通过this.state
来访问state
,通过this.setState()
方法来更新state
。当this.setState()
方法被调用的时候,React
会重新调用render
方法来重新渲染UI
- 首先如果直接在
setState
后面获取state
的值是获取不到的。在React
内部机制能检测到的地方,setState
就是异步的;在React
检测不到的地方,例如setInterval
,setTimeout
,setState
就是同步更新的
因为
setState
是可以接受两个参数的,一个state
,一个回调函数。因此我们可以在回调函数里面获取值
setState
方法通过一个队列机制实现state
更新,当执行setState
的时候,会将需要更新的state
合并之后放入状态队列,而不会立即更新this.state
- 如果我们不使用
setState
而是使用this.state.key
来修改,将不会触发组件的re-render
。 - 如果将
this.state
赋值给一个新的对象引用,那么其他不在对象上的state
将不会被放入状态队列中,当下次调用setState
并对状态队列进行合并时,直接造成了state
丢失
1.1 setState批量更新的过程
在
react
生命周期和合成事件执行前后都有相应的钩子,分别是pre
钩子和post
钩子,pre
钩子会调用batchedUpdate
方法将isBatchingUpdates
变量置为true
,开启批量更新,而post
钩子会将isBatchingUpdates
置为false
isBatchingUpdates
变量置为true
,则会走批量更新分支,setState
的更新会被存入队列中,待同步代码执行完后,再执行队列中的state
更新。isBatchingUpdates
为true
,则把当前组件(即调用了setState
的组件)放入dirtyComponents
数组中;否则batchUpdate
所有队列中的更新- 而在原生事件和异步操作中,不会执行
pre
钩子,或者生命周期的中的异步操作之前执行了pre
钩子,但是pos
钩子也在异步操作之前执行完了,isBatchingUpdates
必定为false
,也就不会进行批量更新
enqueueUpdate
包含了React
避免重复render
的逻辑。mountComponent
和updateComponent
方法在执行的最开始,会调用到batchedUpdates
进行批处理更新,此时会将isBatchingUpdates
设置为true
,也就是将状态标记为现在正处于更新阶段了。isBatchingUpdates
为true
,则把当前组件(即调用了setState
的组件)放入dirtyComponents
数组中;否则batchUpdate
所有队列中的更新
1.2 为什么直接修改this.state无效
- 要知道
setState
本质是通过一个队列机制实现state
更新的。 执行setState
时,会将需要更新的state合并后放入状态队列,而不会立刻更新state
,队列机制可以批量更新state
。 - 如果不通过
setState
而直接修改this.state
,那么这个state
不会放入状态队列中,下次调用setState
时对状态队列进行合并时,会忽略之前直接被修改的state
,这样我们就无法合并了,而且实际也没有把你想要的state
更新上去
1.3 什么是批量更新 Batch Update
在一些
mv*
框架中,,就是将一段时间内对model
的修改批量更新到view
的机制。比如那前端比较火的React
、vue
(nextTick
机制,视图的更新以及实现)
1.4 setState之后发生的事情
setState
操作并不保证是同步的,也可以认为是异步的React
在setState
之后,会经对state
进行diff
,判断是否有改变,然后去diff dom
决定是否要更新UI
。如果这一系列过程立刻发生在每一个setState
之后,就可能会有性能问题- 在短时间内频繁
setState
。React
会将state
的改变压入栈中,在合适的时机,批量更新state
和视图,达到提高性能的效果
1.5 如何知道state已经被更新
传入回调函数
setState({
index: 1
}}, function(){
console.log(this.state.index);
})
在钩子函数中体现
componentDidUpdate(){
console.log(this.state.index);
}
2. setState循环调用风险
- 当调用
setState
时,实际上会执行enqueueSetState
方法,并对partialState
以及_pending-StateQueue
更新队列进行合并操作,最终通过enqueueUpdate
执行state
更新 - 而
performUpdateIfNecessary
方法会获取_pendingElement
,_pendingStateQueue
,_pending-ForceUpdate
,并调用receiveComponent
和updateComponent
方法进行组件更新 - 如果在
shouldComponentUpdate
或者componentWillUpdate
方法中调用setState
,此时this._pending-StateQueue != null
,就会造成循环调用,使得浏览器内存占满后崩溃
3 事务
- 事务就是将需要执行的方法使用
wrapper
封装起来,再通过事务提供的perform
方法执行,先执行wrapper
中的initialize
方法,执行完perform
之后,在执行所有的close
方法,一组initialize
及close
方法称为一个wrapper
。 - 那么事务和
setState
方法的不同表现有什么关系,首先我们把4
次setStat
e简单归类,前两次属于一类,因为它们在同一调用栈中执行,setTimeout
中的两次setState
属于另一类 - 在
setState
调用之前,已经处在batchedUpdates
执行的事务中了。那么这次batchedUpdates
方法是谁调用的呢,原来是ReactMount.js
中的_renderNewRootComponent
方法。也就是说,整个将React
组件渲染到DOM
中的过程就是处于一个大的事务中。而在componentDidMount
中调用setState
时,batchingStrategy
的isBatchingUpdates
已经被设为了true
,所以两次setState
的结果没有立即生效 - 再反观
setTimeout
中的两次setState
,因为没有前置的batchedUpdates
调用,所以导致了新的state
马上生效
4. 总结
- 通过
setState
去更新this.state
,不要直接操作this.state
,请把它当成不可变的 - 调用
setState
更新this.state
不是马上生效的,它是异步的,所以不要天真以为执行完setState
后this.state
就是最新的值了 - 多个顺序执行的
setState
不是同步地一个一个执行滴,会一个一个加入队列,然后最后一起执行,即批处理
----------@----------
React事务机制
----------@----------
React组件和渲染更新过程
渲染和更新过程
- jsx如何渲染为页面
- setState之后如何更新页面
- 面试考察全流程
JSX本质和vdom
- JSX即
createElement
函数 - 执行生成vnode
patch(elem,vnode)
和patch(vnode,newNode)
组件渲染过程
props state
render()
生成vnode
patch(elem, vnode)
组件更新过程
setState-->dirtyComponents
(可能有子组件)render
生成newVnode
patch(vnode, newVnode)
----------@----------
如何解释 React 的渲染流程
- React 的渲染过程大致一致,但协调并不相同,以
React 16
为分界线,分为Stack Reconciler
和Fiber Reconciler
。这里的协调从狭义上来讲,特指 React 的 diff 算法,广义上来讲,有时候也指 React 的reconciler
模块,它通常包含了diff
算法和一些公共逻辑。 - 回到
Stack Reconciler
中,Stack Reconciler
的核心调度方式是递归
。调度的基本处理单位是事务
,它的事务基类是Transaction
,这里的事务是 React 团队从后端开发中加入的概念
。在 React 16 以前,挂载主要通过 ReactMount 模块完成
,更新通过ReactUpdate
模块完成,模块之间相互分离,落脚执行点也是事务。 - 在
React 16
及以后,协调改为了Fiber Reconciler
。它的调度方式主要有两个特点,第一个是协作式多任务模式
,在这个模式下,线程会定时放弃自己的运行权利,交还给主线程,通过requestIdleCallback
实现。第二个特点是策略优先级
,调度任务通过标记tag
的方式分优先级执行,比如动画,或者标记为high
的任务可以优先执行。Fiber Reconciler
的基本单位是Fiber
,Fiber
基于过去的React Element
提供了二次封装,提供了指向父、子、兄弟节点的引用,为diff
工作的双链表实现提供了基础。 - 在新的架构下,整个生命周期被划分为
Render 和 Commit 两个阶段
。Render 阶段的执行特点是可中断、可停止、无副作用
,主要是通过构造workInProgress
树计算出diff
。以current
树为基础,将每个Fiber
作为一个基本单位,自下而上逐个节点检查并构造 workInProgress 树。这个过程不再是递归,而是基于循环来完成 - 在执行上通过
requestIdleCallback
来调度执行每组任务,每组中的每个计算任务被称为work
,每个work
完成后确认是否有优先级更高的work
需要插入,如果有就让位,没有就继续。优先级通常是标记为动画或者high
的会先处理。每完成一组后,将调度权交回主线程,直到下一次requestIdleCallback
调用,再继续构建workInProgress
树 - 在
commit
阶段需要处理effect
列表,这里的effect
列表包含了根据diff 更新 DOM 树
、回调生命周期
、响应 ref
等。 - 但一定要注意,这个阶段是同步执行的,不可中断暂停,所以不要在
componentDidMount
、componentDidUpdate
、componentWiilUnmount
中去执行重度消耗算力的任务 - 如果只是一般的应用场景,比如管理后台、H5 展示页等,两者性能差距并不大,但在动画、画布及手势等场景下,
Stack Reconciler
的设计会占用占主线程,造成卡顿,而fiber reconciler
的设计则能带来高性能的表现
----------@----------
diff算法是怎么运作
每一种节点类型有自己的属性,也就是prop,每次进行diff的时候,react会先比较该节点类型,假如节点类型不一样,那么react会直接删除该节点,然后直接创建新的节点插入到其中,假如节点类型一样,那么会比较prop是否有更新,假如有prop不一样,那么react会判定该节点有更新,那么重渲染该节点,然后在对其子节点进行比较,一层一层往下,直到没有子节点
- 把树形结构按照层级分解,只比较同级元素。
- 给列表结构的每个单元添加唯一的
key
属性,方便比较。 React
只会匹配相同class
的component
(这里面的class
指的是组件的名字)- 合并操作,调用
component
的setState
方法的时候,React
将其标记为 -dirty
.到每一个事件循环结束,React
检查所有标记dirty
的component
重新绘制. - 选择性子树渲染。开发人员可以重写
shouldComponentUpdate
提高diff
的性能
优化⬇️
为了降低算法复杂度,
React
的diff
会预设三个限制:
- 只对同级元素进行
Diff
。如果一个DOM节点
在前后两次更新中跨越了层级,那么React
不会尝试复用他。 - 两个不同类型的元素会产生出不同的树。如果元素由
div
变为p
,React会销毁div
及其子孙节点,并新建p
及其子孙节点。 - 开发者可以通过
key prop
来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:
Diff的思路
该如何设计算法呢?如果让我设计一个Diff算法
,我首先想到的方案是:
- 判断当前节点的更新属于哪种情况
- 如果是
新增
,执行新增逻辑 - 如果是
删除
,执行删除逻辑 - 如果是
更新
,执行更新逻辑
- 按这个方案,其实有个隐含的前提——不同操作的优先级是相同的
- 但是
React团队
发现,在日常开发中,相较于新增
和删除
,更新
组件发生的频率更高。所以Diff
会优先判断当前节点是否属于更新
。
基于以上原因,Diff算法
的整体逻辑会经历两轮遍历:
- 第一轮遍历:处理
更新
的节点。 - 第二轮遍历:处理剩下的不属于
更新
的节点。
diff算法的作用
计算出Virtual DOM中真正变化的部分,并只针对该部分进行原生DOM操作,而非重新渲染整个页面。
传统diff算法
通过循环递归对节点进行依次对比,算法复杂度达到
O(n^3)
,n是树的节点数,这个有多可怕呢?——如果要展示1000个节点,得执行上亿次比较。。即便是CPU快能执行30亿条命令,也很难在一秒内计算出差异。
React的diff算法
- 什么是调和?
将Virtual DOM树转换成actual DOM树的最少操作的过程 称为 调和 。
- 什么是React diff算法?
diff
算法是调和的具体实现。
diff策略
React用 三大策略 将O(n^3)复杂度 转化为 O(n)复杂度
策略一(tree diff):
- Web UI中DOM节点跨层级的移动操作特别少,可以忽略不计。
策略二(component diff):
- 拥有相同类的两个组件 生成相似的树形结构,
- 拥有不同类的两个组件 生成不同的树形结构。
策略三(element diff):
对于同一层级的一组子节点,通过唯一id区分。
tree diff
- React通过updateDepth对Virtual DOM树进行层级控制。
- 对树分层比较,两棵树 只对同一层次节点 进行比较。如果该节点不存在时,则该节点及其子节点会被完全删除,不会再进一步比较。
- 只需遍历一次,就能完成整棵DOM树的比较。
那么问题来了,如果DOM节点出现了跨层级操作,diff会咋办呢?
答:diff只简单考虑同层级的节点位置变换,如果是跨层级的话,只有创建节点和删除节点的操作。
如上图所示,以A为根节点的整棵树会被重新创建,而不是移动,因此 官方建议不要进行DOM节点跨层级操作,可以通过CSS隐藏、显示节点,而不是真正地移除、添加DOM节点
component diff
React对不同的组件间的比较,有三种策略
- 同一类型的两个组件,按原策略(层级比较)继续比较Virtual DOM树即可。
- 同一类型的两个组件,组件A变化为组件B时,可能Virtual DOM没有任何变化,如果知道这点(变换的过程中,Virtual DOM没有改变),可节省大量计算时间,所以 用户 可以通过
shouldComponentUpdate()
来判断是否需要 判断计算。 - 不同类型的组件,将一个(将被改变的)组件判断为
dirty component
(脏组件),从而替换 整个组件的所有节点。
注意:如果组件D和组件G的结构相似,但是 React判断是 不同类型的组件,则不会比较其结构,而是删除 组件D及其子节点,创建组件G及其子节点。
element diff
当节点处于同一层级时,diff提供三种节点操作:删除、插入、移动。
- 插入:组件 C 不在集合(A,B)中,需要插入
- 删除:
- 组件 D 在集合(A,B,D)中,但 D的节点已经更改,不能复用和更新,所以需要删除 旧的 D ,再创建新的。
- 组件 D 之前在 集合(A,B,D)中,但集合变成新的集合(A,B)了,D 就需要被删除。
- 移动:组件D已经在集合(A,B,C,D)里了,且集合更新时,D没有发生更新,只是位置改变,如新集合(A,D,B,C),D在第二个,无须像传统diff,让旧集合的第二个B和新集合的第二个D 比较,并且删除第二个位置的B,再在第二个位置插入D,而是 (对同一层级的同组子节点) 添加唯一key进行区分,移动即��。
总结
tree diff
:只对比同一层的 dom 节点,忽略 dom 节点的跨层级移动
如下图,react 只会对相同颜色方框内的 DOM 节点进行比较,即同一个父节点下的所有子节点。当发现节点不存在时,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。
这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。
这就意味着,如果 dom 节点发生了跨层级移动,react 会删除旧的节点,生成新的节点,而不会复用。
component diff
:如果不是同一类型的组件,会删除旧的组件,创建新的组件
element diff
:对于同一层级的一组子节点,需要通过唯一 id 进行来区分
- 如果没有 id 来进行区分,一旦有插入动作,会导致插入位置之后的列表全部重新渲染
- 这也是为什么渲染列表时为什么要使用唯一的 key。
diff的不足与待优化的地方
尽量减少类似将最后一个节点移动到列表首部的操作,当节点数量过大或更新操作过于频繁时,会影响React的渲染性能
与其他框架相比,React 的 diff 算法有何不同?
diff 算法探讨的就是虚拟 DOM 树发生变化后,生成 DOM 树更新补丁的方式。它通过对比新旧两株虚拟 DOM 树的变更差异,将更新补丁作用于真实 DOM,以最小成本完成视图更新
具体的流程是这样的:
- 真实 DOM 与虚拟 DOM 之间存在一个映射关系。这个映射关系依靠初始化时的 JSX 建立完成;
- 当虚拟 DOM 发生变化后,就会根据差距计算生成 patch,这个 patch 是一个结构化的数据,内容包含了增加、更新、移除等;
- 最后再根据 patch 去更新真实的 DOM,反馈到用户的界面上。
在回答有何不同之前,首先需要说明下什么是 diff 算法。
diff 算法是指生成更新补丁的方式
,主要应用于虚拟 DOM 树变化后,更新真实 DOM
。所以 diff 算法一定存在这样一个过程:触发更新 → 生成补丁 → 应用补丁
- React 的 diff 算法,触发更新的时机主要在 state 变化与 hooks 调用之后。此时触发虚拟 DOM 树变更遍历,采用了深度优先遍历算法。但传统的遍历方式,效率较低。为了优化效率,使用了分治的方式。
将单一节点比对转化为了 3 种类型节点的比对
,分别是树、组件及元素
,以此提升效率。树比对
:由于网页视图中较少有跨层级节点移动,两株虚拟 DOM 树只对同一层次的节点进行比较。组件比对
:如果组件是同一类型,则进行树比对,如果不是,则直接放入到补丁中。元素比对
:主要发生在同层级中,通过标记节点操作生成补丁,节点操作对应真实的 DOM 剪裁操作。同一层级的子节点,可以通过标记 key 的方式进行列表对比。
- 以上是经典的 React diff 算法内容。
自 React 16 起,引入了 Fiber 架构
。为了使整个更新过程可随时暂停恢复
,节点与树分别采用了FiberNode 与 FiberTree 进行重构
。fiberNode 使用了双链表的结构
,可以直接找到兄弟节点与子节点 - 然后拿 Vue 和 Preact 与 React 的 diff 算法进行对比
Preact
的Diff
算法相较于React
,整体设计思路相似,但最底层的元素采用了真实DOM
对比操作,也没有采用Fiber
设计。Vue 的Diff
算法整体也与React
相似,同样未实现Fiber
设计
- 然后进行横向比较,
React 拥有完整的 Diff 算法策略,且拥有随时中断更新的时间切片能力
,在大批量节点更新的极端情况下,拥有更友好的交互体验。 - Preact 可以在一些对性能要求不高,仅需要渲染框架的简单场景下应用。
- Vue 的整体
diff 策略与 React 对齐
,虽然缺乏时间切片能力,但这并不意味着 Vue 的性能更差,因为在 Vue 3 初期引入过,后期因为收益不高移除掉了。除了高帧率动画,在 Vue 中其他的场景几乎都可以使用防抖和节流去提高响应性能。
**学习原理的目的就是应用。那如何根据 React diff 算法原理优化代码呢?**这个问题其实按优化方式逆向回答即可。
- 根据
diff
算法的设计原则,应尽量避免跨层级节点移动。 - 通过设置唯一
key
进行优化,尽量减少组件层级深度。因为过深的层级会加深遍历深度,带来性能问题。 - 设置
shouldComponentUpdate
或者React.pureComponet
减少diff
次数。
----------@----------
合成事件原理
为了解决跨浏览器兼容性问题,
React
会将浏览器原生事件(Browser Native Event
)封装为合成事件(SyntheticEvent
)传入设置的事件处理器中。这里的合成事件提供了与原生事件相同的接口,不过它们屏蔽了底层浏览器的细节差异,保证了行为的一致性。另外有意思的是,React
并没有直接将事件附着到子元素上,而是以单一事件监听器的方式将所有的事件发送到顶层进行处理。这样React
在更新DOM
的时候就不需要考虑如何去处理附着在DOM
上的事件监听器,最终达到优化性能的目的
- 所有的事件挂在document上,DOM 事件触发后冒泡到 document;React 找到对应的组件,造出一个合成事件出来;并按组件树模拟一遍事件冒泡。
- event不是原生的,是SyntheticEvent合成事件对象
- 和Vue事件不同,和DOM事件也不同
React 17 之前的事件冒泡流程图
所以这就造成了,在一个页面中,只能有一个版本的 React。如果有多个版本,事件就乱套了。值得一提的是,这个问题在 React 17 中得到了解决,事件委托不再挂在 document 上,而是挂在 DOM 容器上,也就是
ReactDom.Render
所调用的节点上。
React 17 后的事件冒泡流程图
那到底哪些事件会被捕获生成合成事件呢?可以从 React 的源码测试文件中一探究竟。下面的测试快照中罗列了大量的事件名,也只有在这份快照中的事件,才会被捕获生成合成事件。
// react/packages/react-dom/src/__tests__/__snapshots__/ReactTestUtils-test.js.snap
Array [
"abort",
"animationEnd",
"animationIteration",
"animationStart",
"auxClick",
"beforeInput",
"blur",
"canPlay",
"canPlayThrough",
"cancel",
"change",
"click",
"close",
"compositionEnd",
"compositionStart",
"compositionUpdate",
"contextMenu",
"copy",
"cut",
"doubleClick",
"drag",
"dragEnd",
"dragEnter",
"dragExit",
"dragLeave",
"dragOver",
"dragStart",
"drop",
"durationChange",
"emptied",
"encrypted",
"ended",
"error",
"focus",
"gotPointerCapture",
"input",
"invalid",
"keyDown",
"keyPress",
"keyUp",
"load",
"loadStart",
"loadedData",
"loadedMetadata",
"lostPointerCapture",
"mouseDown",
"mouseEnter",
"mouseLeave",
"mouseMove",
"mouseOut",
"mouseOver",
"mouseUp",
"paste",
"pause",
"play",
"playing",
"pointerCancel",
"pointerDown",
"pointerEnter",
"pointerLeave",
"pointerMove",
"pointerOut",
"pointerOver",
"pointerUp",
"progress",
"rateChange",
"reset",
"scroll",
"seeked",
"seeking",
"select",
"stalled",
"submit",
"suspend",
"timeUpdate",
"toggle",
"touchCancel",
"touchEnd",
"touchMove",
"touchStart",
"transitionEnd",
"volumeChange",
"waiting",
"wheel",
]
如果DOM上绑定了过多的事件处理函数,整个页面响应以及内存占用可能都会受到影响。React为了避免这类DOM事件滥用,同时屏蔽底层不同浏览器之间的事件系统的差异,实现了一个中间层 - SyntheticEvent
- 当用户在为onClick添加函数时,React并没有将Click绑定到DOM上面
- 而是在document处监听所有支持的事件,当事件发生并冒泡至document处时,React将事件内容封装交给中间层 SyntheticEvent (负责所有事件合成)
- 所以当事件触发的时候, 对使用统一的分发函数 dispatchEvent 将指定函数执行
为何要合成事件
- 兼容性和跨平台
- 挂在统一的document上,减少内存消耗,避免频繁解绑
- 方便事件的统一管理(事务机制)
- dispatchEvent事件机制
----------@----------