前端面试题系列文章:
【12】「2023」性能优化相关知识点
【13】「2023」H5相关知识点
X-Mind原图地址:
线程与进程
在MacOS中,打开活动监视器,可以查看该计算机中运行了哪些进程,以及每个进程中包含了多少线程。

进程和线程的概念
进程(Process)和线程(Thread)是操作系统中的基本概念。进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。线程是 CPU 调度的最小单位 (是建立在进程基础上的一次程序运行单位)。
现代的操作系统都是可以同时运行多个任务的,比如在使用浏览器上网的同时还可以打开微信聊天。对于操作系统来说,一个任务就是一个进程,比如打开了一个浏览器就是启动了进程(实际上Chrome浏览器是多进程的),打开一个备忘录,就启动了一个备忘录进程。
有些进程同时不止做一件事情,比如 Word,它同时可以进行打字、拼写检查、打印等事情。在一个进程内部,要同时做多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程。
由于每个进程至少要做一件事情,所以一个进程至少要有一个线程。系统会给每个进程分配独立的内存,因此进程有它独立的资源。同一进程内的各个线程之间共享该进程的内存空间(包括代码段,数据集,堆等)。
进程和线程的区别
- 从应用的角度看:进程可以看成独立的应用,线程不行
- 从资源分配的角度看:进程是 cpu 资源分配的最小单位(是能拥有资源和独立运行的最小单位),线程是 cpu 调度的最小单位(是建立在进程基础上的一次程序运行单位)。
- 从通信的角度看:线程间可以直接共享同一进程中的资源,而进程通信需要借助进程间通信。
- 从调度角度看:进程切换比线程切换的开销要大。线程是CPU调度的基本单位,线程的切换不会引起进程切换,但某个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
- 从系统开销的角度:在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。
进程和线程的关系
- 一个线程只能属于一个进程,而一个进程可以有多个线程(但至少拥有一个)。
- 资源分配给进程,同一进程的所有线程共享该进程的所有资源。
- 处理机分给线程,即真正在处理机上运行的是线程。
- 线程在执行过程中,需要协作同步。不同进程的线程间要利用消息通信的办法实现同步。线程是指进程内的一个执行单元,也是进程内的可调度实体。
浏览器的多进程架构
主进程(Browser Process)
主进程也称主进程,主要负责浏览器界面的显示与交互。各个页面的管理,创建和销毁其他进程。网络的资源管理、下载等。
渲染进程(Renderer Process)
核心任务是将 HTML、CSS 和 JavaScript 转化为用户可以与之交互的页面、排版引擎Blink和 JavaScript 引擎 V8都是运行在该进程中,默认情况下,Chorme会为每一个标签都创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下的。
插件进程(Plugin Process)
主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。
网络进程(NetWork Process)
主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,现在被才独立出来,成为一个单独的进程。
GPU进程(GPU Process)
最多只有一个,用于 3D 绘制等。
浏览器的渲染进程
浏览器的渲染进程是多线程的,如下图所示:

GUI渲染线程
负责渲染浏览器页面,解析HTML、CSS、构建DOM树、构建CSSOM树、构建渲染树和回执页面。当界面需要重绘或由于某种操作引发回流时,改线程就会执行(浏览器的渲染进程的主要任务就是将静态资源转化为可视化界面)。
注意: GUI 渲染线程和 JS 引擎线程是互斥的,当JS引擎执行时候GUI线程就会被挂起,GUI线程更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
JS引擎线程
JS引擎线程也被称为JS内核,负责处理 JavaScript 脚本程序,解析 JavaScript 脚本,运行代码。JS 引擎线程一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页中无论什么时候都只有一个JS引擎线程在运行 JS 程序。
注意:GUI 渲染引擎和 JS 引擎线程是互斥关系,所以如果 JS 执行时间过长,会造成页面的渲染不连贯,导致页面渲染加载阻塞。
事件触发线程
事件触发线程归属于浏览器而不是 JS 引擎线程,用来控制事件循环。当 JS 引擎代码执行如 setTimeout(或者其他如 AJAX异步请求),会将对应任务添加到事件线程中。当对应的事件符合触发条件被触发时候,该线程会把事件添加到待处理队列的队尾,等到 JS 引擎的处理。
注意:由于 JS 引擎是单线程的,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当JS引擎空闲时才会去执行)。
定时器触发线程
setTimeout和setInterval所在的线程。浏览器中的定时计数器并不是由 JS 引擎计数的(因为 JS 引擎是单线程的,如果线程处于阻塞状态就会影响计时器的准确性),因此使用单独线程来计时并触发定时器,计时完毕后,添加到事件队列中,等待JS引擎空闲后执行。
所以定时器中的任务在设定的时间点不一定能够准时执行,定时器只是在指定时间点将任务添加到事件队列中。除此之外,定时器的定时时间不能小于4ms。
异步 http 请求线程
XMLHttpRequest连接后通过浏览器新开一个线程请求。当检测到有状态变更时,如果设有回调函数,异步线程就会将回调函数放入事件队列中,等到 JS 引擎空闲后执行。
浏览器渲染原理
浏览器的渲染过程

