浏览器工作原理之前端系列,知其然,知其所以然

314 阅读1小时+

作为web前端开发工程师每天和浏览器打交道,浏览器工作原理最主要的一个作用就是可以指导我们做性能优化,所以我们还是有必要了解一下浏览器工作原理的,本文主要内容主要涉及页面的网络请求渲染过程数据存储垃圾回收机制js执行机制eventloop缓存本地存储浏览器安全等。

浏览器工作原理知识体系思维导图如下

image.png

1 从输入url到页面呈现发生了什么

1. 网络加载篇

网络请求

构建请求
  • 用户输入:在地址栏输入URL(如 https://www.example.com

  • 解析URL:浏览器解析URL的各个部分:

    • 协议(https:
    • 域名(www.example.com
    • 端口(默认443 for HTTPS, 80 for HTTP)
    • 路径(/ 如果没有指定)

浏览器构建请求行信息(如下所示),构建好后,浏览器准备发起网络请求。

// 请求方法是GET,路径为根路径,HTTP协议版本为1.1 
GET / HTTP/1.1
查找强缓存

在真正发起网络请求之前,先检查强缓存,如果有效可以直接使用浏览器缓存中的文件,否则进入下一步

DNS解析

由于我们输入的是域名,而数据包是通过IP地址传给对方的。因此我们需要得到域名对应的IP地址。

浏览器会请求 DNS 把域名解析为对应的 IP

DNS 也有数据缓存服务

等待 TCP 队列

Chrome 有个机制,同一个域名同时最多只能建立 6 个 TCP 连接,如果在同一个域名下同时有 10 个请求发生,那么其中 4 个请求会进入排队等待状态,直至进行中的请求完成。当然,如果当前请求数量少于 6,会直接进入下一步,建立 TCP 连接。

建立TCP连接

建立 TCP连接经历了下面三个阶段:

  1. 通过三次握手(即总共发送3个数据包确认已经建立连接)建立客户端和服务器之间的连接。
  2. 进行数据传输。这里有一个重要的机制,就是接收方接收到数据包后必须要向发送方确认, 如果发送方没有接到这个确认的消息,就判定为数据包丢失,并重新发送该数据包。当然,发送的过程中还有一个优化策略,就是把大的数据包拆成一个个小包,依次传输到接收方,接收方按照这个小包的顺序把它们组装成完整数据包。
  3. 断开连接的阶段。数据传输完成,现在要断开连接了,通过四次挥手来断开连接。

扩展

你应该明白 TCP 连接通过什么手段来保证数据传输的可靠性,一是三次握手确认连接,二是数据包校验保证数据到达接收方,三是通过四次挥手断开连接。

IP 通过 IP 地址信息把数据包发送给指定的电脑,而 UDP 通过端口号把数据包分发给正确的程序。

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。相对于 UDP,TCP 有下面两个特点:对于数据包丢失的情况,TCP 提供重传机制;TCP 引入了数据包排序机制,用来保证把乱序的数据包组合成一个完整的文件。

发送HTTP请求

现在TCP连接建立完毕,浏览器可以和服务器开始通信,即开始发送 HTTP 请求。浏览器发 HTTP 请求要携带三样东西:请求行、请求头和请求体

扩展

请求行,它包括了请求方法、请求 URI(Uniform Resource Identifier)和 HTTP 版本协议。

如果使用 POST 方法,那么浏览器还要准备数据给服务器,这里准备的数据是通过请求体来发送

请求头形式发送其他一些信息,把浏览器的一些基础信息告诉服务器。比如包含了浏览器所使用的操作系统、浏览器内核等信息,以及当前请求的域名信息、浏览器端的 Cookie 信息,等等

报文首部字段可以传递重要的信息,服务器或者客户端需要处理的请求和响应的内容和属性

首先,网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。

网络响应

HTTP 请求到达服务器,服务器进行对应的处理。最后要把数据传给浏览器,也就是返回网络响应。

跟请求部分类似,网络响应具有三个部分:响应行、响应头和响应体。

响应行类似下面这样:

HTTP/1.1 200 OK

扩展

断开连接

响应完成之后怎么办?TCP 连接就断开了吗?

这时候要判断Connection字段, 如果请求头或响应头中包含Connection: Keep-Alive,表示建立了持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接,否则断开TCP连接, 请求-响应流程结束。

重定向

在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301 或者 302,那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了

响应数据类型处理

URL 请求的数据类型,有时候是一个下载类型,有时候是正常的 HTML 页面,那么浏览器是如何区分它们呢?答案是 Content-Type。Content-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。

下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。但如果是 HTML,那么浏览器则会继续进行导航流程。

渲染进程

什么情况下多个页面会同时运行在一个渲染进程中呢 Chrome 的默认策略是,每个标签对应一个渲染进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫 process-per-site-instance

“同一站点”定义为根域名(例如,geekbang.org)加上协议相同

2. 页面渲染篇

构建DOM树

  • 构建DOM树(深度遍历解析HTML标签)将HTML转化为浏览器可以理解的DOM树
  • 遇到外部资源(CSS/JS/图片)时发起新请求

由于浏览器无法直接理解HTML字符串,因此将这一系列的字节流转换为一种有意义并且方便操作的数据结构,这种数据结构就是DOM树。DOM树本质上是一个以document为根节点的多叉树

解析算法

对应的两个过程就是词法分析和语法分析。

  • 标记化

这个算法输入为HTML文本,输出为HTML标记,也成为标记生成器

  • 建树算法

之前提到过,DOM 树是一个以document为根节点的多叉树。因此解析器首先会创建一个document对象。标记生成器会把每个标记的信息发送给建树器。建树器接收到相应的标记时,会创建对应的 DOM 对象。创建这个DOM对象后会做两件事情:

  • 将DOM对象加入 DOM 树中。
  • 将对应标记压入存放开放(与闭合标签意思对应)元素的栈中。

构建CSSOM树

样式计算 style,将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式

和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets。并且该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础。

关于CSS样式,它的来源一般是三种:

  1. link标签引用
  2. style标签中的样式
  3. 元素的内嵌style属性
  • 格式化样式表

浏览器是无法直接识别 CSS 样式文本的,因此渲染引擎接收到 CSS 文本之后第一件事情就是将其转化为一个结构化的对象,即styleSheets

  • 标准化样式属性

CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值。如em->px,red->#ff0000,bold->700等等。

  • 计算每个节点的具体样式

样式已经被格式化和标准化,接下来就可以计算每个节点的具体样式信息了。

其实计算的方式也并不复杂,主要就是两个规则: CSS 的继承规则和层叠规则

样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。

生成布局树 layout

我们有** DOM 树和 CSSOM 树中元素的样式**,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,也就是要生成一棵布局树(Layout Tree),我们把这个计算过程叫做布局。

布局树生成的大致工作如下:

  • 遍历生成的 DOM 树节点,并把他们添加到布局树中。
  • 计算布局树节点的坐标位置。

值得注意的是,这棵布局树值包含可见元素,对于 head标签和设置了display: none的元素,将不会被放入其中

扩展

  • HTML 的内容是由标记和文本组成。
  • CSS 又称为层叠样式表,是由选择器和属性组成
  • JavaScript(简称为 JS),使用它可以使网页的内容“动”起来

建立图层树 layer

页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)

浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面

并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?

  • 显示合成

第一点,拥有层叠上下文属性的元素会被提升为单独的一层。

  • HTML根元素本身就具有层叠上下文。

  • 普通元素设置position不为static并且设置了z-index属性,会产生层叠上下文。

  • 元素的 opacity 值不是 1

  • 元素的 transform 值不是 none

  • 元素的 filter 值不是 none

  • 元素的 isolation 值是isolate

  • will-change指定的属性值为上面任意一个。(will-change的作用后面会详细介绍) 第二点,需要剪裁(clip)的地方也会被创建为图层。

  • 隐式合成

简单来说就是层叠等级低的节点被提升为单独的图层之后,那么所有层叠等级比它高的节点都会成为一个单独的图层。

生成绘制列表 paint

渲染引擎会把一个图层的绘制拆分成很多小的绘制指令,比如先画背景、再描绘边框......然后再把这些指令按照顺序组成一个待绘制列表

“开发者工具”的“Layers”标签,选择“document”层,来实际体验下绘制列表

生成图块并栅格化(raster)操作 tiles raster

生成图块并栅格化(生成位图)

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。

当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程

合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。

栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中

Chrome 底层优化首屏加载速度

首先,考虑到视口就这么大,当页面非常大的时候,要滑很长时间才能滑到底,如果要一口气全部绘制出来是相当浪费性能的。因此,合成线程要做的第一件事情就是将图层分块。这些块的大小一般不会特别大,通常是 256 * 256 或者 512 * 512 这个规格。这样可以大大加速页面的首屏展示。

因为后面图块数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。

合成和显示 DrawQuad

  • 浏览器将各层合成到一起
  • 最终显示在屏幕上

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存发送给显卡显示在屏幕上。

为什么发给显卡呢?我想有必要先聊一聊显示器显示图像的原理。

无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区和后缓冲区对换位置,如此循环更新。

看到这里你也就是明白,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现

渲染流水线大总结

我们现在已经分析完了整个渲染流程,从 HTML 到 DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面我用一张图来总结下这整个渲染流程:

image.png 结合上图,一个完整的渲染流程大致可总结为如下:

  1. 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
  2. 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
  3. 创建布局树,并计算元素的布局信息。
  4. 对布局树进行分层,并生成分层树。
  5. 为每个图层生成绘制列表,并将其提交到合成线程。
  6. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
  7. 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
  8. 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。

2 v8引擎工作原理

1. 数据存储,栈空间和堆空间

JavaScript 是一种弱类型的、动态的语言

弱类型,声明 意味着你不需要告诉 JavaScript 引擎这个或那个变量是什么数据类型,JavaScript 引擎在运行代码的时候自己会计算出来(检查数据类型)。支持隐式类型转换的语言称为弱类型语言

动态,赋值 意味着你可以使用同一个变量保存不同类型的数据。 我们把这种在使用之前就需要确认其变量数据类型的称为静态语言。 相反地,我们把在运行过程中需要检查数据类型的语言称为动态语言

网上的资料基本是这样说的: 基本数据类型用栈存储,引用数据类型用堆存储。其实还是需要补充一句:闭包变量是存在堆内存中的

具体怎么存储的

  • 基本数据类型的值直接放入调用栈的变量环境中
  • 引用类型,JavaScript 引擎并不是直接将该对象存放到变量环境中,而是将它分配到堆空间里面,分配后该对象会有一个在“堆”中的地址,然后再将该数据的地址写进变量环境中

数据类型

具体而言,以下数据类型存储在栈中: boolean null undefined number string symbol bigint

而所有的对象数据类型存放在堆中。

值访问的区别

基本类型值是按值访问的,因为可以操作保存在变量中的实际的值。

引用类型的值是按引用访问,它是保存在内存中的对象,js中不能直接操作对象的内存空间,我们实际上操作的是对象的引用而不是实际的对象。

赋值操作的区别

原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

为什么一定要分“堆”和“栈”两个存储空间呢?所有数据直接存放在“栈”中不就可以了吗?

答案是不可以的。这是因为** JavaScript 引擎需要用栈来维护程序执行期间上下文的状态**,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。比如文中的 foo 函数执行结束了,JavaScript 引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了,foo 函数执行上下文栈区空间全部回收,

通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存回收内存都会占用一定的时间,带来更大的开销。

2. 垃圾回收机制

垃圾数据

有些数据被使用之后,可能就不再需要了,我们把这种数据称为垃圾数据。如果这些垃圾数据一直保存在内存中,那么内存会越用越多,所以我们需要对这些垃圾数据进行回收,以释放有限的内存空间。

不同语言的垃圾回收策略

垃圾数据回收分为手动回收自动回收两种策略

V8内存限制

两个因素所共同决定的,一个是JS单线程的执行机制,另一个是JS垃圾回收机制的限制

首先 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执。; 另一方面垃圾回收其实是非常耗时间的操作,V8 官方是这样形容的:

以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要50ms 以上,做一次非增量式(ps:后面会解释)的垃圾回收甚至要 1s 以上。

可见其耗时之久,而且在这么长的时间内,我们的JS代码执行会一直没有响应,造成应用卡顿,导致应用性能和响应能力直线下降。因此,V8 做了一个简单粗暴的选择,那就是限制堆内存,也算是一种权衡的手段,因为大部分情况是不会遇到操作几个G内存这样的场景的。

调用栈中的数据是如何回收的

当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文,即上下文切换之后,栈顶的空间会自动被回收。

堆内存如何实现垃圾回收

V8 中会把堆分为新生代和老生代两个区域新生代中存放的是生存时间短的对象老生代中存放的生存时间久的对象。根据这两种不同种类的堆内存,V8 采用了不同的回收策略,以便更高效地实施垃圾回收。副垃圾回收器,主要负责新生代的垃圾回收。主垃圾回收器,主要负责老生代的垃圾回收。

image.png

垃圾回收器的工作流程
  • 第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
  • 第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  • 第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。
副垃圾回收器

副垃圾回收器主要负责新生区的垃圾回收。而通常情况下,大多数小的对象都会被分配到新生区,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。

怎么进行垃圾回收的

新生代中用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域

在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域与空闲区域进行角色翻转,这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

特点

复制操作需要时间成本,为了执行效率,一般新生区的空间会被设置得比较小

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

怎么进行垃圾回收的

主垃圾回收器是采用标记 - 清除Mark-Sweep)和标记 - 整理Mark-Compact)的算法进行垃圾回收的

