问题状况
环境:ios11-14.4
业务场景:一个题目列表(题目由富文本渲染)
DOM结构:滚动父容器使用了**position: absolute + overflow-y: auto 属性**
问题:快速滚动之后,出现白屏
.scroll-container {
position: absolute; /* ⚠️ 脱离文档流 */
overflow-y: auto; /* ⚠️ 独立滚动 */
}
IOS(14.5以下)渲染机制分析
浏览器的完整渲染逻辑
HTML + CSS
→ DOM + CSSOM
→ Render Tree
→ Layout(Reflow)
→ Paint
→ Layer Tree(合成层树)
→ Raster(栅格化)
→ Composite(合成)
→ Display
1.DOM + CSSOM
DOM(Document Object Model)
HTML 解析阶段生成 DOM 树,其本质是:
-
将 HTML 字符串解析为节点结构
-
每个节点包含:
- tag 类型
- 属性
- 层级关系
DOM 的特性:
- 仅表达结构语义
- 不包含任何视觉信息
- 不知道如何渲染
DOM 的输出是结构化树,但不是渲染结构。
CSSOM(CSS Object Model)
CSS 解析生成 CSSOM:
- selector → style rules
- 规则按优先级与层叠关系组织
CSSOM 的特性:
- 表达样式规则集合
- 不绑定具体 DOM 节点
- 不具备几何信息
DOM 与 CSSOM 相互独立:
- DOM:结构
- CSSOM:样式规则
浏览器在此阶段无法确定任何视觉结果。
2.Render Tree
Render Tree 是第一次 “渲染语义合成结构”。
2.1 合成规则
浏览器遍历 DOM,并为可见节点计算:
- 匹配 CSSOM
- 生成 computed style
每个 Render Tree 节点包含:
- DOM 节点引用
- computed style
- 可见性状态
2.2 节点过滤
不会进入 Render Tree 的节点包括:
- display: none
- head 中非可视元素
注意区分:
- visibility: hidden 仍进入 Render Tree(占位)
- display: none 不进入 Render Tree (如果发生变化,会引发 Reflow重排,进而引起Repaint重绘)
如果是希望元素消失变化平滑,可以使用opacity+visibility
因为 opacity 切换只触发“合成(Composite),甚至不会触发Repaint重绘
2.3 阶段本质
Render Tree 的作用是:
确定“哪些节点参与后续渲染计算”。
此阶段不包含几何信息。
3.Layout(布局/重排)
Layout 阶段也称 Reflow(重排)
3.1 输入
Render Tree(包含 style,但无几何信息)
3.2 输出
每个 Render Node 的:
- position(x, y)
- size(width, height)
- box model(margin / padding / border)
- line box(文本换行)
3.3本阶段本质
计算“每个元素在页面上的几何布局结果”
输出
- 完整的几何信息树:Layout Tree / Layout Object
4.Paint(绘制/重绘)
4.1 输入
- layout tree(几何信息)
- computed style
做什么
生成绘制指令:
- draw background
- draw border
- draw text
- draw image
输出
Paint Records(绘制命令列表)
典型内容包括
- fillRect(背景)
- strokeRect(边框)
- drawText(文本)
- drawImage(图片)
- clipPath(裁剪)
4.3 Paint 的特点
- 与像素无关
- 与 GPU 无关
- 是平台无关的绘制命令集合
4.4 阶段本质
Paint 的输出是:
“如何绘制”的指令,而不是结果。
5. Layer Tree
Layer(图层)
5.1 什么是 Layer(图层)?
浏览器(特别是 WebKit/Blink 引擎)的渲染管线中,Layer(合成层,Compositing Layer) 是页面被拆分成的一个个独立的渲染画布。
-
浏览器不会把整个网页画在一张大纸上,而是把页面拆分成多个 Layer。
-
每个 Layer 会独立进行光栅化(将 DOM/CSS 转换为像素点)
然后由浏览器的合成器(Compositor) 按照 Z 轴顺序,
将这些 Layer 叠加、混合,最终输出到屏幕上
5.2Layer生成条件
浏览器创建新 Layer 通常基于:
合成属性触发
- transform
- opacity(非 1)
- filter
- will-change(部分情况)
布局隔离需求
- overflow: scroll(滚动容器)
- position: fixed(某些实现)
内容类型
- video
- canvas
- iframe
5.3 Layer 的意义
- 浏览器划分 Layer 的终极目的是:性能优化(用内存空间换渲染时间),避免昂贵的重排(Reflow)和重绘(Repaint)。
- 隔离 repaint 与 raster 范围,让频繁的局部变化不影响/阻塞整体绘制。
5.4 划分依据
-
高频局部变化隔离 (动画与交互)
如果一个元素经常发生位置、大小、透明度变化(如
transform,opacity),把它隔离出来,变化时就只更新它自己,不影响其他元素。
-
滚动与裁剪隔离(局部更新)
如果一个区域需要局部滚动 (
overflow: scroll) 或裁剪,将其内部提升为 Layer,滚动时只需移动该 Layer,避免全局重绘。
-
层叠上下文与定位隔离(Z轴独立)
脱离正常文档流的元素 (
fixed,sticky,z-index较大的元素) ,它们的渲染顺序和生命周期与主文档流不同,独立成 Layer 可以简化合成器的计算逻辑。
-
特殊内容源隔离(非 DOM 渲染)
内容不是由 DOM 树生成的(
video,canvas,iframe,webgl),必须独立成 Layer 才能将外部像素源接入浏览器的合成管线。
6.Raster(栅格化)
Raster 是从【绘制指令】到【像素】的转换。
Rasterization(栅格化/光栅化) 是浏览器渲染管线中的核心步骤,指将矢量的逻辑渲染指令(DOM树、CSS样式、SVG等)转换为屏幕上实际显示的像素点阵(位图/Bitmap) 的过程。
6.1 输入
- layer paint records
- layout + style 信息
6.2 输出
- bitmap(位图)
- 即像素数据
栅格化是一个极其消耗 CPU/GPU 资源的过程
为了优化性能,现代浏览器采用了以下机制:
- 分块处理(Tiling) :浏览器绝不会一次性栅格化整个页面或整个 Layer。它会将 Layer 切割成一个个小块(Tiles,通常是 256x256 或 512x512 像素)。
- 优先级调度:浏览器会优先栅格化当前视口内的 Tiles,视口外的 Tiles 优先级较低。
- 多线程并行(Raster Threads) :浏览器会开辟专门的【栅格化线程池】在后台并行处理这些 Tiles,不阻塞主线程(Main Thread)。
iOS / WebKit 特性风险点
在移动端 WebKit 中:
- tile cache 有限
- raster 结果可能被回收
- scroll 回退时需重新 raster
如果恢复不及时,会出现:
- DOM 存在
- Layout 正确
- 但像素未恢复
表现为白屏或空白区域
6.3 阶段本质
Raster 输出:【已经确定的像素内容】
7. Composite (合成)
Composite(合成) 是浏览器渲染管线的最后一步,指将多个已经栅格化(Rasterized)好的独立图层(Layer/位图) ,按照 Z 轴顺序(层叠上下文)进行叠加、混合、裁剪,最终生成一帧完整的画面并输出到屏幕上的过程。
7.1 输入
- multiple layer bitmaps
- transform / opacity / z-index 信息
7.2 过程
GPU 执行:
- 图层叠加
- 变换矩阵计算
- alpha blending
- scroll offset 应用
7.3 关键性质
Composite 不修改像素内容,只做空间与层级合成。
合成阶段主要由浏览器的合成线程(Compositor Thread) 和 GPU 接管,它不关心 DOM 结构,只处理【图像叠加】的数学运算:
- 层叠排序(Stacking) :根据 CSS 的
z-index和层叠上下文规则,决定哪个图层在上面,哪个在下面。 - 视觉特效计算:处理图层之间的透明度(
opacity)、混合模式(mix-blend-mode)、裁剪(clip-path/overflow: hidden)和滤镜(filter)。 - 屏幕绘制(Draw) :将最终计算好的像素矩阵提交给屏幕进行刷新(通常是 60Hz,即每秒 60 帧)。
8.Display显示
8.1 输出
GPU framebuffer:
- 完整帧图像
- 发送到显示管线
8.2 显示机制
- vsync 控制刷新
- 每帧提交 framebuffer
浏览器独立合成层?
我们知道合成层Compoisiting 是把Raster光栅化后的bitmap的按照Z轴的index顺序
合成、重叠、剪切生成一张图片
为什么会有独立合成层?
- 如果没有独立合成层,当某个元素发生频繁视觉变化时(例如 transform、opacity、动画),浏览器可能需要重新 Paint 和 Raster 整个受影响区域,造成大量无效计算。
- 因此浏览器会把这些频繁变化的元素提升为独立合成层,让它拥有独立的 bitmap,在变化时只需要进行 Composite 操作,而不影响其他静态内容。
独立合成层的条件
1.transform
.box{
transform:translate3d(0,0,0);
}
transform 不影响布局
2.opacity动画
.fade{
opacity:0;
}
opacity不会改变几何布局,只是改变bitmap的透明度
不需要重绘Paint和Raster光栅化
3.filter滤镜元素
filter:blur(10px)
滤镜需要操作像素bitmap
需要先
Raster Bitmap
然后再GPU处理blur
4.视频/Canvas/WebGL
这些不是普通的DOM绘制
-
canvas内部像素变化
但是DOM没变化
所以单独需要一个 Canvas Layer
5.固定定位
.box{
position:fixed
}
页面滚动的时候,box不懂
所以拆分成 document Layer 和 Fixed Layer
不然每次滚动都需要重新绘制header
6.overflow滚动容器
overflow:auto
例如
<div class="list">
item1
item2
item3
</div>
这个区域需要自己滚动所以就自己创建
Scroll Layer
IOS的特殊情况
IOS webkit会在以下的情况创建独立的Composting Layer
强制创建的情况
| 属性 | 原因 |
|---|---|
| transform | GPU移动 |
| opacity < 1 | 透明合成 |
| filter | 像素处理 |
| video | 外部像素源 |
| canvas | 独立bitmap |
| iframe | 外部页面 |
| position:fixed | 独立滚动 |
| overflow滚动容器 | 滚动优化 |
大概率触发独立合成层的组合
| 情况 | 原因 |
|---|---|
| border-radius + overflow:hidden | 裁剪 |
| 复杂box-shadow | 离屏绘制 |
| 3D transform | GPU |
| will-change | 提前优化 |
| 动画元素 | 预测优化 |
问题分析
环境:IOS11-14.4
业务场景:一个题目列表(题目由富文本渲染)
DOM结构:滚动容器使用了**position: absolute + overflow-y: auto 属性**
问题:快速滚动之后,出现白屏,item不渲染
.scroll-container {
position: absolute; /* ⚠️ 脱离文档流 */
overflow-y: auto; /* ⚠️ 独立滚动 */
}
WebKit 的判断:
-
position: absolute → 元素脱离正常文档流,可能需要absolute Layer
-
overflow: auto → 需要独立滚动,创建 Scrolling Layer
-
item同时属于是富文本 有大量复杂的DOM (img、svg、mathjax)
而且最要命的是 item 有(border-radius + overflow:hidden) 【裁剪】
【导致每个item都创建 Clipping Layer】
100 个 item:
- 最坏情况:100 个独立 Layer
- 每个 Layer:一个 GPU 纹理(几 MB)
- 总开销:几百 MB GPU 内存 → Layer Explosion(层爆炸)
最终导致Layer数量和结构都爆炸了
Root Layer
|
|
+-- Scroll Layer
|
|
+-- Content Layer
|
|
+-- item1 Layer
| |
| +-- Clip Layer
| |
| +-- Texture
|
|
+-- item2 Layer
| |
| +-- Clip Layer
|
|
+-- item3 Layer
|
...
每个 Layer都需要:
- Paint Record
- backing store
- texture
- tile cache
GPU 内存压力爆炸
Webkit本身也有 类似虚拟滚动的 渲染策略
滚动区域的高度假设是10000px+
Webkit不会一次性生成 10000px+的 bitmap
而是将内容拆分成 多个瓦片 (Tiles)
只渲染和缓存 【可见区域】 以及周边区域的部分
scroll-container
+-------------+
| Tile A | 当前屏幕
+-------------+
| Tile B |
+-------------+
| Tile C |
+-------------+
| Tile D |
+-------------+
每一个瓦片都需要
Paint
|
Raster
|
Bitmap
然后存入 Tile Cache
当快速滚动的时候
当前:
- item1 item2 item3
Tile:
[A][B][C]
已经 raster。
但是当滚动的长度过长,超出了TileCache中存在(Raster)的Tile长度之后
当前屏幕
- item80 item81 item82
但是这些item并没有Tile
对应的Tile还没有Raster光栅化
主要核心问题是
- 滚动是Compositor Thread 线程
-
Compositor Thread 改变scroll offset ↓ 马上合成下一帧 ↓ GPU找对应Tile
但是Raster线程还没有生成Tile
导致Composite拿到空的Tile
快速滚动的时候,Raster光栅线程处理不过来
浏览器直接提交了白色背景作为下一帧的画面
所以快速滚动之后,看到的Item是白屏
问题修复
问题的本质是
- Layer层过多
- 大量的Tile需要Raster光栅化
- Compositor滚动速度 远远快于 Raster生成Tile的速度
所以优化方向就是:
- 减少需要 Raster 的内容
- 减少 Layer / Tile 数量
- 降低单个 Tile Raster 成本
- 避免触发旧 WebKit 激进滚动路径
- 必要时让浏览器重新提交 Layer
父容器Layer优化
.scroll-container {
position: absolute; /* ⚠️ 脱离文档流 */
overflow-y: auto; /* ⚠️ 独立滚动 */
}
通过布局优化,取消绝对定位 (优先使用Flex布局解决问题)
.scroll-container {
overflow-y:auto;
}
减少Layer层
Absolute Layer
+
Scrolling Layer
+
独立 Tile 管理
尽量让 WebKit 走:
Document Scroll
而不是:
Independent Async Scrolling Layer
WebKit 两种滚动模式对比
主文档滚动(Root Scroller)
元素对应是<html>/<body>或者根视口滚动
(window.scroll、document.documentElement.scrollTop)
Layer结构
使用主Page TiledBacking(根层级Tiled backing)
Webkit为整个页面提供统一管理的Tile Cache
通用全局覆盖Coverage 和 预缓存Keep 缓存策略
驱动:由WKWebview内部的UIScroll View驱动
核心优势:
- Tile 管理最高效:共用一套策略,快速滚动时瓦片复用率极高,白屏概率最低
- 内存/GPU 最优:Layer 数量少,资源压力小
- 异步滚动体验最佳:惯性滚动、橡皮筋效果等原生行为完全流畅
结论:WebKit 优先走此路径,虚拟化与 tile 创建机制在此路径上最成熟
独立异步滚动层
触发条件
overflow-y: auto+position: absolute(或其他形成独立滚动容器的组合)
Layer结构
- 独立的Scrolling Layer ,映射成一个独立的**
UI Scrollview** - 内部包含Scrolled Content Layer和独立的TiledBacking
- 经常额外产生Clip Layer (裁剪Layer,配有overflow: hidden
或border-radius时)
关键特点:
- 独立异步滚动:滚动逻辑、tile 更新、绘制都相对独立于主文档
- 需要跨进程同步:滚动位置、Layer Tree Commit 需在 Web Process 与 UI Process 间通过 Remote Layer Tree 协调
- 额外开销:独立的 backing store、独立的 tile cache、独立的 paint records
- 代价:复制了一套完整的加速滚动基础设施,非必要时会增加内存与同步成本
正式因为我们触发了独立的异步滚动层 (Independent Async Scrolling Layer)
所以出现Layer更新慢,跟不上Compositor层,才出现空白
子项目Item的Layer优化
.item {
border-radius:20px;
overflow:hidden;
}
这是高风险组合。
border-radius
+
overflow:hidden
容易触发成 Clip Layer 裁剪层
浏览器必须:
- 创建裁剪区域
- 离屏绘制
- 保存bitmap
如果100item,那么就会产生100个Clip Layer
解决方案
取消overflow:hidden
.item {
border-radius:20px;
}
如果一定要裁剪
把裁剪交给父容器
.list-container{
overflow:hidden
}
父容器的裁剪层Clip Layer只会生成一个
(这样能够大量减少Layer的数量)
虚拟滚动(长列表优化方案
原理
- 只渲染可视区域(10–20 个 DOM)
- DOM 数量少 → Raster 压力小 → 根本上解决
这个是长期方案