- DOM树构建:HTML解析器将HTML解析成DOM Tree。
- CSS解析器:CSS解析器将CSS解析成CSS Tree。
- 渲染树构建: 将CSS树和HTML树合并成Render Tree。
- 页面布局:对Layout Tree进行分层生成Layer Tree。
- 构件绘制列表:对每个Layer Tree生成绘制列表,并提交到合成线程。
- 合成线程将图层分成图块:合成线程将图层分成图块
- 光栅化:光栅化线程池中将「图块转化为位图」,光栅化之后会将会将绘制命令给浏览器进程。
- 页面绘制:浏览器进程根据绘图命令生成页面,并显示到显示器上。
浏览器渲染优化
-
针对JavaScript:JavaScript 既会阻塞 HTML 的解析,也会阻塞 CSS 的解析。因此我们可以对 JavaScript 的加载方式进行改变,来进行优化:
(1)尽量将 JS 文件放在 body 的最后。
(2)body中间尽量不要写
script标签。(3)使用
async或defer下载文件。 -
针对CSS:使用 CSS 的方式有三种:使用link,@import,内联样式,其中link和@import都是导入外部样式。他们之前的区别:
(1)link:浏览器会派发一个新等线程(HTTP线程)去加载资源文件,与此同时GUI渲染线程会继续向下渲染代码
(2)@import:GUI渲染线程会停止渲染,去服务器加载资源,返回之前不会继续渲染
(3)内联样式:GUI渲染线程直接渲染
所以,在开发过程中,导入外部样式使用link,而不用@import。如果css少,尽可能采用内嵌样式,直接写在style标签中。
-
针对DOM树、CSSOM树:可以通过以下几种方式:
(1)HTML文件的代码层级尽量不要太深。
(2)使用语义化的标签,来避免不标准语义化的特殊处理。
(3)减少 CSSOM 代码的层级,因为选择器是从左向右进行解析的
-
减少回流与重绘
(1)操作 DOM 时,尽量在低层级的 DOM 节点进行操作。
(2)不要使用
table布局,一个小的改动可能会使整个table进行重新布局。(3)不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。
(4)使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
(5)避免频繁操作DOM,可以创建一个文档片段
documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中。(6)将元素先设置
display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。(7)将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制。
渲染过程中遇到 JS 文件如何处理?
JavaScript 的加载、解析与执行会阻塞文档的解析。也就是说,在构建 DOM 时,HTML parser 如果遇到了 JavaScript,那么它会暂停文档的解析,将控制权交给 JavaScript 引擎,等 JavaScript 引擎运行完毕,浏览器再从中断的地方恢复继续解析文档。也就是说,想要首屏渲染的快,最好不在首屏加载 JS 文件,这也是为什么建议将 script 标签放在 body 标签底部的原因(或者给 script 标签加上 defer 或 async 属性)。
重排和重绘
我们知道,渲染树是动态构建的。所以,DOM 节点和 CSS 节点的改动都可能会造成渲染树的重新构建。渲染树的改动就会造成页面的重排或重绘。
(1)重排:当我们的操作引发了 DOM 树的几何尺寸的变化(改变元素的大小,位置、布局方式等),这时渲染树里有改动的节点和它影响的节点都要重新计算。这个过程就叫做重排,也称为回流。在改动发生时,要重新经历页面渲染的整个流程,所以开销是比较大的。
以下操作都会导致页面重排:
- 页面首次渲染。
- 浏览器窗口大小发生改变。
- 元素的内容发生改变。
- 元素的尺寸或者位置发生改变。
- 元素的字体大小发生改变。
- 激活CSS伪类。
- 添加或删除可见的DOM元素。
在触发重排时,由于浏览器渲染页面是基于流式布局的,所以当触发回流时,会导致周围的DOM元素重新排列,它的影响范围有两种:1. 全局范围:从根节点开始,对整个渲染树进行重新布局; 2. 局部范围:对渲染树的某部分或者一个渲染对象进行重新布局。
(2)重绘:当对 DOM 的修改仅仅影响了样式,但未影响其几何属性(比如修改颜色、背景色)时,浏览器不需要重新计算元素的几何属性、直接为该元素绘制新的样式,这个过程叫做重绘。简单来说,重绘是对元素绘制属性的修改引发的。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。
下面这些属性会导致回流:
-
color、background 相关属性:background-color、background-image等。
-
outline 属性: outline-color、outline-width、text-decotation。
-
border-raduis、visibility、box-shadow。
注意: 当触发重排时,一定会触发重绘,但是重绘不一定会引发重排。
Chorme V8引擎
什么是V8引擎
V8是由 Google 开发的开源的 JavaScript 引擎,也被称为虚拟机,模拟实际计算机各种功能来实现代码的编译和执行。
高级代码为什么需要先编译再执行?
CPU具有指令集(机器语言)用于实现各种功能,但是只能识别二进制的指令,不能直接识别高级语言所编写的代码。
有两种方式可以执行高级代码:
- 解释执行
先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。
特点:启动速度快,执行时速度慢
- 编译执行
先将源代码编译成中间代码,然后编译器再将中间代码编译成机器代码并保存,在需要执行时直接执行。
特点:启动速度慢,执行时速度快
V8是怎么执行JavaScript代码的?解释执行or编译执行?
实际上V8并没有采用某种单一的技术,而是混合编译执行和解释执行这两种手段,我们把这种混合使用编译器和解释器的技术称为JIT(即时编译技术Just In Time),即混合编译执行和解释执行这两种手段。流程如下:
JVM以及luajit,包括oracle最新的graalVM都已经采用了JIT技术。
(1)初始化执行上下文:在V8启动执行 JavaScript 代码之前,它还需要准备执行 JavaScript 时所需要的一些基础环境,包括:堆空间、栈空间、全局执行上下文、全局作用域、消息循环系统、内置函数等。
(2)解析源代码,生成AST、作用域:基础环境准备好之后,就可以向V8中提交要执行的 JavaScript 代码了。不过这些代码对于V8 来说不过只是一对字符串,V8 并不能直接理解这段字符串的含义,他需要结构化这段字符串,将其解析成抽象语法树 AST,AST 便是 V8 编译器能理解的。在生成AST的同时,还会生成相关的作用域。
(3)根据AST、作用域生成字节码:有了AST和作用域之后,就可以生成字节码了,字节码是介于AST和机器码代码的中间代码。解释器可以直接执行字节码,或者通过编译器将其编译成二进制的机器代码再执行。
(4)解释执行字节码:生成了字节码后,解释器就等长了,它会按照顺序解释执行字节码。
(5)监听热点代码:在解释执行字节码的过程中,如果发现了某一段代码被重复执行多次,那么这段代码就会被编辑为热点代码,并将其丢给优化编译器执行优化操作。
(6)优化热点代码为二进制代码:优化编译器会将该段字节码编译为二进制代码,如果下次再执行到这段代码时,V8会优先选择优化后的二进制代码。
(7)反优化生成的二进制机器代码:由于 JavaScript 是一门动态的语言,变量的类型和结构可以任意修改。经过优化过的代码只能针对某个固定的结构,一旦数据的类型、结构发生了改变,那么这段优化过后的代码就失效了,这时候优化编译器需要执行反优化操作。

