网页白屏的原理与优化

327 阅读18分钟

那天,产品经理在给客户演示功能,他自信地打开网页,一秒后页面还没加载出来,他愣了一下自嘲说,今天网速有点慢;三秒后还是白屏,他开始忍不住点击鼠标,试图唤醒网页;五秒后依然白屏,客户尴尬地说要不下次。。。由此可见,白屏时间直接影响用户体验,关乎用户留存率。

作为一名前端工程师,消除网页白屏,我们当然义不容辞。首先,我们得知道浏览器是如何加载网页的。当你在地址栏中输入网址后,浏览器会发送HTTP请求获取网页资源,这些资源最终被组合成用户可交互的网页。这个过程涉及网络通信、解析渲染、脚本执行等多个环节,理解这些原理之后,或许我们就能找到答案。

一、网络通信

关键步骤说明
1.解析URL用户在地址栏输入URL,例如: https://example.com/ 。 浏览器解析URL,识别出协议(https)、域名(example.com)等。
2.浏览器缓存浏览器在获取静态资源时,会优先使用本地缓存的数据。从而提升网页加载速度,降低服务器负载。
3.DNS解析通过向DNS服务器逐级向上查询,获取网站服务器的ip地址。
4.TCP连接通过三次握手和TLS握手,在浏览器和服务器之间建立可靠和安全的连接。
5.HTTP通信浏览器发送HTTP请求,服务器返回网页资源。

1.浏览器缓存

强缓存

服务器在返回资源时指定过期时间,浏览器下次请求时如果资源未过期,直接使用缓存数据,无需请求服务器。

HTTP1.0用Expires头字段指定过期时间。

 Expires: Thu, 05 May 2022 08:13:07 GMT

Expires是一个绝对时间,当浏览器和服务器的系统时间不一致时,会存在误判。

HTTP1.1升级之后,用max-age头字段指定有效时间。

    cache-control: max-age=60

max-age比Expires优先级更高。

协商缓存

当缓存数据过期了,浏览器需要跟服务器协商,如果数据未更新,则继续使用缓存数据,否则返回新的数据。

HTTP1.1判断数据更新的方案:

方案HTTP头特点
文件内容的hash值etag;If-None-Matchetag只有在文件内容变化时更新。
文件最后更新时间last-modified;If-Modified-Sincelast-modified变化时不一定代表文件内容有更新

服务器返回资源时指定etag或last-modified。当缓存资源过期了,浏览器会与服务器协商缓存(If-None-Match或If-Modified-Since)。如果服务器判断资源更新了,返回200和新的资源;否则返回304。

etag比last-modified优先级更高。

关于缓存策略的权衡

实际项目中,缓存策略的配置往往需要最大化命中缓存保证内容及时更新之间取得平衡。为了保证内容及时更新,作为入口的HTML文件通常设置较短的max-age甚至禁用缓存。对于静态资源则设置一个不过期的max-age并配合文件名hash。当内容变更时,HTML中引入的资源文件名称也会更新,直接请求新的资源,省去了协商缓存的步骤,进一步减少白屏时间。缓存失效后的协商缓存虽然能验证资源是否更新,但依然会引发一次网络请求(304响应)。

2.DNS解析

第一步,检查本地缓存,看浏览器和操作系统是否保存过该域名的IP地址,如果没有则下一步。

第二步,递归查询,向DNS服务器发起查询,如果找不到记录,会继续向上查询根域名服务器,最终获取IP地址。

第三步,缓存结果,把解析结果缓存在本地,避免重复查询。

3.TCP连接

三次握手:浏览器通过IP地址与服务器建立可靠的TCP连接,确保数据传输的可靠性。

  • 客户端发送  SYN (同步请求)。
  • 服务器响应  SYN-ACK (确认同步)。
  • 客户端发送  ACK (确认连接)。

TLS握手(HTTPS):若使用HTTPS,还需要建立安全连接,确保数据传输的安全性:

  • 身份验证:通过数字证书验证服务器的合法身份,确保用户连接的是真实的网站。
  • 加密传输:通过对称加密确保数据不被中间人窃听,通常使用非对称加密交换密钥(用于对称加密)。
  • 完整性校验:通过数字签名校验确保数据不被中间人篡改。

4.HTTP通信

