第一部分:DOM 元素------页面的骨架(结合实例)
假设我们有一个简单的 index.html:
html
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
<h1>我的任务</h1>
<ul id="task-list"></ul>
<input id="task-input" type="text" placeholder="输入新任务">
<button id="add-btn">添加</button>
</div>
<script src="app.js"></script>
</body>
</html>
1.1 HTML 解析与 DOM 构建的底层过程(实例化)
当浏览器接收到这段 HTML 字节流:
-
字节→字符:根据 UTF-8 解码为字符串。
-
字符→令牌 :标记化器(Tokenizer)扫描到
<div id="app">,产生StartTagtoken,名称div,带属性id="app"。 -
令牌→节点→DOM 树 :树构建器遇到
StartTag(div),如果当前父节点是body,则创建一个HTMLDivElement节点并插入。然后遇到<h1>,创建HTMLHeadingElement,依此类推。遇到<input>时,因为是自闭合标签,不会产生结束 token。
最终生成的 DOM 树大致为:
text
Document
└── html
├── head
│ ├── meta
│ └── link
└── body
└── div#app
├── h1 (文本:"我的任务")
├── ul#task-list
├── input#task-input
└── button#add-btn (文本:"添加")
脚本阻塞细节 :
我们的
<script src="app.js"></script>没有async/defer,所以在解析到它时,解析器停止,等待app.js下载并执行。由于它之前有
<link>加载 CSS,如果 CSS 此时未完全加载,浏览器会延迟执行app.js(因为 JS 可能要读取CSSStyleDeclaration),但 DOM 解析可以继续?实际上规范是:CSS 不阻塞 DOM 解析,但会阻塞后面的脚本执行 ,直到 CSSOM 就绪。这里app.js在 CSS 之后,所以会被阻塞等待 CSS 加载完毕。这保证了脚本操作样式时得到的是正确值。
1.2 DOM 核心接口层次(结合例子)
我们在控制台运行:
javascript
javascript
const btn = document.querySelector('#add-btn');
console.log(btn instanceof EventTarget); // true
console.log(btn instanceof Node); // true
console.log(btn instanceof Element); // true
console.log(btn instanceof HTMLElement); // true
console.log(btn instanceof HTMLButtonElement); // true
可以看到原型链完美匹配。此时 btn.addEventListener('click', handler) 的能力来自于 EventTarget,btn.parentNode 来自于 Node,btn.classList 来自于 Element,btn.click() 来自于 HTMLElement,而 btn.type 等属性来自于 HTMLButtonElement。
底层,V8 在调用 btn.appendChild 时,通过绑定层调用 Blink 的 C++ 方法,该方法会修改节点树并设置"布局脏标志"。
1.3 属性 vs 特性(以 input 为例)
javascript
javascript
const input = document.querySelector('#task-input');
// 设置 attribute
input.setAttribute('value', '买菜');
console.log(input.getAttribute('value')); // "买菜"
console.log(input.value); // "买菜" (property 同步)
// 用户输入 "健身"
// 现在 input.value === "健身"
console.log(input.getAttribute('value')); // 仍然是 "买菜"
getAttribute 始终返回 HTML 中指定的初始值(或通过 setAttribute 修改的值),而 value 属性反映当前表单控件的状态。表单重置(form.reset())会把 value 恢复到 attribute 的值。这一区别在任务输入框中很重要:如果我们要清空输入框,应该设置 input.value = '' 而不是 removeAttribute('value')。
1.4 事件系统(添加任务按钮的点击)
javascript
javascript
const addBtn = document.getElementById('add-btn');
addBtn.addEventListener('click', function(e) {
console.log('按钮被点击');
}, false); // 默认冒泡阶段
当用户点击按钮,浏览器从捕获阶段开始,路径为 window → document → html → body → div#app → button。如果某个祖先绑定了捕获事件,会依次触发。到达 button 后触发目标阶段,然后冒泡阶段反向传播。我们的回调在冒泡阶段执行。
底层实现为:点击产生 MouseEvent,引擎根据坐标命中测试找到最内层元素 button,然后生成事件路径(数组),依次调用监听器。调用时,e.target 永远是 button,e.currentTarget 是当前监听器绑定的元素。
第二部分:CSS 样式与美观(任务列表的样式设计)
我们的 styles.css 内容如下:
css
css
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
}
#task-list {
list-style: none;
padding: 0;
}
#task-list li {
padding: 8px 12px;
border-bottom: 1px solid #ddd;
transition: transform 0.2s, opacity 0.2s;
}
#task-list li:hover {
background-color: #e0e0e0;
}
#task-list li.fade-out {
transform: translateX(100px);
opacity: 0;
}
2.1 CSS 解析与 CSSOM 构建
浏览器请求 styles.css,获得字节流,解码,词法分析为 Token,例如 #task-list 是 HASH,{、}、: 等。语法分析器构建 CSSOM 树:
text
CSSStyleSheet
├── rule: body { ... }
├── rule: #task-list { ... }
├── rule: #task-list li { ... }
├── rule: #task-list li:hover { ... }
└── rule: #task-list li.fade-out { ... }
每条规则内部的声明被处理成 CSSStyleDeclaration。浏览器会丢弃任何无效声明(比如写错了属性名),不会影响其他规则。
2.2 样式计算(具体到任务项 li)
当 JS 动态添加一个 <li> 到 #task-list 时,该元素需要参与样式计算。
-
选择器匹配 :引擎从右向左,先找到所有
li元素,再检查其祖先是否有#task-list。同时检查伪类:hover是否匹配(目前未悬停),以及.fade-out类是否应用。匹配的声明被收集。 -
层叠与继承:
-
font-family从body继承。 -
list-style、padding等有明确值,来自#task-list li规则。 -
未设置的属性如
color可能使用 UA 默认值或继承。 -
计算出绝对值:
font-size若为相对单位会转换为px;颜色可能转换为rgba等。
-
getComputedStyle(li).paddingLeft 会返回 "12px",无论 CSS 中写的是 12px 还是 1em。
2.3 渲染树、布局树与层(任务项的布局)
生成布局树时,#task-list 和它的 li 都是 display: block(默认),会出现在布局树中。每个 li 内部是文本节点,产生匿名行内盒。
布局阶段:视口宽度假设 400px,#task-list 的 width 默认 auto 为父容器宽度,padding: 0。每个 li 是块级盒,宽度填满父容器,高度由内容(文本行高 + 上下 padding 8px)决定。计算后每个 li 可能是 34px 高,垂直堆叠。
我们没有使用复杂定位,也没有 3D 变换,所以默认不会触发独立合成层。但如果某个 li 使用了 transform 动画(如后面的淡出效果),动画期间可能会被提升为合成层(will-change 或 active 变换触发)。
2.4 绘制、合成及性能优化(删除动画)
当用户删除一个任务,JS 会给 li 添加类 fade-out,触发 CSS transition:
css
css
li.fade-out {
transform: translateX(100px);
opacity: 0;
}
这个动画只改变 transform 和 opacity,浏览器只需要合成(Composite),不需要触发布局和绘制。底层机制:渲染进程中,负责该层的合成器会在每个帧计算插值,改变图层的变换矩阵和透明度,最后由合成线程直接将图层合并提交给 GPU,完全脱离主线程。这就是所谓"硬件加速动画"。
第三部分:JavaScript 操作逻辑------行为引擎(任务管理逻辑)
3.1 渲染任务列表(展示执行上下文与闭包)
在 app.js 中我们这样写:
javascript
javascript
(function() {
const taskList = document.getElementById('task-list');
const input = document.getElementById('task-input');
const addBtn = document.getElementById('add-btn');
// 获取任务数据(稍后结合网络)
let tasks = [];
function renderTasks() {
taskList.innerHTML = ''; // 清空
tasks.forEach((task, index) => {
const li = document.createElement('li');
li.textContent = task.text;
li.addEventListener('click', function() {
removeTask(index);
});
taskList.appendChild(li);
});
}
function removeTask(index) {
// 删除逻辑(后续会加入网络请求)
tasks.splice(index, 1);
renderTasks();
}
addBtn.addEventListener('click', function() {
const text = input.value.trim();
if (text) {
tasks.push({ text, id: null }); // id 后续服务器分配
renderTasks();
input.value = '';
// 发送请求...
}
});
})();
执行上下文与闭包
外层 IIFE 创建了一个函数执行上下文,其词法环境中包含了 taskList、input、addBtn、tasks 等变量。内部定义的 renderTasks、removeTask 函数保留了对外部词法环境的引用,构成了闭包。即使 IIFE 执行完毕,这些变量也不会被回收,因为它们被事件回调所引用。每个 li 的点击回调又是一个闭包,引用了循环中的 index(此处利用了块级作用域或立即执行函数避免经典问题)。
3.2 事件循环与 DOM 更新时机
当用户点击"添加"按钮:
-
click事件作为宏任务被处理。 -
执行回调:向
tasks数组 push 新对象,调用renderTasks()清空ul并重建子节点。此时 DOM 修改在内存中完成,但屏幕还没有变化。 -
设置
input.value = ''(另一个 DOM 属性修改)。 -
回调执行完毕,宏任务结束。
-
浏览器检查微任务队列(可能有一些 Promise 回调),全部执行完。
-
此时,布局树和样式被标记为"脏",浏览器会在下一次渲染机会进行样式计算、布局、绘制和合成,屏幕上的任务列表才真正更新。
重点 :如果我们在回调中立即读取某个布局属性,如 taskList.offsetHeight,会触发强制同步布局,浏览器必须立刻计算布局。所以最好避免在 DOM 写操作后立即读布局属性。
3.3 强制同步布局的实例与规避
假设我们要在添加任务后自动滚动到底部,可能会这么写:
javascript
javascript
renderTasks();
taskList.scrollTop = taskList.scrollHeight; // 读取 scrollHeight 触发同步布局
这里 scrollHeight 需要最新的布局信息,因此浏览器被迫中断 JS 执行去计算布局,再返回结果。若在循环中反复这样操作,会严重降低性能。解决办法是批处理读和写 ,或者使用 requestAnimationFrame 延迟读操作到下一帧。
第四部分:向后端请求的结构(完整的任务同步)
4.1 获取初始任务列表
页面加载后,我们调用 fetch 获取任务:
javascript
javascript
fetch('/api/tasks')
.then(response => {
if (!response.ok) throw new Error('网络错误');
return response.json();
})
.then(data => {
tasks = data.map(item => ({ id: item.id, text: item.text }));
renderTasks();
})
.catch(err => console.error('加载失败', err));
这个请求的过程:
-
JS 线程调用
fetch,浏览器网络进程开始解析 URL、DNS 查询、TCP 连接(如果 HTTP/2 可能复用)。 -
请求发出后,
fetch立即返回一个 Promise,JS 继续执行其他任务(如绑定事件)。网络活动在独立的网络线程中进行。 -
服务器返回 JSON。网络进程将响应头和体传回渲染进程。
-
浏览器创建一个宏任务(或使用现有的微任务机制?实际上,fetch 的响应会进入微任务队列)。当主线程处理该任务时,Promise 被 resolve,
.then回调被排入微任务队列,然后立即执行。我们在回调里更新tasks并调用renderTasks(),DOM 修改被标记。 -
事件循环继续,渲染管道更新页面。
4.2 添加任务(POST 请求 + 乐观更新 + 回滚)
改进添加按钮的逻辑:
javascript
javascript
addBtn.addEventListener('click', async function() {
const text = input.value.trim();
if (!text) return;
// 1. 乐观更新:立即在本地和界面上展示
const tempId = Date.now(); // 临时ID
const newTask = { id: tempId, text };
tasks.push(newTask);
renderTasks();
input.value = '';
try {
// 2. 发送 POST 请求
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (!response.ok) throw new Error('保存失败');
const savedTask = await response.json();
// 3. 用服务器返回的真实数据更新本地
const index = tasks.findIndex(t => t.id === tempId);
if (index !== -1) {
tasks[index] = savedTask; // 包含服务器分配的 id
renderTasks(); // 刷新 DOM(真实场景可仅更新对应 li)
}
} catch (err) {
// 4. 失败回滚:移除本地临时任务
tasks = tasks.filter(t => t.id !== tempId);
renderTasks();
alert('添加失败,请重试');
}
});
底层细节:
-
Content-Type: application/json触发了非简单请求,浏览器会先发送OPTIONS预检请求(CORS 预检)。后端需要设置正确的Access-Control-Allow-Headers、Access-Control-Allow-Methods等。 -
await等待fetch结果,函数执行暂停,但主线程并未阻塞(因为这是异步函数,由事件循环和 Promise 驱动)。在await期间,主线程可以处理其他任务(如点击、输入)。 -
乐观更新让 UI 立即响应,符合"感知性能"。如果网络很快,服务器 id 会无缝替换临时 id。
-
回滚机制保证数据一致性:网络失败时,用户看到任务被创建又消失,体验稍差,可以通过添加"重试"按钮优化。
4.3 删除任务(同样结合网络)
javascript
javascript
async function removeTask(index) {
const task = tasks[index];
// 乐观删除
tasks.splice(index, 1);
renderTasks();
try {
await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' });
} catch (e) {
// 回滚:恢复到原位置
tasks.splice(index, 0, task);
renderTasks();
alert('删除失败');
}
}
这里涉及请求路径中的 task.id,以及 RESTful 风格。
完整流程串讲:从空白到交互(逐步穿越底层)
让我们把上述所有部分串联起来,看看从打开浏览器到完成一次添加任务,底层到底发生了什么。
阶段一:导航与加载
-
用户输入 URL,浏览器进行 DNS 解析、TCP 握手、TLS 握手,发送
GET /请求。 -
服务器返回
index.html(字节流)。网络进程传给渲染主线程。 -
HTML 解析器开始工作:构建 DOM。遇到
<link>发起对styles.css的异步请求,解析器继续。遇到<script src="app.js">,解析器暂停,开始下载脚本(但被 CSS 加载阻塞,直到 CSSOM 构建完毕)。 -
CSS 文件返回,构建 CSSOM。此时脚本解除阻塞,
app.js下载完毕并执行:执行 IIFE,设置变量环境,注册click事件,发起GET /api/tasks的fetch。 -
此时 DOM 构建完成,
DOMContentLoaded事件触发。fetch网络请求仍在进行中。 -
浏览器开始首次渲染:样式计算(DOM+CSSOM)、布局、绘制、合成,将空白任务列表展示给用户。
阶段二:获取初始数据并渲染
-
/api/tasks响应返回,浏览器将响应 Promise 的.then排入微任务队列(在 fetch 完成后的合适时机)。JS 执行.then:tasks被填充,renderTasks()调用,通过document.createElement创建多个<li>,设置文本和点击监听器,并用appendChild插入ul。每次插入都会标记布局脏。 -
当前宏任务(fetch 回调形成的任务)执行结束,微任务执行完毕。浏览器进入渲染循环:样式重计算(新的
li匹配样式规则),布局重计算(每个li的尺寸和位置),绘制,合成。屏幕显示出任务列表。
阶段三:用户交互添加任务
-
用户在
input中输入"学习前端原理",点击"添加"按钮。浏览器生成click事件,按捕获-冒泡流程调用我们绑定的回调。 -
回调执行:读取
input.value,创建临时 ID,push 到tasks数组,调用renderTasks()。此时renderTasks清空ul并重建所有li(包括新任务)。由于输入框的清空也在此回调内(input.value = ''),这是一个写操作批处理。没有同步读取布局属性,所以不会触发强制回流。 -
宏任务结束,微任务(如果有)清空。浏览器开始渲染帧:样式计算,发现新的
li,布局重新计算(列表可能变长),绘制,合成。用户立即看到新任务出现在列表底部(乐观更新)。 -
同时,我们的异步函数继续执行
fetch POST。浏览器发送预检OPTIONS请求(因非简单请求),然后发送带 JSON 体的 POST。 -
服务器保存成功,返回新任务 JSON(包含服务器 ID
123)。网络进程将响应传递,fetch返回的 Promise resolve,.then回调(或await后面的代码)被排入微任务队列。 -
微任务执行:找到临时 ID 的任务索引,替换为服务器对象,再次调用
renderTasks(),用真实数据刷新 DOM。这次渲染仅微调(文本相同,但 ID 更新,DOM 可能复用,但renderTasks是整体重绘)。li的点击回调内部闭包中的index可能失效?因为我们重新渲染了所有li,但事件监听器重新绑定,所以没问题。 -
渲染线程再次绘制,用户可能察觉不到变化(因为内容没变),但底层已经和服务器保持一致。
若 POST 失败,catch 块回滚:删除临时任务,再次渲染,UI 回到添加前状态,并弹出提示。
通过这个例子,我们看到了 HTML 的解析与 DOM 构建 → CSS 的样式计算与布局 → JS 的执行上下文、闭包、事件循环与 DOM 操作 → 网络请求与异步更新 如何紧密协作。理解这些原理,能让我们更自信地处理性能优化、状态同步和复杂交互。