其他热门的JavaScript引擎
- V8(Google)
- JavaScriptCore(Safari)
- SpiderMonkey(Mozilla)
- Chakra(JScript引擎) 用于Internet Explorer、Chakra(JavaScirpt引擎) 用于Microsoft Edge
- JerryScript、KJS、QuickJS、Hermes等
V8引擎的核心模块
- 解析器 Parser
负责将 JavaScript 源码解析成 AST,并生成相关的作用域。
- 基线编辑器 Ignition
V8使用JIT(Just In Time)技术,通过基线编译器(Ignition)快速生成字节码进行执行。
- 优化编译器 TurboFan
优化编译器,利用基线编译器(Ignition)所收集的信息,将字节码编译为二进制代码。
- 垃圾回收器 Orinoco
垃圾回收模块,负责将程序中不需要的内存回收。
垃圾回收机制
总的来说V8中的垃圾回收分为三个步骤:一. 通过 GC Root 标记空间中的活动对象和非活动对象。 二.回收非活动对象的内存。 三. 对内存碎片做内存整理。
V8中有两个垃圾回收器:主垃圾回收器和副垃圾回收器。
- 世代假说
世代假说(generational hypothesis),也称为弱分代假说(weak generational hypothesis)。这个假说将内存中的对象划分为两类,第一类是“朝生夕死”的对象的新生代对象,他们通常不会再内存中存储太久,如函数内声明的变量。第二类是常驻的对象称为老生代对象,比如Window、DOM、Web API。
- 新生代
副垃圾回收器负责新生代对象的回收,采用的是Scavaenge算法。所谓Scavaenge算法就是讲空间分对象区和空闲区两个部分。新加入的对象都会被写入对象区,当对象区的区域快被写满时,就需要执行一次垃圾清理操作。
Step1:在执行回收的过程中,首先要对清除的垃圾做标记。
Step2:标记完成之后就要进入清理阶段,副垃圾回收器会将这些存活对象复制到空闲区,同时还会有序的排列起来(相当于对内存做了整理)。
Step3:完成复制后将空闲区和对象区进行翻转,这样就完成了垃圾回收的操作。
上面说到,当对象区空间快被写满的时候,便会执行垃圾回收,在回收的同时,副垃圾回收器还会采用
晋升策略,那些经过2次垃圾回收依然存活的对象会被移到老生代中。