第一步,进行标记-清除首先会从根对象遍历堆中的所有可访问对象,对它们做上标记,剩下的就是要删除的变量即垃圾数据了,在随后的清除阶段,清除掉未标记的垃圾数据,对其进行空间的回收

image.png 当然这又会引发内存碎片的问题,碎片过多会导致大对象无法分配到足够的连续内存。老生代又是如何处理这个问题的呢?于是又产生了另外一种算法——标记 - 整理(Mark-Compact

第二步,标记 - 整理,整理内存碎片。这个标记过程仍然与标记 - 清除算法里的是一样的,后续步骤是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

image.png

增量标记

JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿Stop-The-World

image.png

由于JS的单线程机制,V8 在进行垃圾回收的时候,不可避免地会阻塞业务逻辑的执行,倘若老生代的垃圾回收任务很重,那么耗时会非常可怕,严重影响应用的性能。那这个时候为了避免这样问题,V8 采取了增量标记的方案,即将一口气完成的标记任务分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成才进入内存碎片的整理上面来。

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

image.png

总结

1. 标记-清除(Mark-and-Sweep) (主流浏览器的默认算法)
  • 步骤

    1. 标记:从根对象(全局变量、活动函数调用栈等)出发,递归标记所有可达对象。
    2. 清除:遍历堆内存,释放未被标记的对象。
  • 特点

    • 解决循环引用问题(旧引用计数法的缺陷)。
    • 可能产生内存碎片。
2. 分代回收(Generational Collection) (V8 引擎优化)
  • 内存分区

    • 新生代(New Space) :短期存活对象(如局部变量),使用 Scavenge 算法(复制清除)。
    • 老生代(Old Space) :长期存活对象(如全局变量),使用 标记-清除 + 标记-整理
  • 晋升机制:对象在新生代经历多次 GC 仍存活,则移至老生代。

3. 增量标记(Incremental Marking)与惰性清理(V8 优化)
  • 将标记过程拆分为小任务,穿插在 JS 执行中,减少页面卡顿。

3. js执行机制

V8 执行一段JS代码的过程

  • i. 首先通过词法分析和语法分析生成 AST
  • ii. 将 AST 转换为字节码
  • iii. 由解释器逐行执行字节码,遇到热点代码启动编译器进行编译,生成对应的机器码, 以优化执行效率

编译器和解释器

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了,比如 C/C++、GO 等都是编译型语言。

而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

image.png

从图中你可以看出这二者的执行流程,大致可阐述为如下:

编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。

解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果。

V8 是如何执行一段 JavaScript 代码的

image.png

1. 生成抽象语法树(AST)和执行上下文

第一阶段是分词tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个 token。所谓 token,指的是语法上不可能再分的、最小的单个字符或字符串。

第二阶段是解析parse),又称为语法分析,其作用是将上一步生成的 token 数据,根据语法规则转为 AST。