浏览器发起HTTP请求,包含:

  • 请求行:方法(GET、POST)、路径(/example)、协议版本(HTTP/1.1)。
  • 请求头:浏览器类型(User-Agent)、可接受的内容类型(Accept)、Cookie等。
  • 请求体(POST请求):表单数据或JSON数据。
    GET /index.html HTTP/1.1
    Host: www.example.com
    Accept: text/html

服务器返回HTTP响应,包含:

  • 状态码:(如200 OK)
  • 响应头:内容类型(Content-Type)、缓存策略(Cache-Control)、Set-Cookie
  • 响应体:HTML、CSS、JS等文件或JSON数据。
    HTTP/1.1 200 OK
    Content-Type: text/html
    <html>...</html>

服务器返回的网页资源包括:

  • HTML 文件,包含网页的内容和结构信息。
  • CSS 文件,包含页面元素的样式和布局信息。
  • JavaScript 文件,用于处理网页的用户交互逻辑。
  • 嵌入网页的媒体资源,例如图片、视频、音频等。

浏览器会根据Content-Type进行处理,如果是text/html,则开始解析渲染。

二、关键渲染路径

浏览器的关键渲染路径有七个阶段:

阶段输入输出主要工作触发条件
ParseHTML/CSS文本DOM树+CSSOM树解析文档结构和样式表首次加载
StyleDOM+CSSOMRender Tree计算每个节点的样式CSS变化
LayoutRender TreeLayout Tree计算元素位置和尺寸几何属性变化
PaintLayout TreeDisplay List生成绘制指令和分层决策视觉样式变化
RasterizationDisplay List位图生成位图视觉样式变化
Composite位图Layer Tree图层转换与合成transform/opacity变化
DisplayLayer Tree屏幕像素GPU输出到屏幕每一帧

1.Parse:解析文档结构和样式表

构建DOM树

解析HTML标签并构造DOM树。一个HTML元素包括开始和结束标签、属性和文本等信息,元素之间有层级关系。

以这个HTML片段为例:

    <p>
      Let's use:
      <span>HTML</span>
      <span>CSS</span>
      <span>JavaScript</span>
    </p>

转成DOM树如下:

DOM树描述了文档结构信息。p元素是一个父节点,它的子节点包括一个文本节点以及三个span元素。这些span元素同时也是父节点,其子节点是文本。

构建CSSOM树

解析样式表并构建CSSOM树。CSS样式表由选择器和声明组成,选择器用来选择元素,即样式要应用到哪些元素上。声明由属性和属性值组成,通过不同的属性控制样式。

当浏览器解析HTML遇到 <style> 或 <link> 时,它就会开始解析CSS。

每个<style>或<link>样式会转换成一个CSSStyleSheet 对象:由若干个Rule组成,每个 Rule 包含 Selector和Declearation{Property:Value}。至于内联样式,在构建 DOM 树时会直接解析成 Declearation 集合。

流式解析

浏览器采用流式解析,不需要到整个HTML下载完才开始解析,而是边下载、边解析、边渲染,让页面尽快显示出来。如果不采用流式解析,浏览器必须等整个HTML下载完成后才开始解析,这会造成严重的白屏。

实际上浏览器接收到第一个数据包就开始解析、渲染了,后续到达的HTML数据包会被继续解析渲染,并触发回流和重绘,这使得页面能够逐步地渲染出来。

HTML解析阻塞

  1. 问题:为什么HTML解析会阻塞

浏览器渲染是单线程的,当主线程在解析HTML时,如果遇到外部资源(如CSS、JavaScript)就会阻塞,导致白屏时间延长。其阻塞逻辑如下:

  • <script>(同步脚本):立即停止HTML解析 → 下载脚本 → 执行脚本 → 恢复解析。因为脚本可能修改DOM。

  • <link>(外联CSS):本身不会阻塞HTML解析,但会阻塞渲染(浏览器为避免重绘,会等CSS加载并解析后再绘制)。同时,如果其后有脚本,该脚本会等待CSS加载完成后才执行(因为脚本可能读取CSSOM),间接阻塞HTML解析。

  1. 关键机制:浏览器的“预加载扫描器”

当HTML解析阻塞时(遇到同步脚本),预加载扫描器会继续扫描后面的HTML内容,提前发现并下载外部资源(JS、CSS),实现资源的并行加载,从而减少整体的加载时间(预加载扫描器不占用主线程)。

  1. 前端编码优化方案

理解原理后,我们可在代码层面主动规避阻塞,核心策略是“让关键资源尽早下载,让非关键资源延后执行”。

