浏览器渲染机制详解(包含渲染流程、树结构、异步js)

公众号:小博的前端笔记

一:渲染机制

核心目标: 将 HTML、CSS、JavaScript 代码转换为用户可见、可交互的像素点。

关键渲染路径: 浏览器完成页面首次渲染所必须经过的一系列步骤。优化关键渲染路径是提升页面加载性能的关键。

主要阶段:

  1. 解析(Parsing)与构建 DOM / CSSOM 树:

    • 输入: 接收 HTML 和 CSS 字节流。

    • 过程:

      • 词法分析: 将字节流分解成有意义的标记(Token)。
      • 语法分析: 根据 HTML/CSS 语法规则,将这些标记构建成树形结构。
    • 输出:

      • DOM(Document Object Model)树: HTML 的树形结构表示。每个 HTML 元素(标签、属性、文本)都是树中的一个节点(Node)。DOM 树捕获了文档的内容和结构。
      • CSSOM(CSS Object Model)树: CSS 的树形结构表示。每个 CSS 规则及其包含的选择器和声明都被映射到树中。CSSOM 树捕获了应用于文档的样式信息。
    • 关键点:

      • 渐进式构建: 浏览器在接收到部分 HTML/CSS 后就会开始解析和构建,无需等待全部下载完成。

      • 阻塞行为:

        • <link rel="stylesheet"> 会阻塞渲染树的构建(Render Tree Construction)和首次渲染(Painting),但通常不会阻塞 DOM 的构建(现代浏览器)。 浏览器需要完整的 CSSOM 来计算样式。因此,它会阻塞后续的渲染步骤,直到 CSSOM 构建完成。
      • 优化提示:

        • 将 CSS 放在文档头部 (<head>),尽早开始 CSSOM 构建。
        • 将 JavaScript 放在文档底部 (</body> 前),或者使用 defer/async 属性来避免阻塞 DOM 构建。
        • 避免使用 @import 在 CSS 中引入其他 CSS 文件,因为它会增加 CSSOM 构建的延迟。
        • 使用媒体查询 (media="print", media="(max-width: 600px)") 让非关键 CSS 不阻塞渲染。
  2. 构建渲染树(Render Tree / Frame Tree):

    • 输入: DOM 树 + CSSOM 树。

    • 过程:

      • 浏览器将 DOM 树和 CSSOM 树合并。
      • 遍历可见的 DOM 节点(不包括 headmetascript[type="text/javascript"]display: none 的元素及其后代等),并为每个需要视觉渲染的节点找到匹配的 CSSOM 规则。
      • 应用这些样式规则,生成一棵只包含可见内容 及其计算样式的树------渲染树。
    • 输出: 渲染树。渲染树节点通常称为"渲染对象"或"框"。

    • 关键点:

      • 渲染树只包含需要绘制到屏幕上的元素。
      • visibility: hidden 的元素会包含在渲染树中(因为它占据空间),而 display: none 的元素则不会。
      • 每个渲染对象知道如何将自己及其子元素绘制出来。
  3. 布局(Layout) / 重排(Reflow):

    • 输入: 渲染树。

    • 过程:

      • 计算渲染树中每个节点在视口(Viewport) 内的确切位置(x, y 坐标)和大小(宽度、高度)。这是一个递归过程。
      • 确定所有元素的几何信息(位置、尺寸)。
    • 输出: 每个渲染对象的精确坐标和尺寸信息。

    • 关键点:

      • "流式布局模型": 网页通常采用流式布局,大多数元素的位置是根据其兄弟元素和父元素的位置计算出来的。

      • 全局布局 vs 增量布局: 首次构建渲染树后的布局称为"全局布局"。之后对渲染树的修改(如改变元素尺寸、位置、添加/删除元素)可能只触发受影响部分的"增量布局"(Reflow)。

      • 性能消耗大: 布局是计算密集型操作,频繁触发布局(重排)会严重影响性能。

      • 触发重排的操作示例:

        • 添加、删除、移动 DOM 元素。
        • 改变元素尺寸(width, height, padding, border, margin)。
        • 改变元素位置(position, top, left)。
        • 改变窗口大小(resize 事件)。
        • 改变字体大小或内容(如文本输入框输入文字)。
        • 激活 CSS 伪类(如 :hover 导致尺寸变化)。
        • 读取某些布局属性(如 offsetWidth, offsetHeight, getComputedStyle())会强制浏览器执行同步布局(也称为"强制同步布局"或"布局抖动"),以获得最新值。
  4. 绘制(Painting) / 栅格化(Rasterization):

    • 输入: 经过布局计算的渲染树。

    • 过程:

      • 绘制(Painting): 将每个渲染对象分解成多个绘制指令(如"画矩形"、"画文字"、"画边框"、"画背景")。这是一个记录绘制步骤的过程,发生在主线程。

      • 栅格化(Rasterization): 将绘制指令实际执行 ,转换成屏幕上的像素点 。这个过程通常由合成线程(Compositor Thread) 将任务分发给栅格化线程(Raster Threads) 在 GPU 上完成(硬件加速)。

        • 现代浏览器会将页面划分为多个图层(Layers)。
        • 每个图层独立栅格化(通常是在单独的栅格化线程中)。
        • 图层内容发生变化时(如动画),只需重新栅格化该图层,而不是整个页面。
    • 输出: 内存中的位图(Bitmap),表示页面特定区域的像素数据。

    • 关键点:

      • 重绘(Repaint): 当元素的外观(颜色、背景色、边框颜色、阴影等)改变,但不影响其几何属性(位置、大小)时,浏览器会触发重绘。重绘不一定会触发重排(Layout),但重排一定会触发重绘(因为布局改变后外观通常也需要更新)。

      • 性能消耗: 绘制和栅格化也是消耗性能的操作,但通常比重排轻量。复杂的 CSS 效果(如阴影、渐变)会增加绘制时间。

      • 分层(Layering)与合成(Composition):

        • 浏览器会根据 CSS 属性(如 transform, opacity, will-change, position: fixed, video 元素)将元素提升到独立的合成层(Compositing Layer)
        • 每个图层被栅格化后存储在 GPU 内存中。
        • 合成(Composition): 合成线程负责收集所有图层(称为"合成器帧"),计算它们在视口中的最终位置(考虑滚动、缩放、变换),并将它们合成为一个最终的屏幕图像帧。这个过程非常高效,因为它主要是在 GPU 上操作位图。
  5. 显示(Display):

    • 过程: 合成线程将最终合成的位图帧提交给 GPU,由 GPU 将其扫描输出到显示器上。

    • 关键点:

      • 显示器通常以固定频率(如 60Hz,即每秒 60 次)刷新。
      • 浏览器会尽量将新的合成帧与显示器的刷新周期(VSync)同步,以实现流畅的动画和滚动效果。这就是 requestAnimationFrame API 的意义所在。