AST 后,那接下来 V8 就会生成该段代码的执行上下文

扩展

AST 是非常重要的一种数据结构,在很多项目中有着广泛的应用。其中最著名的一个项目是 Babel。

Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

还有 ESLint 也使用 AST

ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。

2. 生成字节码

生成 AST 之后,直接通过 V8 的解释器(也叫Ignition)来生成字节码

但是字节码并不能让机器直接运行,那你可能就会说了,不能执行还转成字节码干嘛,直接把 AST 转换成机器码不就得了,让机器直接执行。确实,在 V8 的早期是这么做的,但后来因为机器码的体积太大,引发了严重的内存占用问题。

那什么是字节码呢?为什么引入字节码就能解决内存占用问题呢?

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。

字节码仍然需要转换为机器码,但和原来不同的是,现在不用一次性将全部的字节码都转换成机器码,而是通过解释器来逐行执行字节码,省去了生成二进制文件的操作,这样就大大降低了内存的压力。

3. 执行代码

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。到了这里,相信你已经发现了,解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。在 Ignition 执行字节码的过程中,如果发现有热点代码HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

其实当你听到有人说 JS 就是一门解释器语言的时候,其实这个说法是有问题的。因为字节码不仅配合了解释器,而且还和编译器打交道,所以 JS 并不是完全的解释型语言。而编译器和解释器的

根本区别在于前者会编译生成二进制文件但后者不会。

并且,这种字节码跟编译器和解释器结合的技术,我们称之为即时编译, 也就是我们经常听到的JIT。

3 eventLoop

什么是eventloop

JavaScript的事件轮询是一种非阻塞的、异步的编程模型,用于处理JavaScript中的事件和回调函数。

为什么引入eventloop

JavaScript 运行机制详解:再谈Event Loop

线程 VS 进程

  • 进程

一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程

  • 线程

线程是不能单独存在的,线程是依附于进程的,它是由进程来启动和管理的。

多进程架构

image.png

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

为什么说js的执行环境是单线程的

页面中的任务是在渲染进程的主线程上执行的,所有任务需要排队,每次只能执行一个任务,而其他任务就都处于等待状态,前一个任务结束,才会执行后一个任务。

为什么JavaScript不能有多个线程呢?这样能提高效率啊。

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?在这种情况下,就可能导致不能安全的渲染 UI。

*为什么引入EventLoop

JS 是单线程的,每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是eventloop即消息队列和事件循环系统。

eventloop是怎么执行任务的

浏览器页面是通过事件循环机制来驱动的,渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。如解析 DOM 事件、计算布局事件、执行 JavaScript 事件、用户输入事件等等,如果页面有新的事件产生,那新的事件将会追加到消息队列的尾部。

页面使用单线程有一些不足之处

  • 如何处理高优先级的任务eventloop引入了微任务,把任务区分为宏任务和微任务

    微任务解决任务执行实时性问题,比如MutationObserve监听DOM变化

  • 如何解决单个任务执行时长过久的问题,eventloop又引入了异步

    异步解决任务的执行效率问题

    因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。

    比如在浏览器端运行的 js ,可能会有大量的网络请求,而一个网络资源啥时候返回,这个时间是不可预估的。这种情况也要傻傻的等着、卡顿着、啥都不做吗? ———— 那肯定不行。

    因此,JS 对于这种场景就设计了异步 ———— 即,发起一个网络请求,接着处理其他任务,等网络请求返回结果,到时候再处理这个结果。这样就能保证一个网页的流程运行。

任务的时间颗粒度:宏任务和微任务

宏任务

页面中的大部分任务都是在主线程上执行的,把这些消息队列中的任务称为宏任务,这些任务包括了:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件,定时器

按照顺序执行消息队列中的宏任务主线程执行完一个宏任务之后,便会接着从消息队列中取出下一个宏任务并执行。

微任务

  • 微任务是怎么产生的?在现代浏览器里面,产生微任务有两种方式。

第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。

第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。以及基于promise的api asyn/await

  • 微任务执行时机

现在微任务队列中有了微任务了,那接下来就要看看微任务队列是何时被执行的。 通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

任务的执行模式:同步和异步

同步

同步指任务是连续的执行的,由于是连续执行,任务执行过程中,不能插入其他任务,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的

所以操作系统从硬盘读取文件的这段时间,程序只能干等着。

异步

异步简单说就是一个任务不是连续执行完成的,每一个任务有一个或多个回调函数(callback),该任务被人为分成两段,先执行第一段,然后转而执行下一个任务,等执行第一段有了结果,再回过头执行第二段,即执行回调函数。后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的

"异步"非常重要。在浏览器端,耗时很长的操作都应该异步执行,避免浏览器失去响应,最好的例子就是Ajax操作。在服务器端,"异步模式"甚至是唯一的模式,因为执行环境是单线程的,如果允许同步执行所有http请求,服务器性能会急剧下降,很快就会失去响应

比如,有一个任务是异步读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。

异步操作

  • 网络请求,如ajax
  • IO 操作,如readFile readdir
  • 定时函数,如setTimeout setInterval

异步编程的解决方案

回调函数,Promise,生成器,Async/Await来实现异步

*eventloop的整个执行流程

  1. 一开始整段脚本作为第一个宏任务执行,会将全局执行上下文压入调用栈中,并在执行上下文中创建一个空的微任务队列。
  2. 执行过程中同步代码直接执行,宏任务进入消息队列,微任务进入微任务队列
  3. 当前宏任务执行结束之前,检查微任务队列,如果有则依次执行,遇到微任务添加到微任务队列,直到微任务队列为空
  4. 执行浏览器 UI 线程的渲染工作
  5. 检查是否有Web worker任务,有则执行
  6. 然后开始下一轮 Event Loop,执行队首新的宏任务,回到2,依此循环,直到宏任务和微任务队列都为空

JavaScript 执行环境

JavaScript 在浏览器或 Node.js 中运行时,会有一套“任务管理系统”,主要分为三类队列:

类型示例队列名
同步任务普通代码、变量定义、函数调用等主线程立即执行
宏任务setTimeoutsetIntervalDOM事件宏任务队列(Task Queue)
微任务Promise.thenMutationObserverqueueMicrotask微任务队列(Microtask Queue)

4 缓存

对浏览器的缓存机制来做个简要的总结:

缓存代理服务器或者客户端本地保存资源的副本,利用缓存可减少对源服务器的访问,因此可以节省通信流量和通信时间它能显著提升网页加载速度和用户体验

浏览器分为强缓存和协商缓存(缓存资源)

缓存过程
浏览器在获取资源的时候,首先通过 Cache-Control 或则expires验证强缓存是否可用,如果强缓存可用,直接使用