优化手段工作机制适用场景与建议
async 脚本async 脚本是异步下载的(不阻塞),下载完立刻执行(阻塞)。多个async脚本执行顺序不定。适用于独立性强、不依赖DOM或其它脚本、需要尽快执行的场景。
defer 脚本defer脚本是异步下载的(不阻塞),延迟到HTML解析完成之后,DOMContentLoaded事件之前再执行。多个defer脚本会按HTML中出现的顺序执行。适用于需要访问完整的DOM树、有严格执行顺序要求的脚本(如页面初始化框架)。
关键CSS内联将影响首屏样式的关键CSS直接内嵌在HTML的<style>标签中。彻底消除关键渲染路径上的CSS网络请求,是提升首屏渲染速度的高效手段。
<link rel="preload">以高优先级显式告知浏览器必须提前加载某个资源。用于强制预加载字体、关键CSS/JS等渲染必需资源,比预加载扫描器更可控。

一句话总结优化原则:除必须立即执行的脚本外,优先使用 async 或 defer;同时使用 preload明确告诉浏览器使用预加载。

<head>
  <!-- 1. 将关键资源放在HTML前面 -->
  <link rel="stylesheet" href="critical.css">
  <!-- 2. 使用preload明确告诉浏览器使用预加载加载 -->
  <link rel="preload" as="style"  href="theme.css">
  <link rel="preload" as="script"  href="monitor.js">
</head>

<body>
  <script>
    <!-- 3. 避免过多的同步脚本阻塞 -->
  </script>
</body>

<!-- 4. 优先使用async/defer -->
<script src="non-critical.js" defer></script>

2.Style:计算节点样式

在DOM树和CSSOM树都构建完成后,浏览器会将它们“合二为一”,生成用于实际绘制页面的渲染树(Render Tree)。

计算步骤

遍历DOM树的每个可见节点(不包含display: none),结合CSSOM规则,计算出每个节点的最终样式(ComputedStyle)。

核心步骤:

  • 找出每个节点匹配的Selector。
  • 根据Selector优先级计算节点自身的样式。
  • 根据级联样式的覆盖/继承原则,计算节点最终的ComputedStyle。

CSS选择器的“匹配魔法”

当页面有成千上万的元素和CSS规则时,浏览器如何快速完成匹配?这背后是一场极致的效率优化。

核心挑战:组合爆炸

假设一个页面有10,000个DOM元素和 5,000条CSS规则,最坏情况下需要进行5千万次匹配判断。浏览器必须在毫秒级内解决这个问题。(10,000 × 5,000 = 5千万次判断)

🔑 高效匹配的秘诀:从右向左匹配 & 关键选择器的哈希映射

  1. 从右往左匹配

组合 Selector 的匹配顺序是从右开始,最右边的 Selector 命中了,则会向左匹配下一个 Selector,直到最左边的 Selector 也命中,就说明命中了节点。

为什么是从右边开始匹配,而不是左边?从左边匹配,需要宽度优先遍历树,效率低。从右匹配,只需要逐级匹配父节点即可。

  1. 关键选择器的哈希映射

浏览器会根据选择器最右边的部分(称为“关键选择器”),将所有规则分类存入不同的哈希集合中。例如,所有以 .class 结尾的规则放入“类规则集”,以 #id 结尾的放入“ID规则集”。

当匹配一个具体元素时,浏览器可以根据该元素的ID、类名、标签名等,直接去对应的哈希集合中查找可能匹配的规则,而无需遍历所有规则,实现了“精准检索”。

优先级计算

一个节点可能会命中多个 Selector ,将它们取并集得到节点的样式。如果遇到相同的Property,则取 Selector 优先级高的。

继承与覆盖

获得节点自身的样式之后,还会从父节点继承样式,如果是相同的样式属性,则会被自身样式覆盖,得到节点的ComputedStyle。

优化思路

  • 使用具体的选择器(如类选择器、ID选择器)
  • ✅ 避免过深的嵌套选择器
  • ❌ 避免通配符选择器 *

3.Layout:计算元素的位置和尺寸

场景: 你打开一个响应式网页,浏览器窗口宽度1920px。浏览器需要计算每个元素的确切位置和尺寸。

例如:

  • 导航栏宽度:100% → 1920px
  • flex 布局,两个宽度100px的元素左右两端对齐,中间实际间隔为1720px。
  • position: relative根据父元素确定元素的实际位置。

