网页生成过程
网页的生成是一个复杂的过程,主要包括以下几个阶段:
- HTML解析阶段:
- 浏览器逐行解析HTML文档。
- 构建DOM(Document Object Model)树。
- 遇到
<script>
标签会暂停解析,执行脚本(除非使用async/defer)。
- CSS解析阶段:
- 解析外部CSS文件和内联样式。
- 构建CSSOM(CSS Object Model)树。
- 解析过程是递归的(从右向左选择器匹配)。
- 渲染树构建:
- 合并DOM树和CSSOM树。
- 只包含需要显示的节点(如不包含display:none的元素)。
- 计算每个节点的CSS属性值。
- 布局阶段(Layout/Reflow):
- 计算每个节点在视口中的确切位置和大小。
- 从根节点开始递归计算。
- 生成"盒模型"精确信息。
- 绘制阶段(Paint):
- 将布局计算的几何信息转换为屏幕上的实际像素。
- 通常分为多个图层(Composite Layers)进行绘制。
- 最后进行图层合成(Compositing)。
<!DOCTYPE html>
<html>
<head>
<title>网页生成示例</title>
<style>
/* 定义一个盒子样式 */
.box {
width: 200px;
height: 200px;
background-color: lightblue;
margin: 20px;
}
</style>
</head>
<body>
<!-- 构建DOM节点 -->
<div class="box"></div>
<script>
// 打印信息,表明DOM内容已加载
console.log('DOMContentLoaded');
// 获取元素并修改其样式
const box = document.querySelector('.box');
box.style.border = '2px solid red';
</script>
</body>
</html>
重绘(Repaint)
重绘是指视觉外观改变但是几何位置不变的时候,浏览器需要重新绘制元素外观的过程。
举例
颜色、背景、阴影、文字内容等不会影响元素布局的内容的改变会触发重绘。
触发条件
- 修改元素的 color、background-color、border-style、box-shadow 等样式属性。
- 修改 visibility(不影响布局,但会改变视觉状态)。
- 修改文本内容(如 textContent)。
- 滚动条样式的变化(如 scrollbar-width)。
<!DOCTYPE html>
<html>
<body>
<!-- 改变颜色触发重绘 -->
<p id="color-change">改变颜色触发重绘</p>
<button onclick="changeColor()">改变颜色</button>
<!-- 改变背景触发重绘 -->
<div id="bg-change" style="width: 100px; height: 100px; background-color: lightgreen;">改变背景触发重绘</div>
<button onclick="changeBackground()">改变背景</button>
<!-- 改变文本内容触发重绘 -->
<span id="text-change">改变文本内容触发重绘</span>
<button onclick="changeText()">改变文本</button>
<script>
function changeColor() {
const p = document.getElementById('color-change');
p.style.color = 'blue';
}
function changeBackground() {
const div = document.getElementById('bg-change');
div.style.backgroundColor = 'orange';
}
function changeText() {
const span = document.getElementById('text-change');
span.textContent = '文本已改变';
}
</script>
</body>
</html>
重排(Reflow)
重排是指几何位置或者尺寸发生改变的时候就会触发重排,更新页面布局的行为。
举例
元素宽度、高度、边距、定位、添加或删除DOM节点等操作会触发重排。
触发条件
- 几何属性修改:width、height、padding、margin、border-width、top、left、transform(2D/3D 转换会触发合成,但若涉及布局变化也会触发重排)等。
- 布局相关操作:
- 添加或删除可见的 DOM 节点、修改 display(如 none 会触发重排,visibility: hidden 仅触发重绘)、flex/grid 布局的变化。
- 获取布局信息(浏览器会强制同步布局计算):offsetWidth、offsetHeight、offsetTop、offsetLeft、scrollWidth、scrollHeight、clientWidth、clientHeight、getBoundingClientRect() 等属性或方法的调用。
特点
重排成本很高,会影响整个界面布局,导致父子元素连锁变化。浏览器尽可能批量处理重排,但是频繁重排会导致性能瓶颈。
以下是多个触发重排的代码示例:
<!DOCTYPE html>
<html>
<body>
<!-- 改变宽度触发重排 -->
<div id="width-change" style="width: 100px; height: 100px; background-color: lightblue;">改变宽度触发重排</div>
<button onclick="changeWidth()">改变宽度</button>
<!-- 添加节点触发重排 -->
<div id="node-add">添加节点触发重排</div>
<button onclick="addNode()">添加节点</button>
<!-- 修改display触发重排 -->
<div id="display-change" style="width: 100px; height: 100px; background-color: lightgreen;">修改display触发重排</div>
<button onclick="changeDisplay()">修改display</button>
<script>
function changeWidth() {
const div = document.getElementById('width-change');
div.style.width = '200px';
}
function addNode() {
const div = document.getElementById('node-add');
const newDiv = document.createElement('div');
newDiv.textContent = '新添加的节点';
div.appendChild(newDiv);
}
function changeDisplay() {
const div = document.getElementById('display-change');
div.style.display = 'none';
}
</script>
</body>
</html>
重绘与重排的关系
重排通常伴随着重绘,但是重绘不一定有重排。
性能优化
为了提高网页性能,我们需要尽量减少重绘和重排的次数。以下是一些性能优化的方法:
- 减少重排重绘次数
- 批量修改:
// 不好的写法:多次修改样式(触发多次重排)
const element = document.createElement('div');
element.style.width = '100px';
element.style.height = '200px';
element.style.backgroundColor = 'red';
// 好的写法:通过 class 一次性修改
const newElement = document.createElement('div');
newElement.className = 'new-style';
.new-style {
width: 100px;
height: 200px;
background-color: red;
}
- **使用文档碎片**:
批量操作 DOM 时,先将节点添加到 DocumentFragment 中,再一次性插入 DOM 树,避免多次触发重排。
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const div = document.createElement('div');
fragment.appendChild(div);
}
document.body.appendChild(fragment); // 仅触发一次重排
- 避免频繁的获取布局信息
- 缓存布局信息:如需多次读取元素的布局属性(如 offsetWidth),先将其值缓存到变量中,避免重复触发重排。
// 不好的写法:多次读取 offsetWidth(触发多次重排)
const badElement = document.createElement('div');
for (let i = 0; i < 100; i++) {
console.log(badElement.offsetWidth);
}
// 好的写法:缓存值
const goodElement = document.createElement('div');
const width = goodElement.offsetWidth;
for (let i = 0; i < 100; i++) {
console.log(width);
}
- 利用层叠上下文(CSS 3D/硬文件加速)
将频繁动画的元素脱离文档流,使用 will-change 或 transform: translateZ(0) 使其创建层叠上下文,让浏览器为其单独分配图层,避免影响其他元素的布局。
<!DOCTYPE html>
<html>
<head>
<style>
.animated-element {
width: 100px;
height: 100px;
background-color: lightblue;
will-change: transform;
transform: translateZ(0);
animation: move 2s infinite;
}
@keyframes move {
from {
transform: translateX(0);
}
to {
transform: translateX(200px);
}
}
</style>
</head>
<body>
<div class="animated-element"></div>
</body>
</html>
- 合理使用display:none
对需要频繁操作的元素,先设置 display: none(使其脱离文档流,重排成本降低),操作完成后再显示。
const element = document.createElement('div');
element.style.display = 'none'; // 触发一次重排
// 模拟执行大量 DOM 修改
for (let i = 0; i < 10; i++) {
const newSpan = document.createElement('span');
element.appendChild(newSpan);
}
element.style.display = 'block'; // 触发一次重排
- 使用 CSS 动画替代 JavaScript 动画
CSS 动画(如 transition、animation)由浏览器优化处理,尽量避免通过 JavaScript 频繁修改样式触发重排。
<!DOCTYPE html>
<html>
<head>
<style>
.css-animation {
width: 100px;
height: 100px;
background-color: lightgreen;
transition: width 1s;
}
.css-animation:hover {
width: 200px;
}
</style>
</head>
<body>
<div class="css-animation"></div>
</body>
</html>
- 集中修改样式
<!DOCTYPE html>
<html>
<body>
<div id="box" style="width: 100px; height: 100px; background-color: lightblue;"></div>
<button onclick="badChange()">不好的写法</button>
<button onclick="goodChange()">好的写法</button>
<style>
.new-style {
left: 10px;
top: 200px;
transform: scale(1.1);
}
</style>
<script>
const box = document.getElementById('box');
function badChange() {
box.style.left = '10px';
box.style.top = '200px';
box.style.transform = 'scale(1.1)';
}
function goodChange() {
box.classList.add('new-style');
}
</script>
</body>
</html>
- 不使用table布局
table中任何元素变化都会导致整个表格重排,因此尽量避免使用。以下是一个简单的表格示例及对比:
<!DOCTYPE html>
<html>
<body>
<!-- 使用 table 布局 -->
<table border="1">
<tr>
<td>单元格1</td>
<td>单元格2</td>
</tr>
</table>
<button onclick="changeTable()">改变表格</button>
<!-- 使用 div 布局 -->
<div class="div-layout">
<div class="div-cell">单元格1</div>
<div class="div-cell">单元格2</div>
</div>
<button onclick="changeDiv()">改变 div</button>
<style>
.div-layout {
display: flex;
}
.div-cell {
border: 1px solid black;
padding: 5px;
}
</style>
<script>
function changeTable() {
const table = document.querySelector('table');
const newCell = document.createElement('td');
newCell.textContent = '新单元格';
const row = table.rows[0];
row.appendChild(newCell);
}
function changeDiv() {
const divLayout = document.querySelector('.div-layout');
const newDiv = document.createElement('div');
newDiv.textContent = '新 div';
newDiv.classList.add('div-cell');
divLayout.appendChild(newDiv);
}
</script>
</body>
</html>
- 离线修改DOM
- DOM设置display: none;,将其从渲染树中移除,然后再进行复杂修改操作,最后再将其显示出来。整个过程包含隐藏和显示共两次重排。
- 或者使用DocumentFragment创建一个DOM碎片,然后在其上面批量操作DOM,操作完成之后再添加到文档中,这样只会触发一次重排。
// 方法一:使用 display: none
const element = document.createElement('div');
document.body.appendChild(element);
element.style.display = 'none';
for (let i = 0; i < 10; i++) {
const newSpan = document.createElement('span');
element.appendChild(newSpan);
}
element.style.display = 'block';
// 方法二:使用 DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
const newDiv = document.createElement('div');
fragment.appendChild(newDiv);
}
document.body.appendChild(fragment);
- 分离读写操作
<!DOCTYPE html>
<html>
<body>
<div id="div" style="width: 100px; height: 100px; background-color: lightblue;"></div>
<button onclick="badReadWrite()">不好的读写操作</button>
<button onclick="goodReadWrite()">好的读写操作</button>
<script>
const div = document.getElementById('div');
function badReadWrite() {
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
console.log(div.offsetLeft);
console.log(div.offsetTop);
console.log(div.offsetWidth);
console.log(div.offsetHeight);
}
function goodReadWrite() {
const width = div.offsetWidth;
const height = div.offsetHeight;
div.style.width = (width + 10) + 'px';
div.style.height = (height + 10) + 'px';
console.log(width);
console.log(height);
}
</script>
</body>
</html>
"统一读,再统一写"的优化原理,实际上是浏览器渲染机制中的**布局抖动(Layout Thrashing)**问题的解决方案。以下是详细解释:
核心机制解析
- 浏览器渲染队列优化:
现代浏览器会维护一个"写操作队列",将连续的样式修改操作批量处理。这种批处理可以避免每次样式修改都立即触发重排。 - 强制同步布局(Forced Synchronous Layout):
当JavaScript代码读取布局属性(如offsetWidth、clientHeight等)时,浏览器必须立即计算最新布局以保证返回准确值,这会强制刷新并执行当前队列中的所有待处理写操作。 - 典型问题场景:
// 反模式:读写交替
const element = document.createElement('div');
element.style.width = '100px'; // 写 - 加入队列
let w1 = element.offsetWidth; // 读 - 强制刷新队列(触发重排)
element.style.height = '200px'; // 写 - 加入队列
let h1 = element.offsetHeight; // 读 - 再次强制刷新队列(触发重排)
这样会导致多次不必要的重排。
统一读写法示例
const element = document.createElement('div');
// 统一读写法示例
const width = element.offsetWidth; // 第一次读取(触发1次重排)
console.log(width); // 仅仅是日志输出,不涉及布局读取
console.log(width); // 同上
- 第一次读取:
element.offsetWidth
强制浏览器执行队列中的所有待处理操作,触发1次完整的重排计算,并将结果缓存到width
变量。 - 后续console.log:
只是输出已缓存的变量值,不涉及任何布局属性读取,不会触发额外的重排。
为什么不会多次触发重排?
关键区别在于:
- 读取布局属性:
offsetWidth
等会强制重排。 - 普通变量读取:已缓存的变量不会触发重排。
浏览器的工作流程:
[初始状态] 写队列: []
1. 读取offsetWidth:
- 发现需要最新布局信息
- 立即执行: 刷新写队列 → 计算布局 → 返回结果
- 重排计数 +1
2. 后续console.log:
- 只是JavaScript执行环境中的变量访问
- 不涉及渲染引擎
- 不会触发重排
实际开发中的正确实践
const element = document.createElement('div');
// 1. 先批量读取所有需要的布局信息
const width = element.offsetWidth;
const height = element.offsetHeight;
// 2. 然后执行批量样式修改
element.style.width = (width + 10) + 'px';
element.style.height = (height + 10) + 'px';
// 整个过程只触发1次初始读取时的重排
特殊情况的注意事项
即使统一读取,某些情况下仍可能触发多次重排:
const element1 = document.createElement('div');
const element2 = document.createElement('div');
const width1 = element1.offsetWidth; // 重排1
const width2 = element2.offsetWidth; // 可能再次重排
因为不同元素的布局计算可能有依赖关系,浏览器可能需要多次计算。最安全的做法是使用requestAnimationFrame
来分离读写操作。
这种优化原理正是Facebook的React等框架实现虚拟DOM(Virtual DOM)批处理更新的核心机制之一。