否则进入协商缓存,即发送 HTTP 请求,服务器通过请求头中的If-Modified-Since或者If-None-Match字段检查资源是否更新

  • 若资源更新,返回状态码200和新的资源
  • 否则,返回状态码304,告诉浏览器直接从缓存获取资源

那具体怎么去判断强缓存和协商缓存是否失效?

客户端缓存

强缓存

服务器返回的响应头

Expires 过期时间点(http/1.0) Expires: Wed, 22 Oct 2025 08:41:00 GMT

Cache-Control:过期时长(http/1.1) (优先考虑)

Cache-Control的属性 或者指令,优先级高于Expires

  • max-age=3600
  • public: 客户端和代理服务器都可以缓存。
  • private: 这种情况就是只有浏览器能缓存了,中间的代理服务器不能缓存。
  • no-cache: 需协商缓存验证 跳过当前的强缓存,发送HTTP请求,即直接进入协商缓存阶段。
  • no-store禁止缓存 非常粗暴,不进行任何形式的缓存。
  • s-maxage:这和max-age长得比较像,但是区别在于s-maxage是针对代理服务器的缓存时间

协商缓存

强缓存失效之后,浏览器在请求头中携带相应的缓存tag来向服务器发请求,由服务器根据这个tag,来决定是否使用缓存,这就是协商缓存。

  • Etag 响应头标记(文件生成的唯一标识) (优先考虑)

    下次请求,请求头带上 If-None-Match

  • Last-Modified 响应头最后修改时间

    下次请求 请求头带上If-Modified-Since

  • 区别

  1. 精准度上,ETag优于Last-Modified。优于 ETag 是按照内容给资源上标识,因此能准确感知资源的变化。而 Last-Modified 就不一样了,它在一些特殊的情况并不能准确感知资源变化,主要有两种情况:
  • 编辑了资源文件,但是文件内容并没有更改,这样也会造成缓存失效。
  • Last-Modified 能够感知的单位时间是秒,如果文件在 1 秒内改变了多次,那么这时候的 Last-Modified 并没有体现出修改了。
  1. 性能上Last-Modified优于ETag,也很简单理解,Last-Modified仅仅只是记录一个时间点,而 Etag需要根据文件的具体内容生成哈希值。

另外,如果两种方式都支持的话,服务器会优先考虑ETag

代理缓存

让代理服务器接管一部分的服务端HTTP缓存,客户端缓存过期后就近到代理缓存中获取,代理缓存过期了才请求源服务器,这样流量巨大的时候能明显降低源服务器的压力。

Cache-control

源服务器的缓存控制 响应指令

  • Privatepublic 是否允许缓存到代理服务器
  • must-revalidate的意思是客户端缓存过期就去源服务器获取,
  • proxy-revalidate则表示代理服务器的缓存过期后到源服务器获取。
  • s-maxage 代理服务器中的缓存时间

客户端的缓存控制 请求指令

  • Max-stale 对代理服务器上的缓存进行宽容和限制操作,过期时间内是有效的
  • min-fresh,未过指定时间的缓存资源
  • Only-if-cached 这个字段加上后表示客户端只会接受代理缓存,而不会接受源服务器的响应。如果代理缓存无效,则直接返回504(Gateway Timeout)

缓存位置

前面我们已经提到,当强缓存命中或者协商缓存中服务器返回304的时候,我们直接从缓存中获取资源。那这些资源究竟缓存在什么位置呢?

浏览器中的缓存位置一共有四种,按优先级从高到低排列分别是:

  1. Service Worker:运行在浏览器背后的独立线程,可以拦截请求并返回缓存响应,必须使用HTTPS协议
  2. Memory Cache:内存缓存,读取速度快但容量小,随进程释放而释放
  3. Disk Cache:硬盘缓存,容量大且存储时间长,是主要的缓存位置
  4. Push Cache:HTTP/2特有,会话级别缓存,持续时间短(约5分钟)

Service Worker

Service Worker 借鉴了 Web Worker的 思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问DOM。虽然如此,但它仍然能帮助我们完成很多有用的功能,比如离线缓存消息推送网络代理等功能。其中的离线缓存就是 Service Worker Cache

Service Worker 同时也是 PWA 的重要实现机制,关于它的细节和特性,我们将会在后面的 PWA 的分享中详细介绍。

Memory Cache 和 Disk Cache

Memory Cache指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

Disk Cache就是存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长。稍微有些计算机基础的应该很好理解,就不展开了。

好,现在问题来了,既然两者各有优劣,那浏览器如何决定将资源放进内存还是硬盘呢?主要策略如下:

  • 比较大的JS、CSS文件会直接被丢进磁盘,反之丢进内存
  • 内存使用率比较高的时候,文件优先进入磁盘

Push Cache

即推送缓存,这是浏览器缓存的最后一道防线。它是 HTTP/2 中的内容,虽然现在应用的并不广泛,但随着 HTTP/2 的推广,它的应用越来越广泛。关于 Push Cache,有非常多的内容可以挖掘,不过这已经不是本文的重点,大家可以参考这篇[扩展文章]

缓存场景

  • 对于大部分的场景都可以使用强缓存配合协商缓存解决,但是在一些特殊的地方可能需要选择特殊的缓存策略
  • 敏感数据:对于某些不需要缓存的资源,可以使用 Cache-control: no-store ,表示该资源不需要缓存
  • 静态资源:对于不频繁变化的资源使用强缓存 expires,Cache-Control,通过文件名哈希实现更新
  • 动态资源:对于频繁变动的资源,可以使用 Cache-Control: no-cache 并配合 ETag 使用,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新
  • 对于代码文件来说,通常使用 Cache-Control: max-age=31536000 并配合策略缓存使用,然后对文件进行指纹处理,一旦文件名变动就会立刻下载新的文件

5 本地存储

Cookie

Cookie 最开始被设计出来其实并不是来做本地存储的,而是为了弥补HTTP在状态管理上的不足。 HTTP 是一个无状态的协议,每次 http 请求都是独立、无关的,默认不需要保留状态信息。HTTP 为此引入了 Cookie,Cookie 本质上就是浏览器里面存储的一个很小的文本文件,内部以键值对的方式来存储,向同一个域名下发送请求,都会携带相同的 Cookie,服务器拿到 Cookie 进行解析,便能拿到客户端的状态。而服务端可以通过响应头中的Set-Cookie字段来对客户端写入Cookie

Cookie 属性

// 完整属性设置
document.cookie = "username=John Doe; expires=Thu, 18 Dec 2025 12:00:00 UTC; path=/; domain=.example.com; Secure; HttpOnly; SameSite=Lax";

// 获取所有 Cookie
const allCookies = document.cookie; // "cookie1=value1; cookie2=value2"

// 通过设置过期时间为过去来删除
document.cookie = "username=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";

  • 生存周期

Expires即过期时间 Max-Age用的是一段时间间隔,单位是秒,从浏览器收到报文开始计算。

  • 作用域

关于作用域也有两个属性: Domain和path, 给 Cookie 绑定了域名和路径

  • 安全相关

Secure:如果带上Secure,说明只能通过 HTTPS 传输 cookie。

HttpOnly:如果 cookie 字段带上HttpOnly,那么说明只能通过 HTTP 协议传输不能通过 JS 访问,这也是预防 XSS 攻击的重要手段。

SameSite相应的,对于 CSRF 攻击的预防,也有SameSite属性。

SameSite可以设置为三个值,Strict、LaxNone

  1. Strict模式下,浏览器完全禁止第三方请求携带Cookie。比如请求sanyuan.com网站只能在sanyuan.com域名当中请求才能携带 Cookie,在其他网站请求都不能。
  2. Lax模式,就宽松一点了,但是只能在 get 方法提交表单况或者a 标签发送 get 请求的情况下可以携带 Cookie,其他情况均不能。
  3. None模式下,也就是默认模式,请求会自动携带上 Cookie。

