浏览器

154 阅读1小时+

image.png

浏览器组成

1. 对浏览器的理解

浏览器的主要功能是将用户选择的 web 资源呈现出来,它需要从服务器请求资源,并将其显示在浏览器窗口中,资源的格式通常是 HTML,也包括 PDF、image 及其他格式。用户用 URI(Uniform Resource Identifier 统一资源标识符)来指定所请求资源的位置。

HTML 和 CSS 规范中规定了浏览器解释 html 文档的方式,由 W3C 组织对这些规范进行维护,W3C 是负责制定 web 标准的组织。但是浏览器厂商纷纷开发自己的扩展,对规范的遵循并不完善,这为 web 开发者带来了严重的兼容性问题。

浏览器可以分为两部分,shell 和 内核。其中 shell 的种类相对比较多,内核则比较少。也有一些浏览器并不区分外壳和内核。从 Mozilla 将 Gecko 独立出来后,才有了外壳和内核的明确划分。

  • shell 是指浏览器的外壳:例如菜单,工具栏等。主要是提供给用户界面操作,参数设置等等。它是调用内核来实现各种功能的。
  • 内核是浏览器的核心。内核是基于标记语言显示内容的程序或模块。

2. 对浏览器内核的理解

浏览器内核主要分成两部分:

  • 渲染引擎的职责就是渲染,即在浏览器窗口中显示所请求的内容。默认情况下,渲染引擎可以显示 html、xml 文档及图片,它也可以借助插件显示其他类型数据,例如使用 PDF 阅读器插件,可以显示 PDF 格式。
  • JS 引擎:解析和执行 javascript 来实现网页的动态效果。

最开始渲染引擎和 JS 引擎并没有区分的很明确,后来 JS 引擎越来越独立,内核就倾向于只指渲染引擎。

3. 常见的浏览器内核比较

  • Trident: 这种浏览器内核是 IE 浏览器用的内核,因为在早期 IE 占有大量的市场份额,所以这种内核比较流行,以前有很多网页也是根据这个内核的标准来编写的,但是实际上这个内核对真正的网页标准支持不是很好。但是由于 IE 的高市场占有率,微软也很长时间没有更新 Trident 内核,就导致了 Trident 内核和 W3C 标准脱节。还有就是 Trident 内核的大量 Bug 等安全问题没有得到解决,加上一些专家学者公开自己认为 IE 浏览器不安全的观点,使很多用户开始转向其他浏览器。
  • Gecko: 这是 Firefox 和 Flock 所采用的内核,这个内核的优点就是功能强大、丰富,可以支持很多复杂网页效果和浏览器扩展接口,但是代价是也显而易见就是要消耗很多的资源,比如内存。
  • Presto: Opera 曾经采用的就是 Presto 内核,Presto 内核被称为公认的浏览网页速度最快的内核,这得益于它在开发时的天生优势,在处理 JS 脚本等脚本语言时,会比其他的内核快3倍左右,缺点就是为了达到很快的速度而丢掉了一部分网页兼容性。
  • Webkit: Webkit 是 Safari 采用的内核,它的优点就是网页浏览速度较快,虽然不及 Presto 但是也胜于 Gecko 和 Trident,缺点是对于网页代码的容错性不高,也就是说对网页代码的兼容性较低,会使一些编写不标准的网页无法正确显示。WebKit 前身是 KDE 小组的 KHTML 引擎,可以说 WebKit 是 KHTML 的一个开源的分支。
  • Blink: 谷歌在 Chromium Blog 上发表博客,称将与苹果的开源浏览器核心 Webkit 分道扬镳,在 Chromium 项目中研发 Blink 渲染引擎(即浏览器核心),内置于 Chrome 浏览器之中。其实 Blink 引擎就是 Webkit 的一个分支,就像 webkit 是KHTML 的分支一样。Blink 引擎现在是谷歌公司与 Opera Software 共同研发,上面提到过的,Opera 弃用了自己的 Presto 内核,加入 Google 阵营,跟随谷歌一起研发 Blink。

4. 常见浏览器所用内核

(1) IE 浏览器内核:Trident 内核,也是俗称的 IE 内核;

(2) Chrome 浏览器内核:统称为 Chromium 内核或 Chrome 内核,以前是 Webkit 内核,现在是 Blink内核;

(3) Firefox 浏览器内核:Gecko 内核,俗称 Firefox 内核;

(4) Safari 浏览器内核:Webkit 内核;

(5) Opera 浏览器内核:最初是自己的 Presto 内核,后来加入谷歌大军,从 Webkit 又到了 Blink 内核;

(6) 360浏览器、猎豹浏览器内核:IE + Chrome 双内核;

(7) 搜狗、遨游、QQ 浏览器内核:Trident(兼容模式)+ Webkit(高速模式);

(8) 百度浏览器、世界之窗内核:IE 内核;

(9) 2345浏览器内核:好像以前是 IE 内核,现在也是 IE + Chrome 双内核了;

(10)UC 浏览器内核:这个众口不一,UC 说是他们自己研发的 U3 内核,但好像还是基于 Webkit 和 Trident ,还有说是基于火狐内核。

5. 浏览器的主要组成部分

  • ⽤户界⾯ 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗⼝显示的您请求的⻚⾯外,其他显示的各个部分都属于⽤户界⾯。
  • 浏览器引擎 在⽤户界⾯和呈现引擎之间传送指令。
  • 呈现引擎 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
  • ⽹络 ⽤于⽹络调⽤,⽐如 HTTP 请求。其接⼝与平台⽆关,并为所有平台提供底层实现。
  • ⽤户界⾯后端 ⽤于绘制基本的窗⼝⼩部件,⽐如组合框和窗⼝。其公开了与平台⽆关的通⽤接⼝,⽽在底层使⽤操作系统的⽤户界⾯⽅法。
  • JavaScript 解释器。⽤于解析和执⾏ JavaScript 代码。
  • 数据存储 这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“⽹络数据库”,这是⼀个完整(但是轻便)的浏览器内数据库。

值得注意的是,和⼤多数浏览器不同,Chrome 浏览器的每个标签⻚都分别对应⼀个呈现引擎实例。每个标签⻚都是⼀个独⽴的进程。

过去的浏览器采用单进程架构,在打开一个浏览器应用时,系统就分配了一块供数据使用的内存以及多个线程,线程之间允许通信。比如网络线程、插件线程、渲染线程、GPU 线程,所有标签页都共用一个进程中的所有线程。这样的架构设计意味着一旦其中一个线程崩溃,那么整个进程就会跟着崩溃。这就是为什么当你玩 4399 卡顿时,整个浏览器就疯狂转圈圈的原因,于是不得不重新启动浏览器。

而现代的浏览器大多采用多进程架构,因此在打开一个浏览器时,浏览器进程、网络进程、GPU 进程和插件进程都准备好了,在打开新的标签页时,浏览器会为该标签页创建一个新的渲染进程,它将负责处理该标签页的内容渲染和交互。这些彼此独立的进程让浏览器更加稳定。为了进一步保证数据安全,每一个页面的渲染进程和插件进程会被放入沙箱内。

渲染进程中的组件和功能则与我们息息相关。

组件/功能概述备注
渲染引擎负责解析 HTML、CSS 和 JavaScript,并将其转换为可视化的网页内容排版引擎:WebKit、Blink
布局引擎负责计算和确定网页元素的位置和大小,以便正确显示在浏览器窗口中CSS 盒模型以及其他布局规则来处理网页布局
JavaScript 引擎解析和执行网页中的 JavaScript 代码Chrome 的 V8 引擎。将 JavaScript 代码转换为可执行的指令,并与其他组件进行交互,实现网页的动态功能和交互性
GPU 加速利用计算机的图形处理单元(GPU)来加速图形渲染并行的 GPU 渲染线程
网络栈负责处理网络请求和响应与浏览器的网络进程进行通信,获取网页所需的资源,如 HTML、CSS、JavaScript 文件、图像和视频等
事件处理负责处理用户的交互事件,如鼠标点击、键盘输入和滚动等将这些事件传递给适当的组件和 JavaScript 代码,以触发相应的行为和功能

浏览器渲染原理

1. 浏览器的渲染过程

浏览器渲染主要有以下步骤:

  • 构建DOM树:浏览器从上到下解析 HTML 文档生成DOM节点树;

  • 构建CSSOM树:浏览器解析遇到样式时,会进行异步下载,下载完成后构建 CSSOM树;

  • 浏览器解析过程中遇到 图片时,会进行异步下载;当遇到不带 asyncdeferscript 时,会阻止解析HTML并进行下载和执行;

  • 并且CSSDOM渲染,JSDOM解析之间是有阻塞关系的;

  • 构建渲染树:根据DOM节点树和CSSOM树构建渲染树(Render);

  • 布局(Layout):根据渲染树将DOM节点树每一个节点布局在屏幕上的正确位置;

  • 绘制(Paint):绘制所有节点,为每一个节点适用对应的样式,绘制到屏幕上;

    • 绘制的过程中还有很多细节,包括说:

    • 构建图层树:需要对布局树进行分层,生成图层树(比如说Z轴排序)

    • 生成绘制列表:将图层的绘制拆分为很多的绘制指令,并按顺序组成绘制列表,并提交到合成线程中;

    • 光栅化(栅格化)生成位图:合成线程图层划分成图块,并在光栅化线程池中将图块转换成位图

      • 同时因为用户只能看到视口的这一部分,所以合成线程就会按照视口附近的图块来优先生成位图
    • 显示:一旦所有的图块都被光栅化,合成线程就会提交绘图指令给浏览器进程;浏览器进程生成页面并显示到屏幕上;

大致过程如图所示:

注意: 这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。

2. 浏览器渲染优化

(1)针对JavaScript: JavaScript既会阻塞HTML的解析,也会阻塞CSS的解析。因此我们可以对JavaScript的加载方式进行改变,来进行优化:

(1)尽量将JavaScript文件放在body的最后

(2) body中间尽量不要写<script>标签

(3)<script>标签的引入资源方式有三种,有一种就是我们常用的直接引入,还有两种就是使用 async 属性和 defer 属性来异步引入,两者都是去异步加载外部的JS文件,不会阻塞DOM的解析(尽量使用异步加载)。三者的区别如下:

  • script 立即停止页面渲染去加载资源文件,当资源加载完毕后立即执行js代码,js代码执行完毕后继续渲染页面;
  • async 是在下载完成之后,立即异步加载,加载好后立即执行,多个带async属性的标签,不能保证加载的顺序;
  • defer 是在下载完成之后,立即异步加载。加载好后,如果 DOM 树还没构建好,则先等 DOM 树解析好再执行;如果DOM树已经准备好,则立即执行。多个带defer属性的标签,按照顺序执行。

(2)针对CSS:使用CSS有三种方式:使用link、@import、内联样式,其中link和@import都是导入外部样式。它们之间的区别:

  • link:浏览器会派发一个新等线程(HTTP线程)去加载资源文件,与此同时GUI渲染线程会继续向下渲染代码
  • @import:GUI渲染线程会暂时停止渲染,去服务器加载资源文件,资源文件没有返回之前不会继续渲染(阻碍浏览器渲染)
  • style:GUI直接渲染

外部样式如果长时间没有加载完毕,浏览器为了用户体验,会使用浏览器会默认样式,确保首次渲染的速度。所以CSS一般写在headr中,让浏览器尽快发送请求去获取css样式。

所以,在开发过程中,导入外部样式使用link,而不用@import。如果css少,尽可能采用内嵌样式,直接写在style标签中。

(3)针对DOM树、CSSOM树: 可以通过以下几种方式来减少渲染的时间:

  • HTML文件的代码层级尽量不要太深
  • 使用语义化的标签,来避免不标准语义化的特殊处理
  • 减少CSSD代码的层级,因为选择器是从左向右进行解析的

(4)减少回流与重绘:

  • 操作DOM时,尽量在低层级的DOM节点进行操作
  • 不要使用table布局, 一个小的改动可能会使整个table进行重新布局
  • 使用CSS的表达式
  • 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。
  • 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
  • 避免频繁操作DOM,可以创建一个文档片段documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中
  • 将元素先设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。
  • 将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制

浏览器针对页面的回流与重绘,进行了自身的优化——渲染队列

浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。

将多个读操作(或者写操作)放在一起,就会等所有的读操作进入队列之后执行,这样,原本应该是触发多次回流,变成了只触发一次回流。

3. 渲染过程中遇到 JS 文件如何处理?

JavaScript 的加载、解析与执行会阻塞文档的解析,也就是说,在构建 DOM 时,HTML 解析器若遇到了 JavaScript,那么它会暂停文档的解析,将控制权移交给 JavaScript 引擎,等 JavaScript 引擎运行完毕,浏览器再从中断的地方恢复继续解析文档。也就是说,如果想要首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是都建议将 script 标签放在 body 标签底部的原因。当然在当下,并不是说 script 标签必须放在底部,因为你可以给 script 标签添加 defer 或者 async 属性。

4. 什么是文档的预解析?

Webkit 和 Firefox 都做了这个优化,当执行 JavaScript 脚本时,另一个线程解析剩下的文档,并加载后面需要通过网络加载的资源。这种方式可以使资源并行加载从而使整体速度更快。需要注意的是,预解析并不改变 DOM 树,它将这个工作留给主解析过程,自己只解析外部资源的引用,比如外部脚本、样式表及图片。

5. CSS 如何阻塞文档解析?

理论上,既然样式表不改变 DOM 树,也就没有必要停下文档的解析等待它们。然而,存在一个问题,JavaScript 脚本执行时可能在文档的解析过程中请求样式信息,如果样式还没有加载和解析,脚本将得到错误的值,显然这将会导致很多问题。所以如果浏览器尚未完成 CSSOM 的下载和构建,而我们却想在此时运行脚本,那么浏览器将延迟 JavaScript 脚本执行和文档的解析,直至其完成 CSSOM 的下载和构建。也就是说,在这种情况下,浏览器会先下载和构建 CSSOM,然后再执行 JavaScript,最后再继续文档的解析。