深度优先计算布局

先计算子节点再回头计算父节点。通常需要在子节点确定之后,才能计算出父节点的布局。例如父节点height: auto的高度需要子节点撑起。反过来子节点height: 50%,这种需要先计算父节点的布局,再传递给子节点计算实际高度。

Layout触发机制

触发类型触发场景影响范围
Initial Layout首次加载页面全局
Incremental Layout局部DOM变化局部
Full Layout窗口调整大小全局

触发Layout的CSS属性

  • 盒模型: width, height, padding, margin, border
  • 定位:position, top, left, bottom, right
  • 其他:float,clear,display, overflow, font-size, line-height

回流(reflow):性能隐患

当DOM树节点个数、位置、大小发生变化,需要重新计算布局,称为回流。

问题代码:

    // ❌ 反模式:在循环中交替读写
    function updateLayout() {
      for (let i = 0; i < 1000; i++) {
        const height = cards[i].getBoundingClientRect().height; // 强制Layout
        cards[i].style.marginTop = height * 0.1 + 'px'; // 标记Layout失效
        // 下次循环再读取时,浏览器必须重新Layout
      }
    }

问题分析:

  • 触发Layout次数:1000次
  • 单次Layout耗时:约0.5ms
  • 总耗时:1000 × 0.5ms = 500ms
  • 帧率:约2fps(严重掉帧)

优化方案:批量读写分离

    // ✅ 最佳实践:批量操作
    function updateLayoutOptimized() {
    // 阶段1:批量读取(触发1次Layout)
    const heights = [];
    for (let i = 0; i < 1000; i++) {
        heights[i] = cards[i].getBoundingClientRect().height;
      }

    // 阶段2:批量写入(不触发Layout)
    for (let i = 0; i < 1000; i++) {
        cards[i].style.marginTop = heights[i] * 0.1 + 'px';
      }
    }

4.Paint阶段:生成绘制指令

Paint阶段并没有直接绘制屏幕,而是生成一份"绘制指令"(Display List),交由GPU执行。

    const displayList = [
      { type: 'fillRect', x: 10, y: 10, width: 100, height: 50, color: '#ff0000' },
      { type: 'drawText', text: 'Hello', x: 20, y: 20, font: '16px Arial', color: '#000' },
      { type: 'drawImage', image: src, x: 0, y: 0, width: 200, height: 200 },
    ];

为什么这样设计?

  • 绘制指令可以复用(元素没变化就不用重新生成)
  • 绘制指令可以优化(合并重复操作)
  • 实际绘制可以交给GPU(并行处理)

5.光栅化:生成位图

光栅化是将矢量图转换为像素的过程。浏览器创建一个位图,并按照绘制指令填充像素信息。

例如:

输入绘制指令:{ type: 'fillRect', x: 10, y: 10, width: 100, height: 50, color: '#ff0000' }

输出位图:一个100*50的像素矩阵。

Tile(瓦片化显示):像加载地图一样

场景: 打开一个地图网页,完整的展示所有地点。

挑战: 地图面积是10000*10000px,如果浏览器把整个地图渲染成一张图片,

  • 内存占用:10000 × 10000 × 4字节 = 381MB(仅一个图层!)
  • 渲染时间:可能需要2500ms
  • 而你只能看到屏幕上的1920*900,其余98%的渲染都浪费了

优化

将Layer分割为多个256*256px的瓦片(Tile),实现按需加载和并行处理。

  • 按需加载

优先加载窗口內可见的内容,当窗口滚动时再加载新的内容。

  • 并行处理

每个瓦片独立光栅化,可以充分利用GPU的并行计算能力,提升光栅化效率。

软件光栅化 VS 硬件光栅化

在旧浏览器中,一般是由CPU执行光栅化任务,称为软件光栅化。在现代浏览器中,会优先把光栅化任务交给GPU执行,以利用它的并行处理能力,也叫硬件光栅化。

虽然CPU支持并行计算,但面对大规模的像素矩阵运算,显然力不从心。而GPU是专门为矩阵运算设计的,当然是得心应手。

另外,CPU执行完光栅化,还要把位图传输给GPU展示,传输开销可能大于计算。

6.Composite:图层分层与合成

动静分离,分而治之

假设页面上有一个动画,每次元素变化都触发回流重绘,容易导致交互卡顿。因此,浏览器把频繁变化的元素抽离到独立的Layer,只需要重绘有变化的Layer即可。