Cookie 应用场景

  1. 会话管理:登录状态、购物车内容等
  2. 个性化设置:用户主题、语言偏好等
  3. 跟踪分析:用户行为分析、广告跟踪
  4. 跨页面数据共享:在相同域名的不同页面间共享数据

Cookie 的缺点

  • 容量缺陷。Cookie 的体积上限只有4KB,只能用来存储少量的信息。
  • 性能缺陷。Cookie 紧跟域名,不管域名下面的某一个地址需不需要这个 Cookie ,请求都会携带上完整的 Cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容。但可以通过Domain和Path指定作用域来解决。
  • 安全缺陷。由于 Cookie 以纯文本的形式在浏览器和服务器中传递,很容易被非法用户截获,然后进行一系列的篡改,在 Cookie 的有效期内重新发送给服务器,这是相当危险的。另外,在HttpOnly为 false 的情况下,Cookie 信息能直接通过 JS 脚本来读取。

localStorage

  • 特点

    • 存储容量通常为5MB
    • 持久化存储,除非手动清除
    • 同源策略限制(相同协议、域名、端口)
    • 键值对存储,值必须是字符串

localStorage有一点跟Cookie一样,就是针对一个域名,即在同一个域名下,会存储相同的一段localStorage。

不过它相对Cookie还是有相当多的区别的:

  1. 容量。localStorage 的容量上限为5M,相比于Cookie的 4K 大大增加。当然这个 5M 是针对一个域名的,因此对于一个域名是持久存储的。

  2. 只存在客户端,默认不参与与服务端的通信。这样就很好地避免了 Cookie 带来的性能问题和安全问题

  3. 接口封装。通过localStorage暴露在全局,并通过它的 setItem 和 getItem等方法进行操作,非常方便。

应用场景

利用localStorage的较大容量和持久特性,可以利用localStorage存储一些内容稳定的资源,比如官网的logo,存储Base64格式的图片资源,因此利用localStorage

  • 用户偏好设置: 存储用户的主题选择、语言设置、通知偏好等。

  • 记住登录状态(有限制): 虽然可以用来记住用户,但通常不会直接存储敏感的认证信息,因为本地存储不是为安全敏感数据设计的。

  • 离线数据存储: 存储少量应用数据,使得用户在离线状态下也能访问部分内容(配合 Service Worker 等技术)。

  • 购物车数据: 暂存用户添加到购物车但尚未提交的商品信息。

  • 表单数据草稿: 用户填写表单时自动保存草稿,防止意外关闭后数据丢失。

  • 网站历史记录或浏览足迹: 记录用户在网站上的浏览习惯。

sessionStorage

特点 sessionStorage以下方面和localStorage一致:

  1. 容量。容量上限也为 5M。
  2. 只存在客户端,默认不参与与服务端的通信。
  3. 接口封装。除了sessionStorage名字有所变化,存储方式、操作方式均和localStorage一样。

但sessionStorage和localStorage有一个本质的区别,那就是前者只是会话级别的存储,并不是持久化存储。会话结束,也就是页面关闭,这部分sessionStorage就不复存在了。

应用场景

可以用它对表单信息进行维护,将表单信息存储在里面,可以保证页面即使刷新也不会让之前的表单信息丢失。 可以用它存储本次浏览记录。如果关闭页面后不需要这些记录,用sessionStorage就再合适不过了。事实上微博就采取了这样的存储方式。

indexedDB

IndexedDB是运行在浏览器中的非关系型数据库, 本质上是数据库,绝不是和刚才WebStorage的 5M 一个量级,理论上这个容量是没有上限的。

关于它的使用,本文侧重原理,而且 MDN 上的教程文档已经非常详尽,这里就不做赘述了,感兴趣可以看一下使用文档

接着我们来分析一下IndexedDB的一些重要特性,除了拥有数据库本身的特性,比如支持事务存储二进制数据,还有这样一些特性需要格外注意:

  1. 键值对存储。内部采用对象仓库存放数据,在这个对象仓库中数据采用键值对的方式来存储。
  2. 异步操作。数据库的读写属于 I/O 操作, 浏览器中对异步 I/O 提供了支持。
  3. 受同源策略限制,即无法访问跨域的数据库。

*总结

浏览器中各种本地存储和缓存技术的发展,给前端应用带来了大量的机会,PWA 也正是依托了这些优秀的存储方案才得以发展起来。重新梳理一下这些本地存储方案:

  1. cookie并不适合存储,而且存在非常多的缺陷。
  2. Web Storage包括localStoragesessionStorage, 默认不会参与和服务器的通信。
  3. IndexedDB为运行在浏览器上的非关系型数据库,为大型数据的存储提供了接口。

存储方案比较

特性localStoragesessionStorageIndexedDBCookiesCache API
存储容量~5MB~5MB大(>50MB)~4KB
生命周期永久会话级别永久可设置 Expires、Max-Age永久
作用域同源策略限制同源策略限制同源策略限制可设置Domain、path
服务器自动发送
数据结构键值对键值对结构化字符串请求/响应
适用场景简单数据会话数据复杂数据小数据网络资源

6 浏览器安全

1. web页面安全

同源策略

什么是同源策略和跨域

你打开了一个银行站点,然后又一不小心打开了一个恶意站点,如果没有安全措施,恶意站点就可以做很多事情:修改银行站点的 DOM、CSSOM 等信息;在银行站点内部插入 JavaScript 脚本;劫持用户登录的用户名和密码;读取银行站点的 Cookie、IndexDB 等数据;甚至还可以将这些信息上传至自己的服务器,这样就可以在你不知情的情况下伪造一些转账请求等信息。

在没有安全保障的 Web 世界中,我们是没有隐私的,因此需要安全策略来保障我们的隐私和数据的安全。

这就引出了页面中最基础、最核心的安全策略:同源策略(Same-origin policy)。

image.png 同源政策是浏览器遵循的一种安全策略(a站点去访问b站点的资源时候,两个URL的scheme(协议)、host(主机)和port(端口)都相同则为同源)。非同源站点有这样一些限制:

  • DOM层面:禁止跨源读取DOM,不能读取和修改对方的 DOM

经常需要两个不同源的 DOM 之间进行通信,于是浏览器中又引入了跨文档消息机制,可以通过 window.postMessage 的 JavaScript 接口来和不同源的 DOM 进行通信。

  • 数据层面:Cookie/LocalStorage隔离,不读取和修改对方的 Cookie、IndexDB 和 LocalStorage等数据
  • 网络层面:限制跨源AJAX请求,限制 XMLHttpRequest 请求。(后面的话题着重围绕这个)

兼顾安全性和便利性,目前的页面安全策略

  • 页面中可以引用第三方资源,不过这也暴露了很多诸如 XSS 的安全问题,因此又在这种开放的基础之上引入了 CSP 来限制其自由程度。
  • 使用 XMLHttpRequest 和 Fetch 都是无法直接进行跨域请求的,因此浏览器又在这种严格策略的基础之上引入了CORS跨域资源共享策略,让其可以安全地进行跨域操作。会造成CSRF攻击
  • 两个不同源的 DOM 是不能相互操纵的,因此,浏览器中又实现了跨文档消息机制,让其可以比较安全地通信。

当浏览器向目标 URI 发 Ajax 请求时,只要当前 URL 和目标 URL 不同源,则产生跨域,被称为跨域请求。在 A 站点中去访问不同源的 B 站点的内容

跨域请求的响应一般会被浏览器所拦截,注意,是被浏览器拦截,响应其实是成功到达客户端了。那这个拦截是如何发生呢?

解决跨域的方式
CORS(cross-origin resource sharing)