6. 如何优化关键渲染路径?

为尽快完成首次渲染,我们需要最大限度减小以下三种可变因素:

(1)关键资源的数量。

(2)关键路径长度。

(3)关键字节的数量。

关键资源是可能阻止网页首次渲染的资源。这些资源越少,浏览器的工作量就越小,对 CPU 以及其他资源的占用也就越少。同样,关键路径长度受所有关键资源与其字节大小之间依赖关系图的影响:某些资源只能在上一资源处理完毕之后才能开始下载,并且资源越大,下载所需的往返次数就越多。最后,浏览器需要下载的关键字节越少,处理内容并让其出现在屏幕上的速度就越快。要减少字节数,我们可以减少资源数(将它们删除或设为非关键资源),此外还要压缩和优化各项资源,确保最大限度减小传送大小。

优化关键渲染路径的常规步骤如下:

(1)对关键路径进行分析和特性描述:资源数、字节数、长度。

(2)最大限度减少关键资源的数量:删除它们,延迟它们的下载,将它们标记为异步等。

(3)优化关键字节数以缩短下载时间(往返次数)。

(4)优化其余关键资源的加载顺序:您需要尽早下载所有关键资产,以缩短关键路径长度

7. 什么情况会阻塞渲染?

首先渲染的前提是生成渲染树,所以 HTML 和 CSS 肯定会阻塞渲染。如果你想渲染的越快,你越应该降低一开始需要渲染的文件大小,并且扁平层级,优化选择器。然后当浏览器在解析到 script 标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是都建议将 script 标签放在 body 标签底部的原因。

当然在当下,并不是说 script 标签必须放在底部,因为你可以给 script 标签添加 defer 或者 async 属性。当 script 标签加上 defer 属性以后,表示该 JS 文件会并行下载,但是会放到 HTML 解析完成后顺序执行,所以对于这种情况你可以把 script 标签放在任意位置。对于没有任何依赖的 JS 文件可以加上 async 属性,表示 JS 文件下载和解析不会阻塞渲染。

8.浏览器资源解析机制

整体流程
  • 浏览器开始解析HTML,此时document.readystateloading
  • 解析中遇到不带asyncdeferscript脚本时,需要等待 script脚本 下载完成并执行后,才会继续解析 HTML
  • 当文档完成解析,document.readyState变成 interactive,触发 DOMContentLoaded事件
  • 此时文档完全解析完成,浏览器可能还在等待如图片等内容加载,等这些内容完成载入并且所有异步脚本完成载入和执行,document.readyState变为 completewindow 触发 load 事件
浏览器解析 不同资源 时的行为
  • 浏览器解析遇到 CSS样式资源 时,CSS会异步下载,不会阻塞浏览器构建DOM树,但是会阻塞渲染,在构建渲染树时,会等css下载解析完毕后才进行(防止css规则不断变化)
  • 浏览器解析遇到 JS脚本资源 时,需要等待JS脚本下载完成并执行后才会继续解析HTML;但是当脚本加上deferasync时又不一样,defer是延迟执行,async是异步执行;
  • CSS加载会阻塞后面的的JS语句的执行,因为HTML5标准中有一项规定,浏览器在执行Script脚本前,必须保证当前的的外联CSS已经解析完成,因为JS可能会去获取或者变更DOMCSS样式,如果此时外联CSS还没解析好,获取到的结果就是不准确的;
  • 解析遇到 Img图片 时,直接异步下载,不会阻塞解析;下载完毕后用图片替换原有src的地方;
  • 总结:

    • CSS 会阻塞浏览器渲染;
    • JS 会阻塞浏览器解析;
    • CSS 会阻塞后面的JS执行;
    • IMG 不会阻塞;
为什么 CSS 要放在头部
  • 外链css无论放在html的任何位置都不会影响html的解析,但是会影响html的渲染;
  • 如果将css放在尾部,html的解析不受影响,浏览器会在 css 样式加载解析完后,重新计算样式绘制,造成回流重绘页面闪动等现象;
  • 而如果将css放在头部,css的下载解析时可以和html的解析并行,并且会等待css下载解析完毕后开始绘制;
为什么 Script 要放在尾部
  • 因为当浏览器解析到 script 时,就会立即下载执行,中断 html 的解析过程,因为 js 可能会修改 dom 元素;如果外部脚本加载时间长,就会造成网页长时间未响应;
async 和 defer 的解析过程
  • 浏览器解析到带 async 属性的 script 标签时,不会中断 html 解析,而是并行下载脚本;当脚本下载完成后,中断解析并执行脚本;
  • 浏览器解析到带 defer 属性的 script 标签时,不会中断 html 解析,而是并行下载脚本;当浏览器解析完HTML时、DOMContentLoaded 事件即将被触发时,此时再执行下载完成的脚本;
async 和 defer 的区别
  • async 是html4.0, defer 是html5.0
  • asyncdefer 都仅对外部脚本有效
  • async 标志的脚本文件一旦加载完成就立即执行;而 defer 标志的脚本文件会在 HTML解析完成且DOM构建完毕后再执行;(也就是说defer是延迟执行,async是异步执行)
  • 如果有多个js脚本async标记的脚本哪个先下载结束,就先执行那个脚本。而defer标记则会按照js脚本书写顺序执行。
  • 如果同时使用asyncdefer属性,defer不起作用,浏览器行为由async属性决定。
  • DOMContentLoaded 事件会等待 defer 的脚本执行完后才触发;
DOM树 和 CSSOM树 的构建顺序关系
  • 实际上,构建 DOM树 和 构建 CSSOM树 是并行的;这也正解释了为什么CSS加载不会阻塞DOM解析,但是因为渲染树需要依赖DOM树CSSOM树,所以会阻塞DOM渲染;
CSS 解析规则
  • 浏览器解析 CSS 选择器的方式是从上到下、从右到左,因为从右往左只需要最右边的一个不匹配,就可以直接舍弃,避免了许多无效匹配。
  • 一句话总结: 浏览器的这种查找规则是为了 尽早过滤掉一些无关的样式规则和元素。
Load 和 DOMContentLoaded 区别

推荐阅读:DOMContentLoaded event MDN

  • Load 事件触发代表页面中的 DOMCSSJS,图片已经全部加载完毕。
  • DOMContentLoaded 事件触发代表初始的 HTML 被完全加载和解析,不需要等待 CSS带 async 的 JS,图片加载;此时所有的DOM都构建完毕;
  • 在应用场景下:

    • 如果我们想在回调中操作dom:添加、删除某些元素时,使用domcontentloaded
    • 如果想知道图片宽高、iframe内容等资源信息,需要在load事件里处理;

9.资源预加载 & 预连接

prefetch、preload

prefetchpreload都是告知浏览器提前加载文件(图片视频jscss等),但执行上是有区别的。

  • prefetch :其利用浏览器空闲时间来下载用户在不久的将来可能访问的资源(比如下一个页面)。<link href="/js/xx.js" rel="prefetch">;加载完成后,浏览器在使用资源时自动从prefetch cache读取该资源;
  • preload : 可以指明哪些资源是在页面加载完成后就需要的,这一机制使得资源可以更早的得到加载并可用,且更不易阻塞页面的初步渲染,进而提升性能。(MDN就这么写的) <link href="/js/xxx.js" rel="preload" as="script"> 需要 as 指定资源类型,比如font字体文件、style样式表;
  • preloadprefetch仅仅是加载资源,并不会执行;
  • preloadprefetch均能设置、命中缓存;
preconnect
  • 在我们下载资源时,要先建立链接,然后才能下载资源;建立链接时会涉及 DNS 寻址、TLS 握手、TCP 握手、重定向等步骤;

  • 使用了这个参数后,浏览器就会提前做好连接工作,但是只保留10秒,之后就会被关闭;

  • 不必要的预连接会延迟其他重要资源,因此要限制 preconnect 预连接的数量;

  • 使用场景包括

    • 资源后缀是动态的,还不确定资源具体的地址时
    • 页面上有媒体,但没那么快播放,又希望点击时尽快播放;
dns-prefetch 和 preconnect 的区别

dns-prefetch可以预先解析DNS,它只对跨域的DNS查找有效,

  • dns-prefetchpreconnect的作用类似,都可以用来预链接
  • 区别在于preconnect的浏览器兼容性稍微比dns-prefetch差;
  • dns-prefetch只能预先进行dns查询这一步;
  • 所以可以让dns-prefetch作为不兼容时的后备选择,两个都配置;也可以是只设置关键的preconnect,其余用dns-prefetch

10.回流(重排)、重绘

概念
  • 重绘:当渲染树中的元素外观(如:颜色背景visibility)发生改变,不影响布局时,产生重绘
  • 回流:当渲染树中的元素的布局(如:尺寸位置)发生改变时,重新生成布局,重新排列元素。
  • 回流必将引起重绘,而重绘不一定会引起回流
回流(重排)的触发条件
  • JS获取Layout属性值(如:offsetLeftscrollTopgetComputedStyle等)
  • 页面初始渲染,这是开销最大的一次重排(从没有DOM元素开始渲染)
  • 添加/删除可见的DOM元素
  • 改变元素位置
  • 改变元素尺寸,比如边距、填充、边框、宽度和高度等
  • 改变元素内容,比如文字数量等
重绘的触发条件
  • color
  • visibility
  • background
  • box-shadow
  • 等等......
如何避免触发回流和重绘
  • 避免频繁使用 style,而是采用修改class的方式。
  • 将动画效果应用到position属性为absolutefixed的元素上。
  • 使用 display: noneDOM离线处理,减少回流重绘次数。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘
  • 对于 resizescroll等进行防抖/节流处理。
  • 利用 CSS3transformopacityfilter这些属性可以实现合成的效果,也就是GPU加速。
硬件加速、渲染合成层
  • 硬件加速是指通过创建独立的复合图层,让GPU来渲染这个图层,从而提高性能,
  • 更改一个既不要布局也不要绘制的属性,渲染引擎将跳过布局绘制,只执行后续的合成操作,我们把这个过程叫做合成
  • 一般触发硬件加速的CSS属性有transformopacityfilter

浏览器事件机制

1. 事件是什么?事件模型?

事件是用户操作网页时发生的交互动作,比如 click/move, 事件除了用户触发的动作外,还可以是文档加载,窗口滚动和大小调整。事件被封装成一个 event 对象,包含了该事件发生时的所有相关信息( event 的属性)以及可以对事件进行的操作( event 的方法)。

事件是用户操作网页时发生的交互动作或者网页本身的一些操作,现代浏览器一共有三种事件模型:

  • DOM0 级事件模型,这种模型不会传播,所以没有事件流的概念,但是现在有的浏览器支持以冒泡的方式实现,它可以在网页中直接定义监听函数,也可以通过 js 属性来指定监听函数。所有浏览器都兼容这种方式。直接在dom对象上注册事件名称,就是DOM0写法。
  • IE 事件模型,在该事件模型中,一次事件共有两个过程,事件处理阶段和事件冒泡阶段。事件处理阶段会首先执行目标元素绑定的监听事件。然后是事件冒泡阶段,冒泡指的是事件从目标元素冒泡到 document,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。这种模型通过attachEvent 来添加监听函数,可以添加多个监听函数,会按顺序依次执行。
  • DOM2 级事件模型,在该事件模型中,一次事件共有三个过程,第一个过程是事件捕获阶段。捕获指的是事件从 document 一直向下传播到目标元素,依次检查经过的节点是否绑定了事件监听函数,如果有则执行。后面两个阶段和 IE 事件模型的两个阶段相同。这种事件模型,事件绑定的函数是addEventListener,其中第三个参数可以指定事件是否在捕获阶段执行。

2. 事件触发的过程是怎样的

事件触发三阶段(JS事件流)
  • window往事件触发处传播,遇到注册的捕获事件会触发
  • 传播到事件触发处时触发注册的事件
  • 从事件触发处往 window 传播,遇到注册的冒泡事件会触发
  • 总之:事件捕获阶段 --> 处于目标阶段 --> 事件冒泡阶段先捕获事件再冒泡事件
事件冒泡、事件捕获
  • 冒泡事件:是指子元素向父元素传递的过程(从里到外)
  • 捕获事件:是指父元素向子元素传递的过程(从外到里)

事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个 body 中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行。

javascript
 代码解读
复制代码
// 以下会先打印冒泡然后是捕获
node.addEventListener(
  'click',
  event => {
    console.log('冒泡')
  },
  false
)
node.addEventListener(
  'click',
  event => {
    console.log('捕获 ')
  },
  true
)
注册、绑定事件的方式
  • dom 元素中直接绑定,<div class="an" onclick="aa()">aaaa</div>
  • js 中绑定 document.getElementById("demo").οnclick=function(){}
  • 添加监听事件 document.addEventListener('name',()=>{})

通常使用 addEventListener 注册事件

  • 第一个参数event:指事件的类型(如’click’,‘mousedown’)
  • 第二个参数function:事件触发后调用的函数
  • 第三个参数useCapture:设置传递的类型,默认值为false,即冒泡传递(可省略);值为true时,捕获传递。

第三个参数可以是布尔值,也可以是对象。如果是参数,可以使用以下几个属性:

  • capture:布尔值,和 useCapture 作用一样
  • once:布尔值,值为 true 表示该回调只会调用一次,调用后会移除监听
  • passive:布尔值,表示永远不会调用 preventDefault

一般来说,如果只希望事件只触发在目标上,这时候可以使用 stopPropagation 来阻止事件的进一步传播。通常认为 stopPropagation 是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。

stopImmediatePropagation 同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。

javascript
 代码解读
复制代码
node.addEventListener(
  'click',
  event => {
    event.stopImmediatePropagation()
    console.log('冒泡')
  },
  false
)
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
  'click',
  event => {
    console.log('捕获 ')
  },
  true
)

3. 如何阻止事件冒泡

  • 阻止冒泡捕获事件: e.stopPropagation(); 或e.stopImmediatePropagation()

    • 但是前者只会阻止冒泡和捕获
    • 而后者除此之外还会阻止该元素的其它事件发生;(比如元素绑定了多个捕获事件)。
    • IE浏览器使用:event.cancelBubble = true;
  • 阻止默认事件: e.preventDefault();(比如a标签的跳转事件)

