ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Document对象常用方法详解:DOM操作、节点查询与事件委托实战指南

Document对象常用方法详解:DOM操作、节点查询与事件委托实战指南 1. 先从入口说起Document对象在页面里扮演什么角色翻旧项目的时候我又一次被一堆document开头的方法糊了脸。JavaScript里最容易形成肌肉记忆的 API多半是Document对象上的这些方法查元素、造元素、改属性、挂事件。很多基础教程喜欢只列一张方法清单但真正到了写页面时你会发现同样一件事有四五种写法性能、兼容性、语义还各不相同。这篇文章按实际开发顺序梳理Document对象常见方法讲清楚它们各自的适用场景、返回类型和坑适合刚学完 JavaScript 基础、准备开始操作 DOM 的读者也适合想把自己用法中的坏习惯顺手纠正一下的同学。1.1 document 是 window 上的一个属性也是整棵节点树的根打开浏览器控制台输入document你看到的是一个HTMLDocument对象本质上它就是浏览器根据 HTML 源码解析出来的节点树入口。document window.document所以从 JavaScript 全局作用域里能直接访问它。这个对象并不单纯是“一群方法的集合”它自己是Node接口的实例节点类型是9也就是Document类型。console.log(window.document document); // true console.log(document.nodeType); // 9 console.log(document.documentElement); // html 元素 console.log(document.body); // body 元素Document接口沿袭了Node和EventTarget的原型链所以它既能执行节点相关操作也能监听文档级别的事件。平时写的document.querySelector、document.addEventListener本质上都是在查这棵节点树或者在树上挂回调。理解了这一层再去看方法名就不会觉得它们是一堆散装 API。1.2 方法可以按用途分成四类看源码或者查 MDN 时新同学容易陷进几十个方法的列表里。我习惯把Document对象常见方法分成四组心里先有个地图类别代表方法典型用途查询元素getElementById、querySelector、querySelectorAll从文档中找到目标节点创建节点createElement、createTextNode、createDocumentFragment在内存里造出新的 DOM 节点增删替换appendChild、insertBefore、removeChild、replaceChild把节点挂进文档或从文档摘除属性与事件setAttribute、getAttribute、addEventListener、contains修改节点信息或绑定交互行为还有document.write这类操作“文档流”的方法现在开发中基本用不到但老项目里偶尔会碰到后面单独说。这样分类不是为了背名字而是让排查问题的时候有一个定位路径界面少了元素先走查询方法要动态追加内容先走创建和挂载方法事件完全不触发再回头检查绑定方法。2. 查元素的方法为什么说 getElement 系列是老技术querySelector 系列更通用查询是Document对象上使用频率最高的一类方法几乎每个页面脚本都离不开。你要操作什么节点第一步都是先把它找到。2.1 getElementById 是唯一一个返回单个元素的“老家伙”document.getElementById(app)在很长一段时间里是唯一能直接返回单个元素的查询方法。参数是元素的id字符串不带#号。如果找不到节点返回null所以使用前最好做空值判断。const app document.getElementById(app); if (app) { app.innerHTML p准备渲染/p; }从文档结构上说id在同一份 HTML 里应当唯一所以不需要考虑匹配多条结果。浏览器对这个查询做了优化按 ID 索引定位整体速度在大多数场景下都足够快。但它的局限也明显没法用 CSS 选择器表达“某个容器下的第几个子元素”这种稍复杂的关系。2.2 getElementsByClassName 和 getElementsByTagName 返回动态 HTMLCollection这两个方法不带By之前那个s。很多新手写成getElementsById这是不对的。const items document.getElementsByClassName(item); const divs document.getElementsByTagName(div);它们的返回结果都是HTMLCollection一种类似数组的对象但有两个特点需要记住它是动态的。页面里只要新增或删除匹配的节点集合的length和内容会同步变化。它不是真正的数组不能直接使用map、filter。getElementsByClassName的参数是类名不是选择器所以不要传.item。多个类名用空格隔开比如item active表示同时拥有这两个类的元素。getElementsByTagName的参数是标签名比如div、a、li。动态集合在某些场景里是优势。比如你要实时统计页面里有多少个div不用反复查询。但如果在循环里一边遍历一边往页面追加同类节点长度会不停变化最容易写出死循环这点放到后面的实战坑里细说。2.3 querySelector 和 querySelectorAllCSS 语法直接进 DOMdocument.querySelector接受一个合法的 CSS 选择器字符串返回第一个匹配元素document.querySelectorAll返回所有匹配元素组成的NodeList。和HTMLCollection不一样这个NodeList通常是一个静态快照不会因为后续 DOM 变化而自动更新。const firstCard document.querySelector(#app .card); const cards document.querySelectorAll(#app .card[data-index]); cards.forEach(card { console.log(card.dataset.index); }); const cardArray [...cards]; const visibleCards cardArray.filter(card card.offsetParent ! null);选择器语法是现成的 CSS 语言能表达的结构比古老的getElement*丰富得多。比如document.querySelector(ul li:first-child)、document.querySelector([typetext])一眼就能看懂匹配规则。需要注意返回的NodeList虽然是静态的但它也只是一个类数组对象。现代浏览器支持forEach但map、filter这类数组方法还是不直接提供。想用数组方法老老实实Array.from(cards)或者展开运算符转一下。2.4 document.forms 这类快捷集合也值得记住除了按 ID、类名、标签名查询Document对象还暴露了一批历史遗留的快捷集合document.forms、document.images、document.links、document.scripts。它们分别对应页面里的表单、图片、链接、脚本元素。const loginForm document.forms.login; const username loginForm.elements.username.value;document.forms在操作表单时很有用可以直接按name取表单不需要先写一个字符串 ID 再去查。不过如果你的页面里有多个同名表单这种取法会按文档顺序返回第一个反而容易出问题。我的习惯是外壳节点统一用id配合getElementById表单内部控件再通过form.elements按name访问。如果你想按name属性查任意元素还可以用document.getElementsByName(username)。它返回的也是动态NodeList主要用于 Radio、Checkbox 这类同名校验场景。3. 创建、插入和移除节点演示一遍 DOM 增删改查查询是读创建和挂载是写。动态渲染、列表更新、弹窗展示本质都是在做这几件事。3.1 createElement 和 createTextNode先造出节点再放进树document.createElement(div)的作用是创建一个元素节点参数是标签名。它此时还只在内存里页面不可见。要让它出现还得用appendChild等方式挂到文档树里。要往节点里填文本最安全的方式不是用innerHTML而是通过textContent属性直接赋值或者用document.createTextNode(内容)创建一个文本节点。const li document.createElement(li); li.className item; li.textContent 任务一; const list document.getElementById(list); list.appendChild(li);createTextNode是处理用户输入文本的老办法。它会自动把、、这些字符当作纯文本处理不会被解析成 HTML。现在大部分场景里textContent已经能替代它但在需要显式组装片段、做选区操作时createTextNode仍然有用。3.2 DocumentFragment 可以把多次渲染打包成一次document.createDocumentFragment()创建的是一种特殊的轻量文档对象它不属于页面渲染树只是一个临时容器。你可以把很多子节点先挂进这个容器里最后把容器整体插入页面。插入时容器本身不会出现它的所有子节点会被一次性转移进入目标位置。const list document.getElementById(list); const fragment document.createDocumentFragment(); for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent 条目 i; fragment.appendChild(li); } list.appendChild(fragment);每操作一次 DOM浏览器都可能计算样式和布局。直接循环appendChild一千次就会触发一千次重复计算。用DocumentFragment先把节点临时聚拢最后只触发一次挂载性能和代码可读性都会好很多。这也是我处理长列表时第一个想到的工具。3.3 appendChild、insertBefore、replaceChild、removeChild四个老朋友这四个方法都属于节点层面的操作但在Document和Element上都能看到。它们是增删改查里的“改”和“删”。appendChild(newNode)把节点追加到目标节点的子节点列表末尾。insertBefore(newNode, referenceNode)把新节点插到参考节点前面如果参考节点传null效果等同于appendChild。const first list.firstElementChild; const newItem document.createElement(li); newItem.textContent 置顶项; list.insertBefore(newItem, first);replaceChild(newNode, oldNode)用新节点替换掉目标节点的子节点。removeChild(childNode)把节点从父节点中移除返回值是被移除的节点。这里有一个很多老手会利用的特性当一个节点已经在文档树里你再次appendChild它它会先从原位置自动移走再挂到新父节点末尾。比如拖拽排序、把消息从“未读”列表挪到“已读”列表直接appendChild就行不需要先removeChild再重新插入。const node document.getElementById(dragging); document.getElementById(done-list).appendChild(node);3.4 innerHTML 的“快”和“险”innerHTML不是Document对象上的方法而是Element的属性但通过document.querySelector拿到的元素上到处都能见它。它可以一次性把一段 HTML 字符串解析成节点结构container.innerHTML div classalert操作失败/div;这个写法在构建静态模板时很省事。通过innerHTML插入的script标签通常不会按你预期执行HTML 解析器会把它当作普通节点插入但并不代表它就是安全的。img onerror...这类内容一旦被解析成标签事件属性有可能会触发回调。所以只要内容里掺了用户输入、接口返回文本就不要走innerHTML改用一个一个createElement再用textContent赋值。还有一个容易忽略的细节用innerHTML读取内容会拿到序列化后的 HTML 字符串它和源码里的原始文本不一定一模一样。比如浏览器可能会规范化引号、修复标签闭合。如果只是想读文本应该用textContent避免不必要的 HTML 解析。4. 属性和事件方法名看着在 Element 上实际到处都在用找到节点、造好节点之后接下来就是改属性、绑定事件。这一类方法虽然按规范挂在Element接口上但实际页面代码里离不开document取出来的对象。4.1 getAttribute、setAttribute、removeAttribute操作的是 HTML 属性element.getAttribute(data-id)返回的是 HTML 源码层面的属性值永远是一个字符串或者null。element.setAttribute(disabled, )会把disabled属性写进标签。removeAttribute则把它移除。这里最容易踩坑的是 attribute 和 property 不是一回事。比如input的value用户在页面上输入后input.value这个属性会变化但input.getAttribute(value)仍然是初始设置的值。const input document.createElement(input); input.setAttribute(value, abc); console.log(input.value); // abc input.value xyz; console.log(input.getAttribute(value)); // 还是 abc所以处理用户可编辑控件的当前值优先用input.value不要迷信getAttribute。处理自定义属性时用setAttribute或dataset都可以但现在更推荐dataset。4.2 dataset 与>const card document.querySelector(.card); card.dataset.userId 999; console.log(card.getAttribute(data-user-id)); // 999用dataset的优点是代码更短读起来更直白。不过它在老版本浏览器里可能有兼容问题如果项目还必须兼容古董浏览器保守一点继续用getAttribute也问题不大。4.3 read text: textContent 与 innerText 的区别很多人分不清textContent和innerText。简单说textContent是纯节点层面的文本拼接不管元素是否隐藏、是否可见它都会把所有后代文本节点拼起来。innerText更接近“用户看到的文本”它会考虑样式读取时可能会触发重排速度慢一些而且在某些场景下会忽略隐藏元素的文本。const box document.getElementById(box); console.log(box.textContent);写入文本时我几乎只用textContent。它不会解析 HTML天然规避了 XSS 注入的风险。哪怕用户输入img srcx onerroralert(1)用textContent赋值也会原样显示成文本而不是执行。4.4 addEventListener / removeEventListener: document 本身也能用Document对象继承了EventTarget所以document.addEventListener(click, handler)这种写法是合法的。它通常用于事件委托不在每个子元素上单独绑定监听器而是统一在document上监听再通过event.target判断到底是谁触发的。document.addEventListener(click, (event) { const button event.target.closest(.delete-btn); if (!button) return; const id button.dataset.id; // 执行删除逻辑 });这种写法在动态列表里特别实用。因为节点是后来生成的如果当初逐个绑定事件新节点必须重新绑定。委托给document之后只要事件冒泡到文档层全部都能捕获。removeEventListener想移除某个监听器要求传入同一个函数引用并且布尔参数要和绑定时一致。匿名函数没法移除所以在需要动态取消监听的场景先把函数保存到变量里再绑定。function onScroll() { console.log(window.scrollY); } document.addEventListener(scroll, onScroll); // 后续某处 document.removeEventListener(scroll, onScroll);4.5 contains 方法判断节点归属的最直接办法document.contains(node)和element.contains(child)用来判断一个节点是否在另一个节点的子树内。document.body.contains(modal)就是判断某个弹窗是否还在页面上。这个方法的实用价值经常被低估。比如你写了一个removeChild但不确定节点是不是当前父节点的直接子节点先用contains判断一下能避免很多报错。const list document.getElementById(list); const item document.getElementById(item-1); if (list.contains(item)) { list.removeChild(item); }5. document.write 那套“文档流”操作为什么没人推荐了如果你打开老项目或者某些第三方统计脚本还可能看到document.write。它不是没有能力而是现实太残酷稍微用错时机整个页面都会被它“洗掉”。5.1 document.write 的生存环境document.write原本是把一段 HTML 字符串直接写进当前文档流。浏览器解析 HTML 的过程中如果遇到一个外链脚本脚本里调用document.write内容会插入到正在解析的位置所以老广告脚本喜欢用它。问题在于它建立在“文档正在加载”这个前提下。如果页面已经加载完你再去调用document.write它会在内部先执行document.open把整个文档清空再从零开始写用户看到的页面直接变白。这个后果极其隐蔽因为控制台不一定报错。现在新项目基本不写document.write。需要动态输出内容时正常用createElement配合appendChild或者操作已有的容器节点。真要等页面加载完再渲染也还有DOMContentLoaded事件和各类框架方案。5.2 document.open、document.close 与 readyState 的联动document.open()会打开一个新的文档流它的默认效果是清空当前文档内容。document.close()则结束写入触发浏览器继续解析后续资源。正常情况下你不要手动调用这两个方法。它们和document.write是一套组合拳只有在模拟文档流输出时才可能有意义。实际开发里看到它们绝大多数情况下意味着代码需要重构。和这几个方法绑定在一起的还有document.readyState它的值会在loading、interactive、complete之间变化。DOMContentLoaded触发时大致对应interactiveload触发时对应complete。理解这个变化过程可以帮助你判断脚本运行时机是否正确。6. 实战里最容易绊倒人的几个 Document 方法细节这一节算是我这些年改 bug 攒下的重点。方法本身不难难的是方法之间的差异在真实项目里会以微妙的方式爆发。6.1 动态集合与静态集合数据会自己变前面提到getElementsByTagName返回动态HTMLCollection而querySelectorAll返回静态NodeList。这两者的差别平时不明显一旦你循环里同时操作 DOM就会制造出诡异的结果。const divs document.getElementsByTagName(div); const snapshot document.querySelectorAll(div); console.log(divs.length); // 假设现在有 1 个 console.log(snapshot.length); // 同样 1 个 const newDiv document.createElement(div); document.body.appendChild(newDiv); console.log(divs.length); // 变成 2 console.log(snapshot.length); // 仍然是 1写循环时如果基于动态集合做“删除到没有为止”的操作每删一个集合的长度就会变化要么漏删要么死循环。稳妥做法是先把集合转成数组或者直接用querySelectorAll拿静态快照再操作。6.2 appendChild 前先检查removeChild 报 NotFoundError 怎么排查“运行时报错”最常见的几个场景都集中在节点操作上。比如执行parent.removeChild(child)时child并不是parent的直接子节点浏览器会抛NotFoundError。如果传给它一个根本不是节点的东西又会报TypeError。const parent document.getElementById(list); const child document.getElementById(item-1); if (parent.contains(child)) { parent.removeChild(child); }出现这类报错时先打桩检查三件事parent是否真的取到了值、child是否真的存在、两者之间是否是父子关系。不要只盯着removeChild这一行很多时候是前面查询方法返回了null或者节点已经被另一个父节点移走了。还有一种HierarchyRequestError常见原因是把节点appendChild到了它自己的后代节点里。比如把body往它的子节点里塞这不符合 DOM 树的层级规则。6.3 批量插入时别让重排拉着性能垫底频繁操作 DOM 会触发布局计算批量场景尤其明显。除了用DocumentFragment还可以先修改容器样式让目标元素脱离渲染流程操作完成后再恢复。比如有一个大列表要清空后重填const list document.getElementById(list); list.style.display none; list.innerHTML listData.map(item li${item.name}/li).join(); list.style.display ;这种方式利用display: none让节点脱离渲染修改时不再引起布局反复计算。但要注意如果innerHTML里的内容是用户可控的就不要这样拼接老老实实用节点方法。6.4 缓存查询结果避免在循环里反复访问 document很多人写了多年代码还是会在循环里反复查同一个节点for (let i 0; i items.length; i) { document.getElementById(list).appendChild(items[i]); }每次循环都从document重新查询一次工作量和直接取一个局部变量完全不能比。正确做法是在循环前把目标容器存下来const list document.getElementById(list); for (let i 0; i items.length; i) { list.appendChild(items[i]); }同理如果你要在多个函数里频繁使用某个页面容器可以把查询结果存到一个模块级变量或者配置对象里。这种缓存只是随手一个变量却能显著减少无意义的查询开销。总的来说Document对象的方法并不可怕真正需要留心的永远是返回类型、动态静态差异和合适的装配方式。把方法按“查询、创建、挂载、事件”这条链路串起来再带着“为什么会这样设计”的疑问去写远比死记方法名有用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进