想象南方老祠堂里那本越写越厚的族谱。族长手里总有一沓记录着家族名讳的手写草稿——草稿只是文字,还算不上家族本身。每逢祭祖大典,族长要做的第一件事,就是把草稿逐字念出来,拆成一句句能听懂的指令,再按辈分长幼,把每一位家人摆进一张活生生的座次树里。这张座次树,就是网页世界里的 DOM 树。HTML 源码是那沓草稿,浏览器是那位认真的族长,而 JavaScript 则是家族里最勤快的执事管家:他翻着座次树找到某人、给家族添丁、把某位成员除名、替成员改名换姓。你每天看到的点击展开、列表刷新、弹窗关闭,本质都是管家在对着这棵家族树做“找、改、添、删”四件事。这篇文章,就从族长如何“把草稿变成树”讲起,再教你学会管家翻族谱、写族谱的全部手艺。

岗位关联

学懂 DOM 树构建与节点操作,等于拿到了前端动态页面的总钥匙——无论日后用 jQuery、Vue 还是 React,底层技术都是这份“翻族谱”的本事。

目录
  1. 可视化演示
  2. 构建原理流程
  3. 节点类型识别
  4. 查询元素方法
  5. 创建插入节点
  6. 删除替换节点
  7. 属性内容操作
  8. 节点遍历关系
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示DOM树构建与节点操作的核心机制,通过入门/进阶/实战/优化四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

DOM树构建与节点操作:一DOM 一、DOM树的本质与文档解析流 二、DOM节点的类型体系与继承 核心机制可视化 DOM树构建与节点操作整体架构与关键组件

构建原理流程

想象村口祠堂正在筹备一场百年大祭。族长手里是一沓手写的族谱草稿,上面密密麻麻记着“某公配某氏,生三子,长子居东厢……”。可草稿终究只是文字,不是宴席。族长必须先把草稿念出声来,拆成一句句能听懂的指令,再按辈分长幼,把每一位族人安排进祠堂的座次树里。这一步一步“念稿—拆分—入座”的过程,就是浏览器拿到 HTML 源码后搭建 DOM 树的全过程。

当服务器把 HTML 字符串交给浏览器后,浏览器的 HTML 解析器会进行两轮加工。第一轮叫词法分析(tokenization):把字符流切成一个个有名字的“标记”(Token),比如 <div class="card"> 被切成一个开始标签 Token,</div> 被切成一个结束标签 Token。第二轮叫语法分析(tree construction):解析器维护一个“当前父节点”栈,遇到开始标签就生成一个新节点挂到栈顶元素下,遇到结束标签就弹栈,最终把所有节点拼成一棵以 document 为根的树。

这棵组装出来的树就是 DOM(Document Object Model,文档对象模型),它常驻内存,与屏幕上的页面一一对应。整个构建是流式的:浏览器边下载边解析,看到没有 async/defer 的 <script> 标签时还会暂停解析、去执行脚本,因为脚本可能改写后续节点的结构——这也是为什么把 <script> 放 </body> 之前能减少阻塞的原因。树建成后,JavaScript 通过 document 对象拿到树的入口,剩下的增删改查都由 DOM API 完成。

家族聚会从来不是一秒变出来的:先有草稿,再有念稿拆分,再按辈分入座。理解了这条流水线,你就能解释很多浏览器的“怪脾气”——为什么动态插入脚本影响性能,为什么把 script 放页面底部更顺畅。因为族长念稿念到一半,是不允许管家打断改座次的;等整棵树搭好,管家才有充分的操作空间。

HTML 源码→词法分析Token 流→语法分析DOM 节点树\text{HTML 源码} \xrightarrow{\text{词法分析}} \text{Token 流} \xrightarrow{\text{语法分析}} \text{DOM 节点树}
这条流水线是 DOM 世界的“创世纪”:记住“草稿→拆分→入座”三步,就理解了浏览器从哪里来、DOM 树怎么生。
  1. 词法分析(分词)把 HTML 字符串按尖括号和引号切分成最小单位 Token,每个 Token 记录标签名和属性表,相当于族长把草稿念成一句句指令。
    Token=⟨标签名, 属性表⟩\text{Token} = \langle \text{标签名},\ \text{属性表} \rangle
  2. 语法分析(建树)按标签的嵌套配对关系,把每一个 Token 变成一个节点并挂到当前父节点下,形成层次分明的父子关系。
    children(v)={c∣parent(c)=v}\text{children}(v) = \{ c \mid \text{parent}(c) = v \}
  3. 文档根节点收束所有节点最终以 document 为根连成整棵树,JavaScript 就从这棵树的根部开始遍历和操作。
    DOM=document∪⋃v∈Vnode(v)\text{DOM} = \text{document} \cup \bigcup_{v \in V} \text{node}(v)