4. 对事件委托的理解(事件代理)

(1)事件委托的概念

事件委托本质上是利用了浏览器事件冒泡的机制。因为事件在冒泡过程中会上传到父节点,父节点可以通过事件对象获取到目标节点,可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件,这种方式称为事件委托(事件代理)。

使用事件委托可以不必要为每一个子元素都绑定一个监听事件,这样减少了内存上的消耗。并且使用事件代理还可以实现事件的动态绑定,比如说新增了一个子节点,并不需要单独地为它添加一个监听事件,它绑定的事件会交给父元素中的监听函数来处理。

(2)事件委托的特点
  • 减少内存消耗

如果有一个列表,列表之中有大量的列表项,需要在点击列表项的时候响应一个事件:

html
 代码解读
复制代码
<ul id="list">
  <li>item 1</li>
  <li>item 2</li>
  <li>item 3</li>
  ......
  <li>item n</li>
</ul>

如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的,效率上需要消耗很多性能。因此,比较好的方法就是把这个点击事件绑定到他的父层,也就是 ul 上,然后在执行事件时再去匹配判断目标元素,所以事件委托可以减少大量的内存消耗,节约效率。

  • 动态绑定事件(不需要给子节点注销事件)

给上述的例子中每个列表项都绑定事件,在很多时候,需要通过 AJAX 或者用户操作动态的增加或者去除列表项元素,那么在每一次改变的时候都需要重新给新增的元素绑定事件,给即将删去的元素解绑事件;如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配的,所以使用事件在动态绑定事件的情况下是可以减少很多重复工作的。

javascript
 代码解读
复制代码
// 来实现把 #list 下的 li 元素的事件代理委托到它的父层元素也就是 #list 上:
// 给父层元素绑定事件
document.getElementById('list').addEventListener('click', function (e) {
  // 兼容性处理
  var event = e || window.event;
  var target = event.target || event.srcElement;
  // 判断是否匹配目标元素
  if (target.nodeName.toLocaleLowerCase === 'li') {
    console.log('the content is: ', target.innerHTML);
  }
});
  • 事件代理时获取触发的子元素可以采用获取target来得知

在上述代码中, target 元素则是在 #list 元素之下具体被点击的元素,然后通过判断 target 的一些属性(比如:nodeName,id 等等)可以更精确地匹配到某一类 #list li 元素之上;

(3)局限性

当然,事件委托也是有局限的。比如 focus、blur 之类的事件没有事件冒泡机制,所以无法实现事件委托;mousemove、mouseout 这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的。

当然事件委托不是只有优点,它也是有缺点的,事件委托会影响页面性能,主要影响因素有:

  • 元素中,绑定事件委托的次数;
  • 点击的最底层元素,到绑定事件元素之间的DOM层数;

在必须使用事件委托的地方,可以进行如下的处理:

  • 只在必须的地方,使用事件委托,比如:ajax的局部刷新区域
  • 尽量的减少绑定的层级,不在body元素上,进行绑定
  • 减少绑定的次数,如果可以,那么把多个事件的绑定,合并到一次事件委托中去,由这个事件委托的回调,来进行分发。
target 和 currentTarget 的区别
  • target是指获取事件的目标(实际触发的元素)。
  • currentTarget是指其事件处理程序当前正在处理事件的那个元素(正在冒泡、捕获的元素)

当事件处理程序直接绑定在目标元素上(绑定在父元素,点击父元素),此时e.target===e.currentTargete.target ===this

5. 事件委托的使用场景

场景:给页面的所有的a标签添加click事件,代码如下:

javascript
 代码解读
复制代码
document.addEventListener("click", function(e) {
	if (e.target.nodeName == "A")
		console.log("a");
}, false);

但是这些a标签可能包含一些像span、img等元素,如果点击到了这些a标签中的元素,就不会触发click事件,因为事件绑定上在a标签元素上,而触发这些内部的元素时,e.target指向的是触发click事件的元素(span、img等其他元素)。

这种情况下就可以使用事件委托来处理,将事件绑定在a标签的内部元素上,当点击它的时候,就会逐级向上查找,知道找到a标签为止,代码如下:

javascript
 代码解读
复制代码
document.addEventListener("click", function(e) {
	var node = e.target;
	while (node.parentNode.nodeName != "BODY") {
		if (node.nodeName == "A") {
			console.log("a");
			break;
		}
		node = node.parentNode;
	}
}, false);

6. 同步和异步的区别

  • 同步指的是当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,那么这个进程会一直等待下去,直到消息返回为止再继续向下执行。
  • 异步指的是当一个进程在执行某个请求时,如果这个请求需要等待一段时间才能返回,这个时候进程会继续往下执行,不会阻塞等待消息的返回,当消息返回时系统再通知进程进行处理。

7. 对事件循环的理解

设计由来:

JavaScript是单线程的,意味着它只有一个执行线程来执行代码。这样可以避免多线程情况下的资源竞争和同步问题,但同时也意味着如果有耗时的操作,如网络请求、文件读写或者大量的计算,会阻塞其他代码的执行。

为了解决这个问题,JavaScript将任务分为同步任务和异步任务。同步任务会在调用栈中按顺序执行,而异步任务会被暂存起来,通常是放在一个任务队列中。等到调用栈清空后,JavaScript引擎会定期检查这个任务队列,如果有任务则将它们放到调用栈中执行。这个过程被称为事件循环(Event Loop)。

执行原理:

因为 js 是单线程运行的,JavaScript将任务分为同步任务和异步任务,在代码执行时,同步任务会将不同函数的执行上下文压入执行栈中,来保证代码的有序执行。在执行同步代码时,如果遇到异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当异步事件执行完毕后,再将异步事件对应的回调加入到一个任务队列中等待执行。任务队列可以分为宏任务队列和微任务队列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务队列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。当微任务队列中的任务都执行完成后再去执行宏任务队列中的任务。

Event Loop 执行顺序如下所示:

  • 首先执行同步代码,这属于宏任务
  • 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
  • 执行所有微任务
  • 当执行完所有微任务后,如有必要会渲染页面
  • 然后开始下一轮 Event Loop,执行宏任务中的异步代码

组成:

事件循环的组成:调用栈、任务队列和事件循环线程。调用栈用来管理代码的执行顺序,任务队列用来存放异步任务,事件循环线程则是一个循环,不断地从任务队列中取出任务执行。

8. 宏任务和微任务分别有哪些

  • 微任务包括: promise 的回调、node 中的 process.nextTick 、对 Dom 变化监听的 MutationObserver。
  • 宏任务包括: script 脚本的执行、setTimeout ,setInterval ,setImmediate 一类的定时事件,还有如 I/O 操作、UI 渲染等。

9. 什么是执行栈

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。 当开始执行 JS 代码时,根据先进后出的原则,后执行的函数会先弹出栈,可以看到,foo 函数后执行,当执行完毕后就从栈中弹出了。

平时在开发中,可以在报错中找到执行栈的痕迹:

javascript
 代码解读
复制代码
function foo() {
  throw new Error('error')
}
function bar() {
  foo()
}
bar()

可以看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。当使用递归时,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题

javascript
 代码解读
复制代码
function bar() {  bar()}bar()

10. Node 中的 Event Loop 和浏览器中的有什么区别?process.nextTick 执行顺序?

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。

Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

(1)Timers(计时器阶段) :初次进入事件循环,会从计时器阶段开始。此阶段会判断是否存在过期的计时器回调(包含 setTimeout 和 setInterval),如果存在则会执行所有过期的计时器回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Pending callbacks 阶段。

(2)Pending callbacks:执行推迟到下一个循环迭代的I / O回调(系统调用相关的回调)。

(3)Idle/Prepare:仅供内部使用。

(4)Poll(轮询阶段)

  • 当回调队列不为空时:会执行回调,若回调中触发了相应的微任务,这里的微任务执行时机和其他地方有所不同,不会等到所有回调执行完毕后才执行,而是针对每一个回调执行完毕后,就执行相应微任务。执行完所有的回调后,变为下面的情况。
  • 当回调队列为空时(没有回调或所有回调执行完毕):但如果存在有计时器(setTimeout、setInterval和setImmediate)没有执行,会结束轮询阶段,进入 Check 阶段。否则会阻塞并等待任何正在执行的I/O操作完成,并马上执行相应的回调,直到所有回调执行完毕。

(5)Check(查询阶段) :会检查是否存在 setImmediate 相关的回调,如果存在则执行所有回调,执行完毕后,如果回调中触发了相应的微任务,会接着执行所有微任务,执行完微任务后再进入 Close callbacks 阶段。

(6)Close callbacks:执行一些关闭回调,比如socket.on('close', ...)等。

下面来看一个例子,首先在有些情况下,定时器的执行顺序其实是随机

javascript
 代码解读
复制代码
setTimeout(() => {    console.log('setTimeout')}, 0)setImmediate(() => {    console.log('setImmediate')})

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后

  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
  • 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
  • 那么如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了

当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:

javascript
 代码解读
复制代码
const fs = require('fs')
fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0)
    setImmediate(() => {
        console.log('immediate')
    })
})

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

上面都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask

javascript
 代码解读
复制代码
setTimeout(() => {
  console.log('timer21')
}, 0)
Promise.resolve().then(function() {
  console.log('promise1')
})

对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面。

最后来看 Node 中的 process.nextTick,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

javascript
 代码解读
复制代码
setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})

对于以上代码,永远都是先把 nextTick 全部打印出来。

浏览器垃圾回收机制&内存泄漏及优化

JavaScript 的垃圾回收(Garbage Collection, GC)是自动管理内存的核心机制,目标是识别并释放不再被使用的对象,避免内存泄漏。以下从原理、算法、优化策略三个维度深入解析:

一、核心原理:可达性分析

现代 JavaScript 引擎(如 V8、SpiderMonkey)普遍采用 ** 标记 - 清除(Mark-and-Sweep)** 算法,核心逻辑如下:

  1. 根对象(Roots):
  • 全局变量、当前执行栈中的变量、闭包引用的变量等。
  • 引擎从根对象出发,递归遍历所有可达对象(Mark 阶段)。
  1. 标记阶段:
  • 所有从根对象可达的对象被标记为 “存活”。
  • 不可达的对象(如未被引用的局部变量)被标记为 “垃圾”。
  1. 清除阶段:
  • 回收所有未被标记的对象,释放内存空间。

示例:

function createObject() {
   const obj = { id: 1 }; // obj为局部变量,函数执行完毕后不可达
}
createObject(); // 函数执行后,obj成为垃圾,等待回收

二、机制算法与优化技术

  1. 分代回收(Generational GC)

V8 引擎将内存分为新生代和老生代,针对不同生命周期的对象采用不同策略:

  • 新生代(Young Generation)
    • 特点:存活时间短(如临时变量、函数作用域内的对象)。
    • 算法:Scavenge 算法(半空间复制算法)。
      • 内存分为From和To两个空间,每次只使用其中一个,在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。
      • 存活对象被复制到To空间,From空间整体释放。
      • 多次存活的对象晋升到老生代(晋升阈值由引擎动态调整)。
    • 过程:分为两个区 1使用区2.空闲区
      1. 区分活动对象和非活动对象 活动对象标记
      2. 标记的对象复制到空闲区,并内存排序 避免造成内存碎片
      3. 清理非活动区的对象,释放内存
      4. 两区互换,达到内存清理、整理的目的,当新的使用区占满时候再进行一次垃圾清理
      5. 两次没有被回收的对象进入老生代
  • 老生代(Old Generation)
    • 特点:存活时间长(如全局变量、长生命周期对象)。

    • 算法:标记 - 清除(Mark-Sweep) + 标记 - 压缩 - 整理:(Mark-Compact)。

      • 标记 - 清除:清除垃圾后可能产生内存碎片。
      • 标记 - 整理:将存活对象移动到内存一端,消除碎片。
    • 过程:使用主垃圾回收器(由于老生代内存比较大没办法分区域)

      1. 标记从根元素开始,递归根元素,过程中能达到元素的为活动对象,没有达到就是垃圾数据
      2. 清除垃圾数据
      3. 标记清除后的对象会产生内存碎片,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象向一端移动,直到所有对象都移动完成然后清理掉不需要的内存,就是采用标记整理(会讲活动对象向一端移动排序,从而避免产生碎片)
    • 什么情况下对象会出现在老生代空间中?

      • 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
      • To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。

      老生代中的空间很复杂,有如下几个空间

      javascript
       代码解读
      复制代码
      enum AllocationSpace {
        // TODO(v8:7464): Actually map this space's memory as read-only.
        RO_SPACE,    // 不变的对象空间
        NEW_SPACE,   // 新生代用于 GC 复制算法的空间
        OLD_SPACE,   // 老生代常驻对象空间
        CODE_SPACE,  // 老生代代码对象空间
        MAP_SPACE,   // 老生代 map 对象
        LO_SPACE,    // 老生代大空间对象
        NEW_LO_SPACE,  // 新生代大空间对象
        FIRST_SPACE = RO_SPACE,
        LAST_SPACE = NEW_LO_SPACE,
        FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
        LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
      };
      
    • 在老生代中,以下情况会先启动标记清除算法:

      • 某一个空间没有分块的时候
      • 空间中被对象超过一定限制
      • 空间不能保证新生代中的对象移动到老生代中

为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行。

问题

  • 由于v8是单线程语言,运行在主线程,在垃圾回收时会造成阻塞 (全停顿)
  • 老生代阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题

