一个前端新手的"顿悟"时刻:DOM树、渲染原理和JS动态渲染

一个前端新手的“顿悟”时刻: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>

按理说 .childz-index: 9999 应该盖住一切,但实际效果却让人意外:.parent 整体盖住了 .other,而 .child 只是在 .parent 内部排顺序。.child 再高也压不到 .other 上面。

为什么?

因为父元素 .parent 触发了层叠上下文(Stacking Context) 。规范中定义,当 position 值不为 staticz-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';

这在简单场景下没问题,但在复杂应用中会遇到几个挑战: http://icpc.qlu.edu.cn/user/2144 http://icpc.qlu.edu.cn/user/2145 http://icpc.qlu.edu.cn/user/2146 http://icpc.qlu.edu.cn/user/2147 http://icpc.qlu.edu.cn/user/1818 http://icpc.qlu.edu.cn/user/1819 http://icpc.qlu.edu.cn/user/1820 http://icpc.qlu.edu.cn/user/1821

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 做的就是两件事:

问题原生JSVue/React的解决方案
数据变化后手动更新DOM每次都要写 document.getElementById().innerText = ...响应式系统:数据变化时自动触发视图更新
频繁操作DOM导致性能问题开发者自己管理批量操作虚拟DOM(VDOM) :在内存里计算好差异,一次性更新真实DOM

虚拟DOM的核心思路:不直接操作真实DOM树,而是在JS内存里维护一棵“轻量级的树”(虚拟DOM)。数据变化时,先在内存里计算好“哪里变了”,然后批量、一次性地应用到真实DOM树上,减少回流次数。5

第七章:一个完整的认知拼图——为什么HTML和JS绑定这么深?

走到这一步,一个更大的问题自然浮现了:

为什么JS和HTML绑定得这么紧密?为什么不能用Python或Java直接操作页面?

这其实是浏览器设计的历史选择,也是Web平台的根本架构。

浏览器的设计决策

浏览器在设计时做了两个关键决定:

  1. 把“页面”变成一个内存里的对象模型(DOM)
  2. 把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动态切换”是两回事第一章
2DOM树是内存里的对象模型,不是HTML文件本身第二章
3JS通过document获取引用,操作内存对象,浏览器自动刷新第三章
4DOM树+CSSOM树→渲染树,理解了display:nonevisibility:hidden的本质区别第四章
5层叠上下文是“楼房”,z-index只在楼内有效第五章
6框架(Vue/React)封装了“数据→视图”的同步和性能优化第六章

每个认知节点,都是基于上一个节点的自然延伸,而不是孤立的记忆。

作为一个刚入门的新手,我最大的体会是:

你不需要记住所有的API,你只需要理解它们存在的原因和位置。原理是地图,API是路标。有了地图,路标随时可以查。

希望这篇文章能帮你少走一些弯路,早一点捅破那层窗户纸。🎉


参考资料


如果你在阅读过程中有任何疑问,或者发现了可以改进的地方,欢迎一起讨论。 😊

Footnotes

  1. 参考:MDN Web Docs - Document Object Model (DOM)
  2. 参考:MDN Web Docs - 关键渲染路径
  3. 参考:MDN Web Docs - 渲染树构建、布局及绘制
  4. 参考:W3C CSS Specification - 层叠上下文 (Stacking Context)
  5. 参考:Vue.js 官方文档 - 渲染机制
0
0
0
0
评论
未登录
暂无评论