一个前端新手的"顿悟"时刻:DOM树、渲染原理和JS动态渲染
从一个困惑出发,一步步理解浏览器到底做了什么
写在前面
我刚开始学前端的时候,脑子里全是碎片:
- HTML 是写标签的
- CSS 是写样式的
- JavaScript 是写交互的
但这些东西怎么串起来,我完全没有概念。
这篇文章记录的,就是我从"背概念"到"真正理解"的全过程。如果你也刚接触前端,或者总觉得哪里差一层窗户纸没捅破,也许我的经历能帮到你。
阅读提示 :本文不追求覆盖所有API,只聚焦一条主线------浏览器如何把HTML变成可交互的页面,JS在其中扮演什么角色。理解了这条线,其他细节随时可以查。
第一章:一切的起点------一个具体的困惑
最初困扰我的问题是:
"登录状态"和"未登录状态"两套界面,为什么不能用HTML直接写死然后切换?我记得按钮不是能跳转页面吗?
这个问题看似简单,但它指向了Web开发最核心的分水岭------多页面应用(MPA)与单页面应用(SPA)的区别。
两种方案的本质差异
| 方案 | 本质 | 切换代价 |
|---|---|---|
| 多页面应用(MPA) | 用户跳到另一个URL,浏览器加载全新的HTML文件 | 整个页面刷新,白屏,等待 |
| 单页面应用(SPA) | 用户停留在同一个URL,JS只修改当前页面的局部内容 | 瞬间响应,无白屏 |
多页面应用 :用户点击"进入首页" → 浏览器跳转到 /index.html → 服务器返回一个全新的HTML → 整个页面重新加载。这种方案完全可行,早期网站(如论坛、博客)都是这么做的。
单页面应用 :用户点击"进入首页" → JS执行 document.getElementById('login-panel').style.display = 'none' 和 document.getElementById('main').style.display = 'block' → 页面没刷新,只是某个区域变化了。
这个困惑给我的启示
纯HTML能做到切换界面(通过跳转),但做不到在同一个页面内、不刷新、无感知地切换两套界面。后者需要JS去操作DOM。
这个认知让我意识到:要理解前端,必须先理解浏览器在内存里对页面做了什么。
第二章:DOM树------浏览器在内存里建的"结构蓝图"
带着"JS到底在操作什么"的疑问,我开始接触"DOM树"这个概念。
DOM树是什么?
DOM(Document Object Model,文档对象模型)是浏览器解析HTML后,在内存里创建的一棵"对象树"。它描述了页面的结构和层级关系。
MDN 官方对 DOM 的定义是:
"DOM 是一个编程接口,它将 HTML 文档表示为树形结构,树中的每个节点都是一个对象,拥有属性和方法。" ^1^
什么意思?我们用一段代码来理解:
html
<!DOCTYPE html>
<html>
<head>
<title>我的页面</title>
</head>
<body>
<div>
<p>文字内容</p>
</div>
<span>结尾</span>
</body>
</html>
浏览器解析这段HTML后,会在内存中构建一棵这样的树:
less
Document (根)
└── html
├── head
│ └── title
│ └── #text "我的页面"
└── body
├── div
│ └── p
│ └── #text "文字内容"
└── span
└── #text "结尾"
关键认知(这层窗户纸很重要)
DOM树不是HTML文件本身。 HTML文件是硬盘里的静态文本,DOM树是浏览器在内存里创建出来的"活体对象"。
浏览器把HTML文件读进来,经过字节→字符→Token→节点→DOM树 的完整解析流程,最终在内存中建立起这棵树。^2^
第三章:DOM操作------JS就是一把"螺丝刀"
理解了DOM树,我自然产生了下一个问题:
既然浏览器在内存里建了这棵树,那JS怎么去操作它?
答案:document 是入口,引用是遥控器
浏览器把整棵DOM树的根节点挂载到了一个叫 document 的全局对象上。JS通过 document 这个入口,去树上"找节点"、"改节点"、"删节点"、"加节点"。
当你执行
document.getElementById('id')时,你是在内存里的DOM树上查找对应的对象,然后得到一个指向它的"引用"(可以理解为遥控器)。后续所有操作都通过这个引用进行。
为了让自己直观理解,我想到了这个比喻:乐高积木。
| 比喻 | 前端术语 | 具体操作 |
|---|---|---|
| 一堆乐高零件 | HTML标签(静态源码) | 浏览器把它解析成DOM树 |
| 取出来 | 查询(查) | document.querySelector() |
| 拼接 | 创建并添加(增) | createElement() + appendChild() |
| 拿掉 | 删除(删) | element.remove() |
| 换个颜色 | 修改(改) | element.style.color = 'red' |
| 按一下有反应 | 事件监听 | addEventListener('click', fn) |
一个实际运行的代码例子
html
<div id="house">
<div id="door">🚪 门</div>
<div id="wall"></div>
</div>
<button id="addBtn">添加窗户</button>
javascript
// 1. 查:从DOM树上找到元素(获取引用)
const door = document.getElementById('door');
const wall = document.getElementById('wall');
const addBtn = document.getElementById('addBtn');
// 2. 改:修改门颜色(通过引用改内存对象)
door.style.backgroundColor = 'gold';
// 3. 增:点击按钮添加一扇窗户(修改DOM树结构)
addBtn.addEventListener('click', function() {
const window = document.createElement('div');
window.innerText = '🪟 新窗户';
wall.appendChild(window); // 拼接到墙上
});
// 4. 删:移除门(从DOM树中删除节点)
door.remove();
最重要的认知(值得停下来想一想)
所有的DOM操作,本质上都是在内存里修改那些对象。
- 改
door.style.backgroundColor→ 改的是内存里"门"这个对象的属性- 执行
wall.appendChild(window)→ 改的是内存里DOM树的结构- 执行
door.remove()→ 从内存里删除了一个节点而浏览器会自动把内存里的变化同步到屏幕上。你不需要手动调用"刷新"方法。
第四章:CSSOM和渲染树------页面是怎么画出来的?
理解了DOM树是"结构",我又有了新问题:
DOM树只是"结构",那样式(CSS)是怎么加上去的?浏览器最终是怎么把页面画出来的?
答案是:浏览器同时维护多棵树 ,各司其职,最终合并成"渲染树"再绘制。^3^
第一棵树:DOM树(结构)
把所有HTML标签按层级组织起来。它不关心样式 ------哪怕你写了 display: none,节点依然在这棵树里。
第二棵树:CSSOM树(样式)
CSSOM(CSS Object Model)是浏览器解析所有CSS规则后生成的样式树。它存储了所有样式信息,包括选择器、属性和值的对应关系。它不关心HTML结构,只管理样式规则。
第三棵树:渲染树(最终要画的)
把DOM树和CSSOM树合并 ,合并时会做一件事:过滤掉不需要显示在屏幕上的节点。具体包括:
<head>、<title>、<style>等元数据标签display: none的元素(不占空间,不画)
特别注意 :visibility: hidden 的元素会留在渲染树里------因为它在视觉上透明,但仍然占据布局空间。
一个重要的面试知识点 :
display: none的元素不在渲染树中,所以它不占位、不触发回流 ;而visibility: hidden的元素在渲染树中,占位且会触发布局计算。
完整的流程图
css
┌─────────────────────────────────────────────────────────┐
│ 浏览器渲染流程 │
└─────────────────────────────────────────────────────────┘
│
▼
┌───────────────┐
│ HTML 文件 │
└───────┬───────┘
│
▼
┌───────────────┐ ┌───────────────┐
│ 解析 HTML │ │ 解析 CSS │
└───────┬───────┘ └───────┬───────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ DOM 树 │ │ CSSOM 树 │
└───────┬───────┘ └───────┬───────┘
│ │
└──────────┬──────────┘
▼
┌───────────────────┐
│ 合并 + 过滤 │
│ 不可见节点被移除 │
└─────────┬─────────┘
▼
┌───────────────────┐
│ 渲 染 树 │
└─────────┬─────────┘
▼
┌───────────────────┐
│ 布局(Layout) │
│ 计算位置和尺寸 │
└─────────┬─────────┘
▼
┌───────────────────┐
│ 绘制(Paint) │
└─────────┬─────────┘
▼
┌───────────────────┐
│ 屏幕显示 │
└───────────────────┘
第五章:层叠上下文------为什么 z-index 有时候"不听话"?
在学习CSS的过程中,我遇到过一个经典的问题:为什么有时候 z-index: 9999 还是被挡住了?
一个让人困惑的例子
html
<style>
.parent { position: relative; z-index: 5; }
.child { position: relative; z-index: 9999; }
.other { position: relative; z-index: 1; }
</style>
<div class="parent">
<div class="child">我是子元素,z-index巨大</div>
</div>
<div class="other">我是外面的元素,z-index很小</div>
按理说 .child 的 z-index: 9999 应该盖住一切,但实际效果却让人意外:.parent 整体盖住了 .other,而 .child 只是在 .parent 内部排顺序。.child 再高也压不到 .other 上面。
为什么?
因为父元素 .parent 触发了层叠上下文(Stacking Context) 。规范中定义,当 position 值不为 static 且 z-index 不为 auto 时,该元素会创建一个独立的层叠上下文。^4^
我用一个比喻来理解这件事:
一个触发了层叠上下文的元素,就像盖了一栋独立的楼房:
- 这栋楼有自己的楼层编号(
z-index)- 楼里的所有"家具"(子元素)只能在楼内排顺序
- 无论楼内的家具
z-index多高,都无法伸出去压到隔壁楼的房顶
更进一步的总结
默认情况 :所有节点像一沓纸片,后放的盖住先放的(后来者居上)。 有了层叠上下文:某些纸片变成了"真实的楼房",楼房里的家具只能在楼内排序,无法跨越楼层去压到别的东西。
这解释了为什么 z-index 只在同一个层叠上下文内部有效------不同上下文的元素比较层叠顺序,看的是父元素的"楼层号",而不是自己的 z-index。
第六章:从原理到框架------Vue/React在做什么?
理解了上述所有内容后,一个自然的问题是:
既然浏览器提供了这些API,为什么还要学框架(Vue/React)?
原生DOM操作的问题
我们之前写的代码是这样的:
javascript
const door = document.getElementById('door');
door.style.backgroundColor = 'gold';
这在简单场景下没问题,但在复杂应用中会遇到几个挑战:
1. 状态和视图需要手动同步
javascript
// 数据
let count = 0;
// 每次变化,都要手动更新DOM
count++;
document.getElementById('counter').innerText = count;
count++;
document.getElementById('counter').innerText = count;
// 如果有10个地方用了这个数据,就要写10次更新...
2. 频繁操作DOM的性能问题
javascript
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.innerText = i;
document.getElementById('list').appendChild(li); // 1000次回流!
}
每次 appendChild 都会触发浏览器的回流(Reflow),1000次就会导致页面卡顿。
框架的解决方案
Vue/React 做的就是两件事:
| 问题 | 原生JS | Vue/React的解决方案 |
|---|---|---|
| 数据变化后手动更新DOM | 每次都要写 document.getElementById().innerText = ... |
响应式系统:数据变化时自动触发视图更新 |
| 频繁操作DOM导致性能问题 | 开发者自己管理批量操作 | 虚拟DOM(VDOM) :在内存里计算好差异,一次性更新真实DOM |
虚拟DOM的核心思路 :不直接操作真实DOM树,而是在JS内存里维护一棵"轻量级的树"(虚拟DOM)。数据变化时,先在内存里计算好"哪里变了",然后批量、一次性 地应用到真实DOM树上,减少回流次数。^5^
第七章:一个完整的认知拼图------为什么HTML和JS绑定这么深?
走到这一步,一个更大的问题自然浮现了:
为什么JS和HTML绑定得这么紧密?为什么不能用Python或Java直接操作页面?
这其实是浏览器设计的历史选择,也是Web平台的根本架构。
浏览器的设计决策
浏览器在设计时做了两个关键决定:
- 把"页面"变成一个内存里的对象模型(DOM)
- 把JavaScript设计为唯一能操控这个对象模型的语言
为什么是JS? 1995年,网景公司为了让浏览器具备动态交互能力,设计了JavaScript,并让它与DOM深度绑定。这个决策延续至今------所有现代浏览器都内置JS引擎(Chrome的V8、Firefox的SpiderMonkey等),而没有内置Python、Java等其他语言的引擎。
用一张表说清楚各自的分工
| 角色 | 比喻 | 职责 |
|---|---|---|
| HTML | 骨架/乐高图纸 | 定义静态结构,像白纸上的铅笔线稿 |
| CSS | 皮肤/颜色 | 美化样式,让线稿变好看,但仍静态 |
| DOM | 神经系统 | JS和HTML之间的桥梁,所有变化都通过它传导 |
| JavaScript | 大脑/灵魂 | 让页面"活过来",能思考、能反应、能改变自身 |
DOM给了JS存在意义(操控页面),JS给了DOM生命力(动态变化)。
一个思想实验来验证
- 如果浏览器没有JS:Web就回到了1995年以前------全是静态文档,点击链接只会跳转,没有任何动态反馈。
- 如果JS没有DOM API :JS就是个孤立的计算器(只能做数学运算和
console.log),和页面毫无关系。
所以:它们是共生关系,不是谁依附谁。
第八章:一张图串起所有认知
把整个理解串起来,就是这张图:
css
用户在地址栏输入网址
│
▼
┌───────────────────────────────────────────────────────┐
│ 1. 浏览器请求并接收 HTML 文件(静态字符串) │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 2. 解析 HTML → 在内存中构建 DOM 树(对象模型) │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 3. 解析 CSS → 在内存中构建 CSSOM 树 │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 4. DOM树 + CSSOM树 → 合并 → 过滤不可见节点 → 渲染树 │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 5. 布局(计算每个节点的位置和尺寸)→ 绘制 → 屏幕显示 │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 6. JS通过DOM API操作内存中的对象 → 浏览器自动重绘 │
│ (用户的每次交互都可能触发新一轮循环) │
└───────────────────────────────────────────────────────┘
这就是Web前端最核心的循环:静态HTML → 内存对象树 → 渲染显示 → JS操控变化 → 重新渲染。
总结:给同样在路上的你
回头看,我的学习路径可以归纳为这六个认知节点:
| 阶段 | 核心认知 | 对应章节 |
|---|---|---|
| 1 | 理解"纯HTML跳转"和"JS动态切换"是两回事 | 第一章 |
| 2 | DOM树是内存里的对象模型,不是HTML文件本身 | 第二章 |
| 3 | JS通过document获取引用,操作内存对象,浏览器自动刷新 |
第三章 |
| 4 | DOM树+CSSOM树→渲染树,理解了display:none和visibility:hidden的本质区别 |
第四章 |
| 5 | 层叠上下文是"楼房",z-index只在楼内有效 |
第五章 |
| 6 | 框架(Vue/React)封装了"数据→视图"的同步和性能优化 | 第六章 |
每个认知节点,都是基于上一个节点的自然延伸,而不是孤立的记忆。
作为一个刚入门的新手,我最大的体会是:
你不需要记住所有的API,你只需要理解它们存在的原因和位置。原理是地图,API是路标。有了地图,路标随时可以查。
希望这篇文章能帮你少走一些弯路,早一点捅破那层窗户纸。🎉
参考资料
- MDN Web Docs - Document Object Model (DOM)
- MDN Web Docs - 关键渲染路径
- MDN Web Docs - 渲染树构建、布局及绘制
- W3C CSS Specification - 层叠上下文
- Vue.js 官方文档 - 渲染机制
- What is the DOM? - JavaScript.info
如果你在阅读过程中有任何疑问,或者发现了可以改进的地方,欢迎一起讨论。 😊