优化效果:

  • 新生代回收效率提升 30%+(复制算法避免碎片化)。
  • 老生代通过增量标记(Incremental Marking)和并行回收(Parallel GC)减少主线程阻塞。
    • 增量标记(子任务回收)
      • 2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,与JS交替执行,从而不至于让应用出现停顿情况。
    • 并行回收(并发标记)
      • 在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行
      • 垃圾回收器在主线程执行中,开启多个辅助进程,同时进行这样的工作
      • 清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象向一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
  1. 引用计数(Reference Counting)
  • 原理:每个对象维护一个引用计数器,引用增加时 + 1,减少时 - 1,计数为 0 时回收。
  • 缺陷:无法处理循环引用。
   const objA = { name: 'A' };
   const objB = { name: 'B' };
   objA.ref = objB; // 相互引用,引用计数均为1
   objB.ref = objA;
   // 即使objA和objB不再被外部引用,引用计数仍为1,导致内存泄漏
  • 现状:现代引擎已弃用,仅用于部分场景(如 Web Workers)。

三、内存泄漏与典型场景

  1. 未清理的定时器

    function startTimer() {
       setInterval(() => {
          console.log('定时任务');
       }, 1000);
    }
    startTimer(); // 定时器未清理,回调函数持续引用外部变量
    

    解决方案: 使用clearInterval清除定时器。

  2. 闭包滥用

    function createClosure() {
       const largeArray = new Array(1000000).fill(0); // 大数组
       return function() {
           return largeArray[0]; // 闭包引用largeArray,导致无法回收
       };
    }
    const closure = createClosure(); // closure执行后,largeArray仍被引用
    

    解决方案: 减少闭包对外部变量的依赖,或在不需要时手动置为null。

  3. 无效的 DOM 引用

    const div = document.createElement('div');
    document.body.appendChild(div);
    div.dataset.largeData = new ArrayBuffer(1024 * 1024); // 存储大数据
    document.body.removeChild(div); // div节点被移除,但data属性仍引用数据
    

    解决方案: 移除 DOM 节点前,手动释放其引用的资源。

  4. 全局变量污染

    function leak() {
       leakVar = '全局变量'; // 未声明的变量自动成为全局变量
    }
    leak(); // leakVar无法被回收
    

    解决方案: 严格模式('use strict')下禁止隐式全局变量。

  5. 对象循环引用

    const objA = { name: 'A' };
    const objB = { name: 'B' };
    objA.ref = objB; // 相互引用,引用计数均为1
    objB.ref = objA;
    // 引用计数视角:每个对象的引用计数为 1(自身)+ 1(对方引用)= 2,无法归零。
    // 标记 - 清除视角:若 objA 和 objB 是全局变量(根对象可达),GC 会标记它们为 “存活”,即使它们的相互引用是唯一依赖
    

    解决方案:

    • 若后续执行 objA = null; objB = null,全局引用被移除,但 局部作用域中的循环引用(如函数内的循环引用)不会泄漏(因根引用消失,GC 可回收)。
  • 如何避免循环引用导致的泄漏?

    • 手动断开引用

      在对象不再使用时,将循环引用的属性置为 null:

      // 断开 DOM 元素的循环引用
      divA.parent = null;
      divB.remove(); // 同时从父节点移除
      
      
    • 使用弱引用数据结构

      WeakMap/WeakSet:键或值为弱引用,不影响对象的垃圾回收:

      const weakMap = new WeakMap();
      const obj = { ref: {} };
      weakMap.set(obj, 'data'); // obj 被回收时,WeakMap 自动移除条目     
      
    • 避免全局变量存储循环引用

      将对象限制在函数作用域内,确保根引用消失后,循环引用整体不可达:

       function safeCycle() {
        const a = { ref: null };
        const b = { ref: a };
        a.ref = b; // 局部作用域内的循环,函数执行完后可回收
      }  
      
  • 总结:循环引用泄漏的核心条件

    • 形成闭环:对象 A → B → A(或更多对象形成环)。
    • 根引用存在:环中的任意对象被全局变量、闭包或 DOM 树引用(GC 认为环整体可达)。
  • 代码验证泄漏:

    在 Chrome DevTools 的 Console 中执行以下代码,观察内存变化:

      // 全局循环引用(导致泄漏)
      window.leak = (function() {
        const a = { ref: null };
        const b = { ref: a };
        a.ref = b;
        return { a, b }; // 作为根引用暴露
      })();
    
      // 手动释放根引用(断开泄漏)
      window.leak = null; // 此时 a 和 b 的环无任何根引用,GC 可回收
    
  • 理解循环引用的本质是 “根可达的环”,通过控制作用域和合理使用弱引用,可有效避免此类内存泄漏。

四、性能优化策略

  1. 减少不必要的内存分配
  • 避免频繁创建临时对象:
    // 反例:每次循环创建新对象
    for (let i = 0; i < 1000; i++) {
     const obj = { value: i };
     process(obj);
    }
    
    // 正例:复用对象
    const obj = {};
    for (let i = 0; i < 1000; i++) {
     obj.value = i;
     process(obj);
    }
    
  1. 合理使用弱引用
  • WeakMap:键为弱引用,键对象被回收时自动移除条目。

    const cache = new WeakMap();
    function process(obj) {
     if (!cache.has(obj)) {
         cache.set(obj, expensiveComputation(obj));
     }
    return cache.get(obj);
    }
    // obj不再被引用时,WeakMap自动释放对应的缓存
    
  • WeakSet:存储弱引用对象,适用于跟踪临时对象。

    const visited = new WeakSet();
    function visit(obj) {
       if (!visited.has(obj)) {
           visited.add(obj);
       // 处理逻辑
       }
    }
    
  1. 分代优化
  • 新生代优化:
    • 减少大对象在新生代的分配(如使用ArrayBuffer时预分配空间)。
    • 避免频繁创建短期存活的对象。
  • 老生代优化:
    • 减少全局变量的使用,避免对象长期存活。
    • 避免频繁修改对象属性(可能触发老生代回收)。
  1. 工具辅助
  • Chrome DevTools:
    • Memory 面板:通过快照对比分析内存泄漏。
    • Performance 面板:跟踪垃圾回收时间和内存分配。
  • Node.js:
    • 使用--trace-gc参数打印垃圾回收日志。
    • 通过process.memoryUsage()监控内存使用。

五、前沿特性:FinalizationRegistry

ES2022 引入的FinalizationRegistry允许在对象被回收时执行清理操作:

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`清理资源:${heldValue}`);
});

function createResource() {
  const resource = { id: Date.now() };
  registry.register(resource, `资源-${resource.id}`);
  return resource;
}

const res = createResource();
res = null; // 手动释放引用,触发回收
// 当resource被回收时,打印“清理资源:资源-1629782400000”

总结:垃圾回收的核心逻辑

  1. 自动回收:引擎通过可达性分析识别垃圾,无需手动干预。
  2. 分代策略:新生代快速回收短期对象,老生代优化长期对象。
  3. 避免泄漏:关注闭包、定时器、全局变量等常见泄漏源。
  4. 工具与实践:结合 DevTools 和弱引用数据结构,提升内存管理效率。
  • 理解这些机制后,开发者可更精准地优化代码,避免因内存问题导致的性能瓶颈。例如,在高频操作场景中优先使用栈内存(如基本类型),在复杂数据管理中合理运用WeakMap,并通过性能工具定位内存泄漏点。

浏览器安全

XSS(跨站脚本攻击)

基本概念

  • XSS(跨站脚本攻击)是指攻击者在返回的 HTML 中嵌入 javascript脚本,然后拿到用户的信息并进行操作,用户的信息如 cookie 等
  • XSS 分为三种:存储型反射型文档形

存储型

  • 存储型XSS 将脚本存储到了服务端的数据库,然后在客户端执行这些脚本,从而达到攻击的效果。
  • 常见的场景就是评论区提交一段脚本代码,如果前后端没有做好转义,存储到数据库后,在客户端渲染时直接执行;

反射型

  • 反射形 XSS 攻击指的是恶意脚本作为请求URL的参数;浏览器解析后作为脚本执行,
  • 之所以叫它反射型, 是因为恶意脚本是通过作为网络请求的参数出现在 url 中,经过服务器解析响应,拼接在 HTML 中传回给客户端,然后浏览器解析执行恶意脚本。
  • 存储型不一样的是,服务器并不会存储这些恶意脚本。

文档型

  • 文档形XSS 攻击其实也是恶意脚本被作为请求URL的参数;浏览器解析后作为脚本执行,和反射形的区别在于:由前端JS取出 URL 中的恶意代码并执行

防范措施

  • 可以从浏览器的执行来进行预防,一种是使用纯前端的方式,不用服务器端拼接后返回(不使用服务端渲染),还有普遍的就是转义过滤:对引号,尖括号,斜杠进行转义,让代码在html解析的过程中无法执行;过滤就是把 script标签给删除;
  • 利用 HttpOnlycookie 设置 httponly 后,会禁止 javascript 脚本来访问 cookie,这样,XSS攻击之后也无法获取用户的cookie
  • 使用 CSP ,CSP 也就是浏览器内容安全策略,本质是建立一个白名单,告诉浏览器哪些外部资源(指定域的脚本及样式)可以加载和执行,从而防止恶意代码的注入攻击。
  1. CSP 指的是内容安全策略,它的本质是建立一个白名单,告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截由浏览器自己来实现。
  2. 通常有两种方式来开启 CSP,一种是设置 HTTP 首部中的 Content-Security-Policy,一种是设置 meta 标签的方式

csp详细说明

CSRF(跨站请求伪造)

基本概念

  • CSRF(跨站请求伪造) 就是攻击者诱导用户跳转恶意网站,然乎利用用户的登录态发起绕过后台的用户验证,冒充用户向服务器恶意请求和执行一些操作;
  • 原理就是http请求会自动携带 Cookie,而且是 HTTP 目标请求域名Cookie

攻击类型

常见的 CSRF 攻击有三种:

  • GET 类型的 CSRF 攻击,比如在网站中的一个 img 标签里构建一个请求,当用户打开这个网站的时候就会自动发起提交。
  • POST 类型的 CSRF 攻击,比如构建一个表单,然后隐藏它,当用户进入页面时,自动提交这个表单。
  • 链接类型的 CSRF 攻击,比如在 a 标签的 href 属性里构建一个请求,然后诱导用户去点击。

防范措施

  • SameSite Cookies: 这个属性表示 Cookie 不随着跨域请求发送,可以很大程度减少 CSRF 的攻击;它有Strict(浏览器将只发送相同站点(完全一致)请求的 Cookie)、Lax(第三方get方法可以携带Cookie) 和 None (任何情况下都会发送 Cookie)三个值。
  • 进行同源检测OriginReferer:验证Referer是否是从第三方网站发出来的,阻止第三方网站请求接口,但是这两者可以通过ajax自定义请求头的方式被伪造;
  • 使用 CSRF Token 进行验证CSRF Token:客户端向服务端请求token,然后在所有的请求中带上;

Chrome80 版本的 CSRF 例子

讲到 CSRFcookie,不得不提一下 Chrome 80 版本的的一个默认设置;Chrome 80 版本将 SameSite 的值设置为了 Lax;这导致之前公司有一些业务产生了跨域;

XSS 和 CSRF 的区别

  • 两者的原理区别:CSRF 是利用 网站A 本身的漏洞,去请求 网站Aapi。而 XSS 是向网站A 注入 JS代码,然后执行 JS 里的代码,篡改网站A的内容。
  • CSRF仅仅是利用了http携带cookie的特性进行攻击的,但是无法得到被攻击站点的cookie。这个和XSS不同,XSS一般是直接通过拿到Cookie等信息进行攻击的

SQL 注入

概念
  • 就是通过把SQL命令插入到Web表单页面请求的查询字符串里面提交到服务器,最终达到欺骗服务器执行恶意的SQL命令
原理
  • 服务端在执行sql操作时,可能会拼接前端传入的参数,这就会将一些sql注入的sql拼接起来,导致一些预期之外的操作;
  • 就比如说登录的场景,前端输入用户名和密码,后端也许会select * from user where username = '' AND password = '' 这样子拼接起来;
  • sql中会将#以及--之后的字符串当做注释处理,那么如果我们将password写成 or 1=1#;那么服务端就有可能将所有的用户都查询出来;
防范方法
  • 永远不要信任用户的输入,要对用户的输入进行校验,可以通过正则表达式,或限制长度,对单#号双"-"进行转换等
  • 永远不要使用动态拼装SQL,可以使用参数化的SQL或者直接使用存储过程进行数据查询存取
  • 永远不要使用管理员权限的数据库连接,为每个应用使用单独的权限有限的数据库连接
  • 不要把机密信息明文存放,请加密或者hash掉密码和敏感的信息

点击劫持

概念
  • 点击劫持是一种视觉欺骗的攻击手段。攻击者将需要攻击的网站通过 iframe 嵌套的方式嵌入自己的网页中,并将 iframe 设置为透明,在页面中透出一个按钮诱导用户点击
防范方法

X-FRAME-OPTIONS

X-FRAME-OPTIONS 是一个 HTTP 响应头,在现代浏览器有一个很好的支持。这个 HTTP 响应头 就是为了防御用iframe 嵌套的点击劫持攻击。

该响应头有三个值可选,分别是

  • DENY,表示页面不允许通过 iframe 的方式展示

  • SAMEORIGIN,表示页面可以在相同域名下通过 iframe 的方式展示

  • ALLOW-FROM,表示页面可以在指定来源的 iframe 中展示

有哪些可能引起前端安全的问题?

  • 跨站脚本 (Cross-Site Scripting, XSS): ⼀种代码注⼊⽅式, 为了与 CSS 区分所以被称作 XSS。早期常⻅于⽹络论坛, 起因是⽹站没有对⽤户的输⼊进⾏严格的限制, 使得攻击者可以将脚本上传到帖⼦让其他⼈浏览到有恶意脚本的⻚⾯, 其注⼊⽅式很简单包括但不限于 JavaScript / CSS / Flash 等;

  • iframe的滥⽤: iframe中的内容是由第三⽅来提供的,默认情况下他们不受控制,他们可以在iframe中运⾏JavaScirpt脚本、Flash插件、弹出对话框等等,这可能会破坏前端⽤户体验;

  • 跨站点请求伪造(Cross-Site Request Forgeries,CSRF): 指攻击者通过设置好的陷阱,强制对已完成认证的⽤户进⾏⾮预期的个⼈信息或设定信息等某些状态更新,属于被动攻击

  • 恶意第三⽅库: ⽆论是后端服务器应⽤还是前端应⽤开发,绝⼤多数时候都是在借助开发框架和各种类库进⾏快速开发,⼀旦第三⽅库被植⼊恶意代码很容易引起安全问题。