CORS (Cross-Origin Resource Sharing,跨域资源共享)是一个系统,它由一系列传输的HTTP头组成,这些HTTP头决定浏览器是否阻止前端 JavaScript 代码获取跨域请求的响应。

同源安全策略 默认阻止“跨域”获取资源。但是 CORS 给了web服务器这样的权限,即服务器通过http响应头设置来选择,允许哪些源可以跨域请求访问到它们的资源。

简单请求,非简单请求

JSONP(JSON with padding 填充式json或则参数式json)

jsonp是一个包含json数据的回调函数,由两部分组成:回调函数和数据。回调函数是响应到来时应该在页面中调用的函数,回调函数一般是放在请求的查询参数中指定的。而数据就是传入回调函数中的JSON数据。

script标签可以不受限制的跨域的请求第三方资源,JSONP是正是通过动态的添加script标签来使用的,使用时可以为src属性指定一个跨域的URL.而JOSNP就是作为这个url的查询参数,发出 GET 请求,请求完成后,即在JOSNP响应加载到页面中以后,就会立即执行JOSNP中的回调函数,来实现跨域请求。

    const jsonp = ({ url, params, callbackName }) => {
      const generateURL = () => {
        let dataStr = '';
        for(let key in params) {
          dataStr += `${key}=${params[key]}&`;
        }
        dataStr += `callback=${callbackName}`;
        return `${url}?${dataStr}`;
      };
      return new Promise((resolve, reject) => {
        // 初始化回调函数名称
        callbackName = callbackName || Math.random().toString.replace(',', ''); 
        // 创建 script 元素并加入到当前文档中
        let scriptEle = document.createElement('script');
        scriptEle.src = generateURL();
        document.body.appendChild(scriptEle);
        // 绑定到 window 上,为了后面调用
        window[callbackName] = (data) => {
          resolve(data);
          // script 执行完了,成为无用元素,需要清除
          document.body.removeChild(scriptEle);
        }
      });
    }

当然在服务端也会有响应的操作, 以 express 为例:

    let express = require('express')
    let app = express()
    app.get('/', function(req, res) {
      let { a, b, callback } = req.query
      console.log(a); // 1
      console.log(b); // 2
      // 注意哦,返回给script标签,浏览器直接把这部分字符串执行
      res.end(`${callback}('数据包')`);
    })
    app.listen(3000)

前端这样简单地调用一下就好了:

    jsonp({
      url: 'http://localhost:3000',
      params: { 
        a: 1,
        b: 2
      }
    }).then(data => {
      // 拿到数据进行处理
      console.log(data); // 数据包
    })

CORS相比,JSONP 最大的优势在于兼容性好,IE 低版本不能使用 CORS 但可以使用 JSONP,缺点也很明显,请求方法单一,只支持 GET 请求。

缺点

不容易判断请求是否失败 从其他域中加载代码执行不确定是否安全

jsonp 和 ajax 的原理

json 通过动态添加 script 标签调用服务器端提供的 JS 脚本,来进行资源的获取 ajax 核心是通过 XmlHttpRequest 来获取非本页内容,不一定是 JSON 格式的,也可以处理跨域

Nginx

Nginx 是一种高性能的反向代理服务器,可以用来轻松解决跨域问题。

what?反向代理?我给你看一张图你就懂了。

正向代理帮助客户端访问客户端自己访问不到的服务器,然后将结果返回给客户端。

反向代理拿到客户端的请求,将请求转发给其他的服务器,主要的场景是维持服务器集群的负载均衡,换句话说,反向代理帮其它的服务器拿到请求,然后选择一个合适的服务器,将请求转交给它。

因此,两者的区别就很明显了,正向代理服务器是帮客户端做事情,而反向代理服务器是帮其它的服务器做事情。

好了,那 Nginx 是如何来解决跨域的呢?

比如说现在客户端的域名为client.com,服务器的域名为server.com,客户端向服务器发送 Ajax 请求,当然会跨域了,那这个时候让 Nginx 登场了,通过下面这个配置:

server {
  listen  80;
  server_name  client.com;
  location /api {
    proxy_pass server.com;
  }
}

Nginx 相当于起了一个跳板机,这个跳板机的域名也是client.com,让客户端首先访问 client.com/api,这当然没有跨域,然后 Nginx 服务器作为反向代理,将请求转发给server.com,当响应返回时又将响应给到客户端,这就完成整个跨域请求的过程。

XSS攻击(跨站脚本攻击Cross Site Scripting)

XSS 攻击是指浏览器中执行恶意脚本(无论是跨域还是同域),将页面的一些重要数据上传到恶意服务器,从而拿到用户的信息并进行恶意操作。

XSS 的攻击方式是黑客往用户的页面中注入恶意脚本,然后再通过恶意脚本将用户页面的数据上传到黑客的服务器上,最后黑客再利用这些数据进行一些恶意操作。

恶意脚本都能做哪些事情。

  • 窃取Cookie。
  • 监听用户行为,比如用户输入账号密码后直接发送到黑客服务器。
  • 修改 DOM 伪造登录表单。
  • 在页面中生成浮窗广告。
XSS 攻击模式
存储型 XSS 攻击

存储型的 XSS 将恶意脚本存储到了服务端的数据库,然后在客户端执行这些脚本,从而达到攻击的效果。例如评论内容

  • 特点:恶意脚本被存储在服务器上

  • 攻击流程

    1. 攻击者提交恶意内容到数据库(如论坛发帖)
    2. 普通用户浏览包含恶意内容的页面
    3. 恶意脚本在用户浏览器执行
<!-- 评论内容存储到数据库 -->
<script>stealCookie()</script>
反射型 XSS 攻击

是因为恶意脚本是通过作为网络请求的参数,经过服务器,然后再反射到HTML文档中,执行解析。

恶意脚本来自当前HTTP请求

  • 攻击流程

    1. 攻击者构造特殊URL包含恶意脚本
    2. 诱骗用户点击该URL
    3. 服务器返回包含恶意脚本的页面
    4. 用户浏览器执行脚本
  • 示例

    http://example.com/search?q=<script>alert('XSS')</script>
    
DOM型 (文档型)的 XSS 攻击

文档型的 XSS 攻击并不会经过服务端,而是作为中间人的角色,在数据传输过程劫持到网络数据包,然后修改里面的 html 文档

  • 特点:完全在客户端执行,不经过服务器

  • 攻击流程

    1. 攻击者构造特殊URL
    2. 页面JavaScript读取URL参数并动态更新DOM
    3. 恶意代码被执行
  • 示例

    // 漏洞代码
    document.write(location.hash.substring(1));
    
    // 攻击URL
    http://example.com#<script>alert('XSS')</script>
    
三种类型对比
类型存储型 XSS反射型 XSSDOM 型 XSS
存储位置服务器数据库URL 参数前端 DOM
触发方式用户访问被感染页面用户点击恶意链接用户操作修改 DOM
持久性长期存在一次性取决于用户操作
案例论坛恶意评论钓鱼邮件中的链接修改 location.hash
防范策略

通过客户端或者服务器对输入的内容进行过滤或者转码(内容编码后端处理比较可靠)

输入过滤
-   对用户输入进行严格验证
-   过滤或转义特殊字符(`<``>``&``"``'``/`)
输出编码
-   HTML实体编码:

```js
function encodeHTML(str) {
  return str.replace(/&/g,'&amp;')
            .replace(/</g,'&lt;')
            .replace(/>/g,'&gt;')
            .replace(/"/g,'&quot;')
            .replace(/'/g,'&#39;');
}
```
内容安全策略 CSP(Content-Security-Policy )