- 老生代
主垃圾回收器主要负责老生代对象的垃圾回收。除了一些新晋升的新生代,一些较大的对象会被直接分配到老生代里。由于老生代对象一般比较大,所以继续使用Scavenge算法,会浪费比较多的时间和空间。所以主垃圾回收器采用的策略是标记清除和标记整理。
(1)标记清除:标记清除的原理十分简单。垃圾回收器从根节点开始,标记根直接引用的对象,然后递归标记这些对象的直接引用对象。对象的可达性将作为是否“存活”的依据。
(2)标记整理:由于标记清除算法必然会产生很多内存碎片,而内存碎片过多必然导致无法分配足够的内存(比如一个数组需要分配的就是连续的内存)。于是又引入了标记整理算法,标记的过程和标记清除是一样的,清楚的过程是将所有的存活对象都向一端移动,然后删除这一段之外的内存。
-
垃圾回收过程的优化策略
Orinoco 利用了并行、增量、并发的技术进行垃圾回收,以释放主线程的压力,使其有更多的时间用于正常的 JavaScript 代码执行。
(1)并行:并行回收是指,回收器在主线程和辅助线程同时进行,这样就会加速垃圾回收的速度,在垃圾回收的执行过程中,正在执行的JavaScript会暂停下来,我们称之为
全停顿的垃圾回收方式。(2)增量: 所谓增量式垃圾回收,是指垃圾回收器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。
(3)并发: 并发是指主线程保持 JavaScript 执行不中断,辅助线程完全在后台执行垃圾回收。由于涉及主线程和辅助线程的读写竞争,是三种策略中最复杂的一种。
在新生代中,V8采用的是并行Scanenge算法。在老生代中,V8采用的是并发标记,并行整理,并发回收的策略。
面试常问题:讲一讲V8的垃圾回收机制?
v8的垃圾回收机制是一种分代式垃圾回收机制,v8引擎会把根据对象的存活时间将对象分为新生代对象和老生代对象。对于这两种对象有不同的垃圾回收策略。
- 对于新生代采用的是Scavenge算法,它将新生代内存一分为二,当程序中声明了变量,首先会往From区分配内存,当进行垃圾回收时,如果Form区有存活的对象,会被复制到To区,然后将From区和To区进行角色转化。To区变成From区,Form区变成To区。
- 新生代的对象还有晋升的机制,当某个对象已经经历过一次Scavenge算法,或者To空间的内容占比已经超过了25%,会将对象转移到老生代中。
- 对于老生代采用的是标记清除、标记整理的算法的算法。标记清除算法会从所有的根节点出发,去访问所有可以访问到的子节点,并且标记为活动的,不能到底的地方就会标记为需要回收的垃圾,垃圾回收器会释放这些内存。
- 值得注意的时候,标记清除会出现内存碎片的问题,导致很难分配一个大的内存对象,所以需要对内存空间进行整理。所以在清除的同时会把活跃对象向内存的一端移动,移动完成后再清理内存
3.对于老生代采用的是标记清除、标记整理的算法的算法。标记清除算法会从所有的根节点出发,去访问所有可以访问到的子节点,并且标记为活动的,不能到底的地方就会标记为需要回收的垃圾,垃圾回收器会释放这些内存。
4.值得注意的时候,标记清除会出现内存碎片的问题,导致很难分配一个大的内存对象,所以需要对内存空间进行整理。所以在清除的同时会把活跃对象向内存的一端移动,移动完成后再清理内存
浏览器事件机制
事件是用户操作页面时发生的交互动作,比如click、move、touch。事件除了用户触发的动作之外,还可以是文档加载,窗口滚动和大小调整。事件被封装成一个Event对象,其中包含了事件发生时的所有相关信息,以及可以对事件进行的操作。
事件流
- 事件冒泡:事件冒泡是 IE 团队提出的事件流方案,根据名字我们就可以看出,事件冒泡是从最具体的元素开始触发事件,然后向上传播至
Document节点。 - 事件捕获:事件捕获是 Netscape 开发团队提出的事件流解决方案。和事件冒泡相反,事件捕获是从
Document节点最先接收事件,向下传播至最具体的节点。 - DOM中的事件流:DOM2 Events 规范规定事件流分为 3 个阶段:
事件捕获、到达目标和事件冒泡。事件捕获最先发生,为提前拦截事件提供了可能。然后,实际的目标元素接收到事件。最后一个阶段是冒泡,最迟要在这个阶段响应事件。
三种事件模型
-
DOM0 级事件模型:这种模型不会传播,没有事件流的概念,但是现有的浏览器以冒泡的方式实现,它可以在页面中直接定义监听函数,也可以通过js属性来指定监听函数。
// 写法一: <input type="button" value="click me" onclick="console.log('click')" /> // 写法二: const btn = document.getElementById("myBtn"); btn.onclick = function(){ console.log('Clicked') } -
DOM2 级事件模型:在该事件模型中,事件一共有三个过程,分别是:
事件捕获、到达目标和事件冒泡。捕获指的是事件从 document 一直向下传播到目标元素,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。到达目标阶段执行目标元素绑定的监听事件,然后是事件冒泡阶段,冒泡指的是事件从目标元素冒泡到 document,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。btn.addEventListener("click", (e) => { console.log('btn click capture ') }, true); btn.addEventListener("click", (e) => { console.log('btn click bubble ') }); body.addEventListener("click", (e) => { console.log('body click capture') }, true); body.addEventListener("click", (e) => { console.log('body click bubble') });addEventListener API 的第三个参数为
useCapture(是否在事件捕获阶执行),默认为false。 -
IE 事件模型:在该事件模型中,一次事件共有两个过程,
事件处理阶段和事件冒泡阶段。事件处理阶段会首先执行目标元素绑定的监听事件。然后是事件冒泡阶段,冒泡指的是事件从目标元素冒泡到 document,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。这种模型通过attachEvent 来添加监听函数,可以添加多个监听函数,会按顺序依次执行。const btn = document.getElementById("myBtn"); btn.attachEvent("onclick", function(){ console.log("Clicked"); })
事件委托
事件委托本质上利用了浏览器事件冒泡的机制。因为事件在冒泡过程中总是会上传到父节点,所以在父节点总是能通过事件对象获取到目标节点,因此可以将子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件。
-
事件委托的优点
(1)
减少内存消耗:使用事件委托可以不必要为每一个子元素都绑定一个监听事件,这样减少了内存上的消耗。(2)
动态绑定事件:假设一个列表中的元素是需要增加、减少的,那么每次在新增元素的时候需要绑定新事件、每次删除元素的时候需要解绑事件,所以利用事件委托的模式来动态绑定事件可以减少很多的重复工作。 -
局限性
(1)并不是所有的事件都有冒泡机制,比如
focus、blur之类的事件没有事件冒泡机制,无法实现委托。(2)
mousemove、mouseout这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的。 -
适用场景
(1)当父元素下的每个子元素监听相同的事件时,可以把事件绑定在父元素上,通过
target来判断点击的元素。tab切换,ul下的li。(2)给页面的所有的a标签添加click事件,做全局处理。
浏览器缓存
浏览器的缓存过程
- 当用户第一次请求资源的时候,服务器返回200。浏览器会直接从服务器下载资源,并且将资源文件与
response header缓存起来,供下次加载时比对使用。 - 下一次加载资源时,由于强制缓存优先级较高,先比较当前时间与上一次返回 200 时的时间差,如果没有超过 cache-control 设置的 max-age,则没有过期,并命中强缓存,直接从本地读取资源。
- 如果资源已过期,则表明强制缓存没有被命中,则开始协商缓存,向服务器发送带有 If-None-Match 和 If-Modified-Since 的请求;
- 服务器收到请求后,优先根据 Etag 的值判断被请求的文件有没有做修改,Etag 值一致则没有修改,命中协商缓存,返回 304;如果不一致则有改动,直接返回新的资源文件带上新的 Etag 值并返回 200;
- 如果服务器收到的请求没有 Etag 值,则将 If-Modified-Since 和被请求文件的最后修改时间做比对,一致则命中协商缓存,返回 304;不一致则返回新的 last-modified 和文件并返回 200;
相对于 last-modified ,Etag 更精准一点,因为服务器的时间可能存在差异,last-modified只能精准到秒级
浏览器资源缓存的位置有哪些
- Memory Cache:Memory Cache 就是内存缓存,它的效率最快。虽然内存缓存读取速度快,但可持续性比较短,会随着进程的释放而释放。一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。
- Disk Cache:Disk Cache 是存储在硬盘中的缓存,读取速度相对来说比较慢。但是硬盘缓存的优点是:存储容量大,存储时间长。在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。
- Service Worker:Service Worker 运行在 JavaScript 主线程之外,虽然由于脱离了浏览器窗体无法直接访问 DOM,但是它可以完成离线缓存、消息推送、网络代理等功能。它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。
浏览器刷新方式对缓存的影响
- F5或点击刷新:浏览器直接对本地的缓存文件过期,但是会带上
If-Modified-Since,If-None-Match,这意味着服务器会重新检查文件是否被修改,返回的有可能是304,也有可能是200。 - 用户按 Ctrl + F5(强制刷新):浏览器不仅会对本地文件过期,而且不会带上
If-Modified-Since,If-None-Match,相当于之前从来没请求过,返回结果是200。 - 浏览器地址栏回车:浏览器发起请求,按照正常的流程,本地检查是否过期,然后服务器检查是否修改,最后返回内容。
缓存方案
目前采用的缓存方案
- JS、CSS设置强缓存时间为30天
- 图片、音频、字体文件强缓存,时间为1天
- html文件走的协商缓存(感觉目前会有一些问题,html文件还是不缓存比较好)
浏览器同源策略
同源策略和跨域请求
浏览器的同源策略限制了同一个源加载的文档或脚本如何与另一个源的资源进行交互。这是浏览器用于隔离潜在恶意文件的安全机制。同源指的是:协议、端口号、域名必须一致。
跨域问题其实就是浏览器的同源策略造成的,跨域请求指的是:协议、端口号、域名有一个不相同的请求。
跨域请求举例
假设我们现在浏览器中的地址是http://www.zaoren.com
| URL | 是否跨域 | 原因 |
|---|---|---|
http://www.zaoren.com/index | 否 | 同源 |
https://www.zaoren.com | 是 | 协议不同 |
http://www.zaorenll.com | 是 | 域名不同 |
http://www.zaorenll.com:8080 | 是 | 端口号不同 |
如何解决跨域问题
(1)JSONP
JSONP是单纯为了解决跨域请求而实现的一种方式。我们通过<script>或<img>标签获取资源的方式是不会跨域的。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script>
function show(json) {
console.log(json.s);
}
</script>
<script src="https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=web&cb=show"></script>
</body>
</html>
(2)CORS
跨域资源共享(CORS)是一种机制,它使用额外的 HTTP请求头来告诉浏览器,让运行在当前源上的Web应用被准许接受来自不同源服务器上的指定的资源。当一个应用向不同源的地址发送一个请求时,会发起一个跨域 HTTP 请求。
浏览器将CORS分为简单请求和非简单请求:
简单请求:
简单请求不会触发CORS预检请求。若该请求满足以下两个条件,就可以看做是简单请求:
- 1.请求方法是
HEAD、POST、GET中的一种。 - 2.HTTP的头信息不超出以下几种字段:
Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
对于简单请求,浏览器会直接发出CORS请求,它会在请求的头信息中增加一个Orign字段,该字段用来说明本次请求来自哪个源。服务器会根据这个值来决定是否同意这次请求,如果Origin在指定的范围内,服务器返回的响应会多出一下信息头:
Access-Control-Allow-Origin: * // 也可以指定 origin,如 https://www.zaoren.cool
Access-Control-Allow-Credentials: true // 表示是否允许发送Cookie
Access-Control-Expose-Headers: FooBar // 指定返回其他字段的值
Content-Type: text/html; charset=utf-8 // 表示文档类型
非简单请求:
非简单请求是对服务器有特殊要求的请求,比如请求方法为DELETE或者PUT等。非简单请求的CORS请求会在正式通信之前进行一次HTTP查询请求,称为预检请求。
预检请求使用的请求方法是OPTIONS,表示这个请求是来询问的。他的头信息中的关键字是Origin,表示请求来自哪个源。除此之外,头信息中还包括两个字段:
- Access-Control-Request-Method:该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法。
- Access-Control-Request-Headers: 该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段。
服务器在收到浏览器的预检请求之后,会根据头信息的三个字段来进行判断,如果返回的头信息在中有Access-Control-Allow-Origin这个字段就是允许跨域请求,如果没有,就是不同意这个预检请求,就会报错。
Access-Control-Allow-Origin: http://api.bob.com // 允许跨域的源地址
Access-Control-Allow-Methods: GET, POST, PUT // 服务器支持的所有跨域请求的方法
Access-Control-Allow-Headers: X-Custom-Header // 服务器支持的所有头信息字段
Access-Control-Allow-Credentials: true // 表示是否允许发送Cookie
Access-Control-Max-Age: 1728000 // 用来指定本次预检请求的有效期,单位为秒
注意:
OPTIONS请求过多会损耗页面加载的性能,降低用户体验。所以尽量要减少OPTIONS请求次数,可以后端在请求的返回头部添加:Access-Control-Max-Age:number。它表示预检请求的返回结果可以被缓存多久,单位是秒。
(3)nginx代理
由于跨域请求是浏览器行为,服务端调用HTTP请求并不会跨域。基于这个思路,我们通过Nginx配置一个代理服务器,反向代理访问目标服务器的资源(目前用的比较多)。
(4)nodejs 中间件代理跨域
这个在平时本地开发环境用的比较多,webpack中有一个插件叫webpack-dev-server,提供了这种能力。本质是在本地启动了一个 express 服务器做代理。
devServer: {
proxy: {
'/zren-back/': {
changeOrigin: true,
// 目标地址
target: 'http://***.***.**.***:7001',
// 重写路径
pathRewrite: {
'^/zren-back/': '/zren-back/',
},
},
},
}
还有一些 postMessage 以及 iframe 的跨域问题,待补充。
浏览器安全与隐私
XSS攻击
- 概念
(Cross-Site Scripting)跨站脚本攻击简称XSS,是一种代码注入攻击。攻击者在网站注入恶意脚本,使之在用户的浏览器上运行,从而盗取用户的信息如 cookie 等。
XSS 攻击的本质是因为网站没有对恶意代码进行过滤,与正常的代码混合在一起了,浏览器没有办法分辨哪些脚本是可信的,从而导致了恶意代码的执行。
-
攻击场景
(1)存储型:存储型指的是恶意脚本会存储在目标服务器上,当浏览器请求数据时,脚本从服务器传回并执行。
案例1:假设我在一个论坛底下的评论区中评论了一段恶意js代码,大体内容是获取用户的cookie,并发送到我自己的服务器。那么我就可以冒充该用户肆意调⽤⽬标⽹站接⼝执⾏攻击者指定的操作。
(2)反射型:反射型指的是攻击者构造出特殊的URL,其中包含恶意代码。用户打开带有恶意代码的URL时,网站服务端将恶意代码从URL中取出,拼接在 HTML 中返回给浏览器。浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
案例2:假设我发现一个网站会取url上的name参数,并且将name参数直接返回。我给我的朋友小明在用户私信中发送了一个
http://127.0.0.1:5000/hello4?name=<script>alert('狗子 你完蛋了🐶!')</script>链接,让他打开。服务端如果直接将name返回,我就成功在他的页面上注入了恶意脚本。 -
如何防御 XSS 攻击
(1)永远不要相信用户的输入,记得要在服务端要做转义。
(2)利用httpOnly,不能在客户端直接使用脚本获取到cookie。
(3)使用 CSP ,CSP 的本质是建立一个白名单,告诉浏览器哪些外部资源可以加载和执行,从而防止恶意代码的注入攻击。
1.CSP 指的是内容安全策略,它的本质是建立一个白名单,告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截由浏览器自己来实现。
2.通常有两种方式来开启 CSP,一种是设置 HTTP 首部中的 Content-Security-Policy,一种是设置 meta 标签的方式
CSRF攻击
- 概念
CSRF 攻击指的是跨站请求伪造攻击,指的是攻击者诱导用户点击链接,利用目前已有的登录状态发起跨站请求。CSRF 攻击的本质是利用 cookie 会在同源请求中携带发送给服务器的特点,以此来达到冒充真实用户的目的。
- 攻击场景
网上有一个流传甚广的例子:受害者Bob在银行卡中有一笔存款,通过对银行的网站http://bank.example/withdraw?account=10000&amount=2000&for=bob2发送请求,可以向bob2转账2000元。通常情况下,网站会验证改请求是否来自合法的session,并且该session的用户Bob成功登陆。
攻击者Mallory在该银行也有一个账户,他发现了这个漏洞。于是他自己做了个网站,在这个网站中添加了<src="http://bank.example/withdraw?account=10000&amount=2000&for=Mallory">的代码,并且通过广告诱使 Bob 来访问他的网站。当 Bob 访问该网站时,就会向服务器发送改请求,大多数情况下该请求会失败。但是,如果Bob 当时恰巧登陆网站不久,身份信息还没有过期,这时悲剧就发生了,请求就会生效。受害人Bob就向攻击者Mallory转了2000块钱。
-
如何防御 CSRF 攻击
(1)验证 HTTP Refer 字段:服务器根据 http 请求头中 origin 或者 referer 信息来判断请求是否为允许访问的站点,从而对请求进行过滤。当 origin 或者 referer 信息都不存在的时候,直接阻止请求。
这种方式的缺点是有些情况下 referer 可以被伪造。
(2)使用 CSRF Token 验证:服务器向用户返回一个随机数 token,当网站再次发起请求时,在请求参数中加入服务端返回的 token,然后服务端对 token 进行验证。
这种方法虽然解决了使用 cookie 单一验证方式时,可能会被冒用的问题。但是这种方法存在一个缺点:我们需要给网站中的所有请求都添加上这个 token,操作比较繁琐。还有一个问题是一般不会只有一台网站服务器,如果请求经过负载平衡转移到了其他的服务器,但是这个服务器的 session 中没有保留这个 token 的话,就没有办法验证了。
(3)对 Cookie 进行双重验证:服务器在用户访问网站页面时,向请求域名注入一个Cookie,内容为随机字符串。然后当用户再次向服务器发送请求的时候,从 Cookie 中取出这个字符串,添加到请求参数中,然后服务器通过对 cookie 中的数据和参数中的数据进行比较,来进行验证。
使用这种方式是利用了攻击者只能利用 Cookie,但是不能访问获取 Cookie 的特点。并且这种方法比 CSRF Token 的方法更加方便,并且不涉及到分布式访问的问题。
(4)在设置 Cookie 属性的时候设置
SameSite。Samesite一共有两种模式,一种是严格模式(Strict)。在严格模式下 Cookie 在任何情况下都不可能作为第三方 Cookie 使用,在宽松模式(Lax)下,Cookie 可以被请求类型是 GET 请求,且会发生页面跳转的请求所使用。SameSite以前None是默认值,但最近的浏览器版本将Lax作为默认值,以便对某些类型的跨站请求伪造 (CSRF) 攻击具有相当强的防御能力。 --- MDN
DDOS攻击
- 概念
DDOS攻击是最常见的攻击方式,全称为分布式拒绝服务攻击,又称为“洪水式攻击”。DOS攻击就是利用合理的服务请求来占用过多的服务资源,从而使合法用户无法得到服务的响应。最前面的那个 D 是 distributed (分布式),表示攻击不是来自一个地方,而是来自四面八方。
-
DDOS的攻击场景
(1)SYN Flood:SYN Flood是曾经是最经典的攻击方式之一,要明白他的攻占原理,可以从TCP开始建立连接的过程开始说起。TCP协议三次握手中,第三步中如果服务器没有收到客户端的ACK报文,服务端一般会进行重试,也就是再次发送 SYN +ACK 报文给客户端,并且一 直处于 SYN_RECV 状态,将客户端加入等待列表。重发一 般会进行 3-5次,大约每隔30秒左右会轮询一 遍等待队列,重试所有客户端;同时也会预分配 一 部分资源给即将建立的TCP 连接。然而我们的服务器资源是有限的,当等待列表超过极限后就不再接收新的SYN报文,就是会拒绝建立新的TCP连接。
(2)CC攻击:CC ( Challenge Collapsar)攻击属于DDOS的一 种,是基于应用层 HTTP 协议发起的 DDOS攻击,也被称为HTTP Flood。攻击者通过控制大量的“鸡肉”或者利用从互联网上搜寻的大量知名的HTTP代理,模拟正常用户给网站发起请求直到该网站拒绝服务为止。大部分网站会通过CDN以及分布式缓存来加快服务端响应,提升网站的吞吐量,而这些精心构造的HTTP请求往往有意避开这些缓存,需要进行多次DB查询操作或者一 次请求返回大量的数据,加速系统资源消耗,从而拖垮后端的业务处理系统,甚至连相关存储与日志收集系统也无法幸免。
-
如何防御DDOS攻击
(1)限制单 IP 请求频率。
(2)网络架构上做好优化,采用负载均衡分流。
(3)防火墙等安全设备上设置禁止 ICMP 包等。
(4)通过 DDoS 硬件防火墙的数据包规则过滤、数据流指纹检测过滤、及数据包内容定制过滤等技术对异常流量进行清洗过滤。
(5)编写代码时,尽量实现优化并合理使用缓存技术。尽量让网站静态化,减少不必要的动态查询。
从输入URL到页面展示,发生了什么?
解析URL
首先判断该URL是否是一个URL结构的字符串,如果是非URL结构的字符串,则会用浏览器默认的搜索引擎搜索该字符串。下面是一个URL的组成。
缓存判断
浏览器中的缓存分为两种:强缓存、协商缓存。
- 强缓存:直接浏览器缓存中拿到之前缓存下来的数据,不需要再次向服务端发送请求,通过
Expires(Http1.0)或Cache-Control(http1.1)字段来判断 - 协商缓存:协商缓存是当强缓存不生效的时,浏览器携带缓存标识想服务端发送请求,由服务端根据
Etag、Last-modified标识决定是否使用缓存(分别对应请求头中的If-Nonoe-Match和If-Modified-Since)。
DNS域名解析
我们目前只知道需要访问的域名,但要想在因特网中进行传输数据需要知道对方的IP地址。DNS域名解析系统可以将域名解析为IP地址。在解析IP地址的过程中存在两种查询方式:递归查询和迭代查询
- 递归查询:我们的浏览器、操作系统、路由器都会缓存一些URL对应的IP地址,统称为DNS高速缓存。这是为了加快DNS解析速度,使得不必每次都到根域名服务器中去查询。
- 迭代查询:迭代查询的方式就是,非本地的DNS服务器并不会自己向其他服务器进行查询,而是把能够解析该域名的服务器IP地址返回给客户端,客户端会不断的向这些服务器进行查询,直到查询到了位置。