网络劫持有哪几种,如何防范?

⽹络劫持分为两种:

(1)DNS劫持: (输⼊京东被强制跳转到淘宝这就属于dns劫持)

  • DNS强制解析: 通过修改运营商的本地DNS记录,来引导⽤户流量到缓存服务器
  • 302跳转的⽅式: 通过监控⽹络出⼝的流量,分析判断哪些内容是可以进⾏劫持处理的,再对劫持的内存发起302跳转的回复,引导⽤户获取内容

(2)HTTP劫持: (访问⾕歌但是⼀直有贪玩蓝⽉的⼴告),由于http明⽂传输,运营商会修改你的http响应内容(即加⼴告)

DNS劫持由于涉嫌违法,已经被监管起来,现在很少会有DNS劫持,⽽http劫持依然⾮常盛⾏,最有效的办法就是全站HTTPS,将HTTP加密,这使得运营商⽆法获取明⽂,就⽆法劫持你的响应内容。

什么是中间人攻击?如何防范中间人攻击?

中间⼈ (Man-in-the-middle attack, MITM) 是指攻击者与通讯的两端分别创建独⽴的联系, 并交换其所收到的数据, 使通讯的两端认为他们正在通过⼀个私密的连接与对⽅直接对话, 但事实上整个会话都被攻击者完全控制。在中间⼈攻击中,攻击者可以拦截通讯双⽅的通话并插⼊新的内容。

攻击过程如下:

  • 客户端发送请求到服务端,请求被中间⼈截获
  • 服务器向客户端发送公钥
  • 中间⼈截获公钥,保留在⾃⼰⼿上。然后⾃⼰⽣成⼀个伪造的公钥,发给客户端
  • 客户端收到伪造的公钥后,⽣成加密hash值发给服务器
  • 中间⼈获得加密hash值,⽤⾃⼰的私钥解密获得真秘钥,同时⽣成假的加密hash值,发给服务器
  • 服务器⽤私钥解密获得假密钥,然后加密数据传输给客户端

浏览器缓存

所谓的浏览器缓存指的是浏览器将用户请求过的静态资源,存储到电脑本地磁盘中,当浏览器再次访问时,就可以直接从本地加载,不需要再去服务端请求了。一般只缓存静态资源(js、css、img)

缓存行为

  • 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识
  • 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中

强制缓存优先于协商缓存进行,若强缓存生效则直接使用强缓存,若强缓存不生效则进行协商缓存,浏览器直接从本地缓存中获取数据,不与服务器进行交互

协商缓存由服务器决定是否使用缓存;若协商缓存失效,那么就返回200,重新返回资源和缓存标识,再存入浏览器缓存中;若协商缓存生效则返回304,继续使用缓存。

缓存位置

Web 缓存按存储位置来区分,包括数据库缓存、服务端缓存、CDN 缓存和浏览器缓存。这里我们着重介绍一下浏览器缓存

浏览器缓存通过 HTTP/HTTPS 实现,存储位置有四种:

  • Service Worker:是运行在浏览器背后的独立线程,无法直接访问DOM,但可以用来做离线缓存消息推送网络代理。传输协议必须为 HTTPS
  • Memory Cache:内存中的缓存
  • Disk Cache:存储在硬盘中的缓存
  • Push Cache:(推送缓存)是 HTTP/2 中的内容;

注:HTTP2的服务器推送功能,在Chrome106版本后不可用;

Service Worker

Service Worker 是运行在浏览器背后的独立线程,可以用来实现缓存功能。使用 Service Worker 的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。Service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。

Memory Cache

Memory Cache 是内存中的缓存,主要包含的是当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据高效,但是缓存持续性很短。一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。而且由于计算机中的内存比硬盘容量小得多,我们能够使用存储缓存的内存并不多。

内存缓存在缓存资源时并不关心返回资源的 HTTP 缓存头 Cache-Control,同时资源的匹配也并非仅仅是对 URL 做匹配,还可能会对 Content-Type,CORS 等其他特征做校验。

Disk Cache

Disk Cache 是存储在硬盘中的缓存,读取速度比 Memory Cache 慢,但是存储量更大。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。

Push Cache

Push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。

浏览器什么时候会把缓存存储到内存中,什么时候存储到硬盘中呢?一般来说:

  • 小文件优先存储到内存中,反之存储到硬盘中
  • 使用频率高的缓存到硬盘中

缓存规则过程

  • 浏览器第一次加载资源,服务器返回200,浏览器将资源文件从服务器上请求下载下来,并把response header及该请求的返回时间一并缓存;
  • 下一次加载资源时,先比较当前时间和上一次返回200时的时间差,如果没有超过cache-control设置的max-age,则没有过期,命中强缓存,不发请求直接从本地缓存读取该文件(如果浏览器不支持HTTP1.1,则用expires判断是否过期);如果时间过期,则向服务器发送header带有If-None-MatchIf-Modified-Since的请求
  • 服务器收到请求后,优先根据 Etag 的值判断被请求的文件有没有做修改,Etag 值一致则没有修改,命中协商缓存,返回304;如果不一致则有改动,直接返回新的资源文件带上新的Etag值并返回200
  • 如果服务器收到的请求没有 Etag 值,则将 If-Modified-Since 和被请求文件的最后修改时间做比对,一致则命中协商缓存,返回304;不一致则返回新的 last-modified 和文件并返回200

强制缓存

强制缓存是在浏览器加载资源的时候,先检查缓存时间是否过期,若未过期则直接从缓存中查找请求结果,如果缓存时间过期或不存在该缓存结果,再向服务端发起请求。

设置缓存时间的方法有两种, http 头信息中的 Expires 属性和 Cache-Control 属性(响应头字段):

  • Expires(HTTP/1.0)
  • Cache-Control(HTTP/1.1)

Expires

HTTP/1.0 中使用响应头部字段 Expires 来设置缓存过期时间。客户端第一次请求时,服务端会在响应头部添加 Expires 字段。当浏览器再次发送请求时,先会对比当前时间和 Expires 对应的时间,如果当前时间早于 Expires 时间,那么直接使用缓存;反之,需要再次发送请求。

上述 Expires 信息告诉浏览器:在 2020.10.10 日之前,可以直接使用该请求的缓存。

问题:

  • 服务端和浏览器的时间可能不同,导致缓存过期时间出现偏差
  • 客户端可以通过修改系统时间来继续使用缓存或提前使缓存失效

为了解决这个问题,HTTP/1.1 提出了 Cache-Control 响应头部字段,它提供了对资源的缓存的更精确的控制,它有很多不同的值,。

Cache-Control

它的常用值有:

  • no-cache:表示不使用本地缓存,每次使用缓存前必须向服务端确认缓存资源是否更新,‌会使用协商缓存,可以响应304 Not Modified响应;
  • no-store:禁止任何缓存,每次都会向服务端发起新的请求,拉取最新的资源;
  • public:公有缓存,表示该资源可以被任何节点缓存(包括客户端和代理服务器),可以被多个用户共享,这个字段值不常用,一般还是使用max-age=来精确控制;
  • private:私有缓存,不能被代理服务器缓存,只能被用户浏览器缓存,不可以被多个用户共享,在实际开发当中,对于一些含有用户信息的HTML,通常都要设置这个字段值,避免代理服务器(CDN)缓存,同时当设置了 private 指令后 s-maxage 指令将被忽略;
  • max-age:以秒为单位的数值,表示缓存的有效时间,时间是相对于请求的时间;
  • s-maxage=:优先级高于max-age=,仅适用于共享缓存(CDN),优先级高于max-age或者Expires头;
  • max-stale[=]:表明客户端愿意接收已经过期的资源,但是不能超过给定的时间限制。
  • must-revalidate:当缓存过期时,需要去服务端校验缓存的有效性。

此 Cache-Control 信息告诉浏览器该缓存为公有缓存,有效期 1 年。

强制缓存中,cache-control 的 max-age 优先级高于 Expires

200 状态码一定是服务器返回的吗?

不是。命中强缓存的话,会直接从内存或者磁盘中读取资源,并返回一个200状态码,具体操作可以试试浏览器的前进后退键。

优先级

一般来说只需要设置其中一种方式就可以实现强缓存策略,当两种方式一起使用时,Cache-Control 的优先级要高于 Expires。

no-cache、no-store 的区别

  • no-cacheno-store 这两个指令在请求响应中都可以使用
  • no-store 是真正的不进行任何缓存,每次请求都直接从服务器获取资源
  • no-cache 是指先要和服务器确认是否有资源更新,在进行判断。也就是说没有强缓存,但是会有协商缓存;

协商缓存

不指定缓存的有效时间(首次请求时没有Cache-Control和 Expires;或者Cache-Control 的属性设置为 no-cache 时,又或者如果缓存过期了),而是在请求时直接发送资源标识到服务端确认缓存(验证资源)是否需要更新,发送请求时服务器根据这个请求的请求头里的If-Modified-Since 和 If-None-Match 来判断是否命中协商缓存,如果请求响应返回的 HTTP 状态为 304,则表示缓存仍然有效;否则返回状态码 200 、最新的资源和最新的资源标识。

控制缓存的难题就从浏览器端转移到了服务端。

字段Header类型协议版本缓存类型
Last-ModifiedResponse(响应头)HTTP1.0协商缓存
If-Modified-SinceRequest(请求头)HTTP1.0协商缓存
ETagResponse(响应头)HTTP1.1协商缓存
If-None-MatchRequest(请求头)HTTP1.1协商缓存

资源标识(在 Response Header 中)有两种:

  • Last-Modified:资源的最后修改时间
  • Etag:资源的唯一标识(一个字符串)

Last-Modified 和 If-Modified-Since:

服务端通过响应头部字段 Last-Modified 和请求头部字段 If-Modified-Since 比对双方资源的修改时间,来确定缓存是否需要更新。

具体工作流程如下:

  1. 浏览器第一次请求资源,服务端在返回资源的响应头中加入 Last-Modified 字段,表示这个资源在服务端上的最近修改时间;
  2. 当浏览器再次向服务端请求该资源时,请求头部带上之前服务端返回的 Last-Modified,这个请求头叫 If-Modified-Since;
  3. 服务端再次收到请求,根据 If-Modified-Since 的值,判断相关资源是否有变化,如果没有,则返回 304 Not Modified,浏览器使用资源缓存值;否则返回资源内容,且更新 Last-Modified 响应头内容。

这种方式虽然能判断缓存是否失效,但也存在三个问题:

  1. 精度问题:Last-Modified 的时间精度为秒,如果在 1 秒内发生修改,那么缓存判断会失效
  2. 准度问题:如果一个文件被修改后又被还原,内容没有发生变化,却仍然需要重新请求
  3. 服务器问题:某些服务器不能精确的得到文件的最后修改时间

因此我们需要 ETag

ETag 和 If-None-Match (类似于文件指纹)

为了解决精度问题和准度问题,HTTP 提供了另一种依赖于文件哈希值的精确判断缓存的方式:响应头部字段 ETag 和请求头部字段 If-None-Match。

具体工作流程如下:

  1. 浏览器第一次请求资源,服务端在返响应头中加入 Etag 字段,Etag 字段值为该资源的哈希值;
  2. 当浏览器再次跟服务端请求这个资源时,在请求头上加上 If-None-Match,值为之前响应头部字段 ETag 的值;
  3. 服务端再次收到请求,将请求头 If-None-Match 字段的值和响应资源的哈希值进行比对,如果两个值相同,则说明资源没有变化,返回 304 Not Modified;否则就正常返回资源内容,无论是否发生变化,都会将计算出的哈希值放入响应头部的 ETag 字段中。

这种缓存比较的方式也会存在一些问题,具体表现在以下两个方面:

  1. 计算成本。对于大文件而言,读取完整的文件内容生成哈希值开销较大;只读取文件部分内容,又容易判断出错。
  2. 计算误差。不同服务端可能会采用不同的哈希值计算方式。所以同一个资源在两台服务端产生的 Etag 可能是不相同的。对于使用服务器集群来处理请求的网站来说,使用 Etag 的缓存命中率会有所降低。

两者中会优先使用 Etag:

  • Last-Modified 只能精确到秒级
  • 如果资源被重复生成,而内容不变,Etag 更加精准

响应头示例:

强制缓存和协商缓存的优先级

  • 强制缓存的优先级高于协商缓存:
    • 强制缓存中:cache-control 的 max-age 优先级高于 Expires
    • 协商缓存中:Etag 优先级比 Last-Modified 高。

用户行为(点击刷新按钮或者按 F5、按 Ctrl+F5 (强制刷新)、地址栏回车有什么区别)

  • 打开网页,地址栏输入地址: 查找 disk cache 中是否有匹配。如有则使用;如没有则发送网络请求。
  • 普通刷新 (F5):不使用强缓存,会带上If-Modifed-Since,If-None-Match判断协商缓存;且因为 TAB 并没有关闭,因此 memory cache 是可用的,会被优先使用(如果匹配的话)。其次才是 disk cache
  • 强制刷新 (Ctrl + F5):浏览器不使用缓存,服务器直接返回 200 和最新内容

禁用缓存

服务器禁用缓存:

  • Cache-Control: max-age=0, must-revalidate
  • Cache-Control: no-cache
  • Cache-Control: no-store

浏览器禁用缓存:

  • 使用meta标签:‌在HTML文档的头部添加特定的meta标签可以禁止浏览器缓存网页内容,例如:
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
  • 在URL后面添加随机参数:这样浏览器就会认为每次请求的都是不同的页面,从而不会使用缓存。例如,原始的URL可能是url=test/test.html,您可以将其修改为url=test/test.html?random=123456,其中123456是一个随机生成的数字。

  • 通过编程实现:‌如果页面是由后端动态生成的,‌可以在后端代码中设置相应的HTTP头部信息,‌以确保每次请求都会返回最新的页面内容。‌这种方法适用于动态网页或需要实时更新的内容

  • 使用框架或中间件:‌如果使用的是某些Web开发框架,‌可能会有现成的中间件或配置选项来帮助设置这些头部信息,‌以禁止缓存。‌