流程图总结:

scss 复制代码
字节流 (HTML/CSS)
      ↓
解析 (Parsing)  → [JS 执行可能阻塞 DOM 构建]
      ↓
构建 DOM 树     构建 CSSOM 树 → [CSS 阻塞渲染树构建]
      ↘           ↙
      构建渲染树 (Render Tree) → [只含可见节点+计算样式]
             ↓
           布局 (Layout / Reflow) → [计算精确位置/尺寸]
             ↓
           绘制 (Painting) → [生成绘制指令列表]
             ↓
         栅格化 (Rasterization) → [GPU上执行绘制指令,生成图层位图] (通常在合成线程/栅格线程)
             ↓
          合成 (Composition) → [合成图层,形成最终帧]
             ↓
           显示 (Display) → [GPU输出到屏幕]

延伸点/优化点:

  1. 重排(Reflow)与重绘(Repaint)的优化:

    • 避免触发重排:

      • 避免频繁操作 DOM(使用 DocumentFragment 或离线 DOM 进行批量修改)。
      • 避免逐项修改样式,使用 classcssText 一次性修改。
      • 避免在循环中读取会触发重排的布局属性(offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop, scrollLeft, scrollWidth, scrollHeight, clientTop, clientLeft, clientWidth, clientHeight, getComputedStyle())。如果必须读取,先将它们缓存起来。
      • 对复杂动画元素使用绝对定位 (position: absolutefixed),使其脱离文档流,影响范围缩小。
    • 利用合成(Composition)优化动画: 优先使用 transform (位移、缩放、旋转) 和 opacity 属性来制作动画。这些属性可以由合成器线程直接在 GPU 上处理,跳过主线程的布局和绘制阶段,性能极高。

    • will-change 属性: 提示浏览器哪些元素可能会发生变化(如 transform, opacity),让浏览器提前为其创建独立的合成层,优化后续变化的性能(但要谨慎使用,滥用会增加内存消耗)。

    • content-visibility: auto 现代 CSS 属性,可以跳过屏幕外内容的渲染(布局和绘制),大幅提升长页面加载和滚动性能。

  2. 脚本加载优化:

    • defer: 脚本异步下载,在 DOM 解析完成之后、DOMContentLoaded 事件之前按顺序执行。不阻塞 DOM 构建
    • async: 脚本异步下载,下载完成后立即执行(可能在 DOM 解析完成之前或之后)。执行时会阻塞 HTML 解析。适用于无依赖、不操作 DOM 的独立脚本。
    • 模块脚本 (<script type="module">): 默认具有 defer 行为。
  3. 现代渲染架构(Chromium 为例):

    • 主线程(Main Thread / Renderer Thread): 处理 HTML 解析、DOM 构建、CSS 解析、CSSOM 构建、JS 执行(大部分)、布局(Layout)、绘制(Painting - 生成指令列表)。
    • 合成线程(Compositor Thread): 负责图层管理、滚动处理、动画处理(处理 transform/opacity 的合成层动画)、将图层分块(tiles)分发给栅格线程、合成最终帧。
    • 栅格线程(Raster Threads): 在 GPU 上执行绘制指令,将图层分块栅格化为位图。
    • GPU 进程(GPU Process): 管理 GPU 资源,最终将合成线程提交的帧绘制到屏幕上。