节点类型识别

翻开族谱你会发现,里面不只有“人”,还有“神主牌位”“写在牌位旁的小字注释”“整本族谱的封皮”。DOM 树也一样:除了最常打交道的元素节点(族人本人),还有文本节点(族人说的闲话、牌位上的注释文字)、属性节点(族人身上的特征标签)和文档节点(整本族谱本身)。认人先认身份,操作节点也要先认类型。

DOM 规定每个节点都有一个 nodeType 数字作为“身份腰牌”:Document 文档节点是 9,整棵树的根;元素节点是 1,对应一个 HTML 标签;文本节点是 3,对应标签之间的文字;属性节点是 2,通常通过 getAttribute/setAttribute 间接操作,很少直接创建。nodeName 则返回节点名字:元素节点返回大写标签名如 DIV,文本节点返回 #text。

在日常开发里,判断节点类型的场景非常多。比如遍历子节点时,childNodes 里会混入文本节点,而 children 只包含元素节点;如果你用 nodeType === 1 做过滤,就能精准地只操作元素。文本节点没有 id、class、style 这些元素专属能力,它的内容存在 nodeValue 或 data 里,改它对页面布局几乎没有影响。

就像族长看腰牌决定“这位能不能上主桌”,前端代码也靠 nodeType 这块腰牌决定“这个节点能不能加 class、改样式”。搞混了文本节点和元素节点,会出现“明明改了却不生效”的怪事——因为你一直在给一团“备注文字”下达只有“族人”才能执行的命令。