通过上述方法,‌可以有效地禁止HTML页面被浏览器或代理服务器缓存,‌确保用户每次访问都能获取到最新的页面内容。‌在实际应用中,‌需要根据具体的服务器环境和技术栈来选择合适的实现方式

总结

缓存是解决性能问题的重要手段,使用缓存的好处很多,除了能让浏览器更快地加载网络资源之外,还会带来其他好处,比如节省网络流量和带宽,以及减少服务端的负担。

本地存储

1. 浏览器本地存储方式及使用场景

(1)Cookie

Cookie是最早被提出来的本地存储方式,在此之前,服务端是无法判断网络中的两个请求是否是同一用户发起的,为解决这个问题,Cookie就出现了。Cookie的大小只有4kb,它是一种纯文本文件,每次发起HTTP请求都会携带Cookie。

Cookie的特性:

  • Cookie一旦创建成功,名称就无法修改
  • Cookie是无法跨域名的,也就是说a域名和b域名下的cookie是无法共享的,这也是由Cookie的隐私安全性决定的,这样就能够阻止非法获取其他网站的Cookie
  • 每个域名下Cookie的数量不能超过20个,每个Cookie的大小不能超过4kb
  • 有安全问题,如果Cookie被拦截了,那就可获得session的所有信息,即使加密也于事无补,无需知道cookie的意义,只要转发cookie就能达到目的
  • Cookie在请求一个新的页面的时候都会被发送过去

如果需要域名之间跨域共享Cookie,有两种方法:

  1. 使用Nginx反向代理
  2. 在一个站点登陆之后,往其他网站写Cookie。服务端的Session存储到一个节点,Cookie存储sessionId

Cookie的使用场景:

  • 最常见的使用场景就是Cookie和session结合使用,我们将sessionId存储到Cookie中,每次发请求都会携带这个sessionId,这样服务端就知道是谁发起的请求,从而响应相应的信息。
  • 可以用来统计页面的点击次数
(2)LocalStorage

LocalStorage是HTML5新引入的特性,由于有的时候我们存储的信息较大,Cookie就不能满足我们的需求,这时候LocalStorage就派上用场了。

LocalStorage的优点:

  • 在大小方面,LocalStorage的大小一般为5MB,可以储存更多的信息
  • LocalStorage是持久储存,并不会随着页面的关闭而消失,除非主动清理,不然会永久存在
  • 仅储存在本地,不像Cookie那样每次HTTP请求都会被携带

LocalStorage的缺点:

  • 存在浏览器兼容问题,IE8以下版本的浏览器不支持
  • 如果浏览器设置为隐私模式,那我们将无法读取到LocalStorage
  • LocalStorage受到同源策略的限制,即端口、协议、主机地址有任何一个不相同,都不会访问

LocalStorage的常用API:

javascript
 代码解读
复制代码
// 保存数据到 localStorage
localStorage.setItem('key', 'value');

// 从 localStorage 获取数据
let data = localStorage.getItem('key');

// 从 localStorage 删除保存的数据
localStorage.removeItem('key');

// 从 localStorage 删除所有保存的数据
localStorage.clear();

// 获取某个索引的Key
localStorage.key(index)

LocalStorage的使用场景:

  • 有些网站有换肤的功能,这时候就可以将换肤的信息存储在本地的LocalStorage中,当需要换肤的时候,直接操作LocalStorage即可
  • 在网站中的用户浏览信息也会存储在LocalStorage中,还有网站的一些不常变动的个人信息等也可以存储在本地的LocalStorage中
(3)SessionStorage

SessionStorage和LocalStorage都是在HTML5才提出来的存储方案,SessionStorage 主要用于临时保存同一窗口(或标签页)的数据,刷新页面时不会删除,关闭窗口或标签页之后将会删除这些数据。

SessionStorage与LocalStorage对比:

  • SessionStorage和LocalStorage都在本地进行数据存储
  • SessionStorage也有同源策略的限制,但是SessionStorage有一条更加严格的限制,SessionStorage只有在同一浏览器的同一窗口下才能够共享
  • LocalStorage和SessionStorage都不能被爬虫爬取

SessionStorage的常用API:

javascript
 代码解读
复制代码
// 保存数据到 sessionStorage
sessionStorage.setItem('key', 'value');

// 从 sessionStorage 获取数据
let data = sessionStorage.getItem('key');

// 从 sessionStorage 删除保存的数据
sessionStorage.removeItem('key');

// 从 sessionStorage 删除所有保存的数据
sessionStorage.clear();

// 获取某个索引的Key
sessionStorage.key(index)

SessionStorage的使用场景

  • 由于SessionStorage具有时效性,所以可以用来存储一些网站的游客登录的信息,还有临时的浏览记录的信息。当关闭网站之后,这些信息也就随之消除了。

2. Cookie有哪些字段,作用分别是什么

Cookie由以下字段组成:

  • Name:cookie的名称
  • Value:cookie的值,对于认证cookie,value值包括web服务器所提供的访问令牌;
  • Size: cookie的大小
  • Path:可以访问此cookie的页面路径。 比如domain是abc.com,path是/test,那么只有/test路径下的页面可以读取此cookie。
  • Secure: 指定是否使用HTTPS安全协议发送Cookie。使用HTTPS安全协议,可以保护Cookie在浏览器和Web服务器间的传输过程中不被窃取和篡改。该方法也可用于Web站点的身份鉴别,即在HTTPS的连接建立阶段,浏览器会检查Web网站的SSL证书的有效性。但是基于兼容性的原因(比如有些网站使用自签署的证书)在检测到SSL证书无效时,浏览器并不会立即终止用户的连接请求,而是显示安全风险信息,用户仍可以选择继续访问该站点。
  • Domain:可以访问该cookie的域名,Cookie 机制并未遵循严格的同源策略,允许一个子域可以设置或获取其父域的 Cookie。当需要实现单点登录方案时,Cookie 的上述特性非常有用,然而也增加了 Cookie受攻击的危险,比如攻击者可以借此发动会话定置攻击。因而,浏览器禁止在 Domain 属性中设置.org、.com 等通用顶级域名、以及在国家及地区顶级域下注册的二级域名,以减小攻击发生的范围。
  • HTTP: 该字段包含HTTPOnly 属性 ,该属性用来设置cookie能否通过脚本来访问,默认为空,即可以通过脚本访问。在客户端是不能通过js代码去设置一个httpOnly类型的cookie的,这种类型的cookie只能通过服务端来设置。该属性用于防止客户端脚本通过document.cookie属性访问Cookie,有助于保护Cookie不被跨站脚本攻击窃取或篡改。但是,HTTPOnly的应用仍存在局限性,一些浏览器可以阻止客户端脚本对Cookie的读操作,但允许写操作;此外大多数浏览器仍允许通过XMLHTTP对象读取HTTP响应中的Set-Cookie头。
  • Expires/Max-size : 此cookie的超时时间。若设置其值为一个时间,那么当到达此时间后,此cookie失效。不设置的话默认值是Session,意思是cookie会和session一起失效。当浏览器关闭(不是浏览器标签页,而是整个浏览器) 后,此cookie失效。

总结: 服务器端可以使用 Set-Cookie 的响应头部来配置 cookie 信息。一条cookie 包括了5个属性值 expires、domain、path、secure、HttpOnly。其中 expires 指定了 cookie 失效的时间,domain 是域名、path是路径,domain 和 path 一起限制了 cookie 能够被哪些 url 访问。secure 规定了 cookie 只能在确保安全的情况下传输,HttpOnly 规定了这个 cookie 只能被服务器访问,不能使用 js 脚本访问。

cookie 有效期

Cookie的有效期可以通过ExpiresMax-Age两个属性来设置。

  • Expires过期时间
  • Max-Age用的是一段时间间隔,单位是秒,从浏览器收到报文开始计算
  • 过期时间如果设置为负数0,则浏览器关闭直接被销毁

3. Cookie、LocalStorage、SessionStorage区别

浏览器端常用的存储技术是 cookie 、localStorage 和 sessionStorage。

  • cookie: 其实最开始是服务器端用于记录用户状态的一种方式,由服务器设置,在客户端存储,然后每次发起同源请求时,发送给服务器端。cookie 最多能存储 4 k 数据,它的生存时间由 expires 属性指定,并且 cookie 只能被同源的页面访问共享。
  • sessionStorage: html5 提供的一种浏览器本地存储的方法,它借鉴了服务器端 session 的概念,代表的是一次会话中所保存的数据。它一般能够存储 5M 或者更大的数据,它在当前窗口关闭后就失效了,并且 sessionStorage 只能被同一个窗口的同源页面所访问共享。
  • localStorage: html5 提供的一种浏览器本地存储的方法,它一般也能够存储 5M 或者更大的数据。它和 sessionStorage 不同的是,除非手动删除它,否则它不会失效,并且 localStorage 也只能被同源页面所访问共享。

上面几种方式都是存储少量数据的时候的存储方式,当需要在本地存储大量数据的时候,我们可以使用浏览器的 indexDB 这是浏览器提供的一种本地的数据库存储机制。它不是关系型数据库,它内部采用对象仓库的形式存储数据,它更接近 NoSQL 数据库。

IndexedDB

IndexedDB 具有以下特点:

  • 键值对储存:IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以"键值对"的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。

  • 异步:IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。

  • 支持事务:IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。

  • 同源限制: IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。

  • 储存空间大:IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,存储数据量不超过可用磁盘空间的 50%

  • 支持二进制储存:IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。

浏览器同源策略

1. 什么是同源策略

  • protocol(协议)、domain(域名)、port(端口)三者必须一致。
  • 同源策略是一种最基本的安全策略,他限制了客户端js代码的部分行为; |

同源政策主要限制了三个方面:

  • 当前域下的 js 脚本不能够访问其他域下的 cookie、localStorage 和 indexDB。
  • 当前域下的 js 脚本不能够操作访问操作其他域下的 DOM。
  • 当前域下 ajax 无法发送跨域请求。

同源政策的目的主要是为了保证用户的信息安全,它只是对 js 脚本的一种限制,并不是对浏览器的限制,对于一般的 img、或者script 脚本请求都不会有跨域的限制,这是因为这些操作都不会通过响应结果来进行可能出现安全问题的操作。

2. 跨越

什么是跨域

指的是浏览器不能执行其他网站的脚本,它是由浏览器的同源策略造成的,是浏览器对 javascript 施加的安全限制,防止他人恶意攻击网站

跨域问题其实就是浏览器的同源策略造成的。

(1)CORS
CORS定义

跨域资源共享(CORS)是一种机制,是W3C标准。它允许浏览器向跨域服务器,发出XMLHttpRequestFetch请求。并且整个CORS通信过程都是浏览器自动完成的,不需要用户参与。只要浏览器和服务器同时支持,服务器实现了CORS请求**,就可以跨源通信了。

CORS 请求步骤
  • 当我们发起跨域请求时,如果是复杂请求,浏览器会帮我们自动触发预检请求,也就是 OPTIONS 请求,用于确认目标资源是否支持跨域。如果是简单请求,则不会触发预检,直接发出正常请求。
  • 浏览器会根据服务端响应的 headerAccess-Control-Allow-origin) 进行判断,如果响应支持跨域,则继续发出正常请求,如果不支持,则在控制台显示错误。

CORS分为简单请求非简单请求

简单请求

简单请求不会触发CORS预检请求。若该请求满足以下两个条件,就可以看作是简单请求:

1)请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

若不满足以上条件,就属于非简单请求了。

(1)简单请求过程:

对于简单请求,浏览器会直接发出CORS请求,它会在请求的头信息中增加一个Orign字段,该字段用来说明本次请求来自哪个源(协议+端口+域名),服务器会根据这个值来决定是否同意这次请求。如果Orign指定的域名在许可范围之内,服务器返回的响应就会多出以下信息头:

javascript
 代码解读
复制代码
Access-Control-Allow-Origin: http://api.bob.com  // 和Orign一直
Access-Control-Allow-Credentials: true   // 表示是否允许发送Cookie
Access-Control-Expose-Headers: FooBar   // 指定返回其他字段的值
Content-Type: text/html; charset=utf-8   // 表示文档类型

如果Orign指定的域名不在许可范围之内,服务器会返回一个正常的HTTP回应,浏览器发现没有上面的Access-Control-Allow-Origin头部信息,就知道出错了。这个错误无法通过状态码识别,因为返回的状态码可能是200。

在简单请求中,在服务器内,至少需要设置字段: Access-Control-Allow-Origin

非简单请求

不符合以上条件的就是复杂请求。比如请求方法为DELETE或者PUT等。复杂请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为 预检请求

预检请求使用的请求方法是OPTIONS,通过该请求来查询服务端是否允许跨域请求。他的头信息中的关键字段是Orign,表示请求来自哪个源。除此之外,头信息中还包括两个字段:

  • Access-Control-Request-Method:该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法。
  • Access-Control-Request-Headers: 该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段。

服务器在收到浏览器的预检请求之后,会根据头信息的三个字段来进行判断,如果返回的头信息在中有Access-Control-Allow-Origin这个字段就是允许跨域请求,如果没有,就是不同意这个预检请求,就会报错。

服务器回应的CORS的字段如下:

javascript
 代码解读
复制代码
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  // 用来指定本次预检请求的有效期,单位为秒

只要服务器通过了预检请求,在以后每次的CORS请求都会自带一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。

在非简单请求中,至少需要设置以下字段:

javascript
 代码解读
复制代码
'Access-Control-Allow-Origin'  
'Access-Control-Allow-Methods'
'Access-Control-Allow-Headers'
减少OPTIONS请求次数:

OPTIONS请求次数过多就会损耗页面加载的性能,降低用户体验度。所以尽量要减少OPTIONS请求次数,可以后端在请求的返回头部添加:Access-Control-Max-Age:number。它表示预检请求的返回结果可以被缓存多久,单位是秒。该字段只对完全一样的URL的缓存设置生效,所以设置了缓存时间,在这个时间范围内,再次发送请求就不需要进行预检请求了。

CORS中Cookie相关问题:

在CORS请求中,如果想要传递Cookie,就要满足以下三个条件:

  • 在请求中设置 withCredentials

默认情况下在跨域请求,浏览器是不带 cookie 的。但是我们可以通过设置 withCredentials 来进行传递 cookie.

javascript
 代码解读
复制代码
// 原生 xml 的设置方式
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
// axios 设置方式
axios.defaults.withCredentials = true;
  • Access-Control-Allow-Credentials 设置为 true
  • Access-Control-Allow-Origin 设置为非 *
(2)JSONP

jsonp的原理就是利用<script>标签没有跨域限制,通过<script>标签src属性,发送带有callback参数的GET请求,服务端将接口返回数据拼凑到callback函数中,返回给浏览器,浏览器解析执行,从而前端拿到callback函数返回的数据。 1)原生JS实现:

javascript
 代码解读
复制代码
<script>
    var script = document.createElement('script');
    script.type = 'text/javascript';
    // 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数
    script.src = 'http://www.domain2.com:8080/login?user=admin&callback=handleCallback';
    document.head.appendChild(script);
    // 回调执行函数
    function handleCallback(res) {
        alert(JSON.stringify(res));
    }
 </script>

服务端返回如下(返回时即执行全局函数):

javascript
 代码解读
复制代码
handleCallback({"success": true, "user": "admin"})

2)Vue axios实现:

javascript
 代码解读
复制代码
this.$http = axios;
this.$http.jsonp('http://www.domain2.com:8080/login', {
    params: {},
    jsonp: 'handleCallback'
}).then((res) => {
    console.log(res); 
})

后端node.js代码:

javascript
 代码解读
复制代码
var querystring = require('querystring');
var http = require('http');
var server = http.createServer();
server.on('request', function(req, res) {
    var params = querystring.parse(req.url.split('?')[1]);
    var fn = params.callback;
    // jsonp返回设置
    res.writeHead(200, { 'Content-Type': 'text/javascript' });
    res.write(fn + '(' + JSON.stringify(params) + ')');
    res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');

JSONP的缺点:

  • 具有局限性, 仅支持get方法
  • 存在被 XSS 攻击的可能, 缺乏安全性保证
  • 需要服务端配合改造
(3)postMessage 跨域

postMessage是HTML5 XMLHttpRequest Level 2中的API,且是为数不多可以跨域操作的window属性之一,只需要拥有另一个窗口的引用,就可以传递消息给另一个窗口;通过onmessage监听传递过来的数据;

用于解决以下方面的问题:

  • 页面和其打开的新窗口的数据传递
  • 多窗口之间消息传递
  • 页面与嵌套的iframe消息传递
  • 上面三个场景的跨域数据传递

用法:postMessage(data,origin)方法接受两个参数:

  • data: html5规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用JSON.stringify()序列化。

  • origin: 协议+主机+端口号,也可以设置为"*",表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为"/"。

1)a.html:(domain1.com/a.html)

javascript
 代码解读
复制代码
<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>       
    var iframe = document.getElementById('iframe');
    iframe.onload = function() {
        var data = {
            name: 'aym'
        };
        // 向domain2传送跨域数据
        iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com');
    };
    // 接受domain2返回数据
    window.addEventListener('message', function(e) {
        alert('data from domain2 ---> ' + e.data);
    }, false);
</script>

2)b.html:(domain2.com/b.html)

javascript
 代码解读
复制代码
<script>
    // 接收domain1的数据
    window.addEventListener('message', function(e) {
        alert('data from domain1 ---> ' + e.data);
        var data = JSON.parse(e.data);
        if (data) {
            data.number = 16;
            // 处理后再发回domain1
            window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com');
        }
    }, false);
</script>

跨域请求如何携带 cookie

例如我们想要在跨域请求中带上cookie,需要满足以下条件:

  • Request 请求设置withCredentialstrue
  • samesite值要设置为none
  • 服务器设置首部字段Access-Control-Allow-Credentialstrue
  • 服务器的Access-Control-Allow-Origin 设置为* 或者对应的域名;
(4)nginx代理跨域

nginx代理跨域,实质和CORS跨域原理一样,通过配置文件设置请求响应头Access-Control-Allow-Origin…等字段。

1)nginx配置解决iconfont跨域 浏览器跨域访问js、css、img等常规静态资源被同源策略许可,但iconfont字体文件(eot|otf|ttf|woff|svg)例外,此时可在nginx的静态资源服务器中加入以下配置。

http
 代码解读
复制代码
location / {
  add_header Access-Control-Allow-Origin *;
}

2)nginx反向代理接口跨域 跨域问题:同源策略仅是针对浏览器的安全策略。服务器端调用HTTP接口只是使用HTTP协议,不需要同源策略,也就不存在跨域问题。 实现思路:通过Nginx配置一个代理服务器域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域访问。

nginx具体配置:

javascript
 代码解读
复制代码
#proxy服务器
server {
    listen       81;
    server_name  www.domain1.com;
    location / {
        proxy_pass   http://www.domain2.com:8080;  #反向代理
        proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
        index  index.html index.htm;
        # 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
        add_header Access-Control-Allow-Origin http://www.domain1.com;  #当前端只跨域不带cookie时,可为*
        add_header Access-Control-Allow-Credentials true;
    }
}
(5)nodejs 中间件代理跨域

node中间件实现跨域代理,原理大致与nginx相同,都是通过启一个代理服务器,实现数据的转发,也可以通过设置cookieDomainRewrite参数修改响应头中cookie中域名,实现当前域的cookie写入,方便接口登录认证。

1)非vue框架的跨域 使用node + express + http-proxy-middleware搭建一个proxy服务器。

  • 前端代码:
javascript
 代码解读
复制代码
var xhr = new XMLHttpRequest();
// 前端开关:浏览器是否读写cookie
xhr.withCredentials = true;
// 访问http-proxy-middleware代理服务器
xhr.open('get', 'http://www.domain1.com:3000/login?user=admin', true);
xhr.send();
  • 中间件服务器代码:
javascript
 代码解读
复制代码
var express = require('express');
var proxy = require('http-proxy-middleware');
var app = express();
app.use('/', proxy({
    // 代理跨域目标接口
    target: 'http://www.domain2.com:8080',
    changeOrigin: true,
    // 修改响应头信息,实现跨域并允许带cookie
    onProxyRes: function(proxyRes, req, res) {
        res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
        res.header('Access-Control-Allow-Credentials', 'true');
    },
    // 修改响应信息中的cookie域名
    cookieDomainRewrite: 'www.domain1.com'  // 可以为false,表示不修改
}));
app.listen(3000);
console.log('Proxy server is listen at port 3000...');

2)vue框架的跨域

node + vue + webpack + webpack-dev-server搭建的项目,跨域请求接口,直接修改webpack.config.js配置。开发环境下,vue渲染服务和接口代理服务都是webpack-dev-server同一个,所以页面与代理接口之间不再跨域。

webpack.config.js部分配置:

javascript
 代码解读
复制代码
module.exports = {
    entry: {},
    module: {},
    ...
    devServer: {
        historyApiFallback: true,
        proxy: [{
            context: '/login',
            target: 'http://www.domain2.com:8080',  // 代理跨域目标接口
            changeOrigin: true,
            secure: false,  // 当代理某些https服务报错时用
            cookieDomainRewrite: 'www.domain1.com'  // 可以为false,表示不修改
        }],
        noInfo: true
    }
}
(6)document.domain + iframe跨域

此方案仅限主域相同,子域不同的跨域应用场景。实现原理:两个页面都通过js强制设置document.domain为基础主域,就实现了同域。 1)父窗口:(domain.com/a.html)

javascript
 代码解读
复制代码
<iframe id="iframe" src="http://child.domain.com/b.html"></iframe>
<script>
    document.domain = 'domain.com';
    var user = 'admin';
</script>

1)子窗口:(child.domain.com/a.html)

javascript
 代码解读
复制代码
<script>
    document.domain = 'domain.com';
    // 获取父窗口中变量
    console.log('get js data from parent ---> ' + window.parent.user);
</script>
(7)location.hash + iframe跨域

实现原理:a欲与b跨域相互通信,通过中间页c来实现。 三个页面,不同域之间利用iframe的location.hash传值,相同域之间直接js访问来通信。

具体实现:A域:a.html -> B域:b.html -> A域:c.html,a与b不同域只能通过hash值单向通信,b与c也不同域也只能单向通信,但c与a同域,所以c可通过parent.parent访问a页面所有对象。

1)a.html:(domain1.com/a.html)

javascript
 代码解读
复制代码
<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');
    // 向b.html传hash值
    setTimeout(function() {
        iframe.src = iframe.src + '#user=admin';
    }, 1000);
    
    // 开放给同域c.html的回调方法
    function onCallback(res) {
        alert('data from c.html ---> ' + res);
    }
</script>

2)b.html:(.domain2.com/b.html)

ini
 代码解读
复制代码
<iframe id="iframe" src="http://www.domain1.com/c.html" style="display:none;"></iframe>
<script>
    var iframe = document.getElementById('iframe');
    // 监听a.html传来的hash值,再传给c.html
    window.onhashchange = function () {
        iframe.src = iframe.src + location.hash;
    };
</script>

3)c.html:(www.domain1.com/c.html)

javascript
 代码解读
复制代码
<script>
    // 监听b.html传来的hash值
    window.onhashchange = function () {
        // 再通过操作同域a.html的js回调,将结果传回
        window.parent.parent.onCallback('hello: ' + location.hash.replace('#user=', ''));
    };
</script>
(8)window.name + iframe跨域

window.name属性的独特之处:name值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)。

1)a.html:(domain1.com/a.html)

javascript
 代码解读
复制代码
var proxy = function(url, callback) {
    var state = 0;
    var iframe = document.createElement('iframe');
    // 加载跨域页面
    iframe.src = url;
    // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
    iframe.onload = function() {
        if (state === 1) {
            // 第2次onload(同域proxy页)成功后,读取同域window.name中数据
            callback(iframe.contentWindow.name);
            destoryFrame();
        } else if (state === 0) {
            // 第1次onload(跨域页)成功后,切换到同域代理页面
            iframe.contentWindow.location = 'http://www.domain1.com/proxy.html';
            state = 1;
        }
    };
    document.body.appendChild(iframe);
    // 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域frame js访问)
    function destoryFrame() {
        iframe.contentWindow.document.write('');
        iframe.contentWindow.close();
        document.body.removeChild(iframe);
    }
};
// 请求跨域b页面数据
proxy('http://www.domain2.com/b.html', function(data){
    alert(data);
});

2)proxy.html:(domain1.com/proxy.html)

中间代理页,与a.html同域,内容为空即可。 3)b.html:(domain2.com/b.html)

javascript
 代码解读
复制代码
<script>    
    window.name = 'This is domain2 data!';
</script>

通过iframe的src属性由外域转向本地域,跨域数据即由iframe的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

(9)WebSocket协议跨域

WebSocket protocol是HTML5一种新的协议。它实现了浏览器与服务器全双工通信,同时允许跨域通讯,是server push技术的一种很好的实现。

原生WebSocket API使用起来不太方便,我们使用Socket.io,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。

1)前端代码:

javascript
 代码解读
复制代码
<div>user input:<input type="text"></div>
<script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
<script>
var socket = io('http://www.domain2.com:8080');
// 连接成功处理
socket.on('connect', function() {
    // 监听服务端消息
    socket.on('message', function(msg) {
        console.log('data from server: ---> ' + msg); 
    });
    // 监听服务端关闭
    socket.on('disconnect', function() { 
        console.log('Server socket has closed.'); 
    });
});
document.getElementsByTagName('input')[0].onblur = function() {
    socket.send(this.value);
};
</script>

2)Nodejs socket后台:

javascript
 代码解读