CSP通过指定有效域——即浏览器认可的可执行脚本的有效来源——使服务器管理者有能力减少或消除XSS攻击所依赖的载体 CSP,即浏览器中的内容安全策略,它的核心思想就是服务器设置 HTTP响应头部Content-Security-Policy ,决定浏览器加载哪些资源,具体来说可以完成以下功能:

  1. 限制其他域下的资源加载。
  2. 禁止向其它域提交数据。
  3. 提供上报机制,能帮助我们及时发现 XSS 攻击。
Content-Security-Policy: default-src 'self'; script-src 'unsafe-inline'
httpOnly Cookie

服务器通过 HTTP 响应头set-cookie来设置 Cookie 的HttpOnly 属性来保护重要的 Cookie 信息,JavaScript 便无法读取 Cookie 的值。

Set-Cookie: sessionId=abc123; HttpOnly; Secure

CSRF攻击(跨站请求伪造攻击Cross-site request forgery)

CSRF(Cross-site request forgery), 即跨站请求伪造,指的是黑客诱导用户点击链接,打开黑客的网站,然后黑客利用用户目前的登录状态发起跨站请求

CSRF 攻击并不需要将恶意代码注入用户当前页面的html文档中,而是跳转到新的页面,利用服务器的验证漏洞和用户之前的登录状态来模拟用户进行操作。

CSRF 攻击的三个必要条件:

  1. 第一个,目标站点一定要有 CSRF 漏洞;
  2. 第二个,用户要登录过目标站点,并且在浏览器上保持有该站点的登录状态;
  3. 第三个,需要用户打开一个第三方站点,可以是黑客的站点,也可以是一些论坛。

1. 基本概念

攻击者诱骗用户在已认证的Web应用中执行非预期的操作。

2. 攻击流程

  1. 用户登录可信网站A,获得认证Cookie
  2. 用户未登出情况下访问恶意网站B
  3. 网站B包含向网站A发起请求的代码
  4. 用户浏览器自动携带Cookie发送请求
  5. 网站A认为这是用户的合法请求
CSRF攻击模式
  1. 自动发 GET 请求
<img src="https://xxx.com/info?user=hhh&count=100">

黑客将一些get请求接口隐藏在页面中,比如img标签中,用户进入页面后自动发送 get 请求这个请求会自动带上关于 xxx.com 的 cookie 信息,假如服务器端没有相应的验证机制,它可能认为发请求的是一个正常的用户,因为携带了相应的 cookie,然后进行相应的各种操作,可以是转账汇款以及其他的恶意操作。

  1. 自动发起 POST 请求
        <form id='hacker-form' action="https://xxx.com/info" method="POST">
          <input type="hidden" name="user" value="hhh" />
          <input type="hidden" name="count" value="100" />
        </form>
        <script>document.getElementById('hacker-form').submit();</script>

黑客将一些post请求隐藏在页面中,用户进入页面后自动提交 POST 请求,同样也会携带相应的用户 cookie 信息,让服务器误以为是一个正常的用户在操作,让各种恶意的操作变为可能。

  1. 诱导点击连接发送 GET 请求
        <a href="https://xxx/info?user=hhh&count=100" taget="_blank">点击进入修仙世界</a>
防范措施
验证来源站点

服务器端通过请求头中的两个字段: Origin和Referer验证请求来源的站点 Origin只包含域名信息,而Referer包含了具体的 URL 路径。 两者都是可以伪造的,通过 Ajax 中自定义请求头即可,安全性略差。

sameSite Cookie

CSRF攻击中重要的一环就是自动发送目标站点下的 Cookie,然后就是这一份 Cookie 模拟了用户的身份。

利用Cookie的SameSite属性,对请求中 Cookie 的携带作一些限制

SameSite可以设置为三个值,Strict、Lax和None。

a. 在Strict模式下,浏览器完全禁止第三方请求携带Cookie。比如请求sanyuan.com网站只能在sanyuan.com域名当中请求才能携带 Cookie,在其他网站请求都不能。

b. 在Lax模式,就宽松一点了,但是只能在 get 方法提交表单况或者a 标签发送 get 请求的情况下可以携带 Cookie,其他情况均不能。

c. 在None模式下,也就是默认模式,请求会自动携带上 Cookie。

Set-Cookie: sessionId=abc123; SameSite=Strict; Secure
CSRF Token

登录令牌,用以标识用户的登录状态,用户登录的时候服务器会返回一个token作为用户登录令牌,

首先,浏览器向服务器发送请求时,服务器生成一个字符串,返回给浏览器。 然后浏览器如果要发送请求,就必须带上这个字符串,然后服务器来验证是否合法,如果不合法则不予响应

各防御方案对比
方案优点缺点适用场景
CSRF Token安全性高需要前后端协调表单提交类操作
SameSite实现简单兼容性问题(旧浏览器)所有 Cookie
验证头部无前端改动依赖浏览器正确发送AJAX 请求
双重Cookie前后端分离友好存在子域安全问题现代 Web 应用

综合防御策略

  1. 前端防御

    • 所有动态内容输出前编码
    • 使用现代框架(XSS防护)
    • 敏感操作添加二次确认
  2. 后端防御

    • 实施CSRF Token机制
    • 严格校验输入数据
    • 设置安全HTTP头
  3. 运维配置

    • 部署CSP策略
    • 启用SameSite Cookie
    • 使用HTTPS加密传输
  4. 用户教育

    • 不点击可疑链接
    • 及时登出重要网站
    • 定期清除Cookie

2. 浏览器系统安全

安全沙箱:页面和系统之间的隔离墙

3. 浏览器网络安全

https:让数据传输更安全

7 页面的重绘和回流

我们首先来回顾一下渲染流水线的流程:

接下来,我们将来以此为依据来介绍重绘和回流,以及让更新视图的另外一种方式——合成。

回流

首先介绍回流回流也叫重排

触发条件

简单来说,就是当我们对 DOM 结构的修改引发 DOM 几何尺寸变化的时候,会发生回流的过程。

具体一点,有以下的操作会触发回流:

  1. 一个 DOM 元素的几何属性变化,常见的几何属性有widthheightpaddingmarginlefttopborder,位置变化,字体大小、行高变化 等等, 这个很好理解。

  2. 使 DOM 节点发生增减或者移动

  3. 读写 offset族、scroll族和client族属性的时候,浏览器为了获取这些值,需要进行回流操作。 计算属性获取

    • offsetTop/Left/Width/Height
    • scrollTop/Left/Width/Height
    • clientTop/Left/Width/Height
    • getComputedStyle()
    • getBoundingClientRect()
  4. 调用 window.getComputedStyle 方法。

回流过程

依照上面的渲染流水线,触发回流的时候,如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。

相当于将解析和合成的过程重新又走了一篇,开销是非常大的。

重绘

触发条件

当 DOM 的修改导致了样式的变化,并且没有影响几何属性的时候,会导致重绘(repaint)。

  1. 颜色、背景色变化
  2. 文本装饰变化
  3. 阴影变化
  4. visibility/outline变化

重绘过程

由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程。流程如下:

跳过了生成布局树建图层树的阶段,直接生成绘制列表,然后继续进行分块、生成位图等后面一系列操作。

可以看到,重绘不一定导致回流,但回流一定发生了重绘。

合成

还有一种情况,是直接合成。比如利用 CSS3 的transformopacityfilter这些属性就可以实现合成的效果,也就是大家常说的GPU加速

GPU加速的原因

在合成的情况下,会直接跳过布局和绘制流程,直接进入非主线程处理的部分,即直接交给合成线程处理。交给它处理有两大好处:

  1. 能够充分发挥GPU的优势。合成线程生成位图的过程中会调用线程池,并在其中使用GPU进行加速生成,而GPU 是擅长处理位图数据的。
  2. 没有占用主线程的资源,即使主线程卡住了,效果依然能够流畅地展示。

实践意义