回答技巧:

  1. 结构化清晰: 按阶段(解析、构建树、布局、绘制、合成、显示)一步步讲清楚。
  2. 突出关键点: 强调阻塞行为(JS 阻塞 DOM 构建,CSS 阻塞渲染树构建)、重排重绘的区别与优化、合成(Composition)的优势。
  3. 联系实际: 解释为什么要把 CSS 放头部、JS 放底部?为什么 transform 动画更高效?什么是强制同步布局?
  4. 提及优化策略: 主动说出常见的优化手段(如避免重排、使用 transform/opacitydefer/async)。
  5. 了解核心概念: 确保理解 DOM、CSSOM、渲染树、图层(Layer)、合成(Composition)、重排(Reflow)、重绘(Repaint)等术语的含义。
  6. 结合浏览器架构(加分项): 如果深入,可以提一下主线程、合成线程、栅格线程的分工。

二:css树和dom树哪个先构建

核心结论:

DOM树和CSSOM树是并行构建的,但CSSOM的完成会阻塞渲染树构建,而同步JavaScript会阻塞DOM构建

详细构建流程:

关键规则解析:

  1. 并行启动

    • 浏览器同时开始解析HTML构建DOM树和下载/解析CSS构建CSSOM树

    • 示例时间线:

      makefile 复制代码
      0ms: 开始解析HTML → 启动DOM构建
      5ms: 发现<link> → 启动CSS下载
      10ms: 发现<img> → 启动图片下载(非阻塞)
      20ms: CSS下载完成 → 启动CSSOM构建
  2. 阻塞关系

    • CSSOM构建

      • 不会阻塞DOM树的构建(现代浏览器)
      • 但会阻塞渲染树构建(必须等待CSSOM完成)
      • 会阻塞后续JavaScript执行(JS可能依赖样式)
    • JavaScript

      • 同步脚本(<script>)会立即阻塞DOM构建
      • 遇到脚本时,必须等待当前所有CSS下载完成才执行(避免JS操作未解析的样式)
  3. 特殊场景

    xml 复制代码
    <head>
      <link href="style.css" rel="stylesheet"> <!-- 并行构建CSSOM -->
      <script>
        // 此脚本需等待style.css下载完成才执行!
        console.log(getComputedStyle(document.body).color)
      </script>
    </head>
    <body> <!-- DOM构建在此处被脚本阻塞 -->
      <div>内容</div>
    </body>

构建优先级总结:

资源类型 阻塞DOM构建 阻塞渲染树构建 并行性
HTML解析 - - 基础解析流
外部CSS ✅ 并行下载
同步JavaScript ❌ 顺序执行
异步JavaScript ✅ 并行
图片 ✅ 并行下载

最佳答案:

"浏览器会并行启动DOM树和CSSOM树的构建过程,但两者存在关键依赖:

  1. CSS不会阻塞DOM构建:现代浏览器能边下载CSS边构建DOM
  2. CSSOM会阻塞渲染:必须等CSSOM完成才能构建渲染树
  3. JavaScript是最大阻塞源:同步脚本会阻塞DOM构建,且需等待前置CSS下载完成

优化核心:CSS放头部快速构建CSSOM,JavaScript放底部或用async/defer避免阻塞DOM构建"

附加考点:

  • 为什么CSS要放头部? 尽早开始CSS下载,避免渲染树构建延迟导致白屏时间过长

  • 为什么JS放底部? 防止阻塞DOM构建,让用户更快看到页面骨架

  • async vs defer 的区别:

    • async:下载完立即执行,可能中断HTML解析
    • defer:下载完等待HTML解析完成后执行