满足以下任一条件,元素会被提升为独立的 Layer:

  • 布局:overflow: scroll、position: fixed/sticky、
  • 动画:transform、opacity、filter(滤镜)
  • CSS属性:will-change
  • 元素类型:<video>, <canvas>, <iframe>

GPU加速合成

每个Layer对应一个位图,GPU可以对Layer进行矩阵变换和合成,最后再输出到屏幕。同时,Composite是在独立的合成线程执行的,不影响渲染线程。即使页面在处理复杂数据时无法响应交互,也不影响页面滚动和动画播放。应用场景如下:

  • 动画加速

transform或opacity等动画,只需要在GPU对Layer进行矩阵变换,无需经过重排重绘。

修改transform属性
↓ 跳过Layout ✅
↓ 跳过Paint ✅
↓ 仅触发Composite(2ms)- GPU直接处理
  • 滚动优化

将固定元素(position:fixed)提升为独立Layer,滚动时只需要移动内容 Layer,无需重新绘制。

内存成本:看不见的内存杀手

分层虽好,但如果使用不当,容易引发内存崩溃。

场景: 在一个商品列表页面,所有卡片都触发动画分层。

.product-card {
  will-change: transform;
}

分析:

单个Layer内存大小 = 宽度 × 高度 × 4字节(RGBA)

  • 单个商品卡片尺寸:375×200

  • 单个Layer内存:375 × 200 × 4 = 300KB

  • 100个Layer总内存:300KB × 100 = 30MB

问题:移动设备内存紧张,导致频繁触发内存回收,甚至崩溃

优化: 只对可见区域的卡片添加will-change

    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        const card = entry.target;
        
        if (entry.isIntersecting) {
          // 即将可见,提前优化
          card.style.willChange = 'transform';
        } elseif (entry.intersectionRatio === 0) {
          // 完全离开视口,移除优化
          img.style.willChange = 'auto';
        }
      });
    }, {
    rootMargin: '100px'// 提前100px开始优化
    });

    productCardList.forEach(card => observer.observe(card));

三、总结

浏览器加载网页的过程涉及网络通信(解析URL、浏览器缓存、DNS解析、TCP连接、HTTP通信)和解析渲染(Parse、Style、Layout、Paint、Rasterization、Composite、Display),通过合理的缓存配置提升网页加载速度;利用好浏览器的预加载机制,优先使用async/defer脚本和preload属性,避免HTML解析阻塞;使用具体的选择器,避免过深的嵌套选择器,提升CSS匹配效率;减少DOM操作,或使用批量操作代替,减少回流和重绘;借助GPU加速合成,合理使用分层,优先使用transform或opacity实现动画。

附录

1. HTML标签的解析过程

2. CSS选择器优先级

    switch (m_match) {
      case Id: 
        return 0x010000;
      case PseudoClass:
        return 0x000100;
      case Class:
      case PseudoElement:
      case AttributeExact:
      case AttributeSet:
      case AttributeList:
      case AttributeHyphen:
      case AttributeContain:
      case AttributeBegin:
      case AttributeEnd:
        return 0x000100;
      case Tag:
        return 0x000001;
      case Unknown:
        return 0;
    }
    return 0;

把所有 Selector 的权重后相加,得到组合 Selector 的优先级。

举个例子:

    // 65793 = 65536 + 1 + 256
    #container p .text {
      font-size: 16px;
    }

    // 2 = 1 + 1
    div p {
      font-size: 14px;
    }

3. chrome性能分析工具

chrome自带的性能分析工具:chrome://tracing,可以看到整个渲染过程的细节。

  • CrRendererMain(渲染主线程) 主要是生成layerTree,Compositor(合成线程) 负责对layerTree进行合成 ,CrGpuMain负责执行光栅化和合成
  • parseHTML流式解析
  • token词化
  • 触发预加载扫描器
  • 停止HTML解析,开始加载和执行脚本
  • 异步脚本加载完成,执行脚本
  • 解析内部样式表
  • 解析外部样式表
  • 计算样式和布局
  • 提交绘制指令
  • 将绘制指令推送给合成器线程

  • 更新图层树
  • 计算图层的绘制属性,包括变换矩阵、滚动偏移、不透明度、可见性等。(合成线程)
  • 瓦片化
  • 光栅化(GPU)
  • 合成(GPU)