
1. 先想清楚取元素这一步为什么决定了后面所有代码的质量写前端代码写到最后你会发现真正让人抓狂的不是算法也不是样式布局而是我以为我拿到了那个元素结果它是 null。DOM 访问元素这个动作看着是入门第一课document.getElementById敲下去就完事但我在实际项目里见过的 bug至少有三分之一跟这一步有关脚本跑在节点出生之前、选择器写错一个空格、动态列表渲染后还抱着旧的引用不放、事件绑到了会被替换掉的节点上。这些问题的共同点是它们不会在控制台抛出漂亮的错误堆栈只会在某个特定操作之后静默失效让你查上半天。这篇内容想做的事情很具体把如何访问 DOM 中的元素这件事从一句 API 说明还原成一份能直接照着抄的实践方案。我会讲清楚几种取元素方式的底层差异和适用边界给你一套可以直接放进项目里的封装函数把脚本执行时机、动态集合、选择器转义这些容易翻车的点一个个拆开最后附上我自己踩过的坑和排查顺序。刚接触 DOM 的朋友可以把它当成一份带注释的操作手册写了几年代码但总觉得取元素没什么好讲的朋友也可以看看里面关于静态集合与实时集合、作用域隔离、性能量级的部分这些细节在数据量一大的页面里差距很明显。这里先把一个前提摆出来DOM 访问本质上是在一棵树上做定位。浏览器把 HTML 文本解析成节点树document是这棵树的入口对象你写的每一个选择器最终都会被翻译成一次或多次树遍历。理解了这一点后面所有关于为什么这样写更快为什么这里拿不到的疑问基本都能自己推出来。至于用哪个 API、要不要封装、什么时候缓存引用那是策略问题我下面会给出具体的判断标准。1.1 一个被 null 支配的真实开场我印象最深的一次是给一个后台列表页加批量操作。页面上有一个全选复选框代码写得很简洁拿到全选框监听 change把下面所有行的复选框状态同步过去。本地跑得好好的上了测试环境就失灵偶尔灵偶尔不灵。控制台没有报错事件也确实绑上了就是点了没反应。查下去的原因很朴素那段脚本写在了head里而列表是由一个异步请求回来之后渲染的。首次加载时 JS 执行完表格还是空的querySelectorAll(.row-checkbox)拿到的是一个长度为 0 的 NodeList——注意它不报错它只是空。等数据回来渲染出二十行的时候事件早就绑完了那二十个新节点身上什么都没有。后来我把绑定改成了事件委托挂在表格容器上问题立刻消失。这件事给我的教训不是要用事件委托而是取元素这个动作必须和节点的生命周期对齐否则取到的只是那一刻的快照。所以第一篇我要强调的不是 API 列表而是时机与作用域这两个维度。同样一句querySelector写在不同的位置、挂在不同的根节点上行为完全不同。很多人把取不到元素归咎于选择器写错实际上十次里有六七次是时机问题。1.2 从浏览器视角看一次取元素到底做了什么当你在代码里写下document.querySelector(.item.active)浏览器内部大致做了三步解析这段选择器字符串把它编译成匹配规则从根节点开始按规则筛选候选节点把匹配结果收集成集合返回。querySelector在找到第一个匹配项时就停下querySelectorAll要遍历完整个子树。而getElementById走的是一条捷径——浏览器的文档对象内部维护着 id 的索引可以直接命中不需要遍历整棵树。这就是为什么在极端场景下id 查找的性能优势能拉开一个量级。还有一个常被忽略的区别返回值的类型。querySelectorAll返回的是NodeList而且是静态的——你拿到它的那一刻内容就固定了后面 DOM 再变它不会跟着变。getElementsByClassName和getElementsByTagName返回的是HTMLCollection是实时的——DOM 一变它立刻反映最新状态。这个差异不带任何提示但会直接决定你的循环逻辑对不对。我后面会专门用一节讲这个坑这里先埋个引子。提示判断一个集合是静态还是实时不要在文档里翻直接做实验——取到集合后往页面里插一个新节点再看集合的 length 有没有变。这是最可靠的确认方式。1.3 选型思路先问三个问题再动手我在写任何一段要操作 DOM 的代码之前会先在心里过三个问题答案基本能锁定该用哪种取法。第一个问题这个元素的 id 是确定的吗如果是页面上唯一的、结构稳定的锚点比如一个搜索框、一个主容器直接用getElementById快且稳还不用考虑选择器转义的问题。第二个问题我需要的是一批元素还是一个一批的话querySelectorAll是默认答案它支持完整的 CSS 选择器语法表达力最强如果这批元素是同类标签或同类 class 且数量可能动态变化getElementsByClassName的实时特性反而有用。第三个问题这个查询会执行多少次如果在一个循环里、或者在滚动、输入这种高频回调里反复查询同一个元素那就必须把结果缓存到变量或者模块作用域里。DOM 查询本身不慢但每帧查几十次就是另一回事了。这三个问题的答案组合起来就是一张很实用的决策表我在下面第 2 章会用表格展开。先把结论说在前面日常开发里 80% 的场景用querySelector/querySelectorAll就够了剩下 20% 分给 id 查找、实时集合和相对查找。不要为了追求那点理论性能把代码写得千奇百怪可读性同样是成本。2. 五种取元素方式的核心差异与适用边界这一章把常用的访问手段拆开讲。我不按 API 文档的顺序排而是按你在什么场景下会想到它来组织每一种都给出它的真实优势和它的坑。2.1 getElementById 与 getElementsByClassName老派但依然锋利getElementById是 DOM 里历史最久、语义最直白的查找方法。它只接受一个字符串参数返回匹配的单个元素或null。它有三个特点值得记住第一它是唯一一个不需要遍历树的查找方式靠文档内部的 id 索引直接命中第二它只挂在 document 上元素节点上没有这个方法所以不能拿它做局部查找第三它对参数不做选择器解析所以就算 id 里含有.、:、[这类字符也能照常查——当然id 里塞特殊字符本身就不是好习惯遇到需要转义的场景时用这个方法反而是最省事的。getElementsByClassName接受一个或多个 class 名多个用空格分隔表示同时具备返回实时的HTMLCollection。它的坑集中在两点一是返回值实时循环里增删节点会导致索引错位二是它只能按 class 名匹配不支持层级组合想要某个容器下的 item必须先拿到容器再调用它写成container.getElementsByClassName(item)。很多新手直接写document.getElementsByClassName(a .b)期望它像 CSS 那样理解后代关系结果拿到空集合——它把整串当成一个 class 名去找了当然找不到。getElementsByTagName与上面那个同理返回实时集合参数是标签名传*表示所有元素。它在需要遍历整棵子树做通用处理时有用比如把所有节点过一遍收集文本内容。但在现代写法里它出现频率已经很低了querySelectorAll(*)表达更统一。注意这三个返回实时集合的方法在遍历中删除元素时最容易出问题。文档里删了第 0 项第 1 项会自动前移到第 0 位你的索引i就跳过了一个。要么倒序遍历要么先Array.from()转成普通数组再操作。2.2 querySelector 与 querySelectorAll现代写法的默认答案这两个方法支持完整的 CSS 选择器语法可以写在document上也可以写在任意元素节点上。写在元素上时查找范围被限定在该元素的后代中——这一点非常关键它让我们可以做到作用域隔离避免不同模块之间的选择器互相干扰。我个人的习惯是只要查询发生在一个组件容器内部就先拿到容器再从容器上调用查询绝不用全局document去捞。querySelector返回第一个匹配项或nullquerySelectorAll返回静态的NodeList。静态这一点是优点也是陷阱优点是循环过程中即使 DOM 变化你的集合也不会突然变长变短陷阱是你以为它会自动更新结果看到的是旧数据。我在做表格行状态同步的时候就吃过这个亏取了一次行集合之后又渲染进去几行再遍历这个集合新行完全不在里面。两个方法都支持逗号分隔的多选择器比如querySelectorAll(.btn, .link)返回的是两类元素的并集顺序按文档顺序而不是选择器顺序——这一点经常被误解很多人以为结果会先排.btn再排.link实际上不是。另一个实用技巧是:scope伪类。从元素上调用querySelector时选择器默认匹配的是该元素的后代不包含它自己。如果你想在自己或后代范围内找可以写el.querySelector(:scope .item)这表示只用直接子元素里的.item。这个写法在做精细结构控制时很顺手比写一堆children加判断要清晰。2.3 那些被遗忘的快捷入口body、documentElement 与命名集合除了通用查询方法DOM 还给了一些直达车。document.body直接指向bodydocument.documentElement指向htmldocument.head指向head。这三个不需要查询直接取没有失败的可能只要文档解析到那个阶段。表单相关的入口更值得一说。document.forms是一个实时的HTMLCollection可以按索引取也可以按 name 取document.forms[loginForm]。拿到表单之后form.elements又是一个集合里面包含该表单的所有控件同样支持按 name 或 id 索引。这意味着一个结构规范的登录表单取用户名输入框可以一行写完const username document.forms.loginForm.elements.username;比querySelector(#loginForm input[nameusername])短而且不依赖选择器解析。它的前提是表单控件必须有 name 属性——这本来也是提交表单的必要条件。我在处理老项目或者需要大量读取表单值的页面时会优先用这条路径。还有一个冷门但偶尔管用的方式document.links、document.images、document.scripts。它们分别返回文档里所有a、img、script的实时集合。做统计或者批量处理时省一次查询。不过要注意document.links只包含带 href 的a和area不带 href 的锚点不在其中这点跟直觉有点偏差。2.4 相对查找closest、children、siblings 这一族真实项目里很多元素访问不是从文档根找下来而是从当前节点往上或往旁边走。这类操作必须掌握closest它在事件委托里几乎是标配事件对象上的target可能是最里层的span而你想操作的是它所在的那一行于是e.target.closest(.row)一步到位。closest的匹配范围包含自身从自身开始逐级向上直到根节点找不到返回null。往上走还有parentElement父元素节点可能为null比如html的父节点和parentNode父节点可能是文档或文档片段。这两个的区别在常规结构里体现不出来但在处理文档片段时会有差异一般用parentElement更符合元素的语义。往下走有children实时的元素集合不含文本节点、firstElementChild、lastElementChild。注意带Element的版本和不带的版本区别很大childNodes会把换行、空格产生的文本节点也算进去所以firstChild经常返回一个文本节点而不是你以为的元素。我在早期代码里被这个坑过不止一次明明结构没问题firstChild.className却是undefined。统一用带Element的版本能躲掉一大半问题。平级走有nextElementSibling和previousElementSibling同样建议用带Element的版本。还有一个常被忽略的matches(selector)判断当前元素是否匹配某个选择器配合closest一起用可以做很精细的分支判断。2.5 五种方式横向对比与性能量级把上面这些整理成一张表用的时候直接对照就行。方式返回值静态/实时适用范围典型场景getElementById单个元素或 null不涉及仅 document页面唯一锚点、主容器getElementsByClassNameHTMLCollection实时document 或元素需要随 DOM 变化的同类集合getElementsByTagNameHTMLCollection实时document 或元素全量遍历、通用处理querySelector单个元素或 null不涉及document 或元素复杂条件、首选写法querySelectorAllNodeList静态document 或元素批量操作、快照式处理性能方面我在几个不同规模的页面上做过简单对比用循环跑一万次取平均单次耗时。结论大致是getElementById最快通常在亚微秒级getElementsByClassName次之querySelector因为要解析选择器字符串单次开销更高但如果选择器简单比如单个 class差距会被压缩到很小。真正拉开差距的不是方法本身而是选择器的复杂度——一串后代选择器加属性选择器比单个 class 慢一个量级是常事。所以性能优化的重点应该放在两件事上一是减少查询次数缓存引用二是让选择器尽可能靠右和简单。document.querySelector(.list .item)这种写法浏览器需要先找到所有.item再向上验证父级如果页面里.item很多成本就上去了。给容器加个 id从容器上查范围立刻小一大截。3. 实操手搭一套可复用的元素访问小工具光讲 API 没什么体感这一章我直接带着搭一套东西一个真实的小页面结构加上几个我会在项目里反复用的封装函数再配合性能测试和动态内容处理。你可以把这一整套搬到自己项目里改。3.1 页面骨架与目录结构先定结构。我做的是最常见的卡片列表一个容器里面若干张卡片每张卡片有标题、描述和一个操作按钮。按钮点击后切换所在卡片的选中状态另外顶部有一个全选按钮和一行统计文字。div idapp classapp header classapp__bar button typebutton classbtn>/** * 在指定根节点下查单个元素 * param {string} selector CSS 选择器 * param {ParentNode} [rootdocument] 查找根节点 * returns {Element|null} */ function $(selector, root document) { if (!selector || typeof selector ! string) return null; try { return root.querySelector(selector); } catch (err) { console.warn([query] 选择器非法, selector, err); return null; } } /** * 查多个元素始终返回真正的数组 * param {string} selector CSS 选择器 * param {ParentNode} [rootdocument] 查找根节点 * returns {Element[]} */ function $$(selector, root document) { if (!selector || typeof selector ! string) return []; try { return Array.from(root.querySelectorAll(selector)); } catch (err) { console.warn([query] 选择器非法, selector, err); return []; } }几个设计取舍解释一下。返回数组而不是 NodeList是为了让调用方可以放心用map、filter、forEach、indexOf这些数组方法不用每次Array.from。try/catch包住查询是因为选择器写错时浏览器会直接抛SyntaxError在工具函数里兜住并给出明确日志比在业务代码里各自排查高效得多。默认根节点是document但同时允许传第二个参数这就把作用域隔离变成了默认能力——只要我在组件里const root $(#list)后续所有查询都写成$$(.card, root)就永远不会捞到别的模块的元素。实操心得工具函数的命名不要太花哨。用$和$$在小型项目里很顺手但在多人协作的大项目里我建议改成queryOne和queryAll避免和第三方库的全局变量冲突。命名这件事团队统一比个人喜好重要。3.3 事件委托让动态内容自动生效接着解决第 1 章提到的那个问题。列表内容如果是异步渲染的逐个卡片绑事件必然失效正确做法是把监听挂在不会变的容器上。const listEl $(#list); const countEl $(.app__count); const selected new Set(); listEl.addEventListener(click, (event) { const card event.target.closest(.card); if (!card || !listEl.contains(card)) return; // 只有点击按钮才切换状态点卡片其他地方不动 if (!event.target.closest([data-actiontoggle])) return; const id card.dataset.id; if (selected.has(id)) { selected.delete(id); card.classList.remove(is-selected); } else { selected.add(id); card.classList.add(is-selected); } updateCount(); }); function updateCount() { countEl.textContent 已选 ${selected.size} 项; }这段代码里有两个细节值得单独拎出来。第一个是event.target.closest(.card)它从实际被点中的节点开始往上找最近的卡片不管点的是标题、描述还是按钮内部的图标都能定位到整张卡片这就是相对查找的价值。第二个是listEl.contains(card)这层校验它的作用是排除卡片套卡片的极端情况——虽然这个例子里不会有但在嵌套列表里closest可能找到外层的卡片加一层归属校验能保证拿到的确实是当前容器直接管辖的那个。event.target本身也是个容易误用的点。它的类型是事件目标可能是一个文本节点吗在标准的 click 事件里不会target一定是元素。但在某些事件比如DOMCharacterDataModified或者老浏览器的兼容场景下target可能是文本节点此时它没有closest方法代码会报错。稳妥的写法是先判断event.target instanceof Element不过在现代浏览器的 click 场景里这个判断可以省掉我用得不多。3.4 一次真实的性能对照缓存引用的收益有多大很多人对缓存 DOM 引用这件事没有体感觉得反正快无所谓。我做过一次测量场景是模拟滚动时更新统计文字每帧要读一次列表容器和统计节点。测试方法很简单用performance.now()卡时间。const N 10000; function bench(label, fn) { // 预热避免首次编译干扰 for (let i 0; i 100; i) fn(); const t0 performance.now(); for (let i 0; i N; i) fn(); const t1 performance.now(); const avg (t1 - t0) / N; console.log(${label}: 总耗时 ${(t1 - t0).toFixed(2)} ms单次 ${avg.toFixed(4)} ms); } // 方案 A每次查询 bench(每次 querySelector, () { const el document.querySelector(#list); return el ? el.children.length : 0; }); // 方案 B缓存引用 const cached document.querySelector(#list); bench(缓存引用, () (cached ? cached.children.length : 0));在几千个节点的页面上跑下来两者差出一个数量级是很常见的结果。即便绝对时间都很短可一旦放到滚动、输入、动画这些每帧或每次按键都触发的回调里累积起来就不可忽视了。这里顺带说一下参数换算的思路如果单次查询是 0.02 毫秒一帧 16.7 毫秒里查 50 次就是 1 毫秒看起来不多但页面同时还有布局、绘制、其他脚本1 毫秒的纯查询开销可能就是掉帧的最后一根稻草。优化的原则不是把每个操作都优化到极致而是把高频路径上的重复查询砍掉这是投入产出比最高的动作。顺便给一个选择器复杂度的对照参考写法相对开销说明#list最低直接命中 id 索引.card低单类名一次遍历.list .card中需要验证祖先关系.list .card[data-id]中偏高组合条件匹配成本上升div.card:not(.is-disabled) .btn高伪类加多层关系尽量避免表格里的相对开销不是绝对值不同的节点规模和浏览器实现都会影响结果但它表达的排序关系是稳定的。实践建议就一句能靠 id 或单类名解决的事情别写成复杂的组合选择器。4. 各种翻车现场与排查顺序这一章是我最想写的部分。前面讲的是应该怎么写这里讲的是写错了怎么找。我把这几年遇到过的高频问题整理成一套排查顺序遇到取不到元素的时候按这个顺序过一遍基本五分钟内能定位。4.1 时机问题null 的第一嫌疑人取到null先别怀疑选择器先问自己这段代码执行的时候那个节点存在吗判断方法很直接在查询前后各打一个日志看看document.readyState的值。它有三个状态loading文档还在解析、interactive解析完成但图片等资源可能还在加载、complete全部完成。如果你的脚本在loading阶段就跑那只能拿到它之前已经解析出来的节点。三种常见解法各有适用场景。第一种是把script放到body末尾最简单也最不容易出错适合小页面。第二种是给脚本加defer属性它会让脚本在文档解析完成后、DOMContentLoaded事件之前按顺序执行适合有多个脚本且存在依赖关系的场景。第三种是用DOMContentLoaded监听document.addEventListener(DOMContentLoaded, () { const listEl $(#list); if (!listEl) { console.warn(列表容器不存在跳过初始化); return; } init(listEl); });注意defer和DOMContentLoaded不要重复用defer的脚本执行时机本来就在这个事件之前再包一层监听等于绕远路。另外async属性的脚本不保证顺序、也不保证在 DOM 解析完成后执行它适合完全独立的第三方脚本不适合操作 DOM 的业务代码。注意动态插入的节点不受DOMContentLoaded保护。事件已经触发过了你之后再插入节点那个监听不会重跑。动态内容必须用事件委托或者在新节点插入后单独绑定。4.2 静态集合与实时集合循环里的隐形杀手前面提过这个坑这里展开讲。假设你要给列表里所有卡片加一个 class写了这么一段const cards document.getElementsByClassName(card); // 实时集合 for (let i 0; i cards.length; i) { if (cards[i].dataset.done 1) { cards[i].remove(); // 删掉已完成的 } }这段代码的问题在于每次删除都会让后面的元素前移i继续自增就跳过了一个。如果连续三个都要删实际只会删掉两个。修法有两种倒序遍历或者转成数组// 修法一倒序删除不影响尚未访问的索引 for (let i cards.length - 1; i 0; i--) { if (cards[i].dataset.done 1) cards[i].remove(); } // 修法二转数组拿快照再遍历 Array.from(cards).forEach((card) { if (card.dataset.done 1) card.remove(); });我个人更常用第二种因为语义清楚不会因为循环方向问题纠结。而如果一开始就用querySelectorAll拿到的是静态集合循环中删节点完全不影响遍历这类 bug 根本不会出现。这也是我推荐querySelectorAll作为默认选择的原因之一——它的不变反而减少了一类错误。4.3 选择器写错的那几种典型姿势排除了时机和集合类型的问题接下来才是语法。我整理了几个高频错误配上了现象和修法。错误写法现象原因正确写法querySelector(.item .title)少写空格匹配范围过大或过小意为后代关系不是并列明确层级或改用逗号并集getElementsByClassName(a .b)返回空集合该方法不解析后代关系先取容器再取子元素querySelector(#1item)抛 SyntaxErrorid 不能以数字开头getElementById(1item)querySelector(.a:b)抛 SyntaxError冒号需转义CSS.escape或改用 id属性选择器漏引号抛 SyntaxError含特殊字符的属性值须加引号[data-keyv-1]querySelectorAll(.a, .b)[0]误以为按选择器排序结果顺序和预期不符返回文档顺序分别查询后合并关于特殊字符浏览器提供了一个标准工具CSS.escape把任意字符串安全地转成选择器可用的形式const rawId user:1001; const el document.querySelector(#${CSS.escape(rawId)}); // 等价于 document.getElementById(user:1001)但后者更快更省事遇到这种带特殊字符的 id我的建议直接是换用getElementById别折腾转义。选择器解析有成本还有出错风险而 id 查找既快又不解析没有理由不用。4.4 常见问题速查与我的排查顺序把上面的内容压成一张速查表遇到问题从上往下对。症状优先怀疑排查动作返回 null脚本执行时机打印document.readyState确认节点是否已存在返回空集合选择器层级写错用document.querySelectorAll在控制台单独验证一次拿到元素但事件不触发节点被替换或未绑定检查是否有innerHTML重写、是否用了委托循环漏处理元素实时集合加删除操作改倒序或转数组报 SyntaxError选择器含特殊字符打印选择器字符串检查引号和转义样式类名匹配不到class 带空格或大小写确认元素实际的 className在 iframe 里取不到跨文档边界通过iframe.contentDocument进入该文档作用域我自己的排查顺序是这样的第一步在控制台手动执行一遍选择器确认它能不能选中。这一步能排掉一大半语法问题。第二步如果手动能选中、代码里选不中那就是时机或者作用域问题检查脚本位置和查询用的根节点。第三步如果都正常但逻辑不对就怀疑集合类型和引用缓存看看是不是拿着旧快照在操作。第四步以上都排除了才去看是不是有别的代码把节点替换掉了——这种情况下你会看到元素被重新渲染但事件监听还挂在已经被丢弃的旧节点上表现为点了没反应但页面看起来正常。5. 边界场景那些让常规写法失效的地方前面讲的是常规页面里的 DOM 访问。真实项目里还会遇到几种边界情况按常规写法就是取不到这里单独拎出来说清楚。5.1 Shadow DOM 与 iframe两个独立作用域用上 Shadow DOM 之后document.querySelector是穿不过去的。影子根内部的节点对外部文档来说是隐藏的必须拿到宿主元素的shadowRoot再从它上面查询const host document.querySelector(my-widget); const shadow host?.shadowRoot; if (shadow) { const innerBtn shadow.querySelector(.inner-btn); innerBtn?.addEventListener(click, handleClick); }这里要注意闭包模式closed的影子根host.shadowRoot会返回null外部完全无法访问。碰到这种就只能靠组件自身暴露方法或者派发事件来通信这是设计上的限制不是 bug。另外影子根内部的event.target在跨越边界时会被重定向retargeting外部监听器看到的是宿主元素而不是内部的实际节点做事件委托时要用event.composedPath()来拿到真实路径。iframe 是另一回事。它有自己的文档对象父页面不能直接用document.querySelector进去捞得先拿到 iframe 元素再访问它的contentDocumentconst frame document.querySelector(#preview); frame.addEventListener(load, () { const doc frame.contentDocument; if (!doc) { console.warn(无法访问 iframe 文档可能受同源策略限制); return; } const title doc.querySelector(h1); console.log(title?.textContent); });必须等load事件之后再访问否则contentDocument可能还是个空文档。跨源的情况下访问会抛安全错误这时只能通过postMessage通信不要去尝试绕过。5.2 表单元素的快捷访问与批量读取前面提过form.elements这里补一个实际用法的例子。假设要一次性收集表单数据用传统写法要一个个querySelector用elements可以循环处理const form document.forms.loginForm; const data {}; Array.from(form.elements).forEach((field) { if (!field.name || field.disabled) return; if (field.type checkbox) { data[field.name] field.checked; } else if (field.type radio) { if (field.checked) data[field.name] field.value; } else { data[field.name] field.value; } });这段逻辑覆盖了最常见的控件类型disabled的字段会被跳过因为它们不会被提交收集进去反而干扰。用name作为键是因为服务端接收的字段名本来就是这个前后一致不需要额外映射。如果表单结构复杂还可以考虑new FormData(form)它会自动按提交规则收集比自己遍历更省事缺点是不带类型信息需要自己转换。form.elements这个集合支持按 name 直接索引所以常见的form.elements.username写法是合法的比按索引取可读性好得多。注意如果表单里有两个控件同名比如一组 radio按 name 取会返回一个RadioNodeList而不是单个元素取值方式和单个元素不同这是个容易踩的细节。5.3 我自己沉淀下来的几条习惯最后分享几条我在实际项目里固定下来的做法不成体系但每一条都是踩坑之后加的。凡是查询结果可能为空的地方一律加空值判断宁可多两行if (!el) return也不要在深层调用里被Cannot read properties of null打断。这类错误在压缩后的代码里报出来行号都对不上排查成本极高。凡是反复使用的元素一律在模块初始化时查一次存到变量里。组件被销毁时把变量置空避免持有已移除节点的引用造成内存泄漏。这一点在单页应用里尤其要注意路由切换后旧组件的引用如果还挂着垃圾回收就回收不掉。凡是给动态内容绑事件一律用委托把监听挂在稳定的父容器上。委托还有个附带好处新增节点不需要重新绑定代码里少一个渲染完记得重新绑的步骤就少一个忘记的机会。凡是选择器里出现变量拼接一律先想能不能不拼。能靠>