由于递归查询对于被查询的服务器负担太大,通常采用以下模式:从请求主机到本地域名服务器的查询是递归查询,其余的查询是迭代查询
TCP三次握手
- 第一次握手: 客户端发送SYN报文,以及Seq=x的数据包到服务器,服务器收到SYN报文后知道,客户端要求建立联机。
- 第二次握手:服务器收到请求联机信息后,向客户端发送确认连接报文SYN=1,ACK=1,ack=x+1,并且发送Seq=y,告诉客户端,我已经准备好了,你发送数据吧。
- 第三次握手:客户端收到请求先检查ack是否正确,以及SYN是否为1。若正确,则向服务端发送ACK=1,ack=y+1,seq=x+1。告诉服务器,我马上就要发送数据了,准备好接收。
HTTPS握手
- 第一次握手: 客户端向服务端发送使用的协议的版本号、一个随机数和可以使用的加密方法。
- 第二次握手:服务器端收到后,确认加密的方法,也向客户端发送一个随机数和自己的数字证书。
- 第三次握手:客户端收到后,首先检查数字证书是否有效,如果有效,则会生成一个随机数,并使用证书中的公钥对随机数加密,然后发送给服务端,并且还会提供一个前面所有内容的hash值供服务端校验。
- 第四次握手:服务器接收后,使用自己的秘钥对数据解密,同时向客户端发送一个前面所有内容的hash值供客户端校验。
服务器处理请求并返回结果
服务器上通常会运行着响应请求的web server,常见web server的有Nginx,Apache,IIS。HTTP请求的资源一般可分为两种: 静态资源、动态资源。
静态资源:一般根据url的地址去服务器中寻找。
动态资源:就需要web server把不同请求委托给服务器上处理请求的应用程序进行处理,应用程序根据业务逻辑返回响应的数据或其他资源。
浏览器渲染页面
参照文章上述内容
TCP4次挥手
-
第一次挥手:客户端给服务端发送
FIN=1,报文中会指定一个序列号。此时客户端处于FIN_WAIT1状态 -
第二次挥手: 服务端收到
FIN=1报文后,会发送一个ACK=1,且把客户端的ISN+1作为ack,表明已经收到了客户端的报文了,此时服务端处于CLOSE_WAIT状态。 -
第三次挥手:如果服务端也想断连接了(服务端如果此时还有没发完的数据会继续发送,完毕后会向客户端发送连接释放请求),和客户端第一次挥手一样,发个
FIN=1的报文,且指定一个序列号。此时服务端处于LSAT_ACK(最后确认)状态,等待客户端的确认。 -
第四次挥手:客户端收到
FIN之后,会发送ACK报文,且把服务端的服务端的序列号值+1作为自己的ack,此时客户端处于TIME_WAIT状态。需要过一阵子以(2MSL)确保服务端收到自己的ACK报文后才会进入ClOSE状态,服务端收到ACK报文后,就处于CLOSE状态,关闭连接。