三、DOM树结构和CSS树结构

DOM树和CSSOM树在底层不是普通的JavaScript对象 ,而是浏览器渲染引擎用C++/Rust等系统语言实现的高度优化的专用数据结构。下面从底层实现角度详细解析:

DOM树的对象结构

核心实现原理:

kotlin 复制代码
// 以Chromium的Blink引擎为例(简化版C++)
class Node {
  NodeType type;          // 节点类型(元素/文本等)
  Node* parent;           // 父节点指针
  Node* first_child;      // 首子节点指针
  Node* next_sibling;     // 兄弟节点指针
  AtomicString node_name; // 节点名称(如"div")
};
​
class Element : public Node {
  HashMap<AtomicString, AtomicString> attrs; // 属性键值对
  StylePropertyMap style_properties;         // 内联样式
  ComputedStyle* computed_style = nullptr;    // 计算后的样式
};
​
class Text : public Node {
  String data; // 文本内容
};

在JavaScript中的表现:

javascript 复制代码
// 浏览器暴露给JS的DOM对象只是底层对象的包装器
const div = document.createElement('div');
​
// 实际内存结构:
   JavaScript Heap
      ↓
   [JS Wrapper Object] ← 通过V8引擎绑定
        │
        └──→ [C++ Node对象] ← Blink引擎内存
               │
               ├── type: ELEMENT_NODE
               ├── tag: "DIV"
               ├── parent: <body的C++对象指针>
               └── style: <指向CSSOM的StylePropertyMap>

🔥 关键真相

  1. 你操作的 div 只是系统对象的JS代理
  2. 每次访问 div.style 都会触发跨引擎通信(JS引擎 ↔ 渲染引擎)
  3. DOM操作昂贵的原因:跨越语言边界 + 触发渲染管线更新

CSSOM树的对象结构

底层实现(以Blink为例):

kotlin 复制代码
// CSS规则存储结构
class CSSRule {
  CSSStyleSheet* parent_sheet;
  Vector<CSSSelector> selectors; // 选择器列表
  StylePropertySet properties;   // 样式键值对
};
​
// 样式表结构
class CSSStyleSheet {
  Vector<CSSRule> rules;
  bool disabled;
};
​
// 计算样式缓存(每个元素独立)
class ComputedStyle {
  const CSSValue* getProperty(CSSPropertyID id) const;
  // 存储最终计算值,如:
  // width: 100px → 实际像素值
  // color: red → RGBA数值
};

JS访问时的表现:

ini 复制代码
// 浏览器暴露的有限接口
const stylesheet = document.styleSheets[0];
const rule = stylesheet.cssRules[0];
​
// 底层实际结构:
   JavaScript访问层
      ↓
   [CSSStyleSheet JS对象] ← 受限代理
        │
        └──→ [C++ CSSStyleSheet对象]
               │
               ├── rules: [C++ CSSRule列表]
               └── 无法直接访问底层样式计算缓存

与普通JS对象的本质差异

特性 普通JS对象 DOM/CSSOM对象
存储位置 JS引擎堆内存 渲染引擎专用内存
操作成本 纳秒级 微秒~毫秒级(涉及跨进程通信)
内存结构 无序属性表 树形结构+样式继承链
修改代价 仅影响JS内存 可能触发重排/重绘
属性访问 直接内存读取 可能触发引擎间IPC通信
垃圾回收 V8引擎管理 跨引擎协同回收

性能陷阱示例

ini 复制代码
// 看似简单的操作,实际发生的事:
for(let i=0; i<1000; i++) {
  div.style.width = i + 'px'; 
}
​
// 底层代价:
1. JS → C++: 1000次跨引擎通信
2. 每次修改: 
   - 更新CSSOM计算样式
   - 检查是否触发重排(这里是会触发的!)
   - 重新计算布局
3. 可能触发1000次重排!

高级调试技巧

在Chrome DevTools中验证:

ini 复制代码
# 1. 访问原生函数(非JS包装器)
> document.body.appendChild.toString()
< "function appendChild() { [native code] }"
​
# 2. 查看隐藏的底层引用
> const div = document.createElement('div');
> %DebugPrint(div);  # 需启用--allow-natives-syntax flag
< 0x1e3e25c0aed9: [JSObject]
   - map=0x1e3e25d82ed1 [FastProperties]
   - prototype=0x1e3e25d0b111
   - elements=0x1e3e25d82671 [HOLEY_ELEMENTS]
   - embedder fields: 2
   - backing_store=0x55aabbcc3340  # ← 指向C++内存的指针!

终极总结