知道上面的原理之后,对于开发过程有什么指导意义呢?

  1. CSS 避免频繁使用 style,而是采用修改class的方式。
  2. DOM 使用createDocumentFragment进行批量的 DOM 操作。
// 使用documentFragment
const fragment = document.createDocumentFragment();
for(let i = 0; i < 100; i++) {
  const li = document.createElement('li');
  fragment.appendChild(li);
}
list.appendChild(fragment);

// 或先display:none,操作后再显示
el.style.display = 'none';
// ...大量DOM操作
el.style.display = 'block';
  1. 事件 对于 resize、scroll 等进行防抖/节流处理。
  2. 添加 will-change: tranform ,让渲染引擎为其单独实现一个图层,当这些变换发生时,仅仅只是利用合成线程去处理这些变换,而不牵扯到主线程,大大提高渲染效率。当然这个变化不限于tranform, 任何可以实现合成效果的 CSS 属性都能用will-change来声明。这里有一个实际的例子,一行will-change: tranform拯救一个项目,点击直达

动画优化 拖拽动画 使用transform代替absolute

/* 不好:使用top/left触发重排 */
.box {
  position: absolute;
  top: 10px;
  left: 10px;
  transition: top 1s, left 1s;
}

/* 好:使用transform不触发重排 */
.box-optimized {
  position: absolute;
  transform: translate(10px, 10px);
  transition: transform 1s;
}

8 事件的防抖和节流

防抖和节流是两种常用的性能优化技术,主要用于控制事件处理函数的执行频率,避免高频率事件触发导致的性能问题。

事件的防抖和节流都是防止一定时间操作频繁触发,但是这两个的原理不一样

防抖Debounce:一定时间内执行的操作被连续触发只执行最后一次

节流Throttle:一定时间内执行的操作被连续触发只执行最先一次

防抖

在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时
核心思想: 每次事件触发则删除原来的定时器,建立新的定时器。跟王者荣耀回城功能类似,你反复触发回城功能,那么只认最后一次,从最后一次触发开始计时。

代码实现

function debounce(func, delay) {
  let timer = null;
  
  return function(...args) {
    // 每次触发都清除之前的定时器
    clearTimeout(timer);
    
    // 设置新的定时器
    timer = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}
 function debounce(fn, delay) {
      let timer = null;
      return function (...args) {
        let context = this;
        if(timer) clearTimeout(timer);
        timer = setTimeout(function() {
          fn.apply(context, args);
        }, delay);
      }
    }

适用场景

  • 窗口大小调整(resize)
  • 搜索框输入联想(search suggest)
  • 表单验证
  • 防止按钮重复点击

节流

节流的核心思想: 在事件被触发n秒后再执行回调,如果在定时器的时间范围内再次触发,则不予理睬,等当前定时器完成,才能启动下一个定时器任务。这就好比公交车,10 分钟一趟,10 分钟内有多少人在公交站等我不管,10 分钟一到我就要发车走人!

代码实现

代码如下:


function throttle(func, delay) {
  let timer = null;
  
  return function(...args) {
    if (!timer) {
      timer = setTimeout(() => {
        func.apply(this, args);
        timer = null;
      }, delay);
    }
  };
}

function throttle(fn, delay) {
  let flag = true;
  return function(...args) {
    if (!flag) return;
    flag = false;
    setTimeout(() => {
      fn.apply(this, args);
      flag = true;
    }, delay);
  };
};

写成下面的方式也是表达一样的意思:

const throttle = function(fn, delay) {
  let last = 0;
  return function (...args) {
    let now = +new Date();
    
    if(now - last >= delay){
      last = now;
      fn.apply(this, args)
    }
    // 还没到时间
    //if(now - last < interval) return;
    //last = now;
    //fn.apply(this, args)

    
  }
}

关键点解析:

  1. last初始值为0

    • 第一次调用时,now - last等于当前时间戳(一个很大的数字),肯定大于任何合理的delay
    • 因此第一次会立即执行
  2. 后续调用行为

    • 只有距离上次执行时间超过delay时才会再次执行
    • 否则调用会被忽略

适用场景

  • 页面滚动(scroll)
  • 鼠标移动(mousemove)
  • 拖拽操作
  • 游戏中的按键操作

加强版防抖节流

现在我们可以把防抖节流放到一起,为什么呢?因为防抖有时候触发的太频繁会导致一次响应都没有,我们希望到了固定的时间必须给用户一个响应,事实上很多前端库就是采取了这样的思路。

function debounce(fn, delay) {
  let last = 0, timer = null;

  return function (...args) {
    let now = + new Date();
    
    if(now - last < delay){
      clearTimeout(timer);
      timer=setTimeout(()=> {
        fn.apply(this, args);
        last = now;
      }, delay);
    } else {
      // 这个时候表示时间到了,必须给响应
      fn.apply(context, args);
      last = now;
    }
  }
}

防抖与节流的对比

特性防抖(Debounce)节流(Throttle)
执行时机事件停止触发后执行固定时间间隔执行
适用场景关注结果的操作(如搜索)关注过程的连续操作(如滚动、拖拽)
效果将多次执行变为最后一次执行将多次执行变为每隔一段时间执行
类比电梯门(最后一个人进入后关门)水龙头(固定流量放水)

9 图片的懒加载

clientHeight、scrollTop 和 offsetTop

首先给图片一个占位资源:

<img src="default.jpg" data-src="http://www.xxx.com/target.jpg" />

接着,通过监听 scroll 事件来判断图片是否到达视口:

let img = document.getElementsByTagName("img");
let num = img.length;
let count = 0;//计数器,从第一张图片开始计

lazyload();//首次加载别忘了显示图片

window.addEventListener('scroll', lazyload);

function lazyload() {
  let viewHeight = document.documentElement.clientHeight;//视口高度
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;//滚动条卷去的高度
  for(let i = count; i <num; i++) {
    // 元素现在已经出现在视口中
    if(img[i].offsetTop < scrollHeight + viewHeight) {
      if(img[i].getAttribute("src") !== "default.jpg") continue;
      img[i].src = img[i].getAttribute("data-src");
      count ++;
    }
  }
}

当然,最好对 scroll 事件做节流处理,以免频繁触发:

// throttle函数我们上节已经实现
window.addEventListener('scroll', throttle(lazyload, 200));

getBoundingClientRect

现在我们用另外一种方式来判断图片是否出现在了当前视口, 即 DOM 元素的 getBoundingClientRect API。

上述的 lazyload 函数改成下面这样:

function lazyload() {
  for(let i = count; i <num; i++) {
    // 元素现在已经出现在视口中
    if(img[i].getBoundingClientRect().top < document.documentElement.clientHeight) {
      if(img[i].getAttribute("src") !== "default.jpg") continue;
      img[i].src = img[i].getAttribute("data-src");
      count ++;
    }
  }
}

IntersectionObserver

这是浏览器内置的一个API,实现了监听window的scroll事件判断是否在视口中以及节流三大功能。

我们来具体试一把:

let img = document.getElementsByTagName("img");

const observer = new IntersectionObserver(changes => {
  //changes 是被观察的元素集合
  for(let i = 0, len = changes.length; i < len; i++) {
    let change = changes[i];
    // 通过这个属性判断是否在视口中
    if(change.isIntersecting) {
      const imgElement = change.target;
      imgElement.src = imgElement.getAttribute("data-src");
      observer.unobserve(imgElement);
    }
  }
})
Array.from(img).forEach(item => observer.observe(item));

这样就很方便地实现了图片懒加载,当然这个IntersectionObserver也可以用作其他资源的预加载,功能非常强大。

参考资料

《浏览器工作原理与实践》 极客时间
《图解 Google V8》极客时间

(1.6w字)浏览器灵魂之问,请问你能接得住几个?
(建议精读)HTTP灵魂之问,巩固你的 HTTP 知识体系