nodeType(v)={9,v 为文档节点1,v 为元素节点3,v 为文本节点\text{nodeType}(v) = \begin{cases} 9, & v \text{ 为文档节点}\\[4pt] 1, & v \text{ 为元素节点}\\[4pt] 3, & v \text{ 为文本节点} \end{cases}
数字腰牌虽小,却是身份认证的根基——四类节点各有各的能力边界,认错了就操作错。
  1. 文档节点(9)整棵树的根,对应浏览器里的 document 对象,是访问一切节点的入口,相当于族谱的封皮与总目录。
    nodeType(document)=9\text{nodeType}(\text{document}) = 9
  2. 元素节点(1)HTML 标签实例化成的成员,拥有标签名、属性、子节点,是操作的主力对象,对应家族里一个个有名有姓的人。
    nodeName(v)=TAGNAME,nodeType(v)=1\text{nodeName}(v) = \text{TAGNAME}, \quad \text{nodeType}(v) = 1
  3. 文本节点(3)标签与标签之间的文字内容,没有子节点,内容放在 data 属性里,对应牌位上那一行行小字备注。
    data(v)=文字内容,nodeType(v)=3\text{data}(v) = \text{文字内容}, \quad \text{nodeType}(v) = 3

查询元素方法

祭典前夕,管家要找人布置任务。最快的办法是直接喊大名——getElementById('老张'),全家族只有一个老张,一嗓子出去人就到了。如果只记得姓氏,喊一声“姓李的!”——getElementsByTagName('li'),呼啦啦站起来一队人。如果条件更复杂,比如“穿红衣服、坐在第二排的姑姑”,就得用一句完整的话描述——querySelector('.card .title')。

DOM 查询 API 分几大家族。getElementById 按 id 精确查找,速度最快,返回单个元素;getElementsByTagName 和 getElementsByClassName 按标签名、类名查找,返回 HTMLCollection(动态集合);querySelector 接受任意 CSS 选择器,返回第一个匹配元素;querySelectorAll 返回所有匹配元素组成的 NodeList(静态集合)。

“动态”与“静态”的区别是高频考点:动态集合就像一个“实时直播窗口”,每次读取它都会去文档里重新数一遍;静态集合则是查询那一瞬间的“合照”,之后文档怎么变,照片里的人都不变。所以如果你边遍历边往页面里加同标签元素,动态集合会越数越多——这一点在误区部分还会细聊。

管家找人从“喊名字”学到“说条件”,越来越省力。现代代码里 querySelector 出现频率最高,因为它把“找什么样的人”浓缩成一句话。但记住:喊名字(getElementById)永远最快,条件简单时别扛着选择器这个重武器满街跑。

query(sel)={v∈DOM∣v 匹配选择器 sel},一般复杂度 O(n)\text{query}(sel) = \{ v \in \text{DOM} \mid v \text{ 匹配选择器 } sel \}, \quad \text{一般复杂度 } O(n)
查询的本质是对整棵树做一次“找人扫描”:条件越精确,范围越小,速度越快。
  1. 精确查找按 id 属性直接定位唯一元素,引擎内部有哈希索引,几乎不遍历整棵树,对应管家扯着嗓子喊全名。
    v=document.getElementById("app")v = \text{document.getElementById}(\text{"app"})
  2. 标签集合收集文档中所有同名标签组成动态集合,每次访问都与实时文档同步,对应“姓李的”一声招呼站起来的一队人。
    S=document.getElementsByTagName("li")S = \text{document.getElementsByTagName}(\text{"li"})
  3. 选择器匹配用 CSS 选择器描述条件组合,返回第一个匹配节点,对应“穿红衣服坐第二排的姑姑”这种复合寻人法。
    {v∣match(v, ".card .title")}\{ v \mid \text{match}(v,\ \text{".card .title"}) \}

创建插入节点

家族要添丁,绝不能从天上掉下一个活人。管家先要去登记处“报户口”——document.createElement('div') 在内存里造出一个叫"div"的新成员;然后决定把他挂到哪一房之下——parent.appendChild(child) 把他记到父节点的子列表末尾;如果想插在某位哥哥前面,用 parent.insertBefore(newChild, refChild) 精准落座。

这三个动作对应 DOM 最核心的三个 API。createElement 创建的节点一开始是“游离状态”:它存在于内存,但不在文档树里,页面看不到它,直到被 appendChild 或 insertBefore 挂载。appendChild 把新节点追加为父节点的最后一个孩子;insertBefore 接受两个参数,把新节点插到参考节点之前。若参考节点传 null,则效果等同于 appendChild。

批量添加几十个节点时,如果每加一个都直接挂到页面上,浏览器就会一次又一次地重新计算布局,非常浪费。这时用 document.createDocumentFragment() 创建一个“草稿本”(文档碎片),把新节点先全部登记在草稿本里,最后一次性把草稿本挂到父节点下,页面只重排一次——这就是性能优化的经典手法。

添丁讲究“先报户口、再选位置、批量时用草稿本”。家族不会为每一个新人都重新摆一次全族宴席,管家也绝不会为每一条数据都刷新一遍页面——把“批量新增”攒成“一次入册”,正是前端工程师从小白走向熟练的标记。

insertBefore(new,ref):children(p)=[c0,…,new,ref,…,ck]\text{insertBefore}(new, ref): \quad \text{children}(p) = [c_0, \dots, new, ref, \dots, c_k]
插入的本质是“改子列表顺序”:把新节点的位置信息写进父节点的 children 数组,树形结构就地更新。
  1. 创建节点在内存中造出一个游离的新元素,此时它不属于任何父节点,页面不可见,相当于刚报完户口还没入族谱的人。
    new=document.createElement("div")new = \text{document.createElement}(\text{"div"})
  2. 尾部追加把新节点挂到父节点子列表的末尾,父节点子元素数量加一,对应家族记谱本末页添一行。
    p.appendChild(new)⇒∣children(p)∣+1p.\text{appendChild}(new) \Rightarrow \bigl|\text{children}(p)\bigr| + 1
  3. 指定位置插入把新节点放到参考节点之前,参考节点及之后的所有兄弟顺势后移,对应插队入座但绝不打乱后面秩序。
    p.insertBefore(new,ref)⇒new 位于 ref 之前p.\text{insertBefore}(new, ref) \Rightarrow new \text{ 位于 } ref \text{ 之前}
  4. 文档碎片缓冲碎片是一个脱离文档树的临时容器,批量攒好再一次性挂载,减少页面重排次数。
    frag=document.createDocumentFragment()frag = \text{document.createDocumentFragment()}

删除替换节点

有添丁就有除名。家族规矩讲究“谁的孩子谁管教”:要从族谱里划掉某个人,必须由他的父辈来执行。DOM 里对应 parent.removeChild(child)——只有父节点能删除子节点。若是要换人继位,parent.replaceChild(newChild, oldChild) 让新人坐在旧人的同一个位置上,连座位号都不用变。

新手常直觉地用 node.remove()(现代浏览器支持)或更经典的 node.parentNode.removeChild(node)。前者简洁,后者是兼容性最稳的写法。replaceChild 相当于“原地替换”:旧节点从树中断开,新节点占用它在 children 中的原下标,父元素的子节点数量保持不变。

想清空一个容器下所有子节点,不要用 container.innerHTML = '' 这种粗暴方式(它会额外走一遍 HTML 解析),更稳妥的是循环删除:while (container.firstChild) container.removeChild(container.firstChild); 每次移除第一个孩子,直到没有孩子为止。注意 removeChild 会返回被删除节点的引用,如果之后还要复用它,可以把返回值接住。

删除和替换都强调“从父节点发起”,因为 DOM 树靠 parent 指针维系血缘;断了一条链,整棵树就散了。还要记得循环里“边走边删”的坑——队伍在前移,你的计数还停在原地,会跳过本该处理的成员。

replaceChild(new,old):children(p)[i]←new,old 从树中断开\text{replaceChild}(new, old): \quad \text{children}(p)[i] \gets new, \quad old \text{ 从树中断开}
删除与替换都发生在父节点这一层:树结构只认“父—子”这条线,线的两端都动,才算真正变更。
  1. 移除子节点父节点调用该方法删除指定孩子,被删节点的 parent 指针变为 null,彻底脱离家族树。
    p.removeChild(c)⇒c.parent=nullp.\text{removeChild}(c) \Rightarrow c.\text{parent} = \text{null}
  2. 替换子节点新节点原位顶替旧节点,数组中旧节点的下标被新节点占用,兄弟关系无缝衔接。
    p.replaceChild(n,o)⇒o 的原位置由 n 继承p.\text{replaceChild}(n, o) \Rightarrow o \text{ 的原位置由 } n \text{ 继承}
  3. 循环清空反复删除第一个孩子直到没有孩子,最后子节点数量归零,容器变回“空房间”。
    ∣children(p)∣→0\bigl|\text{children}(p)\bigr| \to 0

属性内容操作

在家族里,改个名字、换件衣裳、理个发型,都是日常。DOM 的属性与内容操作也是这么三件事:setAttribute('class', 'active') 相当于给成员别上一枚"active"徽章;classList.add/remove/toggle 则是穿衣、脱衣、翻衣领的优雅动作;textContent 只改文字备注,innerHTML 则把整间屋子推倒重建。

textContent 读取时会把节点及其所有后代里的文本节点内容拼接起来;写入时先清空所有子节点,再创建一个文本节点存下字符串——传进去的 <b>hi</b> 会被当成普通文字,不会生成 b 元素。innerHTML 则完全相反:赋值时浏览器会对字符串做一次完整的 HTML 解析,把它变成一段子树挂进去。

这两个 API 是最容易踩 XSS 的入口。如果用 innerHTML 插入用户输入的内容,用户提交的 <img onerror="alert(1)"> 会被解析成真实标签并执行脚本;而 textContent 会把整段文字原样显示,天然安全。所以原则是:改文字用 textContent,确认内容可信、确需结构时才用 innerHTML。

管家改家族档案,必须分清“改字”和“盖房子”。只说一句话,就改牌位上的备注;要换格局,才拆屋重建,而且一定要确认建材没有夹带危险品(脚本)。这个分寸感,就是新手与老手的一道分水岭。

textContent(v)=⨁t∈text(v)data(t),innerHTML(v)↦重新解析生成子树\text{textContent}(v) = \bigoplus_{t \in \text{text}(v)} \text{data}(t), \qquad \text{innerHTML}(v) \mapsto \text{重新解析生成子树}
二者最大的分歧在于“是否解析标签”:一个把字当字写,一个把字当图纸盖房子。
  1. 读取拼接文本把节点下所有后代文本节点的 data 按顺序拼接成一个字符串,不含任何标签,对应把牌位上所有小字连起来读一遍。
    textContent(v)=∑t∈text(v)data(t)\text{textContent}(v) = \sum_{t \in \text{text}(v)} \text{data}(t)
  2. 写入纯文本清空原有子节点,只生成一个文本节点来存放字符串,尖括号等特殊字符原样显示。
    children(v)←{Text(str)}\text{children}(v) \gets \{ \text{Text}(str) \}
  3. 解析 HTML 子树innerHTML 把字符串当作 HTML 源码走一遍词法+语法分析,生成一段子树再挂载,对应按图纸拆旧屋盖新房。
    innerHTML: String→Subtree\text{innerHTML}:\ \text{String} \to \text{Subtree}

节点遍历关系

管家要按辈分巡访整个家族,从任何一位成员出发都能走遍全族:向上问 parentNode 找到父亲,向下问 children 找到儿女们,左右问 nextSibling / previousSibling 找到兄弟。沿着这几条通道,整棵家族树畅通无阻——这就是 DOM 遍历。

常用的关系属性里,parentNode 返回父节点;children 只统计元素子节点,而 childNodes 会把文本节点也算进去;firstElementChild / lastElementChild 取第一个、最后一个元素孩子;nextElementSibling / previousElementSibling 取相邻的兄弟元素。注意带 Element 的版本会跳过文本节点,遍历时能省去大量 nodeType 过滤。

遍历时最怕“边走边改地图”。如果你在遍历 children 的同时删除成员,下标会错位,漏掉本应处理的节点;如果遍历的是动态集合(HTMLCollection),新增节点还会让集合越变越长,甚至死循环。稳妥做法是先把集合转成静态数组(如 Array.from(children) 或 [...children])再遍历修改。

走亲访友要先懂“谁是我爸、谁是我儿、谁是我兄弟”。前端代码里最常见的操作就是“从按钮往上摸到表单,再往下摸到列表项,再往旁找到同级的输入框”——这套在家族树里摸爬滚打的本事,是 DOM 操作的底层地图。

siblings(v)=children(parent(v))∖{v},nextSibling(v)=ci+1\text{siblings}(v) = \text{children}\bigl(\text{parent}(v)\bigr) \setminus \{v\}, \quad \text{nextSibling}(v) = c_{i+1}
遍历的本质是沿着“父—子—兄弟”三条关系链行走:关系明确,路线就不迷。
  1. 向上找父每个节点最多只有一个父节点,通过 parentNode 属性一步到达,对应“这是我爹”的直系认定。
    v.parentNode=pv.\text{parentNode} = p
  2. 向下找子children 返回所有元素子节点的集合,逐个访问即可递归遍历整棵子树,对应“这是我的儿女们”。
    v.children={c1,c2,…,ck}v.\text{children} = \{ c_1, c_2, \dots, c_k \}
  3. 横向找兄弟同父节点的相邻子节点互为兄弟,通过 nextSibling / previousSibling 左右移动,对应按长幼排序的辈分名单。
    nextSibling(v)=ci+1,previousSibling(v)=ci−1\text{nextSibling}(v) = c_{i+1}, \quad \text{previousSibling}(v) = c_{i-1}

AI 应用场景

电商订单列表的批量渲染

阶段:数据驱动渲染(前端展示层)

解决的问题:后端返回商品数组,传统做法是拼 HTML 字符串再整块 innerHTML,既慢又容易 XSS。用 DocumentFragment + createElement 把每条商品数据变成一个节点,攒好一次性挂载,页面只重排一次,性能提升明显。

具体案例:某电商后台管理页的“今日订单”表格,接口返回 200 条订单。前端先用 fragment 批量创建 200 个 tr 节点并填充 textContent,再一次性 appendChild 到 tbody;用户滚动到底部时再增量追加下一批,全程无整页刷新。

表单实时校验提示

阶段:交互反馈(用户输入即时校验)

解决的问题:传统表单要提交后才能知道哪里填错,体验很差。用 querySelector 定位输入框,classList.toggle('input-error') 即时切换错误样式,再用 textContent 更新错误文案,不刷新页面就能边填边提示。

具体案例:注册页密码框失焦时,JS 用 getElementById('pwd') 拿到输入框,检查长度后用 classList 控制红色边框样式,同时把“至少 8 位”等提示写入旁边 span 的 textContent;符合条件则 classList 换回绿色成功样式。

单页应用的无刷新视图切换

阶段:前端路由渲染

解决的问题:传统多页面每次跳转都整页刷新,白屏体验差。SPA 用 replaceChild 把“上页面视图”替换成“下页面视图”,容器节点不变,只换子树,实现无刷新导航。

具体案例:后台系统的侧边栏菜单点击后,路由解析出目标视图的根节点,mainContainer.replaceChild(newView, oldView) 完成切换,离开页面时再用 removeChild 清理不再需要的节点,避免内存泄漏。

Python 演示

python98 行
import html.parser  class Node:    """模拟 DOM 树中的一个节点"""     def __init__(self, tag, attrs=None):        self.tag = tag        self.attrs = dict(attrs or [])        self.children = []        self.parent = None     def append_child(self, child):        child.parent = self        self.children.append(child)        return child     def remove_child(self, child):        if child in self.children:            self.children.remove(child)            child.parent = None     def get_element_by_id(self, target_id):        if self.attrs.get("id") == target_id:            return self        for child in self.children:            result = child.get_element_by_id(target_id)            if result is not None:                return result        return None     def query_selector_all(self, tag):        result = []        if self.tag == tag:            result.append(self)        for child in self.children:            result.extend(child.query_selector_all(tag))        return result     def display(self, depth=0):        line = "  " * depth + "<" + self.tag        for key, value in self.attrs.items():            line += ' %s="%s"' % (key, value)        line += ">"        print(line)        for child in self.children:            child.display(depth + 1)  class TinyHTMLParser(html.parser.HTMLParser):    """把 HTML 字符串解析成 Node 树"""     def __init__(self):        super().__init__()        self.root = None        self.stack = []     def handle_starttag(self, tag, attrs):        node = Node(tag, attrs)        if not self.stack:            self.root = node        else:            self.stack[-1].append_child(node)        self.stack.append(node)     def handle_endtag(self, tag):        if self.stack and self.stack[-1].tag == tag:            self.stack.pop()  if __name__ == "__main__":    sample = (        '<html><body>'        '<header id="top">主页</header>'        '<main><h1>标题</h1><p>段落</p></main>'        '<footer>底部</footer>'        '</body></html>'    )    parser = TinyHTMLParser()    parser.feed(sample)     print("=== 解析出的 DOM 树 ===")    parser.root.display()     main_node = parser.root.query_selector_all("main")[0]    button = Node("button", {"id": "btn", "class": "btn-blue"})    main_node.append_child(button)    print("\n=== appendChild 添加 button 之后 ===")    parser.root.display()     found = parser.root.get_element_by_id("btn")    print("\n=== getElementById 找到 ===")    print(found.tag, found.attrs)     footer = parser.root.query_selector_all("footer")[0]    footer.parent.remove_child(footer)    print("\n=== removeChild 移除 footer 之后 ===")    parser.root.display()
目的:用 Python 复刻一套迷你 DOM 树,演示“解析 HTML 建树、查询节点、添加节点、删除节点”的完整过程,帮助理解浏览器 DOM 的底层原理。
  1. self.children = []每个节点持有一个孩子列表,对应 DOM 的 childNodes,是树形结构的核心。
  2. child.parent = self挂载孩子的同时登记 parent 指针,有了它才能“谁的孩子谁管教”。
  3. self.stack[-1].append_child(node)把新节点挂到栈顶父节点下,模拟浏览器语法分析的建树过程。
  4. parser.root.display()从根节点递归打印整棵家族树,相当于在控制台查看 DOM 树形结构。
  5. main_node.append_child(button)对应页面里 appendChild 动态添加节点,新节点立刻成为树的一部分。
  6. footer.parent.remove_child(footer)删除必须由父节点发起,对应 DOM 的 removeChild 语义。
  7. result.extend(child.query_selector_all(tag))深度优先收集所有匹配标签的节点,对应 querySelectorAll 的遍历逻辑。

常见误区

误区我一直用 innerHTML 给元素塞文本,换成 textContent 效果也差不多吧,反正页面上都显示出来了。

正解差远了。textContent 把内容当纯文本,<b>加粗</b> 会带着尖括号原样显示;innerHTML 会解析 HTML 标签,生成真实的加粗元素。更危险的是,用 innerHTML 插入用户输入等于引狼入室——XSS 攻击最爱从这里进来。规矩是:改文字用 textContent,确需生成结构且内容可信时才用 innerHTML。

为何常见:新手看到两个 API 都能“改内容”,只观察到“结果都显示在页面上”,没留意底层一个是“当字写”,一个是“当图纸盖房子”。

误区我调用 getElementsByTagName('li') 得到一份列表,之后页面怎么变,这份列表应该都不变吧?

正解恰恰相反,它返回的是动态集合 HTMLCollection,每次访问都实时同步文档。你拿到的不是“照片”,而是“实时直播”。想要快照要用 querySelectorAll 返回的 NodeList(静态集合)。如果边遍历边给容器添加 li,动态集合的长度会一直增长,稍不留神就是死循环。

为何常见:名字里带“Elements“太像”查询结果集合“,很容易让人以为是查询那一瞬间固化的数组;直到有人在循环里被它坑出死循环,才真正明白”动态”二字的含义。

误区我想把同一个按钮同时放到两个容器里,用 appendChild 复制两次不就行了吗?

正解appendChild 不会复制节点,它会把原节点从原位置“连根拔起”,再挂到新位置——同一个节点在整棵树里只能有一个父节点。想复制得先 cloneNode(true) 生成深拷贝,那才会出现两个独立的按钮。

为何常见:生活里“把文件复制到另一个文件夹”的直觉太强了,而 DOM 的语义更接近“搬家”而不是“复印”,所以第一次用的人几乎都会踩这个坑。

误区删除节点不就是 node 自己调用一个删除方法吗?为什么我写了 node.removeChild(node) 却报错说 removeChild 未定义?

正解经典 API 是 parentNode.removeChild(child),删除权在父节点手里;node.remove() 是较新浏览器提供的便捷方法,老旧环境不支持。报错通常是因为调用对象搞反了——你在子节点上调用了父节点才拥有的方法,好比让儿子去开除他爹,规矩上说不通。

为何常见:面向对象的直觉是“对象管理自己”,但 DOM 的删除语义刻意强调“父子关系”,新手容易把方法的归属对象搞颠倒。

面试题

Q1. 要读取某个元素内的纯文本(不含 HTML 标签),最合适的属性是什么?

单选题 · easy

  • A.textContent 属性← 正确答案
  • B.innerHTML 属性
  • C.outerHTML 属性
  • D.value 属性

答案:A。textContent 专门负责拼接所有后代文本节点的内容,完全不解析标签;innerHTML 会返回包含标签的字符串;outerHTML 连当前元素自身标签一起返回;value 是表单控件的专用属性。这就像读牌位文字选“读字”而非“连房子一起看”。

Q2. document.getElementById('app') 的返回值是什么?

单选题 · easy

  • A.一个包含所有元素的数组
  • B.第一个匹配的元素节点← 正确答案
  • C.匹配元素的字符串描述
  • D.一个布尔值表示是否存在

答案:B。getElementById 按 id 精确查找,id 在整个文档中唯一,所以它直接返回那一个元素节点,而不是数组。找不到时返回 null,也不是布尔值。这就像管家喊全名,回来的是“那个人”而不是“一份名单”。

Q3. 下面哪个方法可以在内存中创建新的元素节点?

单选题 · easy

  • A.document.createDiv('div')
  • B.document.createElement('div')← 正确答案
  • C.document.newElement('div')
  • D.document.makeElement('div')

答案:B。标准 API 是 document.createElement(tagName),它返回一个脱离文档树的游离节点,调用后还需要 appendChild 等挂载方法才能上树。其他三个方法都不存在,属于臆造 API。这就像“报户口”只有一个正规窗口,其他窗口都是假的。

Q4. 把页面上已存在的节点 A 用 appendChild 挂到新父节点下,会发生什么?

单选题 · mid

  • A.在页面上复制一份 A 节点
  • B.原位置的 A 被移动到新位置← 正确答案
  • C.页面上同时出现两个相同节点
  • D.抛出一个运行时类型异常

答案:B。appendChild 的语义是“移动”而非“复制”:同一节点在文档树中只能有一个父节点,所以它会被先从原位置移除,再挂到新父节点的末尾。想复制必须用 cloneNode(true)。这就像把同一个人从东厢房搬到西厢房,他不可能同时出现在两间屋里。

Q5. querySelectorAll 与 getElementsByTagName 返回的集合,最重要的区别是什么?

单选题 · mid

  • A.前者是动态集合后者是静态集合
  • B.前者是静态集合后者是动态集合← 正确答案
  • C.两者都是动态集合
  • D.两者都是静态集合

答案:B。querySelectorAll 返回静态 NodeList,是查询瞬间的“合影照片”;getElementsByTagName 返回动态 HTMLCollection,是实时同步文档的“直播窗口”。文档变化后,动态集合会自动反映变化,静态集合不会。这个区别在遍历中添加元素时会导致完全不同的行为。

Q6. 下列哪些写法可以移除页面中的节点 node?

多选题 · mid

  • A.node.parentNode.removeChild(node)← 正确答案
  • B.node.remove()← 正确答案
  • C.node.deleteNode()
  • D.parent.innerHTML = ''← 正确答案

答案:ABD。A 是经典写法,由父节点执行删除;B 是 modern API,节点自己调用即可;C 的 deleteNode 方法并不存在;D 虽然粗暴,但如果 parent 是 node 的父节点,清空 innerHTML 会连带把 node 一起移除,只是会误伤兄弟节点。前两种精准,后一种属于“拆整屋”。

Q7. 使用 DocumentFragment 批量添加节点的主要好处是什么?

单选题 · mid

  • A.可以跨浏览器访问任意节点
  • B.减少多次操作引发的性能开销← 正确答案
  • C.让新节点自带样式效果
  • D.让代码执行速度变慢便于调试

答案:B。DocumentFragment 是一个游离的轻量容器,往里面添加节点不会触发页面的布局计算;最后一次性挂到父节点下,页面只重排一次。这就像族谱登记先用草稿本攒着,最后一页一页誊清,而不是每添一人就重翻一遍全册。

Q8. 关于 innerHTML 与 textContent,下列说法正确的是?

单选题 · hard

  • A.两者设置内容的效果完全一致
  • B.innerHTML 赋值会重新解析 HTML 标签← 正确答案
  • C.textContent 会解析字符串中的标签
  • D.innerHTML 只能读取不能写入

答案:B。innerHTML 赋值时浏览器会对字符串走词法、语法分析,把 <p>hi</p> 变成真实的 p 元素子树;textContent 则把字符串当纯文本,尖括号原样显示,绝不解析标签。两者都可读可写。用 innerHTML 插入不可信内容会引入 XSS 风险,这是它和 textContent 最本质的分歧。

Q9. 关于 DOM 树的构建过程,下列描述正确的是?

多选题 · hard

  • A.先对 HTML 字符流做词法分析← 正确答案
  • B.Token 直接渲染成屏幕像素不生成节点
  • C.按嵌套关系把 Token 组装成节点树← 正确答案
  • D.文档解析完成后 DOM 树永远不能改变

答案:AC。浏览器先把 HTML 字符流切成 Token(词法分析),再按标签嵌套关系把 Token 组装成节点树(语法分析),A 和 C 正确。B 错在 Token 之后还要生成节点、布局、绘制才能上屏;D 错在 DOM 树建成后依然可被 JavaScript 增删改查,这正是“动态页面”的根基。整个过程好比族长念稿、拆分、再按辈分入座。

Q10. 执行以下代码时会发生什么?

单选题 · extreme

  • A.正常遍历固定次数后结束
  • B.items 是动态集合导致死循环← 正确答案
  • C.第一次循环后就抛出异常
  • D.只创建了一个新 li 就停止

答案:B。getElementsByTagName 返回的是动态 HTMLCollection,每轮循环调用 items.length 都会重新统计,而循环体里又在不断新增 li,导致 length 永远追不上 i,形成死循环。解决方案是先把集合转成静态数组或先缓存初始长度。这就像边数人头边往队伍里加人,永远数不到头。