"DOM树和CSSOM树不是JavaScript对象,而是:

  1. 浏览器内核实现的C++树结构(如Blink中的Node/ComputedStyle)
  2. JS对象只是底层对象的跨语言代理(通过V8绑定实现)
  3. 每次访问 element.style 都涉及 JS引擎 ↔ 渲染引擎的IPC通信

这解释了:

  • 为什么DOM操作比JS对象慢100倍+
  • 为什么前端框架要用虚拟DOM批量更新
  • 为什么直接操作CSSOM的API受限(性能和安全考虑)"

四:什么是异步js

异步 JavaScript 的核心特征

不阻塞 DOM 解析,允许浏览器继续构建 DOM 树


异步 JS 的 5 种实现方式

1. async 属性(经典异步)

xml 复制代码
<script src="app.js" async></script>
  • 行为

    • 立即并行下载 脚本,下载完成立即中断 HTML 解析执行脚本
    • 执行顺序:不可控(先下载完先执行)
  • 适用场景:独立脚本(如埋点统计、广告加载)

2. defer 属性(延迟异步)

xml 复制代码
<script src="app.js" defer></script>
  • 行为

    • 并行下载脚本,但延迟到 DOM 解析完成后执行DOMContentLoaded 前)
    • 执行顺序:按文档位置顺序执行
  • 适用场景:依赖 DOM 的脚本(如页面初始化逻辑)

3. 动态脚本注入(高级异步)

ini 复制代码
const script = document.createElement('script');
script.src = 'app.js';
document.head.appendChild(script); // 此时开始异步加载
  • 行为

    • 默认具有 async 行为(可通过 script.async=false 改为按顺序执行)
    • 完全不阻塞解析

4. ES6 模块 (type="module")

xml 复制代码
<script type="module" src="app.mjs"></script>
  • 行为

    • 默认具有 defer 行为(延迟到 DOM 解析后执行)
    • 添加 async 属性可转为立即执行模式

5. Web Worker(线程级异步)

arduino 复制代码
const worker = new Worker('task.js'); // 在独立线程运行
  • 行为

    • 在后台线程执行,完全不阻塞主线程渲染

同步 vs 异步 关键区别

特性 同步 JS (<script>) 异步 JS (async/defer/动态加载)
阻塞 DOM 构建 ✅ 立即停止解析 ❌ 不阻塞
执行时机 下载完立即执行 async:下载完立即执行 defer:DOM 解析后
执行顺序 按文档顺序执行 async:乱序 defer:顺序
依赖 DOM 可能操作未解析的 DOM defer可安全操作完整 DOM

黄金答案

"异步 JavaScript 是指 不阻塞 HTML 解析器 的脚本加载方式,包含 5 种实现:

  1. async 属性:并行下载,下载完立即执行(可能中断解析),执行顺序不可控
  2. defer 属性:并行下载,DOM 解析完成后按序执行
  3. 动态脚本注入 :通过 JS 创建的 <script> 默认异步
  4. ES Module (type="module"):默认等效 defer
  5. Web Worker:在独立线程运行,彻底避免阻塞

核心价值:加速首屏渲染,避免白屏时间过长"


⚠️ 常见面试陷阱

问题

xml 复制代码
<head>
  <script async src="A.js"></script>
  <script defer src="B.js"></script>
  <script src="C.js"></script>
</head>

执行顺序是什么? 答案C.jsA.js(如果先下载完) → B.js 解析

  1. 同步脚本 C.js 立即阻塞解析优先执行
  2. async 脚本 A.js 下载完可能抢在 B.js 前执行
  3. defer 脚本 B.js 最后执行(DOM 解析后)

性能优化必记法则

相关推荐
摸鱼仙人~19 分钟前
styled-components:现代React样式解决方案
前端·react.js·前端框架
sasaraku.1 小时前
serviceWorker缓存资源
前端
RadiumAg2 小时前
记一道有趣的面试题
前端·javascript
yangzhi_emo2 小时前
ES6笔记2
开发语言·前端·javascript
yanlele2 小时前
我用爬虫抓取了 25 年 5 月掘金热门面试文章
前端·javascript·面试
中微子3 小时前
React状态管理最佳实践
前端
烛阴4 小时前
void 0 的奥秘:解锁 JavaScript 中 undefined 的正确打开方式
前端·javascript
小兵张健4 小时前
武汉拿下 23k offer 经历
java·面试·ai编程
中微子4 小时前
JavaScript 事件与 React 合成事件完全指南:从入门到精通
前端
Hexene...4 小时前
【前端Vue】如何实现echarts图表根据父元素宽度自适应大小
前端·vue.js·echarts