对于前端开发来说,我们必须要掌握浏览器的运行原理。本文将尝试从浏览器基本结构、页面加载完整流程、浏览器渲染流程、浏览器缓存策略、浏览器存储、Web 安全基础 等几个方面介绍浏览器的基本工作原理。
浏览器基本结构(谁在干活): 多进程架构,渲染进程里 GUI 和 JS 共享主线程且互斥。页面加载流程(资源怎么来): DNS 找 IP,TCP 三次握手,TLS 加密,HTTP 收发,四次挥手。浏览器渲染流程(页面如何展示): DOM+CSSOM 成渲染树,布局绘制后分层栅格化合成。缓存策略(资源如何复用): 强缓存不发请求,协商 304 用本地、200 换新。浏览器存储(数据存哪儿): Cookie 小随请求,local 大同步,session 跟标签,IndexedDB 异步大。Web 安全(如何防守): XSS 注入代码,CSRF 借身份;输出编码防 XSS,SameSite+Token 防 CSRF。
在开始正文之前,我们可以先鸟瞰一下:在浏览器输入网址并确认后,大致经历以下大致阶段,每一步的细节会在文章的第二章和第三章详细展开:
DNS 解析 :浏览器先查自身 DNS 缓存;未命中则查hosts文件和本地DNS缓存;仍未命中时,客户端向本地 DNS 解析器发起递归查询;本地 DNS 解析器再以迭代方式依次查询根、TLD、权威服务器,最终把 IP 返回给客户端并缓存。TCP 连接 :找到服务器后,浏览器通过 TCP 三次握手与服务器建立连接;如果是 HTTPS,还需要 TLS 握手。发送 HTTP 请求 :连接建立后,客户端发起 HTTP/HTTPS 请求。接收 HTTP 响应 :服务端处理请求并返回 HTTP 响应报文;浏览器通常先拿到 HTML 文档,再解析 HTML 并额外请求 CSS、JS、图片等静态资源。浏览器渲染 :解析 HTML 构建 DOM,解析 CSS 构建 CSSOM;解析过程中会下载并执行 JS,JS 可能修改 DOM/CSSOM;随后合并生成渲染树,布局后绘制页面。连接关闭/复用 :HTTP/1.1 默认使用持久连接,连接可被复用;当连接不再使用或超时后,通过四次挥手关闭 TCP 连接,如果是 HTTPS,先做 TLS 关闭,再关 TCP。
一、浏览器的基本结构
本章主要解释:谁在干活,怎么干活。一句话结论:浏览器是多进程架构;渲染进程里 GUI 渲染和 JS 执行是同一个渲染主线程的两个逻辑角色,二者互斥,JS 执行会阻塞 DOM 解析和渲染。
本章讲浏览器的基本结构,分两个视角来看:
功能模块 :浏览器由哪些部分组成——用户界面、浏览器引擎、渲染引擎、UI 后台、网络模块、JS 解释器、数据存储 。运行时架构 :浏览器运行时怎么划分进程 和线程 ——有哪些进程、渲染进程里有哪些线程、它们怎么协作。
我们先看功能模块,再看运行时的进程线程,最后落到前端最关心的渲染进程线程;网络请求、渲染流程、缓存、存储、安全等模块在后续文章中展开。
1、浏览器的功能模块:
下面是经典的浏览器分层模型,便于建立整体印象。现代浏览器(如 Chromium)的实际模块划分更复杂,部分职责被拆分到不同进程或模块中,但整体分层思想仍然适用。
用户界面 :就是浏览器的外壳,浏览器的界面 ,包括搜索栏、书签、前进后退按钮、打开历史记录等用户可操作功能;它可以将我们的操作转入浏览器引擎进行处理。浏览器引擎 :用于接收处理用户界面传递的'操作指令' ,然后调用相应的渲染引擎;是浏览器中各个部分可以互相通信的核心 。渲染引擎 :浏览器最核心的模块 ,我们常说的浏览器内核主要就是指它。它负责解析 HTML/CSS,构建 DOM/CSSOM 树,生成渲染树,计算布局,并生成绘制指令 。UI 后(UI Backend) :作为渲染引擎的下游,经典架构中负责把渲染引擎生成的绘制指令真正画到屏幕上 ,包括浏览器基础元件和网页内容,底层调用的是操作系统原生绘图 API。现代 Chromium 中这部分职责更多由合成线程、光栅化线程、GPU 进程和 UI 工具包承担 ,不再叫“UI 后台”。网络模块 :负责 HTTP 网络请求 或者下载资源的模块。JS引擎 :用于解析和执行 JS 代码 。数据存储 :主要把数据存入硬盘 ,涉及 Cookie、localStorage、IndexedDB 等客户端存储技术。
2、浏览器运行时的进程 和线程
知道了浏览器功能模块之后,我们需要了解一下浏览器运行时的进程和线程,它可以帮助我们更加清晰地了解浏览器的运行原理。什么是进程和线程 :
2.1 什么是进程和线程
进程 :操作系统分配资源的基本单位 ,是能拥有资源和独立运行的最小单位;CPU 调度实际落到线程上。进程默认有独立地址空间,不直接共享内存资源,但可以通过 IPC(进程间通信,如管道、消息队列、共享内存、socket)通信。线程 :CPU 调度的基本单位 ,是建立在进程基础上的一次程序运行单位。一个进程中可以有多个线程,同一进程内的线程共享该进程的资源。
我们通常会用工厂和工人之间的关系来区分进程和线程:社会将资源和空间分配给不同工厂;每个工厂都有自己不同的资源和空间;每个工厂又会有很多工人,工人没有自己的资源和空间,但是他们使用、共享工厂的资源和空间。对应到计算机里:工厂相当于进程,拥有独立的内存等资源;工人相当于线程,共享工厂资源,被 CPU 调度执行。
2.2 进程间通信与线程间通信
进程间通信 :不同进程地址空间隔离,不能直接读写对方内存,必须走 IPC(进程间通信) ,常见有管道、消息队列、共享内存、信号、socket 等。隔离好、更安全,但代价比线程间通信大。线程间通信 :同一进程内的线程共享地址空间 ,直接读写共享内存即可交换数据,不需要 IPC;但共享内存会有竞态问题,需要互斥锁、条件变量、原子操作等同步机制。轻量,但要防竞态。
一句话:进程靠 IPC,线程靠共享内存 + 同步。 放到浏览器里,渲染进程和网络进程、GPU 进程之间走 IPC;渲染进程内部各线程共享进程资源,靠任务队列和同步机制协作。
2.3 浏览器进程的划分(简化模型)
下面是便于理解的简化划分 。现代 Chromium 的实际进程模型更复杂,除下列进程外,还可能有存储进程、Utility 进程等;渲染进程也不绝对“一 Tab 一进程”,受站点隔离、进程复用、iframe 跨进程等因素影响。
浏览器进程(通常一个) :浏览器的主进程,主要负责页面管理、书签、前进后退、资源下载管理以及创建和销毁其他进程等工作;通常整个浏览器应用只有一个浏览器主进程,对应上述浏览器组成中的浏览器引擎。渲染进程(多个) :内核进程,负责页面渲染和 JS 执行,对应上述的渲染引擎和 JS 引擎;一个浏览器可以包含多个渲染进程。简化理解可以认为“一个 Tab 通常对应一个渲染进程”,但实际受站点隔离、进程复用、iframe 跨进程等影响,并不绝对。渲染进程下面还划分了多个线程。 GPU 进程(通常一个) :现代 Chromium 中又称 Viz 进程,负责跨进程的最终显示合成 ——聚合来自各个渲染进程和浏览器 UI 的合成帧,输出到屏幕;也负责 GPU 纹理上传、WebGL 等图形任务。它不替代渲染进程内的合成与光栅化,而是负责全局的最终呈现。 插件进程(多个) :浏览器安装的插件/扩展程序可按需创建进程;不是所有扩展都常驻独立进程,具体取决于浏览器实现和扩展类型。网络进程(通常一个) :负责网络请求、DNS 解析、代理、网络缓存等网络相关工作,现代 Chromium 中独立存在。它把响应数据交给渲染进程,但网络请求本身不在渲染进程内完成。
2.4 渲染进程里的线程划分
浏览器各进程都有自己的线程。对前端开发来说,最关心的是渲染进程内部的线程,因为它们直接决定页面渲染和 JS 执行;下面重点展开渲染进程的线程。浏览器主进程还有 UI 线程、IO 线程等,GPU 进程负责跨进程的最终显示合成,网络进程负责网络请求,这些对前端日常开发影响较小,本章不展开。
先澄清几个容易混的概念:渲染进程 :容器,包含渲染主线程、合成线程、光栅化线程池、Worker 线程池等。渲染主线程 :渲染进程里的主线程,物理上只有一条,负责解析 HTML/CSS、布局、绘制、执行 JS。GUI 渲染线程 / JS 引擎线程 :不是两条独立线程,而是渲染主线程的两个逻辑角色。主线程执行 JS 时扮演 JS 引擎线程,处理布局绘制时扮演 GUI 渲染线程;二者互斥,不能并行。
GUI 渲染线程 :负责页面的渲染,解析 HTML、CSS,创建 DOM 树、CSSOM 树、渲染树、布局、绘制、重绘、回流等操作;在 JS 运行期间该线程处于挂起状态,等到 JS 线程空闲时再被执行。JS 引擎线程 :一个页面只会有一个 JS 引(单线程);负责解析和执行 JS 代码,处理用户交互,操作 DOM 树和 CSSOM 树等。定时器线程 :浏览器通过定时器线程创建定时器、完成计时操作(如果放到 JS 引擎线程,可能因阻塞导致计时不准确);计时完毕后将任务添加到事件队列,等 JS 引擎空闲后执行,setInterval 与 setTimeout 所在的线程。合成线程 :负责图层合成,将绘制指令合成到图层,不阻塞主线程;transform、opacity 等动画只影响合成阶段,因此更流畅。光栅化线程池 :负责将图层栅格化为像素,通常由多个线程并行处理,可能使用 GPU 加速。
需要特别说明两点:
事件循环 :事件循环不是上面列表里的某个线程,而是渲染主线程的工作机制。主线程执行完一个宏任务后会清空微任务队列,浏览器再决定是否进行渲染;它并非由某个独立的“事件线程”单独控制。GUI 渲染和 JS 执行是这条主线程的两个逻辑角色,二者互斥,不能并行。详细机制参见《JS 事件循环机制 》。网络请求 :不在渲染进程内完成,现代浏览器多由网络进程/网络服务处理,响应回调进入任务队列,等待主线程空闲后执行;渲染进程里不再常驻独立 HTTP 线程。
注意:DOM 解析、样式计算和 JS 执行都在渲染主线程上执行,JS 执行时主线程被占用,DOM 解析和渲染会被阻塞;等到 JS 引擎空闲的时候 GUI 才会继续被执行。所以当 JS 线程执行时间过长时,就会造成页面渲染不连贯,产生卡顿现象。
另外,渲染进程里还有 Worker 线程池 ,按需创建,用于执行 Web Worker,不参与渲染流水线核心流程;开发者用 new Worker() 时才会创建,用于把耗时计算移出主线程。后文渲染流程会再涉及。
本章小结
本章讲的是浏览器的静态架构:功能模块组成、进程与线程划分、渲染进程内的线程协作。核心记住以下几点:
浏览器是多进程架构,浏览器进程、渲染进程、GPU 进程、网络进程、插件进程各司其职;现代 Chromium 还有存储进程、Utility 进程等,本章是简化模型。 进程是资源分配的基本单位,线程是 CPU 调度的基本单位;线程间靠共享内存 + 同步,进程间靠 IPC。 渲染进程内 GUI 渲染和 JS 执行是同一个渲染主线程的两个逻辑角色,二者互斥;JS 执行会阻塞 DOM 解析和渲染。
本章自测
进程和线程的区别? 进程管资源,线程被调度;线程共享所属进程资源。 浏览器为什么多进程? 稳定性、安全性、性能隔离;一个页面崩溃通常不影响其他页面。 渲染进程里主要有哪些线程? 渲染主线程、合成线程、光栅化线程池、Worker 线程池;网络请求多由网络进程处理。 JS 执行和 DOM 解析、渲染什么关系? 共享渲染主线程,JS 执行会阻塞 DOM 解析和渲染。 事件循环是独立“事件线程”控制的吗? 不是;是渲染主线程的工作机制。
二、页面加载完整流程
本章主要解释:从输入URL到页面展现,底层的网络资源是如何被加载出来的 。一句话概括主线流程:DNS寻址找到IP → 建立底层连接(TCP/TLS 握手) → 交换HTTP请求与响应 → 连接复用或释放 。除此之外,本章还会深度解析 HTTPS 的加密原理、POST 接口的防重实战,以及 HTTP 从 1.1 到 3.0 解决网络“堵车”的演进史。
1.DNS解析
DNS(Domain Name System,域名系统)是互联网的'分布式电话簿 + 逐级问路系统' :它把人类好记的域名(如www.example.com)解析成机器可用的IP地址,也保存邮件、别名等其他记录。 设备通常配置一个本地DNS递归解析器地址;这个解析器具备按DNS委派链逐级查询的能力,先问根、再问TLD、再问权威服务器,由权威服务器返回最终IP记录 ,解析器再把结果返回给设备并按TTL缓存。
当我们在地址栏输入一个www.baidu.com网址的时候,浏览器会先在自己的缓存 里查看是否有当前地址记录,如果有就直接使用该IP,没有的话执行step2 查看当前电脑主机hosts文件 是否存在输入的网址,如果存在就直接使用hosts文件中的IP,如果没有执行step3 查找本地DNS缓存 ,如果存在搜索地址,就返回给主机,再通过主机进行访问,不存在就执行step4 本地DNS解析器向根服务器 发起查询;根服务器没有完整网址记录,返回 .com顶级域服务器地址 本地DNS解析器向.com TLD服务器 查询,TLD服务器返回baidu.com的权威服务器 地址,而不是直接返回完整网址。 本地DNS再向baidu.com的权威服务器查询www.baidu.com,权威服务器返回对应IP ,给本地DNS缓存服务器 本地DNS接收到IP地址之后将其存入缓存,以便以后访问 主机使用接收到的IP地址发起连接
其实DNS解析过程就是查找当前网址对应的IP地址的过程;我们常输的网址就是方便记忆的IP地址的映射
2.连接远程服务器(TCP三次握手 + TLS握手)
2.1 什么是TCP
TCP就是不同终端实现通信的网络传输控制协议 。现有的网络通信方式一般是TCP (传输控制协议)和UDP (用户数据报协议)。我们大部分应用使用的是TCP传输层协议,相比UDP它更加安全可靠。UDP只负责发送数据包,不管有没有送达,虽然效率高但不可靠。
TCP是一个面向连接的、可靠的、基于字节流的传输层协议 ,并且它是全双工 的。所谓全双工,你可以理解为:在建立连接后,双方之间相当于拉起了两条独立的消息线 (一条是客户端到服务端;一条是服务端到客户端)。这两条线互不干扰,可以同时传输数据。(注:这两个消息线是非常“粗放”的大通道,后续我们在 3.5.3 中会看到,HTTP/3 是如何把这“两条大通道”拆解成“无数条独立小流”来实现极致加速的) 。正因为如此,TCP 在建立连接时需要'双向确认'(三次握手),断开连接时也需要'双向独立关闭'(四次挥手)。
TCP 连接分为三个步骤:客户端发送SYN到服务端 ;服务端收到后回复SYN + ACK ;客户端收到后再发送ACK给服务端 。
第一次握手(试探客户端→服务端线) :客户端向服务端发送SYN标志位为1 的报文,表示请求建立连接。该报文中包含客户端随机生成的seq (初始序列号) = 1000,且此时ACK标志位为0 。表示:我想建连,并且我这条线(客户端发)的初始序列号是1000。第二次握手(确认客户端→服务端线,并试探服务端→客户端线 ):服务端收到后,回复 SYN = 1, ACK = 1,ack = 1001,seq = 2000。表示:我收到了你的 1000(ack = 1001),同时我这条线(服务端发)的初始序列号是2000。第三次握手(确认服务端→客户端线 ):客户端收到后,回复ACK = 1,ack = 2001。表示:我收到了你的2000(ack = 2001)。至此,两条线都完成了双向确认,双方进入ESTABLISHED状态。
三次握手拟人化描述
TCP连接为什么是三次握手?两次、四次不可以吗?
双向确认两条消息线(核心逻辑 ):因为TCP是全双工,有两条独立的消息线。第一次握手,服务端确认了客户端发的线是通的;第二次握手,客户端确认了服务端发的线也是通的;只有第三次握手,服务端才能确认客户端收到了自己的回应 。两次握手无法实现双向确认,四次握手又多余。避免历史连接(防死锁/防错乱 ):如果客户端发送的旧SYN在网络中延迟了,重启后又发了新SYN建立了连接。等旧SYN到达服务端时,客户端会回RST报文让服务端关闭。如果是两次握手,服务端直接建立连接并消耗资源,就会产生错误。同步双方序列号 :三次握手是确保双方都拥有对方初始序列号的最少次数。双方都得存下对方的随机数,后续传数据才能保证有序可靠。
TCP三次握手的最核心目的,可以概括为两点:一是确认双向通道畅,二是同步双方的初始序列号 。
2.2 HTTPS 与 TLS 握手
如果建立的是HTTPS安全连接,则还需要TLS(Transport Layer Security,传输层安全性协议)。目前互联网的主流标准已经是 TLS 1.3,它比旧版本更快、更安全。但要理解它为什么这么设计,我们先用经典的 TLS 1.2 RSA 流程来搞清楚核心逻辑。TLS要解决三件事:机密性、完整性、身份认证 。无论是TLS 1.2 还是 TLS 1.3,它们在设计上都遵循同一个核心套路(两种加密方式的组合) :
握手阶段(非对称加密) :利用证书和密钥交换算法,完成身份认证,并安全地协商出一个对称密钥 。数据传输阶段(对称加密) :利用协商好的会话密钥,对真正的 HTTP 业务数据进行高速加解密。
因为非对称加密太慢,所以 TLS 只在最开始的“握手阶段”用它来安全地交换信息;一旦商量出对称密钥,后续海量的网页数据全部交给速度极快的对称加密来处理。
对称加密 :使用同一个密钥进行加解密 。它的核心问题是密钥分发 :怎么安全地把同一把密钥交给对方?如果第三方知道了密钥,通信内容就暴露了。非对称加密 :有公钥和私钥,公钥加密,私钥解密 。实际应用中,不能随便把公钥丢给对方,否则会有中间人攻击;需要数字证书和CA 来证明这个公钥确实属于该服务器。非对称加密计算开销大,所以只用来协商密钥,不用来加密大量数据。
2.2.1 TLS 1.2 RSA 密钥交换的主干(经典)
下面我们以 TLS 1.2 的RSA (经典的非对称加密方式)密钥交换为例,理解'证书公钥加密预主密钥'的基本思路:
Client Hello :客户端发送支持的TLS版本和加密套件候选,以及第 1 个随机数 A 。Server Hello :服务端确认版本、选择加密套件,发送第 2 个随机数 B 。发送证书 :服务端发送证书,客户端验证证书可信后,从中提取公钥 。发送预主密钥 :客户端生成预主密钥 (48 个字节的字符串:2 个字节的 TLS 版本号 + 46 个字节的随机数),用证书公钥加密后发给服务端 。服务端解密 :服务端用私钥解密,得到预主密钥 。派生会话密钥 :双方用随机数 A、随机数 B、预主密钥,通过 PRF 算法派生出会话密钥 。 核心:PRF (Pseudo-Random Function,伪随机函数) 是一种基于密码学哈希的密钥派生算法。你可以把它理解为一台“密码学榨汁机” :单靠某一个随机数可能不够安全,但把三个独立的原材料(随机数A、随机数B、预主密钥)扔进 PRF 里“榨”一下,它就能吐出一段固定长度、毫无规律的密文 ,这就是最终的会话密钥 。它是单向不可逆 的就算黑客截获了最终的会话密钥,也绝不可能反推出那三个原材料。TLS 1.2 正是通过这种混合机制,确保了哪怕其中一个随机数意外泄露,攻击者也无法推算出会话密钥。对称加密通信 :后续双方用会话密钥 进行高速的对称加密通信。
用一句话总结TLS1.2就是: 客户端用服务端证书里的公钥 ,加密预主密钥 发给服务端;服务端用私钥 解密得到预主密钥 ;最后双方使用客户端随机数、服务端随机数、预主密钥,通过PRF算法生成会话密钥 ,供后续高速对话使用。 TLS 1.2的RSA加密方式虽然是经典的网络安全传输协议但是还是存在以下致命缺陷:
无前向安全 :如果服务器的私钥在未来某天泄露,攻击者就可以通过私钥解密预主密钥,这样就可以配合明文的随机数A,随机数B解密以前录下的所有加密流量。速度慢 :需要2个往返(2-RTT)才能完成握手; 第一个RTT:客户端打招呼,服务端回资源 ;第二个RTT:客户端加密密钥,服务端确认 。易受攻击 :RSA密钥交换存在Bleichenbacher等安全漏洞; RSA密钥交换依赖的 PKCS#1 填充机制存在“错误预言机”漏洞,攻击者能通过海量试探反推出预主密钥,直接破解通信 。
2.2.2 TLS 1.3 的核心改进与流程(重点)
TLS 1.3 彻底移除了RSA交换,强制使用ECDHE临时密钥交换 。双方不再用公钥加密密钥,而是各自生成一个临时的公钥(key_share )直接发给对方,然后各自在自己的电脑上算出一个相同的密钥。ECDHE(椭圆曲线迪菲-赫尔曼临时密钥交换 ),简单理解就是一种不用把最终密码发到网上,双方也能隔空商量出同一把密钥 的魔法。具体过程分为两步:
算出原材料 :双方各自在本地生成一对临时公私钥 ,然后把自己的公钥发给对方。接着,各自用'自己的临时私钥 + 对方的公钥',在本地算出一个相同的数学结果 ,这叫共享秘密 。(注意,它只是原材料,不是最终钥匙)榨出最终密钥 :双方把这个共享秘密扔进HKDF(密钥派生函数 )这个榨汁机里,分步派生出真正用来加密的密钥 (TLS 1.3 会先派生出一把'握手密钥 '来保护证书,然后再派生出一把最终的'应用数据密钥'来保护HTTP业务数据)。
ECDHE 里面最核心的设计是Ephemeral(临时的) :客户端和服务端临时私钥用完立刻销毁 。服务器的长期证书私钥只负责签名 (证明身份),根本不参与密钥计算。因此,哪怕未来黑客偷到了服务器的长期私钥,也解不开你们过去聊了什么(这就是前向安全 )。
步骤 1:Client Hello :客户端发送支持的TLS版本、随机数,并直接附带发送自己的临时公钥 。(对比 1.2:1.2 的第一步只是打招呼,而 1.3 直接把自己的密钥材料押上了) 步骤 2:Server Hello :服务端确认版本、发送随机数,并附带自己的临时公钥 。关键点 :此时,双方各自拥有自己的私钥 + 对方的公钥。利用ECDHE算法,双方各自算出了同一个共享秘密 。榨汁 :双方立刻把这个共享秘密扔进 HKDF 算法中,派生出第一把钥匙——握手密钥 。(对比 1.2:1.2 需要等客户端用证书公钥加密预主密钥发过来,1.3 在这里就已经把加密通信的钥匙配好了) 步骤 3:服务端发送加密的握手信息 :服务端发送:证书 + 数字签名 + Finished。因为已经算出了握手密钥,从此刻起,服务端发的所有消息都是用握手密钥加密的 (中间人抓包也看不到证书明文)。步骤 4:客户端验证并完成握手 :客户端利用握手密钥解密报文 ,验证证书和签名合法后,发送Finished消息。双方再次通过HKDF,基于之前的参数派生出一把最终的应用数据密钥(即会话密钥 )。步骤 5:开始高速对称加密通信 :握手圆满结束,后续所有的HTTP请求和响应,全部使用那把应用数据密钥 进行对称加密传输。
TLS 1.3 最大的亮点就是速度和安全性的双重飞跃。我们先用最直观的方式看看它为什么这么快。RTT是数据从客户端发出去,到收到服务端响应,这一来一回所花费的时间 。少一个 RTT 意味着网页加载能快几十毫秒。两者的差距本质上是因为“密钥是怎么商量的” 不同:
TLS 1.2(2-RTT,分两步走) :第 1 个 RTT 客户端打招呼,服务端发证书(内含公钥);第 2 个 RTT 客户端拿到公钥后,加密“预主密钥”发过去,服务端解密后回复确认。(因为客户端必须等对方把公钥发过来,才能进行下一步,多等了一个往返) 。TLS 1.3(1-RTT,一步到位) :客户端打招呼时,直接把自己的临时公钥(key_share)一起塞过去 。服务端回复自己的临时公钥,一来一回直接把密钥算完,立刻开始发加密数据。(把原本要等的环节提前押注,省掉了一个 RTT) 。
除了把握手缩短到 1-RTT,TLS 1.3 还有三个极其重要的设计:
完美前向安全(PFS) :得益于 ECDHE 临时密钥用后即毁。即使服务器的长期私钥未来被盗,黑客也无法解密过去已经发生过的通信记录。0-RTT 极速恢复与重放风险 :如果之前连接过,双方共享了 PSK(预共享密钥)。再次连接时,客户端在第一条消息里直接用 PSK 加密业务数据发过去,实现 0-RTT。注意 :黑客如果截获这段 0-RTT 数据包,可以原封不动重发给服务器(重放攻击 )。因此 0-RTT 只能用于幂等请求 (如 GET),绝不能用来发 POST 支付请求。握手阶段全副武装 :TLS 1.2 发送证书是明文的,中间人能看到你访问了哪个网站;而 TLS 1.3 在 Server Hello 之后的所有握手消息(含证书)全部加密 ,极大提升了隐私性。
一句话总结:TLS 1.2 解决安全,TLS 1.3 在安全基础上解决了快(1-RTT/0-RTT)和前向安全,代价是 0-RTT 的重放风险需要业务侧权衡。
2.3 宏观串联:一次完整的 HTTPS 请求是怎么发生的?
既然现在几乎所有的网站都是 HTTPS,那我们把刚才讲的底层协议串起来。当你访问一个 HTTPS 网站时,实际上经历了以下几个阶段(以目前最主流的 HTTP/2 或 HTTP/3 为例):
建立底层通信通道 :如果是HTTP/2 :双方先完成TCP三次握手 ,打通底层网络通道。 如果是HTTP/3 :抛弃 TCP,基于 UDP 进行QUIC握手 。 TLS 1.3 握手(建立加密隧道 ):双方交换临时公钥,使用ECDHE算出共享秘密。 通过HKDF派生握手密钥 ,加密传输并验证证书(确认身份)。 握手完成后,再派生最终的应用数据密钥 。 HTTP 请求与响应(加密送信) :客户端将HTTP报文用应用数据密钥进行对称加密,通过安全通道发给服务端。服务端解密处理,并用同样的密钥加密响应返回。 连接复用与关闭(结局) :复用 :HTTP/1.1及之后默认保持长连接(Keep-Alive),后续的请求直接复用这条加密通道。关闭 :当不再使用时,如果是TCP,通过四次挥手 释放资源;如果是QUIC,直接发送一个CONNECTION_CLOSE帧 即可极速断开。
3. HTTP 请求与响应
在 TCP 连接建立(以及 HTTPS 的 TLS 握手完成)之后,浏览器才真正开始通过 HTTP 协议与服务端交换数据。本章围绕“请求发出去、响应收回来、协议如何演进 ”展开。
3.1 HTTP 报文结构:请求与响应长什么样?
HTTP 请求报文 主要由三部分组成:
请求行 :包含请求方法(GET/POST 等)、URL 路径、HTTP 版本。请求头(Headers) :包含 Host(目标域名)、User-Agent(浏览器信息)、Accept(能接收的数据类型)、Cookie 等。请求体(Body) :GET 请求通常为空,POST 请求会在这里携带表单数据或 JSON。
HTTP 响应报文 同样由三部分组成:
状态行 :包含 HTTP 版本和状态码(如 200 OK、304 Not Modified、404 Not Found)。响应头(Headers) :包含 Content-Typ(告诉浏览器文件类型)、Set-Cookie 等。(关于缓存相关的响应头,将在第四章详细展开)响应体(Body) :真正的 HTML 文档、CSS、JS 或 JSON 数据。
3.2 请求方法
请求行中的第一个词是请求方法 ,它决定了这次请求的语义。面试中最常考的是 GET 与 POST 的区别 以及幂等性 。以下是常用的请求方法:
GET :获取资源。参数在 URL 上,可被浏览器缓存。POST :提交数据/创建资源。参数在请求体,默认不缓存。PUT / DELETE :全量更新 / 删除资源。HEAD :只获取响应头(常用来测试资源是否存在或检查更新时间)。OPTIONS :询问服务器支持的方法(跨域预检请求专用)。
幂等性 :执行一次和执行 N 次,对服务器资源状态的影响相同 。GET、PUT、DELETE 是幂等的;POST 不是幂等的(连续提交两次表单,会产生两条数据)。所以在业务中,涉及支付的 POST 接口必须做防重放/防重复提交处理。GET 与 POST 的核心区别: 1. 语义与幂等性 :GET 用于获取资源,是幂等的 (执行多次结果一致);POST 用于提交数据,不是幂等的 (重复提交可能产生多笔订单),所以支付接口必须做防重。2. 参数位置 :GET 参数拼接在 URL 上,POST 参数放在请求体Body中。3. 长度限制 :HTTP 协议本身不限制长度 。GET 受限于浏览器对 URL 的长度限制和服务器对 Header 的限制;POST 理论上无限制,但受限于服务器配置(如请求体大小限制)。4. 安全性 :GET 参数暴露在 URL 中,会被记入历史记录和日志;但 POST 的 Body 也能被抓包,真正的安全必须依赖 HTTPS 。5. 缓存机制 :GET 请求可以被浏览器主动缓存;POST 默认不缓存。
3.2.1 进阶实战:POST 请求如何防止重复提交?
由于 POST 是非幂等的,网络超时重试或用户狂点按钮极易导致重复下单、重复扣款 。在真实开发中,前端给按钮加 Loading/禁用态或防抖,是成本最低的第一道防线 ,但它防不住网络重试和黑客脚本。因此,真正的安全兜底必须依靠服务端,常见方案如下:
方案一:前端 UUID + 数据库唯一索引(轻量级,普通业务首选)
机制 :进入页面生成UUID放入请求头。后端用UUID字段做数据库UNIQUE索引。关键点 :网络超时重试时必须复用原UUID ;只有明确业务失败或彻底成功后,前端才重新生成新UUID。缺点: 前端生成的 UUID 容易被黑客伪造;高并发下如果海量请求直接打到数据库,会给数据库造成较大压力;适用 :发帖、评论、普通表单。
方案二:后端下发 Token + Redis + 无感刷新(重装级,核心资金业务 )
机制 :后端下发一次性Token存入Redis。前端提交时携带,后端校验后立刻删除。Token过期前端使用请求拦截器静默换新Token重试。优势 :有状态管控,防伪造、防重放攻击,安全性极高。缺点: 系统复杂度高:要多发一次获取 Token 的请求,需要维护 Redis 高可用,还要实现无感刷新逻辑,研发成本较高。适用 :支付、秒杀、抢购。
方案三:业务状态机(天然幂等,适用于支付回调/状态流转 )
机制 :不依赖额外存储,直接利用业务数据自身的状态流转来防重。比如处理微信支付回调时,先查数据库订单状态,如果已经是“已支付”,直接返回成功,不再执行任何后续逻辑。优势 :零额外成本,最贴合业务本质。局限 :只适用于有明确状态机的业务(如订单、审批流),不能覆盖所有 POST 接口。
方案四:并发控制锁(解决同时修改同一条数据的冲突 )
乐观锁(数据库版本号) :不加锁,提交更新时对比版本号(UPDATE ... WHERE version = 1)。适合读多写少、冲突概率低 的普通业务。分布式锁(Redis SETNX) :执行业务前先抢锁,抢到才能操作数据库。适合极高并发、写冲突严重 的秒杀场景,把流量挡在数据库外。
防重是一个多层级的防御体系:交互层 ,前端通过 Loading/防抖拦截无效点击;接口层 ,普通表单用'前端 UUID + 数据库唯一索引'做轻量防重,核心支付必须用'后端 Token + Redis'做强管控;并发与状态层 ,高并发抢购依靠'乐观锁/分布式锁'防止数据覆盖,支付回调等状态流转则利用'业务状态机'天然幂等。前端防抖只是第一道防线,真正的数据安全兜底永远在服务端。
3.3 状态码:服务端的结果分类
状态码由三位数字组成,第一位决定了响应的类别:
2xx(成功) :200 OK 最常见;204 No Content 成功但无响应体;206 断点续传/分片下载。3xx(重定向) :301 永久重定向;302 临时重定向;304 Not Modified 协商缓存命中(详见第四章)。4xx(客户端错误) :400 请求语法错误;401 未认证(没登录);403 已认证但无权限;404 资源不存在。5xx(服务端错误) :500 内部错误;502 网关错误(上游服务挂了);504 网关超时。
3.4 底层限制与传输特性
屏 14KB 法则 :当客户端拿到 HTML 准备渲染时,新的 TCP 连接受限于慢启动,无法立即全速下载。现代 TCP 的初始拥塞窗口通常允许发送 10 个 MSS(最大报文段),约合 14.6KB。因此,前端常将首屏关键内容(如 HTML、内联的关键 CSS)经过 Gzip/Brotli 压缩后 控制在 14KB 内,以确保在首个网络往(RTT)中就能拿到并开始渲染。注意,这 14KB 通常不建议包含大段阻塞渲染的 JS;JS 应尽量通过 defer 或 async 异步加载,避免挤占首屏带宽并阻塞 DOM 解析。 连接复用与管线化 :HTTP/1.0 默认每个请求都新建 TCP 连接,代价极高。HTTP/1.1 引入了持久连 (Keep-Alive,默认不关连接)和管线化 (可以一次性整批发多个请求,不等响应)。但由于 HTTP/1.1 规定响应必须按顺序返回,如果第一个请求慢,依然会堵住后面的响应,这就是应用层队头阻塞 。
需要注意的是虽然HTTP/3 底层换成了QUIC(基于 UDP),但为了防止网络拥堵QUIC 依然需要慢启动,所以在HTTP/3下,14KB法则依然存在。 根据标准,QUIC 的初始拥塞窗口同样是 10 个数据包(约 12KB ~ 14KB)。因此,控制首屏体积依然是核心的优化手段。不过,HTTP/3 有 0-RTT 的加成 :如果是老用户再次访问,浏览器可以直接在第一个握手包里带上 HTTP 请求数据,直接绕过了初始窗口的限制 ,真正实现了“首屏 0 等待”。
3.5 HTTP 版本演进:与“队头阻塞”作斗争
HTTP 的演进史,就是一部解决网络“堵车”的历史,接下来为我们看一下各个版本解决的问题和遇到的困难:
3.5.1. HTTP/1.0 & 1.1(应用层排队 )
HTTP/1.0 :每个请求都要新建 TCP 连接,用完立刻断开,效率极低。HTTP/1.1 :引入了持久连接 (同域名复用 TCP)和管线化 。但 HTTP/1.1 规定响应必须按序返 (存在应用层队头阻塞 )。前端的辛酸泪 :为了缓解排队,浏览器被迫对同一个域名开启6个并发TCP连接 。如果有7个请求,第7个只能等前6个中有一个完成。如果第一个请求卡住了,后面的全得死等。
3.5.2. HTTP/2(应用层提速,却受限于底层 )
怎么解决应用层HOL的? 引入二进制分帧 。把请求A和请求B都拆成无数个小碎片(帧),打上标签,在一条 TCP 连接里交错发送 。到了客户端再按标签拼装回来。一个请求慢,不会堵住其他请求。致命的痛点(传输层 HOL ):HTTP/2 的底层依然只用一条TCP连接 。TCP是严格按顺序交付 的。一旦网络中丢了任何一个包 (比如路由器拥堵导致丢包),TCP就会卡住,必须等这个包重传成功,后续的所有数据)不管属于哪个请求)都得跟着死等。
3.5.3. HTTP/3(底层革命,彻底解决堵车 )
为什么用 UDP? 既然操作系统里的 TCP 改不动,HTTP/3 干脆抛弃它,基于UDP 来开发。(前面说过UDP不可靠,但QUIC在UDP之上自己写代码实现了丢包重传和拥塞控制,相当于给裸奔的UDP穿上了防弹衣) 。QUIC 怎么连的? 没有TCP的三次握手了。QUIC 把传输握手和TLS 1.3加密握手合并,首次连接只需1个 RTT ,恢复会话直接0-RTT (第一个包就发数据)。怎么彻底解决丢包堵塞的? 从全双工 的视角来看:TCP建立连接,相当于拉起了两条巨大的信息通道 (客户端到服务端、服务端到客户端),所有请求都在这两个大通道里混在一起排队。一旦丢包,整条通道都会卡死。而QUIC的全双工做得更加极致:它为每一个HTTP请求都创建了一个专属的流(Stream )。流A丢包了,只会卡住流A的重传,流B和流C的数据照常交付。用无数条独立小流替代了两条大通道 ,彻底消灭了传输层队头阻塞。连接迁移 :TCP靠IP+端口认人,换个网(WiFi切5G)就断。QUIC靠Connection ID 认人,换网了连接也能瞬间恢复。
HTTP/3没有了三次握手:因为HTTP/3跑在QUIC上,它把原本属于操作系统的传输控制逻辑搬到了应用层。QUIC把传输层协商和 TLS 1.3 加密握手合并成了一步 。所以HTTP/3根本不存在传统的 TCP 三次握手,首次连接只需1个 RTT ,老用户恢复会话甚至只需0-RTT (第一个包就发业务数据)。 HTTP/3 并非完全抛弃 TCP,QUIC 协议内置了回退机制:当遇到封锁 UDP 的网络环境时,客户端会自动回退使用基于 TCP 的 HTTP/2 或 HTTP/1.1,以保证可用性。接下来我们做个总结:
HTTP/1.1 把拥堵归咎于响应必须按序返回 (应用层 HOL),用 6 个并发硬抗。 HTTP/2 通过拆帧多路复用 解决了应用层 HOL,但被底层 TCP 的丢包重传 拖垮(传输层 HOL)。 HTTP/3 基于 QUIC (UDP) ,在应用层重写了可靠传输,让每个流彻底独立,并合并了加密握手,是未来的大势所趋。
4.断开连接(四次挥手关闭 TCP 连接)
因为TCP是全双工通信,客户端与服务端断开连接时,需要把两个消息通道关闭,通常会经历以下四个步骤:
前两次挥手(拆除客户端→服务端这条线) :第一次挥手 :客户端发送FIN = 1, seq = a。进入FIN-WAIT-1,表示我这条线没数据要发了。第二次挥手 :服务端回复ACK = 1, ack = a + 1。进入CLOSE-WAIT。客户端收到后进入FIN-WAIT-2。此时,客户端→服务端这条线正式关闭(客户端处于半关闭状态)。后两次挥手(拆除服务端→客户端这条线) :第三次挥手 :服务端把剩下未发完的数据发完,然后发送 FIN = 1, seq = c, ack = a + 1。进入LAST-ACK,表示我这条线也没数据要发了。第四次挥手 :客户端回复ACK = 1, ack = c + 1。进入TIME-WAIT。等待2MSL后关闭。此时服务端→客户端这条线也正式关闭。
四次挥手拟人化描述
1. 为什么关闭的时候是四次挥手?为什么第四次挥手不能省? 核心原因在于:TCP 是全双工协议,也就是我们前面说的“有两条独立的单向消息线” 。断开连接时,必须把这两条线分别拆除:
第一次挥手(客户端发起) :客户端发送FIN = 1, seq = a,进入FIN-WAIT-1状态。这代表客户端 → 服务端这条消息线没数据要发了 ,请求拆除。第二次挥手(服务端确认 ):服务端回复ACK = 1, ack = a + 1,进入CLOSE-WAIT状态。客户端收到后进入FIN-WAIT-2状态。注意:此时客户端→服务端这条线已经彻底拆除完毕 ,客户端进入半关闭状态(不能再发数据,但还能收数据)。第三次挥手(服务端发起 ):第一条线虽然拆了,但服务端可能还有数据没发完。等服务端数据彻底发完,它才发送FIN = 1,ACK = 1,seq = c, ack = a + 1,进入LAST-ACK状态。这代表服务端→客户端这条线也没数据要发了 。第四次挥手(客户端确认 ):客户端回复ACK = 1,seq = a + 1,ack = c + 1,进入TIME-WAIT状态,等待2MSL后最终关闭。注意:当服务端收到这个ACK时,服务端→客户端这条线也正式拆除完毕 ,服务端释放资源。
为什么第四次挥手不能省? 如果省掉第四次挥手,服务端发完第三次挥手的FIN后,根本不知道客户端到底有没有收到。只有客户端回了第四次挥手的ACK,服务端才能确信服务端→客户端这条线也安全拆除了 ,从而安心释放底层的端口和内存资源。
2. 为什么客户端发出第四次挥手的确认报文后,要等2MSL的时间才能释放TCP连接? MSL指报文段最大生存时间,在不同的系统中被设置的时间也不一样(30秒,60秒,120秒)。等待2MSL(1~4分钟)有两个核心原因:
保证第四次挥手(ACK)能送达 :如果第四次挥手报文丢失,服务端会超时重传第三次挥手(FIN)。此时客户端如果在TIME-WAIT状态,就能再次收到并重发ACK。一来一回刚好是2MSL。如果客户端发完ACK直接关了,服务端就会一直重传直到报错。防止历史旧报文干扰新连接 :等待2MSL,足以让本次连接在网络中残留的旧数据包全部'自然死亡'。否则这些旧包飘到下一个使用相同端口的新连接里,会导致数据错乱。
3. 如果已经建立了连接,但是客户端突然出现故障了怎么办? TCP协议本身有keepalive保活机制,但并非协议强制默认开启 (默认通常需要 2 小时才发探测包,且参数因操作系统而异)。在实际真实业务中,更常用的是应用层心跳包 (例如WebSocket的ping/pong,或前端定时发请求)。如果启用了TCP keepalive,连续多次探测无响应后,系统会认为对端故障并自动关闭连接。
4. HTTP/3 的 QUIC 是怎么断开连接的?
因为 HTTP/3 底层用的是 QUIC,所以它没有传统的四次挥手,也没有2MSL等待。QUIC的断开机制是完全'双向对称'的,不区分谁是客户端、谁是服务端,任何一方都可以随时发起。 断开方式极其干脆:
关闭整个连接 :任何一方想断开整个连接,只需发送一个带有CONNECTION_CLOSE帧 的数据包。对方收到后,连接直接关闭,不需要一来一回等对方确认 。独立流的关闭 :如果你只是想取消某个具体的请求,任何一方直接在该流(Stream)上发一个FIN帧 即可,表示'这条流我没数据要发了',完全不影响同一条连接上的其他流。暴力流重置 :如果某个流卡住了,任何一方直接发送RESET_STREAM帧 ,强行中断这条流的数据传输,不阻塞其他流。
对比 TCP:TCP的断开是串行的,必须严格进行四次挥手,且通常由客户端处于TIME-WAIT状态并死等2MSL。而QUIC是事件驱动的,谁想断谁就发帧,极其轻量,彻底消灭了断开连接时的延迟和资源占用。
本章小结
本章讲的是从输入 URL 到页面展示的网络加载流程:DNS 找 IP、TCP 建连接、TLS 握手(HTTPS)、发请求收响应、连接复用或关闭。核心记住以下几点:
页面加载主线 :DNS解析 → TCP三次握手 → TLS握手(HTTPS) → 发送HTTP请求并接收响应 → 连接复用或四次挥手关闭。全双工与握手挥手 :TCP是全双工的(有两条独立消息线)。三次握手是为了双向确认 这两条线都通;四次挥手是为了双向独立拆除 这两条线。客户端最后等2MSL释放连接,既是为了保证第四次ACK能送达,也是为了防止历史旧报文干扰下一次新连接。底层限制与协议演进 :首屏14KB源于TCP(及 QUIC)慢启动 的初始拥塞窗口限制 ;HTTP/1.1存在应用层队头阻塞,HTTP/2用多路复用 解决但它受限于 TCP的传输层HOL,HTTP/3基于QUIC(UDP)实现了流 的彻底独立,并合并了加密握手 。安全与防重 :HTTPS依靠TLS 1.2/1.3保障安全;POST请求必须做防重,轻量级用'前端UUID + 数据库唯一索引',核心支付用'后端Token + Redis',并发场景配合'乐观锁/分布式锁'。
本章自测
三次握手为什么不是两次? 双向确认两条消息线 (全双工,必须确认收发双向都正常); 避免历史失效连接消耗资源; 同步双方的初始序列号,保证后续有序可靠传输。 四次挥手为什么多一次? 因为TCP是全双工,需要拆除两条独立消息线 。服务端收到客户端的FIN后,可能还有数据没发完,只能先回ACK,等数据发完再发FIN。这中间多了一个'服务端确认发完数据'的动作。第四次挥手后为什么等 2MSL? 保证客户端最后的ACK能送达服务端( 如果丢了,服务端会重传FIN,一来一回刚好2MSL);防止本次连接残留的历史旧报文,干扰下一个使用相同端口建立的新连接 。GET 和 POST 的核心区别? 语义与幂等性(GET 是幂等的,POST 不是); 参数位置与长度限制(URL vs Body;HTTP 不限制,受限于浏览器/服务器配置); 缓存(GET默认缓存,POST默认不缓存)。 安全(GET参数暴露在URL中,历史记录和日志都会存在;POST的Body同样能被抓包,真正的安全必须依赖 HTTPS ); 什么是幂等性?POST 如何防重? 幂等是指调用1次和N次对系统状态的影响相同 。 防重分层级:交互层:前端防抖 限制按钮点击,接口层:普通表单用'前端 UUID + 数据库唯一索引 '(注意网络重试时要复用原UUID);核心支付用'后端Token + Redis ';并发状态用'乐观锁/状态机 '。 首屏14KB是怎么来的?HTTP/3还有这个限制吗? 源于TCP慢启动 的初始拥塞窗口(10 个MSS × 1460字节 ≈ 14.6KB)。HTTP/3 底层的QUIC也需要慢启动,同样有约12KB ~ 14KB的初始限制。但HTTP/3支持0-RT T,老用户复访时可直接带数据发请求,绕过限制。 TLS 1.2和1.3核心区别? 1.2 解决'安全'(2-RTT);1.3 在安全基础上解决'快'和'前向安全'(1-RTT/0-RTT,强制ECDHE)。 HTTP/2和HTTP/3分别解决什么HOL? HTTP/2通过二进制分帧 解决应用层HOL ,但底层TCP丢包 依然会引发传输层HOL ;HTTP/3基于QUIC(UDP),流之间彻底独立 ,解决传输层HOL ,并内置了TLS 1.3加速握手。
三、浏览器渲染流程
本章主要解释:页面是如何被展示出来的。一段话总结:解析 HTML 构建 DOM,解析 CSS 构建 CSSOM;渲染主线程完成样式计算、布局、绘制并生成绘制指令列表,最后通过 Commit 把结果提交给合成线程;合成线程负责分层、分块并调度光栅化 Worker 将内容栅格化为像素;最终由 Viz 进程/GPU 聚合合成帧并上屏。 传统说法中常把'DOM + CSSOM 合并生成渲染树'作为中间一步,可以把它理解为'哪些节点参与显示、带什么样式'的简化叫法;严谨流水线中,这一职责由样式计算和布局树承接。回流通常引起重绘,重绘不一定引起回流;transform/opacity 动画通常绕过主线程布局和绘制,直接进入合成阶段。
第一部分:渲染流水线分步
1、构建 DOM 树
当浏览器接收到服务器响应来的 HTML 文档后,HTML 解释器会将收到的字节流 转换生成 DOM 树 。生成过程:HTML 解释器首先将收到的字节流 解码成字符流 ,然后再通过词法分析解析成词语 ,之后再经过语法分析构建成节点 ,最后将节点组建成一棵 DOM 树 。
DOM树构建过程
补充说明 :
HTML 解析不等待 CSS 下载和解析,CSS 不阻塞 HTML 解析本身。 普通 <script> 会阻塞 HTML 解析:主线程遇到同步脚本时必须暂停 DOM 构建,下载并执行完 JS 后才能继续。 外链脚本的下载可被预加载扫描器并行发现,但下载不阻塞解析,不等于执行不阻塞主线程 。defer/async 改的是执行时机 :普通 <script>:下载和执行都阻塞 HTML 解析,按文档顺序执行。 async:下载不阻塞解析,下载完立刻执行,顺序不保证 ,执行时也不保证 DOM 已解析完。 defer:下载不阻塞解析,等 DOM 解析完成后、DOMContentLoaded 前按文档顺序执行 ,执行时可以安全操作 DOM。 defer/async 只对外链脚本有效,内联脚本加了也无效;两者都只是下载不挡解析,执行仍占用渲染主线程 ,长脚本依然会卡渲染。
2、构建 CSSOM 规则树
CSSOM 的构建与 DOM 非常相似,都是解析字节数据最后生成 CSSOM。由于元素的样式会受到很多因素的影响(我们设定的样式、通过继承获得的样式等),在这过程中浏览器需要递归 CSSOM 树才能确定元素最终的样式,是非常消耗性能的。CSSOM 构建完成后,浏览器还要为每个节点匹配规则、处理层叠和继承,计算最终样式;选择器匹配的成本和优化建议放在第 3 节“样式计算”展开。
CSSOM树构建过程
补充说明 :CSS 本身不阻塞 HTML 解析,但 CSSOM 不完整会阻塞渲染 ,并且会延迟后续同步脚本的执行 。如果脚本在 CSSOM 完成前需要读取样式,浏览器必须先完成 CSSOM,脚本才能继续。
3、样式计算(Recalculate Style)
样式计算做什么 :渲染主线程从 DOM 根节点开始遍历,为每个节点匹配适用的 CSS 规则,处理层叠、继承、优先级、默认样式、行内样式 等,最终计算出每个节点的最终计算样式(Computed Style) 。许多预设值在这一步被解析为绝对值,例如 red → rgb(255, 0, 0),em → px,auto 在部分属性上被解析为具体值。
输入与输出 :
输入:DOM 树 + CSSOM 树 + 浏览器默认样式 + 行内样式。 输出:每个 DOM 节点对应的计算样式 。注意,这一步的输出不是“渲染树”,也不是“布局树”,而是带有计算样式的节点集合 ;几何信息要到第 4 步布局才计算。
为什么样式计算耗性能 :元素样式受继承、层叠、选择器匹配等多因素影响,浏览器需要递归 CSSOM 才能确定最终样式。选择器越复杂、嵌套越深,匹配成本越高。所以实践建议是:
DOM 树尽量小; CSS 尽量用 id 和 class,少用深层后代选择器; 保证 DOM 和 CSS 层级扁平。
< div >
< a > < span > < /span > < /a >
< /div >
<style>
/* 只需要找到全局 span 标签,并设置样式即可 */
span {
color : red ;
}
/* 需要先找全局 span,再找 span 上的 a,再找 a 上的 div,
最后才给符合条件的 span 设置样式,多几层递进会浪费性能 */
div > a > span {
color : red ;
}
</style>
传统模型中的“渲染树”对照 :传统教材常说DOM + CSSOM 合并生成渲染树。更准确的理解是:
传统渲染树描述的是哪些节点参与显示、各自带什么样式 ; 现代流水线中,这一职责由样式计算 (确定计算样式)和布局树 (确定几何信息)共同承接; display:none 的节点没有几何信息,不进入布局树;visibility:hidden 的节点保留几何位置,进入布局树但不可见;<head>、<script>、<meta> 等不参与显示的节点也不会进入布局树。
4、布局(Layout)
布局阶段会从布局树的根节点开始遍历,确定每个可见节点在页面上的确切大小与位置。布局阶段的输出是每个可见节点的几何信息集合 ,精确捕获每个元素在屏幕内的确切位置与大小;传统说法中常把它概括为“盒子模型”,但严格说布局输出不是单个盒子模型,而是布局树中各节点的几何信息。
布局树与“渲染树”的关系 :
传统模型说“渲染树是用于显示的”,强调哪些节点参与显示、带什么样式; 严谨流水线中,样式计算输出计算样式,布局阶段再生成布局树(Layout Tree / Fragment Tree) ,承载几何信息; display:none 不进入布局树;visibility:hidden 进入布局树,保留几何位置,只是不可见;<head>、<script>、<meta> 等不参与显示,也不进入布局树。
5、绘制(Paint)
在绘制阶段,遍历布局树/渲染树,调用渲染器的 paint() 方法生成绘制指令 (显示列表)。经典架构中,这些绘制指令由 UI 后台组件执行到屏幕;现代 Chromium 中,绘制指令交给合成线程、光栅化线程和 GPU 进程完成分层、栅格化和最终合成输出。
6、合成线程阶段:分层、分块与栅格化
主线程完成绘制并 Commit 后,渲染主线程在流水线中的工作结束,接下来由合成线程 接手。简化理解:主线程生成图层树,交给合成线程;合成线程调度光栅化线程将图层栅格化为像素(通常用 GPU)。
严谨补充 :现代 Chromium 中,合成线程根据属性树决定合成层,依次进行:
分层(Layerize) :根据滚动、堆叠上下文、transform、opacity、will-change 等决定哪些内容属于同一合成层。分块(Tiling) :把每个图层划分为多个小区域(Tile),便于并行处理。栅格化(Raster) :调度光栅化 Worker 将分块内容转换为像素,通常使用 GPU 加速。
这些步骤不占用渲染主线程 ,因此主线程被 JS 占用时,滚动和合成驱动动画仍可保持流畅。但要注意:合成线程负责“调度和产出像素”,并不直接负责最终上屏。
7、Viz/GPU 合成与上屏
合成线程将栅格化后的图层组装为合成帧(Compositor Frame) ,提交给 Viz 进程 。Viz 进程聚合来自各个渲染进程和浏览器 UI 的合成帧,通过 GPU 最终合成并输出到屏幕 。
第二部分:渲染相关专题
专题 1、渲染阻塞:DOM、CSS 与脚本执行时机
对 DOM 树的阻塞 :在渲染过程中,如果遇到了 <script> 就会立刻停止渲染,然后执行 JS 代码。这是因为 DOM 解析、样式计算和 JS 执行共享渲染主线程,JS 执行时主线程被占用,DOM 解析和渲染会被阻塞。也就是说,执行 JS 代码时 DOM 渲染被挂起,JS 执行完毕,DOM 才继续渲染。
CSS 对 JS 执行和渲染的阻塞 :JS 可以操作 DOM 的样式,而不完整的 CSSOM 无法被使用,因此执行 JS 代码时可能必须拿到完整的 CSSOM 树。浏览器如果尚未完成 CSSOM 的下载和构建,却要执行 JS 代码,就会延迟 JS 代码的执行和 DOM 树的构建,直至完成 CSSOM 的下载和构建。这种情况下浏览器会先构建 CSSOM,然后执行 JS,最后渲染 DOM,这样就很可能会出现白屏。
外链脚本的下载可被预加载扫描器并行发现,但脚本执行仍会阻塞主线程;使用 defer/async 可改变执行时机。defer 按顺序在 DOM 解析完成后、DOMContentLoaded 前执行;async 下载完即执行、顺序不保证。
基于以上原因,我们经常会把 CSS 样式放在 HTML 最上面,把 script 标签放在 HTML 最底部,这样可以有效避免 DOM 树的渲染被 JS 打断;或者使用 defer/async 改变脚本执行时机。
专题 2、重绘与回流(重排)
HTML 默认是流式布局的,但 CSS 和 JS 都有可能打破这种布局,如改变 DOM 的外观、大小、位置等样式,这就会造成浏览器的重绘和回流。
重绘 :渲染树节点发生改变,但不影响该节点在页面当中的空间位置及大小。如某个 div 节点的背景颜色、字体颜色等发生改变,但该节点的宽、高、内外边距不发生变化,此时会触发浏览器的重绘(repaint)。
回流 :当渲染树节点发生改变,影响了节点的几何属性(如宽、高、内边距、外边距,或布局属性 float、position、display:none 等),导致节点位置发生变化,此时触发浏览器的回流,也称重排(reflow),需要重新生成布局信息。
理论上每一次修改 DOM 或者 DOM 的几何属性,都会引起一次浏览器的回流和重绘;如果修改的只是 DOM 的非几何属性,则只会引起重绘过程。所以说,回流通常会引起重绘,而重绘不一定会引起回流 。浏览器的回流和重绘很消耗性能,实际开发中应减少对 DOM 的操作,进而减少回流和重绘。
现代浏览器对回流和重绘的优化 :现代浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值,浏览器就会将队列清空,进行一次批处理。这样可以把多次回流和重绘合并成一次,进而减少浏览器的回流和重绘次数,降低性能开销。
但要注意强制同步布局 :在修改样式后立即读取 offsetHeight、getBoundingClientRect()、scrollTop 等布局属性,浏览器必须同步重新计算布局才能返回准确值,这会触发强制同步布局 。循环中“读—写—读—写”会造成布局抖动 ,是动画和滚动卡顿的常见原因。应批量读、批量写,读写分离。
减少回流的一些方法 :
不要一个个地修改 DOM 的样式,应通过 class 来修改。 使用 visibility: hidden 替代 display: none。因为前者通常只引起重绘,后者会引发回流。 使用 transform 替代 top。 不要用 table 布局,table 一个很小的改动就有可能引起回流。 避免用 top、width、margin 等几何属性做动画;优先使用 transform/opacity。@keyframes 是否触发回流取决于动画属性,而不是速度越快回流越多。需要 JS 驱动动画时可用 requestAnimationFrame。 可以将需要多次修改的 DOM 设置成 display:none,等所有操作结束后再设置为 block。 使用文档片段(DocumentFragment)在当前 DOM 之外构建一个子树,操作完之后再把它拷贝回文档。
专题 3、合成优化:transform 为什么比 top 快
transform 和 opacity 通常只影响合成阶段,不触发主线程的布局和绘制,所以动画更流畅;top、width、margin 等几何属性会触发回流,成本高。will-change: transform 可以提前提示浏览器创建合成层,但滥用会导致层数过多、内存上升,不要到处加。一句话总结:不是'transform 走 GPU'这么简单,而是 transform 通常绕开了主线程的布局和绘制,只进入合成阶段。
专题 4、渲染性能度量:Core Web Vitals 与流水线映射
CWV 不是渲染流水线的一个阶段,而是把前面DOM → CSSOM → 样式计算 → 布局 → 绘制 → 提交 → 合成翻译成用户可感知的性能结果:加载快不快、交互跟不跟手、页面稳不稳 。
LCP(Largest Contentful Paint,最大内容绘制) :衡量加载速度,良好 ≤ 2.5s,2.5–4.0s 需改进,>4.0s 差。对应链路 :资源加载 + 样式计算 + 布局 + 绘制 + 提交/合成上屏。优化方向 :压缩关键资源、内联关键 CSS、优化图片、减少阻塞渲染的 JS。INP(Interaction to Next Paint,交互到下一次绘制) :衡量交互响应,良好 ≤ 200ms,200–500ms 需改进,>500ms 差。INP 已取代 FID,看的是访问期间大约最差的那次交互,由输入延迟、处理时间、呈现延迟组成。对应链路 :输入延迟 + 事件处理(可能触发样式/布局/绘制)+ 呈现延迟。优化方向 :拆长任务、避免强制同步布局、减少事件处理中的重计算。CLS(Cumulative Layout Shift,累积布局偏移) :衡量视觉稳定性,良好 ≤ 0.1,0.1–0.25 需改进,>0.25 差。对应链路 :布局阶段的位置稳定性。优化方向 :图片/视频预留尺寸,避免在现有内容上方动态插入元素。
加分认知 :Lighthouse 是实验室数据,CrUX 是真实用户数据;实验室数据用于调试,真实用户数据用于判断是否达标,两者不能互相替代。CWV 的价值不是背阈值,而是建立“指标 ↔ 流水线阶段 ↔ 优化动作”的映射。
本章小结
本章讲的是浏览器渲染流程:从 HTML/CSS 到屏幕像素的完整链路,以及回流重绘和渲染性能。核心记住以下几点:
渲染流水线 :解析 HTML 构建 DOM → 解析 CSS 构建 CSSOM → 样式计算(传统模型称合并生成渲染树)→ 布局计算位置大小并输出布局树 → 绘制生成绘制指令 → Commit → 合成线程分层/分块/栅格化 → Viz 进程合成上屏。渲染阻塞 :JS 执行和 DOM 解析、样式计算都在渲染主线程上执行,JS 执行会阻塞 DOM 解析和渲染;CSSOM 不完整时 JS 也会被延迟,可能白屏。所以 CSS 放头部、script 放底部,或用 defer/async 改变执行时机。回流与重绘 :回流(重排)是几何属性变化导致重新布局,重绘只重新绘制;回流通常引起重绘,重绘不一定引起回流。transform/opacity 通常只进入合成阶段,不触发主线程布局和绘制,所以动画更流畅;Core Web Vitals 中 LCP 衡量加载速度(≤2.5s),INP 衡量交互响应(≤200ms),CLS 衡量视觉稳定性(≤0.1)。
本章自测
渲染流水线顺序? 传统简化记忆:DOM → CSSOM → 渲染树 → 布局 → 绘制 → 分层 → 栅格化 → 合成。 严谨流水线:DOM → CSSOM → 样式计算 → 布局树 → 绘制 → Commit → 合成线程分层/分块/栅格化 → Viz 合成上屏。样式计算的输入、输出是什么? 输入是 DOM 树、CSSOM 树、浏览器默认样式和行内样式;输出是每个 DOM 节点的最终计算样式(Computed Style),几何信息要到布局阶段才计算。哪些元素不进渲染树/布局树? <head>、<script>、<meta>、display:none 的元素不进入布局树;visibility:hidden 会进入布局树但不可见。回流和重绘区别? 回流是几何属性变化导致重新布局;重绘只重新绘制。回流通常引起重绘,重绘不一定引起回流。为什么 transform/opacity 动画通常更流畅? 通常只影响合成阶段,不触发主线程布局和绘制;但合成层过多会增加内存和 GPU 开销,will-change 不要滥用。defer 和 async 区别? 下载都不阻塞 HTML 解析;defer 按顺序在 DOM 解析完成后执行,async 下载完即执行、顺序不保证。什么是强制同步布局? 修改样式后立即读取布局属性(如 offsetHeight、getBoundingClientRect()),浏览器必须同步重新计算布局。应批量读、批量写,读写分离。Lighthouse 和 CrUX 区别? Lighthouse 是实验室数据用于调试;CrUX 是真实用户数据用于判断是否达标。
四、 浏览器缓存策略
本章主要解释:已加载的资源如何复用。一句话结论:强缓存命中不发请求;强缓存失效走协商;协商 304 用本地,协商 200 换新资源。
本章讲浏览器缓存:资源存在哪(缓存位置)、什么时候用缓存(强缓存与协商缓存)、缓存决策顺序。核心记住一句话:强缓存命中不发请求;强缓存失效走协商;协商 304 用本地,协商 200 换新资源。
1、什么是浏览器缓存
浏览器缓存是指:浏览器在访问网站的时候,将一部分数据存储在浏览器本地空间的行为 ,如html,css,js,img等 。浏览器缓存更多的是浏览器和后端之间的交互策略,前端操作空间不大,但是理解浏览器的缓存策略对于前端来说至关重要。
2、浏览器缓存的作用
减少服务器压力:通过浏览器缓存不用每次都通过后端查取数据。 加快网站访问速度:通过缓存减少数据加载时间,提高访问速度。 减少带宽流量消耗:减少数据传输,从而减少流量的消耗。 离线访问:通过 Service Worker 等可编程缓存,可以在没有网络的情况下展示页面;普通 HTTP 缓存不保证离线可用。
3、缓存位置:资源存在哪里
Service Worker 缓存:Service Worker 是独立于主线程运行的脚本上下文,可以拦截请求,用 Cache API 自定义缓存哪些资源、何时缓存、如何读取缓存。使用 Service Worker 要求页面在 HTTPS 下,localhost 例外。 内存缓存(Memory Cache):可将最近请求过的资源存储在内存中,可快速访问,但关闭标签页或浏览器后缓存将被清除。 磁盘缓存(Disk Cache):资源被存储在硬盘上,持久性比内存缓存更强,下次访问相同资源时加载更快,关闭标签页或浏览器不会被清理。 推送缓存(Push Cache):HTTP/2 服务器推送资源的存储位置,当以上三种缓存都没有命中时才会使用。它只在会话中存在,会话结束即释放,存活时间很短;现代浏览器中已不常用。
简化记忆顺序:Service Worker > Memory Cache > Disk Cache > Push Cache > 网络请求。注意这不是严格规范顺序;Push Cache 是 HTTP/2 时代概念。Service Worker 要求 HTTPS,localhost 例外。
4、缓存分类:强缓存与协商缓存
4.1、强制缓存
强制缓存是指:浏览器在进行资源请求的时候,先检查缓存里面有没有该资源的缓存,如果该资源缓存存在并没有过期,就直接使用该资源缓存起来的数据,否则就重新请求资源拿到数据进行缓存。
实现强制缓存的方式: 强缓存的实现依赖于HTTP响应头中的两个字段:Expires和Cache-Control。Expires(HTTP1.0)是一个具体的过期时间,浏览器会根据该时间判断资源是否过期,例如:Expires: Sat, 24 Aug 2024 00:00:00 GMT 表示该缓存到该 GMT 时间前有效。注意 Expires 必须是 HTTP-date 格式,不能用 Unix 时间戳(如 1724509582)。浏览器通过比较当前时间和Expires失效时间,来确定缓存是否有效;Cache-Contro(HTTP/1.1)通过 max-age 等指令表示相对有效期,本身是一组缓存指令。例如:Cache-Control:max-age=2592000 表示:该缓存在2592000秒后过期,浏览器会记录初始请求资源的时间戳,当前请求资源的时间戳 > 初始请求时间戳+ 2592000,则强制缓存过期,重新请求资源更新缓存数据。在同时设置 Cache-Control 和 Expires 的响应中:Cache-Control > Expires。
Cache-Control主要用于控制页面缓存,主要有以下属性
public :响应可以被任何缓存(客户端和代理服务器)缓存。 private :允许客户端缓存,通常用于只允许终端用户缓存、不允许代理服务器缓存的响应。不要把它理解为 Cache-Control 的默认值。 no-cache: 可以缓存,但每次使用缓存前必须向服务器发起协商缓存验证,验证通过才使用本地缓存;它不是“完全不缓存”。完全不缓存是 no-store。 no-store :所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存。 max-age=xxx(xxx is numeric) :缓存内容将在xxx秒后失效
4.2、协商缓存
协商缓存是指 :当浏览器请求资源的时候,发现有缓存数据,且缓存结果失效,此时浏览器会携带对应的缓存数据发送 HTTP 请求, 通过对比缓存数据的Last-Modified (后台数据最后一次改变的时间)或者ETag (内容标识,常见实现为 hash 或版本号)与服务器上的数据是否一致来确定是否使用缓存。如果数据一致,服务器返回304,浏览器继续使用缓存中的数据作为请求数据;如果数据不一致,服务器返回200和最新的数据。
浏览器再次请求时,会把 Last-Modified 的值放进 If-Modified-Since,把 ETag 的值放进 If-None-Match 发给服务器;服务器对比后决定返回 304 还是 200。一般 ETag 优先级高于 Last-Modified。
5、缓存决策顺序:强缓存与协商缓存谁先生效
一次资源请求,浏览器大致按以下顺序判断是否使用缓存:
先看强缓存是否命中 :如果 Cache-Control(优先于 Expires)判定未过期,直接使用本地缓存,不发请求 ,状态码表现为 200(from disk cache / from memory cache)。强缓存失效,再走协商缓存 :浏览器携带 If-None-Match(对应 ETag)和 If-Modified-Since(对应 Last-Modified)向服务器验证。协商缓存命中 :服务器返回 304 Not Modified ,浏览器继续使用本地缓存,只更新响应头中的缓存元信息。协商缓存未命中 :服务器返回 200 和新资源,浏览器用新响应更新缓存。
协商缓存字段之间也有优先级:ETag / If-None-Match 优先于 Last-Modified / If-Modified-Since 。因为 Last-Modified 只精确到秒,且文件内容未变但修改时间变化时会误判;ETag 基于内容标识,更精确。可以用一句话概括:强缓存命中不发请求;强缓存失效走协商;协商 304 用本地,协商 200 换新资源。 缓存位置之间的查找顺序(Service Worker > Memory Cache > Disk Cache > Push Cache > 网络)见前文“缓存位置”,这里是“ 强缓存与协商缓存谁先生效 ”的决策顺序,两者不是一回事。
6、缓存实践与 Vary
HTML 用 no-cache 或短 max-age,保证入口文件及时更新;JS/CSS/图片用内容 hash 文件名 + 长 max-age,内容变则文件名变,天然破缓存。 Vary 响应头会影响缓存键,比如 Vary: Accept-Encoding 表示不同压缩格式分别缓存。
本章小结
本章讲的是浏览器缓存策略:缓存位置、强缓存、协商缓存、决策顺序。核心记住以下几点:
缓存位置:Service Worker > Memory Cache > Disk Cache > Push Cache > 网络请求,但这不是严格规范顺序,Push Cache 现代浏览器已不常用。 强缓存靠 Cache-Control(优先)和 Expires,命中不发请求,状态码表现为 200(from disk cache / from memory cache)。 协商缓存靠 ETag/If-None-Match(优先)和 Last-Modified/If-Modified-Since,命中返回 304,未命中返回 200 和新资源。
本章自测
强缓存和协商缓存谁先生效? 先判断强缓存;未命中或过期再走协商缓存。 强缓存命中发请求吗?返回什么状态? 不发请求;状态表现为 200 from disk/memory cache。 协商缓存命中返回什么?→ 304 Not Modified,继续用本地缓存,只更新响应头缓存元信息。 ETag 和 Last-Modified 谁优先?为什么? ETag / If-None-Match 优先;Last-Modified 只精确到秒,内容未变但修改时间变化会误判。 no-cache 和 no-store 区别? no-cache 可缓存但每次用前要协商;no-store 完全不缓存。 Cache-Control 和 Expires 谁优先? Cache-Control 优先;Expires 是 HTTP/1.0 绝对时间。
五、 浏览器存储
本章主要解释:获取到的数据存哪儿。一句话结论:Cookie 小且随请求带;localStorage 大但同步;sessionStorage 跟标签页走;IndexedDB 异步、能存大量结构化数据。
浏览器存储解决的是“应用数据存在哪里”的问题,和第四章的“浏览器缓存”不是一回事:缓存针对 HTTP 资源,由 HTTP 头控制;存储针对应用数据,由 JS 读写 。两者不要混用。本章讲浏览器存储:四类存储的横向对比、同源策略在存储层面的表现、Cookie 安全属性、localStorage/sessionStorage 的坑、IndexedDB 的定位、Service Worker 缓存策略、Token 存储权衡。核心记住一句话:Cookie 小且随请求带;localStorage 大但同步;sessionStorage 跟标签页走;IndexedDB 异步存大量数据。
1. 四类存储横向对比
Cookie localStorage sessionStorage IndexedDB 容量 单个Cookie约 4KB;每域名还有数量限制 约 5MB 约 5MB 几十 MB 到数百 MB 生命周期 可设过期时间 除非主动清除,否则一直存在 标签页/窗口关闭即清除 除非主动清除,否则一直存在 是否随请求发送 是,匹配 Domain/Path 的请求自动携带 否 否 否 作用域 Cookie —— 域 + 路径(受 Domain/Path 约束,不严格按同源)) 同源 同源 + 标签页 同源 同步/异步 同步(指 document.cookie 读写) 同步,会阻塞主线程 同步 异步 典型用途 会话标识、小段状态 不常变的小量数据 标签页级临时状态 大量结构化数据、复杂查询
一句话记差异:Cookie 小且每次请求都带;localStorage 大但同步;sessionStorage 跟着标签页走;IndexedDB 异步、能存大量结构化数据。
2. 同源策略在存储层面的表现
对比表里的“同源”需要展开,否则容易误以为同源数据都自动共享:
localStorage :同源的所有标签页共享同一份数据,但不自动同步 ;一个标签页修改后,其他同源标签页需要监听 storage 事件才能感知。sessionStorage :同源且同一标签页 隔离。同源的两个标签页不共享;刷新不丢,关闭标签页即清除。 Cookie :不按严格同源策略共享,而受 Domain、Path、Secure、SameSite 等属性约束。Domain=.example.com 可让子域共享;Path=/admin 只在该路径下携带;同源 JS 也只能读取非 HttpOnly 的 Cookie。 IndexedDB :同源共享,不同源完全隔离。
3. Cookie 的使用细节与安全属性
读写方式:JS 通过 document.cookie 读写,但只能读到非 HttpOnly 的 Cookie。写入时可设置 expires / max-age、path、domain、Secure、SameSite。实际项目中会话 Cookie 通常由后端通过 Set-Cookie 写入,前端不直接操作。为什么容量必须小 :Cookie 会随每个同源请求自动携带,单个 Cookie 的大小上限约 4KB(名称、值、属性合计),是为了避免每个请求都背上过大的头部开销。 三个安全属性 :
HttpOnly :禁止 JS 通过 document.cookie 读取,缓解 XSS 窃取 Cookie。注意它只防“读”,不防“请求被伪造携带”。Secure :只在 HTTPS 下发送。SameSite :Strict 跨站请求完全不携带;Lax 跨站顶层导航 GET 携带,其他不携带(现代浏览器常见默认);None 跨站也携带,但必须同时加 Secure。SameSite 主要用于缓解 CSRF。
同源策略与跨域的详细方案,参见《跨域的几种方法 》。
4. localStorage / sessionStorage 的细节与坑
只能存字符串 :存对象需要 JSON.stringify,取出来再 JSON.parse;直接存 undefined 会变成字符串 "undefined"。同步 API,会阻塞主线程 :大量读写或存大对象时可能造成页面卡顿,不适合频繁读写大数据。storage 事件 :其他同源标签页修改 localStorage 时,当前页会收到 storage 事件,可用于简单的跨标签页通信;当前页自己修改不触发自身事件,sessionStorage 也不触发。容量约 5MB :超出通常抛 QuotaExceededError,业务侧需要捕获。可能被浏览器清理 :隐私模式、存储压力大时,浏览器可能清理 localStorage 和 IndexedDB,所以不能当“永久可靠存储”,重要数据要有服务端兜底。跨标签页通信的轻量方案 :多标签页登录状态同步、主题切换同步,可以用 storage 事件实现;但它只传字符串,且当前页不触发,需要设计好事件来源判断。
注意:复制标签页时,新标签页会复制原标签页的 sessionStorage;通过链接打开的新标签页则不共享。
5. IndexedDB 的定位与使用场景
异步、面向对象存储 :不是简单 key-value,而是“数据库 → 对象仓库 → 索引 → 游标”的模型,支持事务、索引和范围查询。适合的场景 :离线应用(PWA)、大量结构化数据缓存、富文本/复杂表单自动保存、客户端日志持久化。不适合的场景 :只存少量简单配置时,localStorage 更合适,不必引入 IndexedDB 的复杂度。使用成本高 :原生 API 较繁琐,实际项目常配合 idb 等封装库使用。
IndexedDB 有版本概念,结构变更时通过 onupgradeneeded 升级;实际项目常用 idb 等库简化。
6. Service Worker 缓存策略
Service Worker 作为缓存位置已在第四章介绍,这里重点讲它的可编程缓存策略。本质是通过 fetch 事件拦截请求,用 Cache API 实现可编程的缓存策略,比 HTTP 缓存更灵活,但需要自己处理版本更新和缓存清理。常见四类策略:
Cache First :优先读缓存,适合静态资源。Network First :优先请求网络,失败回退缓存,适合动态接口。Cache then Network :先返回缓存,同时后台请求更新。Stale-While-Revalidate :返回旧缓存的同时异步更新,下次请求用新的。
7. Token 存储的安全权衡
这是存储和安全交叉的高频面试点:Token 放 localStorage 还是 Cookie?
放 localStorage :读写方便,但 XSS 一旦发生,攻击者可以直接读取 Token,因为 localStorage 对同源 JS 完全开放。放 HttpOnly Cookie :JS 读不到,能缓解 XSS 窃取;但会引入 CSRF 面,需要配合 SameSite 和 CSRF Token。折中方案 :短时效 access_token 放内存,长时效 refresh_token 放 HttpOnly Cookie,刷新时用后者换新的 access token。
没有绝对安全的存储位置,只有权衡——localStorage 防 CSRF 不防 XSS,HttpOnly Cookie 防 XSS 不防 CSRF。 Token 存储与 XSS/CSRF 防御的详细关系,参见第六章 Web 安全基础。
本章小结
本章讲的是浏览器存储:四类存储、同源策略、Cookie 安全属性、localStorage/sessionStorage、IndexedDB、Service Worker、Token 权衡。核心记住以下几点:
四类存储一句话:Cookie 小且随请求带;localStorage 大但同步;sessionStorage 跟标签页走;IndexedDB 异步、能存大量结构化数据。 选型:会话标识和小段状态用 Cookie,加 HttpOnly/Secure/SameSite;不常变的小量数据用 localStorage,注意同步阻塞和可能被清理;标签页级临时状态用 sessionStorage;大量结构化数据和离线场景用 IndexedDB。 扩展:需要可编程缓存策略时用 Service Worker,但要处理版本和清理;Token 存储没有银弹,按 XSS/CSRF 风险权衡选择。
本章自测
四类存储最大区别? Cookie 小且随请求带;localStorage 大但同步;sessionStorage 跟标签页走;IndexedDB 异步、能存大量结构化数据。 localStorage 同源标签页会自动同步吗? 不会;需监听 storage 事件,当前页自己修改不触发自身事件。 sessionStorage 作用域和生命周期?→ 同源且同一标签页隔离;刷新不丢,关闭标签页/窗口即清除。 Cookie 三个安全属性防什么? HttpOnly 防 JS 读取缓解 XSS 窃取;Secure 只在 HTTPS 发送;SameSite 限制跨站携带缓解 CSRF。 Token 放 localStorage 还是 HttpOnly Cookie? localStorage 防 CSRF 不防 XSS;HttpOnly Cookie 防 XSS 不防 CSRF;折中是短效 access_token 放内存,长效 refresh_token 放 HttpOnly Cookie。 IndexedDB 适合什么场景? 离线应用、大量结构化数据、复杂表单自动保存、客户端日志持久化。 Service Worker 常见缓存策略? Cache First、Network First、Cache then Network、Stale-While-Revalidate。
六、Web 安全基础:XSS 与 CSRF
本章主要解释:常见的攻击手段以及防守策略。一句话结论:XSS 是往页面注入代码,CSRF 是借用户身份发请求;XSS 核心防御是输出编码,CSRF 核心防御是 SameSite Cookie 和 CSRF Token。
浏览器安全话题很多,面试最常问的是 XSS 和 CSRF。两者经常被混在一起,核心区别一句话:XSS 是“往页面里注入代码”,CSRF 是“借用户的身份发请求”。
本章讲 Web 安全基础:XSS、CSRF、CSP、点击劫持,以及 Token 存储、依赖安全等常见话题。核心记住一句话:XSS 是“往页面注入代码”,CSRF 是“借用户身份发请求”;XSS 核心防御是输出编码,CSRF 核心防御是 SameSite Cookie 和 CSRF Token。
1. XSS(跨站脚本攻击)
定义 :攻击者把恶意脚本注入页面,在用户浏览器中执行。本质是往页面里注入代码 ,窃取 Cookie、Token、篡改页面、发起恶意请求等。
三种类型 :
存储型 XSS :恶意脚本被存入服务(如评论、昵称、帖子),其他用户访问页面时被加载执行。危害最大 ,因为影响所有访问者。反射型 XSS :恶意脚本作为请求参数传给服务端,服务端未转义直接拼回页面,脚本在响应中执行。通常通过诱导用户点击恶意链接触发。DOM 型 XSS :服务端响应本身安全,但前端 JS 把不可信数据直接插入 DOM(如 innerHTML、document.write),在浏览器端触发执行。完全发生在前端,后端无法感知 。
一句话区分:存储型和反射型是“服务端把恶意内容吐回页面”,DOM 型是“前端自己把不可信数据插进页面”。 防御层次:
输出编码是核心 ,不是输入过滤。输入过滤容易被编码绕过;输出时按上下文(HTML 文本、属性、URL、JS)编码才可靠。避免危险 DOM API :innerHTML、document.write、eval 是 XSS 的高危汇聚点;改用 textContent 或框架的安全绑定。CSP(内容安全策略)是纵深防御 :通过白名单限制脚本来源,即使有 XSS 漏洞,恶意脚本也可能无法执行。HttpOnly Cookie :不能阻止 XSS,但能防止 XSS 直接读取 Cookie 中的会话凭证。框架默认转义是安全的,但逃生舱要警惕 :React 的 dangerouslySetInnerHTML、Vue 的 v-html 会绕过转义,仍需自行校验。
2. CSRF(跨站请求伪造)
定义 :攻击者诱导已登录用户向目标站点发起请求,借用用户身份执行操作。本质是借用户的身份发请求 。四步攻击流程:
用户登录站点 A,浏览器保存了 A 的 Cookie。 用户在未退出 A 的情况下访问恶意站点 B。 B 页面构造一个跨站 指向 A 的请求(图片、表单自动提交等),浏览器自动带上 A 的 Cookie 。 A 的服务端以为是用户本人操作,执行了请求。
关键点 :CSRF 不需要攻击者读到响应 ,只需要让请求带上用户凭证。这也是它和 XSS 的本质区别。防御层次:
SameSite Cookie :目前最有效的浏览器内置防线之一,但不能 100% 防住,需要配合 CSRF Token 等措施。Strict 跨站请求完全不携带;Lax 跨站顶层导航 GET 携带,其他不携带(现代浏览器常见默认值);None 跨站也携带,但必须同时加 Secure。CSRF Token :服务端生成随机 Token,页面提交时带上,攻击者无法提前获知。校验 Origin / Referer :检查请求来源是否合法。注意 :CORS 不是 CSRF 防御。CORS 管的是“能不能读响应”,CSRF 管的是“请求能不能被伪造携带凭证”。
3. XSS 与 CSRF 的核心区别
XSS CSRF 本质 往页面注入代码 借用户身份发请求 是否需要读到响应 需要执行脚本,可读页面数据 不需要读响应,只需请求带上凭证 主要防御 输出编码、CSP、HttpOnly SameSite、CSRF Token、Origin/Referer 与 CORS 的关系 无关 CORS 不是 CSRF 防御
XSS 防的是“代码被执行”,CSRF 防的是“请求被伪造携带凭证”;两者防御手段不能互相替代。
4. CSP(内容安全策略)
CSP 通过 HTTP 响应头 Content-Security-Policy 定义资源加载白名单,限制脚本、样式、图片等来源。
核心指令 :default-src(兜底))、script-src(脚本来源)、style-src、img-src 等。禁止内联脚本 :CSP 可以禁止 <script> 内联脚本和 onclick 等内联事件,从根源上减少 XSS 执行面。禁止 eval :限制动态代码执行。定位 :CSP 是纵深防御的一层,不能替代输出编码;它的价值在于“兜住你漏掉的 XSS”。
5. 点击劫持(Clickjacking)
攻击者用透明 iframe 覆盖页面,诱导用户点击,用户在不知情的情况下触发了目标站点的操作。防御:
X-Frame-Options: DENY 或 SAMEORIGIN,禁止或限制页面被 iframe 嵌入。 CSP 的 frame-ancestors 指令限制谁可以嵌入页面;两者同时存在时,CSP frame-ancestors 优先。
6. 其他常见安全话题(点到为止)
中间人攻击 :HTTPS/TLS 解决,见第二章 TLS 部分。依赖安全 :第三方脚本是供应链风险,可用 SRI(子资源完整性)校验。敏感信息泄露: 密码、银行卡号等敏感信息不应存储在客户端任何位置;会话 Token 应优先走 HttpOnly Cookie,而不是 localStorage。Token 存储权衡 :详见第五章第 7 节——localStorage 防 CSRF 不防 XSS,HttpOnly Cookie 防 XSS 不防 CSRF,没有绝对安全的存储位置。
本章小结
本章讲的是 Web 安全基础:XSS、CSRF、CSP、点击劫持。核心记住以下几点:
XSS 是注入代码执行 :分存储型、反射型、DOM 型;核心防御是输出编码,CSP 和 HttpOnly 是补充。CSRF 是借身份发请求 :核心防御是 SameSite Cookie 和 CSRF Token,CORS 不是 CSRF 防御。两者关系: XSS 可能绕过部分 CSRF 防御——它能读取页面 DOM 中的 CSRF Token,但读不到 HttpOnly Cookie 中的凭证;所以安全要纵深防御,不能只靠一层。同源策略是理解跨域和 CSRF 的基础,参见《跨域的几种方法 》。
本章自测
XSS 和 CSRF 本质区别? XSS 往页面注入代码并执行;CSRF 借用户身份发请求,不需要读到响应。 XSS 三种类型? 存储型、反射型、DOM 型;前两者是服务端把恶意内容吐回页面,DOM 型是前端把不可信数据插入 DOM。 XSS 核心防御? 输出编码是核心,按 HTML 文本、属性、URL、JS 上下文编码;CSP、HttpOnly 是纵深防御。 CSRF 核心防御?CORS 是 CSRF 防御吗? SameSite Cookie、CSRF Token、Origin/Referer 校验;CORS 不是 CSRF 防御。 CSP 的定位? 纵深防御的一层,通过白名单限制资源来源并禁止内联脚本和 eval,不能替代输出编码。 点击劫持怎么防? X-Frame-Options: DENY/SAMEORIGIN 或 CSP frame-ancestors;同时存在时 CSP frame-ancestors 优先。
附:一页纸速记卡
一条主线 :DNS 找 IP,TCP 三次握手,TLS 加密,HTTP 收发,渲染上屏,四次挥手。
渲染流水线 :DOM + CSSOM → 渲染树 → 布局 → 绘制 → 分层 → 栅格化 → 合成。
缓存 :强缓存不发请求;协商 304 用本地,200 换新。
存储 :Cookie 小随请求,local 大同步,session 跟标签,IndexedDB 异步大。
安全 :XSS 注入代码,CSRF 借身份;XSS 输出编码,CSRF SameSite + Token。
线程 :进程管资源,线程被调度;GUI 和 JS 同主线程,互斥。
附:易错点清单
强缓存命中不发请求,状态表现为 200 from cache。 协商缓存命中返回 304。 no-cache 不是不缓存,是每次用前要协商;no-store 才是完全不缓存。 visibility:hidden 进渲染树,display:none 不进。 回流一定引起重绘,重绘不一定引起回流。 GUI 和 JS 不是两条独立线程,是渲染主线程的两个逻辑角色。 transform/opacity 通常只影响合成阶段,不触发主线程布局和绘制。 CORS 不是 CSRF 防御。 HTTP/2 解决应用层队头阻塞,HTTP/3 基于 QUIC 解决传输层队头阻塞。 三次握手服务端收到第三次 ACK 后才进入 ESTABLISHED。 四次挥手客户端最后等 2MSL 是为了保证服务端能收到最后的 ACK。 ETag / If-None-Match 优先于 Last-Modified / If-Modified-Since。 localStorage 同源标签页共享数据但不自动同步,需监听 storage 事件。 sessionStorage 刷新不丢,关闭标签页即清除。 HttpOnly 只防 JS 读 Cookie,不防请求被伪造携带。 SameSite 缓解 CSRF,但不能 100% 防住,需配合 CSRF Token。 首屏 14KB 是经验法则,不是绝对硬标准。 Lighthouse 是实验室数据,CrUX 是真实用户数据,不能互相替代。