复制代码
var http = require('http');
var socket = require('socket.io');
// 启http服务
var server = http.createServer(function(req, res) {
    res.writeHead(200, {
        'Content-type': 'text/html'
    });
    res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');
// 监听socket连接
socket.listen(server).on('connection', function(client) {
    // 接收信息
    client.on('message', function(msg) {
        client.send('hello:' + msg);
        console.log('data from client: ---> ' + msg);
    });
    // 断开处理
    client.on('disconnect', function() {
        console.log('Client socket has closed.'); 
    });
});

3. 正向代理和反向代理的区别

  • 正向代理:

客户端想获得一个服务器的数据,但是因为种种原因无法直接获取。于是客户端设置了一个代理服务器,并且指定目标服务器,之后代理服务器向目标服务器转交请求并将获得的内容发送给客户端。这样本质上起到了对真实服务器隐藏真实客户端的目的。实现正向代理需要修改客户端,比如修改浏览器配置。

  • 反向代理:

服务器为了能够将工作负载分不到多个服务器来提高网站性能 (负载均衡)等目的,当其受到请求后,会首先根据转发规则来确定请求应该被转发到哪个服务器上,然后将请求转发到对应的真实服务器上。这样本质上起到了对客户端隐藏真实服务器的作用。 一般使用反向代理后,需要通过修改 DNS 让域名解析到代理服务器 IP,这时浏览器无法察觉到真正服务器的存在,当然也就不需要修改配置了。

两者区别如图示: 正向代理和反向代理的结构是一样的,都是 client-proxy-server 的结构,它们主要的区别就在于中间这个 proxy 是哪一方设置的。在正向代理中,proxy 是 client 设置的,用来隐藏 client;而在反向代理中,proxy 是 server 设置的,用来隐藏 server。

4. Nginx的概念及其工作原理

Nginx 是一款轻量级的 Web 服务器,也可以用于反向代理、负载平衡和 HTTP 缓存等。Nginx 使用异步事件驱动的方法来处理请求,是一款面向性能设计的 HTTP 服务器。

传统的 Web 服务器如 Apache 是 process-based 模型的,而 Nginx 是基于event-driven模型的。正是这个主要的区别带给了 Nginx 在性能上的优势。

Nginx 架构的最顶层是一个 master process,这个 master process 用于产生其他的 worker process,这一点和Apache 非常像,但是 Nginx 的 worker process 可以同时处理大量的HTTP请求,而每个 Apache process 只能处理一个。

进程与线程

1. 进程与线程的概念

从本质上说,进程和线程都是 CPU 工作时间片的一个描述:

  • 进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。
  • 线程是进程中的更小单位,描述了执行一段指令所需的时间。

进程是资源分配的最小单位,线程是CPU调度的最小单位。

一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程进程是运行在虚拟内存上的,虚拟内存是用来解决用户对硬件资源的无限需求和有限的硬件资源之间的矛盾的。从操作系统角度来看,虚拟内存即交换文件;从处理器角度看,虚拟内存即虚拟地址空间。

如果程序很多时,内存可能会不够,操作系统为每个进程提供一套独立的虚拟地址空间,从而使得同一块物理内存在不同的进程中可以对应到不同或相同的虚拟地址,变相的增加了程序可以使用的内存。

进程和线程之间的关系有以下四个特点:

(1)进程中的任意一线程执行出错,都会导致整个进程的崩溃。

(2)线程之间共享进程中的数据。

(3)当一个进程关闭之后,操作系统会回收进程所占用的内存, 当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。

(4)进程之间的内容相互隔离。 进程隔离就是为了使操作系统中的进程互不干扰,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信的机制了。

Chrome浏览器的架构图 从图中可以看出,最新的 Chrome 浏览器包括:

  • 1 个浏览器主进程
  • 1 个 GPU 进程
  • 1 个网络进程
  • 多个渲染进程
  • 多个插件进程

这些进程的功能:

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU 进程:其实, GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
  • 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

所以,打开一个网页,最少需要四个进程:1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程。如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。

虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:

  • 更高的资源占用:因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
  • 更复杂的体系架构:浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。

2. 进程和线程的区别

  • 进程可以看做独立应用,线程不能
  • 资源:进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位);线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)。
  • 通信方面:线程间可以通过直接共享同一进程中的资源,而进程通信需要借助 进程间通信。
  • 调度:进程切换比线程切换的开销要大。线程是CPU调度的基本单位,线程的切换不会引起进程切换,但某个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
  • 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存、I/O 等,其开销远大于创建或撤销线程时的开销。同理,在进行进程切换时,涉及当前执行进程 CPU 环境还有各种各样状态的保存及新调度进程状态的设置,而线程切换时只需保存和设置少量寄存器内容,开销较小。

3. 浏览器渲染进程的线程有哪些

浏览器的渲染进程的线程总共有五种: (1)GUI渲染线程 负责渲染浏览器页面,解析HTML、CSS,构建DOM树、构建CSSOM树、构建渲染树和绘制页面;当界面需要重绘或由于某种操作引发回流时,该线程就会执行。

注意:GUI渲染线程和JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

(2)JS引擎线程 JS引擎线程也称为JS内核,负责处理Javascript脚本程序,解析Javascript脚本,运行代码;JS引擎线程一直等待着任务队列中任务的到来,然后加以处理,一个Tab页中无论什么时候都只有一个JS引擎线程在运行JS程序;

注意:GUI渲染线程与JS引擎线程的互斥关系,所以如果JS执行的时间过长,会造成页面的渲染不连贯,导致页面渲染加载阻塞。

(3)时间触发线程 时间触发线程属于浏览器而不是JS引擎,用来控制事件循环;当JS引擎执行代码块如setTimeOut时(也可是来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件触发线程中;当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理;

注意:由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行);

(4)定时器触发进程 定时器触发进程即setInterval与setTimeout所在线程;浏览器定时计数器并不是由JS引擎计数的,因为JS引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确性;因此使用单独线程来计时并触发定时器,计时完毕后,添加到事件队列中,等待JS引擎空闲后执行,所以定时器中的任务在设定的时间点不一定能够准时执行,定时器只是在指定时间点将任务添加到事件队列中;

注意:W3C在HTML标准中规定,定时器的定时时间不能小于4ms,如果是小于4ms,则默认为4ms。

(5)异步http请求线程

  • XMLHttpRequest连接后通过浏览器新开一个线程请求;
  • 检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将回调函数放入事件队列中,等待JS引擎空闲后执行;

4. 进程之前的通信方式

  • 信号:信号是进程间通信唯一的异步通信机制,因为可以在任何时候发送信号给某一个进程

  • 匿名管道:是一个内核缓冲区,进程以先进先出的方式从缓冲区中存取数据,管道一端的进程在缓冲区的末尾写数据,管道另一端的进程在缓冲区的头部读数据;只能用于父子关系的进程

  • 命名管道:正常的匿名管道需要父子关系,而命名管道提供了一个路径名与之相连,从而以文件的形式存在于文件系统中;就不受父子关系的限制;

  • 消息队列:消息队列本质上是保存在内核当中的消息链表

  • 共享内存:共享内存可以使得多个进程可以直接读写在同一块内存空间中,这是效率最高的进程间通信方式。

  • SocketSocket可以让跨网络的不同主机之间进行通讯,还可以在同主机上进程间通讯;

具体细节

(1)管道通信

管道是一种最基本的进程间通信机制。管道就是操作系统在内核中开辟的一段缓冲区,进程1可以将需要交互的数据拷贝到这段缓冲区,进程2就可以读取了。

管道的特点:

  • 只能单向通信
  • 只能血缘关系的进程进行通信
  • 依赖于文件系统
  • 生命周期随进程
  • 面向字节流的服务
  • 管道内部提供了同步机制

(2)消息队列通信

消息队列就是一个消息的列表。用户可以在消息队列中添加消息、读取消息等。消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。

使用消息队列进行进程间通信,可能会收到数据块最大长度的限制约束等,这也是这种通信方式的缺点。如果频繁的发生进程间的通信行为,那么进程需要频繁地读取队列中的数据到内存,相当于间接地从一个进程拷贝到另一个进程,这需要花费时间。

(3)信号量通信

共享内存最大的问题就是多进程竞争内存的问题,就像类似于线程安全问题。我们可以使用信号量来解决这个问题。信号量的本质就是一个计数器,用来实现进程之间的互斥与同步。例如信号量的初始值是 1,然后 a 进程来访问内存1的时候,我们就把信号量的值设为 0,然后进程b 也要来访问内存1的时候,看到信号量的值为 0 就知道已经有进程在访问内存1了,这个时候进程 b 就会访问不了内存1。所以说,信号量也是进程之间的一种通信方式。

(4)信号通信

信号(Signals )是Unix系统中使用的最古老的进程间通信的方法之一。操作系统通过信号来通知进程系统中发生了某种预先规定好的事件(一组事件中的一个),它也是用户进程之间通信和同步的一种原始机制。

(5)共享内存通信

共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问(使多个进程可以访问同一块内存空间)。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

(6)套接字通信

上面说的共享内存、管道、信号量、消息队列,他们都是多个进程在一台主机之间的通信,那两个相隔几千里的进程能够进行通信吗?答是必须的,这个时候 Socket 这家伙就派上用场了,例如我们平时通过浏览器发起一个 http 请求,然后服务器给你返回对应的数据,这种就是采用 Socket 的通信方式了。

5. 僵尸进程和孤儿进程是什么?

  • 孤儿进程:父进程退出了,而它的一个或多个进程还在运行,那这些子进程都会成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
  • 僵尸进程:子进程比父进程先结束,而父进程又没有释放子进程占用的资源,那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵死进程。

6. 死锁产生的原因? 如果解决死锁的问题?

所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。

系统中的资源可以分为两类:

  • 可剥夺资源,是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU和主存均属于可剥夺性资源;
  • 不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。

产生死锁的原因:

(1)竞争资源

  • 产生死锁中的竞争资源之一指的是竞争不可剥夺资源(例如:系统中只有一台打印机,可供进程P1使用,假定P1已占用了打印机,若P2继续要求打印机打印将阻塞)
  • 产生死锁中的竞争资源另外一种资源指的是竞争临时资源(临时资源包括硬件中断、信号、消息、缓冲区内的消息等),通常消息通信顺序进行不当,则会产生死锁

(2)进程间推进顺序非法

若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁。例如,当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁

产生死锁的必要条件:

  • 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
  • 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
  • 环路等待条件:在发生死锁时,必然存在一个进程——资源的环形链。

预防死锁的方法:

  • 资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件)
  • 只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏请保持条件)
  • 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
  • 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

7. 如何实现浏览器内多个标签页之间的通信?

实现多个标签页之间的通信,本质上都是通过中介者模式来实现的。因为标签页之间没有办法直接通信,因此我们可以找一个中介者,让标签页和中介者进行通信,然后让这个中介者来进行消息的转发。通信方法如下:

  • 使用 websocket 协议,因为 websocket 协议可以实现服务器推送,所以服务器就可以用来当做这个中介者。标签页通过向服务器发送数据,然后由服务器向其他标签页推送转发。
  • 使用 ShareWorker 的方式,shareWorker 会在页面存在的生命周期内创建一个唯一的线程,并且开启多个页面也只会使用同一个线程。这个时候共享线程就可以充当中介者的角色。标签页间通过共享一个线程,然后通过这个共享的线程来实现数据的交换。
  • 使用 localStorage 的方式,我们可以在一个标签页对 localStorage 的变化事件进行监听,然后当另一个标签页修改数据的时候,我们就可以通过这个监听事件来获取到数据。这个时候 localStorage 对象就是充当的中介者的角色。
  • 使用 postMessage 方法,如果我们能够获得对应标签页的引用,就可以使用postMessage 方法,进行通信。

8.Web Worker

现代浏览器为JavaScript创造的 多线程环境。可以新建并将部分任务分配到worker线程并行运行,两个线程可 独立运行,互不干扰,可通过自带的 消息机制 相互通信。

一般使用 Web Worker 的场景是代码中有很多计算密集型或高延迟的任务,可以考虑分配给 Worker 线程。

限制
  • 同源限制:分配给worker线程运行的脚本文件,必须与主线程的脚本文件同源。

  • 文件限制:worker线程是运行在后台的,它所加载的脚本都必须是网络上的,不能读取本地文件

  • DOM限制:worker线程是不能直接操作dom对象的,如果要处理dom对象的话,应该是worker线程将内容返回给主线程,然后主线程再去操作DOM对象。

  • 脚本限制: worker线程不能执行alert()confirm等方法,但可以使用XMLHttpRequest发出ajax请求。

  • 通信限制: worker线程和主线程不在同一个上下文环境,它们不能直接通信,可以通过postMessage来进行通信;

9. 对Service Worker的理解

Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。

  • Service Worker 的生命周期包括 installactiveworking 三个阶段。

  • 一旦 Service Workerinstall,它将始终存在,只会在 activeworking 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件。

Service Worker 实现缓存功能(一般分为三个步骤):首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。以下是这个步骤的实现:

javascript
 代码解读
复制代码
// 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是在Web worker的基础上实现了离线缓存消息推送网络代理等功能。

  • 借助 Service worker 实现的离线缓存就称为 Service Worker Cache

  • Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器,且只能由HTTPS承载

10.JS 为什么是单线程的

  • JavaScript作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题

前端路由

hash 模式

  • 使用window.location.hash属性及窗口的onhashchange事件,可以实现监听浏览器地址hash值变化,执行相应的js切换网页。
hash 模式的特点
  • hash指的是地址中#号以及后面的字符,hash也称作锚点,本身是用来做页面跳转定位的。
  • 失去原生页面锚点定位能力
  • 可以通过 location.hash 来获取和设置hash值,值变化会直接反应到浏览器地址栏,但是不会重新加载页面;
触发 hashchange 事件的几种情况
  • 浏览器地址栏 hash 值的变化(包括浏览器的前进、后退导致的),会触发 onhashchange 事件
  • html<a> 标签的属性 href 可以设置为页面的元素ID,点击后自动跳转并设置 hash

history 模式

  • window.history 属性指向 History 对象,它表示当前窗口的浏览历史。当发生改变时,只会改变页面的路径,不会刷新页面。
  • History 对象保存了当前窗口访问过的所有页面网址。通过 history.length 可以得出当前窗口一共访问过几个网址。
  • 由于安全原因,浏览器不允许脚本读取这些地址,但是允许在地址之间导航。
  • 浏览器工具栏的前进后退按钮,其实就是对 History 对象进行操作。
history 的 API
  • History.back():移动到上一个网址,等同于点击浏览器的后退键。对于第一个访问的网址,该方法无效果。
  • History.forward():移动到下一个网址,等同于点击浏览器的前进键。对于最后一个访问的网址,该方法无效果。
  • History.go():接受一个整数作为参数,以当前网址为基准,移动到参数指定的网址。如果参数超过实际存在的网址范围,该方法无效果;如果不指定参数,默认参数为0,相当于刷新当前页面。
  • History.pushState():该方法用于在历史中添加一条记录。pushState()方法不会触发页面刷新,只是导致 History 对象发生变化,地址栏会有变化。
  • History.replaceState():该方法用来修改 History 对象的当前记录,用法与 pushState() 方法一样。
  • popstate():调用History.back()History.forward()History.go()方法时才会触发。
如何监听 replaceState 和 pushState

监听这两个时间,我们需要对 replaceStatepushState,去创建新的全局Event事件。然后 window.addEventListener 监听我们加的 Event 即可

简单的代码已经贴在下面,详细的可以看一文摸清前端监控自研实践(二)行为监控:【路由跳转】

ts
 代码解读
复制代码
// 派发出新的 Event
const wr = (type: keyof History) => {
  const orig = history[type];
  return function (this: unknown) {
    const rv = orig.apply(this, arguments);
    const e = new Event(type);
    window.dispatchEvent(e);
    return rv;
  };
};

// 添加 pushState replaceState 事件
export const wrHistory = (): void => {
  history.pushState = wr('pushState');
  history.replaceState = wr('replaceState');
};