ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JavaScript核心技巧与DOM操作实战:从基础到性能优化

JavaScript核心技巧与DOM操作实战:从基础到性能优化 JavaScript 这门语言说简单也简单说复杂真的复杂。我从入行到现在十来年前端框架换了好几波从 jQuery 到 Angular、React、Vue一个比一个抽象但无论框架怎么变DOM 操作永远是前端绕不开的硬功夫。尤其是你想真正吃透框架源码、处理复杂交互、或者排查线上诡异问题的时候原生 JavaScript 和 DOM 的底子就决定了你能走多远。这篇文章不打算讲那些花哨的新特性我想把日常开发里最常用、也最容易踩坑的JavaScript 核心技巧和 DOM 操作实战一次性说透。包括类型判断、事件委托、节点增删改查、数据保留精度、报错排查思路再延伸到虚拟 DOM、Canvas、跨端交互这些高频词汇背后的原理。适合刚入门的前端新人打基础也适合写了两三年业务却很少深究原生 API 的同学查漏补缺。1. 先把核心概念捋清楚DOM 到底是什么为什么它这么重要很多人写了好几年前端被问到“DOM 是什么”还是会愣一下。简单说DOMDocument Object Model是浏览器把 HTML 文档解析成一棵节点树之后暴露给 JavaScript 的编程接口。没有 DOMJavaScript 就是个纯计算工具没法跟页面产生任何关系。1.1 DOM 不是 JavaScript 的一部分——浏览器 API 解析这是个非常常见的误解。JavaScript 语言本身只定义了语法、类型、运算、流程控制这些基础能力它没有任何操作网页的能力。你之所以能在浏览器里用document.getElementById、document.querySelector这些方法是因为浏览器在 JavaScript 引擎之外额外提供了一套Web APIDOM 就是其中最核心的一部分。打个生活化的比方JavaScript 像一个通用的“人的思维”DOM 是专门给这个思维配套的“手臂”。不管是在 Chrome、Firefox 还是 Safari 里JavaScript 这门语言的语法都一样但每个浏览器提供的 DOM API 细节可能略有差异。这也是为什么我们需要学习“DOM 操作兼容性”的原因——你在自己浏览器上跑得好好的代码换个环境就可能报错。1.2 从“节点树”说起文档对象模型到底是什么当你打开一个 HTML 页面浏览器会做两件事先把 HTML 文本解析成内存中的节点树结构再把节点渲染到屏幕上。DOM 节点树里的每个节点都对应 HTML 里的一个元素、一段文本、一条注释或者一个属性。几个核心节点类型你要心里有数节点类型对应内容常见获取方式元素节点HTML 标签如div、pdocument.querySelector系列文本节点标签内部文字内容element.childNodes中nodeType 3属性节点标签上的属性如class、idelement.attributes注释节点!-- 注释 --element.childNodes中nodeType 8我见过不少同学在遍历子节点的时候直接用element.children结果拿不到文本节点就找不着北。children返回的只是元素节点childNodes则包含所有类型的节点。如果你要处理文本内容、或者统计某个节点内部到底有多少内容节点时搞混这两个属性就会出问题。1.3 什么时候最容易在 DOM 上翻车结合我这些年的排错经验DOM 操作翻车主要集中在三种情况下第一种是节点未渲染完成就操作。脚本放在head里直接document.getElementById结果null这就是经典的“脚本执行时机”问题。第二种是对空节点直接操作内部属性。比如你查到一个元素不存在还在它身上继续调用.style、.innerHTML直接就抛TypeError。第三种是把 DOM 操作放在高频事件回调里。比如scroll、mousemove、resize一秒钟触发几十次每次都去直接改 DOM 结构页面卡成幻灯片性能就是这么被浪费掉的。熟悉这些踩坑点之后再往下学技巧你会更有针对性。2. JavaScript 判断数据类型一个老生常谈但总有人写错的面试题“如何准确判断 JavaScript 数据类型”几乎是我每年面试都必问的题目。因为它太能看出一个人对语言底层模型的理解程度了。2.1 typeof 的局限与陷阱最基础的类型判断方法就是typeof它的返回值有这些undefined、boolean、number、bigint、string、symbol、function、object。看着挺全但有两个著名坑点typeof null返回object。这是语言设计初期的历史遗留 bug但为了兼容性一直没修复必须靠 null额外判断。typeof []返回objecttypeof {}也返回object你根本分不清数组和对象。所以只靠typeof根本没法应对实际开发你需要更精确的手段。2.2 Object.prototype.toString.call() 才是兜底方案真正稳定可靠的方法是借助Object.prototype.toString它会返回类似[object Array]、[object Date]、[object RegExp]这样的格式字符串。注意一定要用.call()来调用因为很多对象可能重写了toString方法。function getType(value) { const typeStr Object.prototype.toString.call(value); return typeStr.slice(8, -1); } getType(null) // Null getType(undefined) // Undefined getType([]) // Array getType({}) // Object getType(new Date())// Date getType(/abc/) // RegExp用这个方法几乎可以精确识别所有内置对象类型这是我在多年开发里最常用的“兜底”方案也是实现jQuery.isArray、lodash.isPlainObject这类工具函数的核心原理。2.3 实战中的类型判断工具函数实际项目里我通常会封装一个一次性判断多种常见类型的工具函数function isType(value, type) { return Object.prototype.toString.call(value) [object ${type}]; } function isObject(value) { return value ! null typeof value object; } function isPlainObject(value) { if (!isObject(value)) return false; const proto Object.getPrototypeOf(value); return proto Object.prototype || proto null; }其中isPlainObject用来判断“是不是纯粹的对象字面量”特别有用。因为new Date()、new RegExp()也是typeof object但它们不是“纯对象”。在深拷贝、合并配置项这类场景里如果你没有区分纯对象和其他对象很容易把 Date 处理成空对象造成数据丢失。我记得有一次处理一个接口返回的复杂嵌套数据里面混着null、数组、字符串、Date字符串就是因为类型判断没处理干净导致深拷贝之后日期字段全变成了{}排查了半天。从那以后我写任何通用工具函数第一件事就是先把“准确判断数据类型”这件事做扎实。3. DOM 操作的实用核心技巧从选择到增删改查说完类型判断进入我们今天的重头戏DOM 操作。这部分我总结了日常开发里最高频的几个场景和对应技巧。3.1 选择元素的几种方式与选择器性能常用的选择元素 API 有这几种document.getElementById、document.getElementsByClassName、document.getElementsByTagName、document.querySelector、document.querySelectorAll。我个人的使用习惯是确定有id的用getElementById这是原生选择器里最快的。需要灵活选择器语法时用querySelector/querySelectorAll选择器支持所有 CSS 语法使用体验极好。尽量不要频繁用querySelectorAll去查找同一个静态节点可以缓存结果。一个比较影响性能的细节是querySelectorAll返回的是静态 NodeList而getElementsByClassName返回的是动态 HTMLCollection。所谓“动态”是指每次访问它都会实时查询当前文档状态。如果你在循环里反复操作 DOM同时又在用动态集合的长度做循环条件很容易出现长度实时变化导致的死循环或者遗漏。// 这种方式非常危险 const items document.getElementsByClassName(item); for (let i 0; i items.length; i) { // 如果在这个循环里删除了某个 itemitems.length 会变小可能跳过元素 }所以当你需要增删节点时我建议优先用querySelectorAll拿到静态列表或者先把长度缓存到一个变量里。3.2 创建、插入、删除节点createElement、insertBefore 等创建节点最基础的是document.createElement插入节点常用appendChild和insertBefore删除节点用removeChild或element.remove()。这几个 API 本身不难但有几个使用细节值得单独说说。appendChild只能把节点追加到父容器的末尾如果你要插到指定位置就得用insertBefore(newNode, referenceNode)。这里注意referenceNode必须是父容器的直接子节点如果是其他位置的节点会报错或者插到意想不到的位置。现代浏览器提供了更顺手的element.append()和element.prepend()它们支持一次性插入多个节点或字符串也更直观。不过在较老的项目里如果你需要兼容旧内核浏览器还是得回到appendChild那套老方法。删除节点的两种方式对比// 方式一通过父节点删除 parentNode.removeChild(childNode); // 方式二直接删除自身现代浏览器推荐 childNode.remove();remove()是后来新增的便捷方法语义清晰、代码更简洁不需要先parentNode跳转。唯一的问题是兼容性IE 不支持。如果项目必须兼容老浏览器就用removeChild否则我推荐直接用remove()。3.3 classList 操作类的正确姿势以前我们操作类名用的是element.className拼接字符串到处是active currentClass这种丑陋代码。现在完全可以用classListAPI 来解决问题。element.classList.add(active); element.classList.remove(active); element.classList.toggle(active); element.classList.contains(active);这里有个经验之谈同时给一个元素添加多个类名时classList.add可以一次传多个参数不需要循环调用// 一次添加多个类 element.classList.add(is-open, is-visible, is-loaded);还有一点classList是 DOMTokenList 对象它本身是类数组也支持forEach遍历但如果你需要把类名转换成普通数组来过滤可以这样做const classNames Array.from(element.classList);3.4 事件委托性能优化的第一课事件委托是基于“事件冒泡”机制的经典做法——当事件触发时先经过目标元素再逐级冒泡到祖先元素。利用这个特性我们可以把子元素的事件处理统一挂到父元素上。为什么要这么做因为两个原因第一减少事件监听器数量。假设列表有一千个li每个都单独绑定click监听器不仅占用内存绑定时还会消耗性能。委托之后只需要一个监听器。第二动态元素免绑定。如果你用 JavaScript 动态往列表里加了新的li传统做法需要重新给新元素绑定事件。委托方式下新元素自然会被父元素的事件监听器捕获压根不用额外处理。常见的委托写法const list document.getElementById(list); list.addEventListener(click, function (event) { const target event.target; if (target.tagName LI) { console.log(点击了列表项, target.textContent); } });这里要特别留意event.target不一定是LI因为事件目标可能是li内部的span或a比如lispan文字/span/li点击的时候event.target是span。所以更稳健的写法是使用closest方法向上查找list.addEventListener(click, function (event) { const item event.target.closest(li); if (item) { console.log(点击了列表项, item.textContent); } });closest()会从当前元素开始向上逐级匹配选择器直到找到匹配的元素或者达到根节点。这个 API 在做事件委托时几乎是必备的能省掉大量手写父级判断逻辑。3.5 样式操作的几个坑操作样式时最直观的方法是element.style.color red这种方式适合修改少量内联样式。但如果你想批量修改样式用cssText会更高效element.style.cssText color: red; font-size: 20px; background: #fff;;一次性替换整串内联样式不需要逐条赋值。不过要注意cssText会覆盖原有内联样式使用之前先确认你已知全量。另一个常见需求是读取最终生效的样式。element.style只能拿到内联样式拿不到样式表里定义的样式。这时候要用getComputedStyle(element)来读取最终计算后的样式值const styles window.getComputedStyle(element); console.log(styles.color); // rgb(255, 0, 0) console.log(styles.fontSize); // 16px注意返回的颜色值可能是rgb()格式而不是你写的#f00格式转换的时候别大惊小怪。4. JavaScript 常用细节保留小数、函数与易错点除了 DOM 操作本身热词里还有几个高频关键词也很值得展开“javascript保留两位小数”、“javascript函数”、“javascript运行时报错”。这些点看似简单但实战里细枝末节的坑特别多。4.1 保留两位小数toFixed 没有你想的那么简单我经常在代码评审里看到有人用Number(value.toFixed(2))来保留两位小数。这里有一个精度陷阱就是 JavaScript 浮点数的二进制表示问题。先看个经典例子console.log(0.1 0.2); // 0.30000000000000004这意味着如果你直接对一个浮点数做运算再toFixed(2)得到的结果可能在意料之外。(0.1 0.2).toFixed(2); // 0.30这里看起来没事但换一个例子(1.005).toFixed(2); // 在部分浏览器里返回 1.00而不是 1.01为什么因为1.005在二进制浮点精度下实际的存储值是1.004999999999999999...所以四舍五入到两位小数时就变成了1.00。如果要实现真正可靠的“四舍五入保留两位小数”更稳的方法是先把数字放大到整数再处理function roundToTwo(num) { return Math.round((num Number.EPSILON) * 100) / 100; }其中Number.EPSILON用来修正浮点误差让1.005这种情况能被正常进位。但要注意Number.EPSILON很微小对特别大的数也没有魔法效果。所以更严谨的商业场景建议用专门的库比如decimal.js或big.js。如果你只是做普通展示可以记住这个经验不要迷信toFixed涉及金额计算尤其需要谨慎。4.2 函数声明与函数表达式的差异关于 JavaScript 函数最常见的面试点是函数声明与函数表达式的区别。函数声明function sayHello() { console.log(Hello); }函数表达式const sayHello function () { console.log(Hello); };两者核心区别在于提升hoisting的机制函数声明会整体提升到当前作用域顶部所以你在声明之前调用也能正常执行函数表达式只会提升变量声明赋值过程保留在原位如果你在赋值之前调用就会报ReferenceError。看这个例子sayHello(); // 正常输出 Hello function sayHello() { console.log(Hello); } // 而下面这个会报错 sayHi(); // TypeError: sayHi is not a function const sayHi function () { console.log(Hi); };很多人会奇怪为什么sayHi报的是TypeError而不是ReferenceError因为const sayHi被提升了声明但是处于“暂时性死区”所以真正执行到那一行之前访问它会报“还没初始化”。这类报错平时遇到时顺着“提升 赋值时机”去排查往往很快。4.3 常见的运行时报错排查JavaScript 运行时报错种类繁多但我遇到的频率最高的几个Cannot read properties of null (reading xxx)最常见节点没找到或者数据没返回就访问了属性。Cannot read properties of undefined (reading xxx)对象本身是undefined多半是接口字段结构变了。xxx is not a function类型判断出错比如把 DOM 元素当成函数调用了。Unexpected token语法错误常见于手写多行字符串或对象字面量时漏了逗号括号。Maximum call stack size exceeded无限递归最常见的是构造函数里调用了自身。排查这些报错我有一套固定流程先看浏览器 console 里报错的前几行堆栈信息定位到具体文件和行号然后打开 Sources 面板打断点最后一步步看调用栈里的变量值。盲目加console.log打印多轮效率远低于直接在关键节点打断点观察。5. 进阶话题虚拟 DOM、diff 算法、Canvas 与跨端交互热词里还有几个偏进阶的关键词比如“虚拟dom和diff算法面试”“javascript canvas”“oc和javascript互相调用”。这些点对很多面试者来说是难点但对实际工作也很重要。5.1 虚拟 DOM 和 diff 算法到底在面试中怎么答“虚拟 DOM 是什么”我的理解是用 JavaScript 对象结构来模拟真实 DOM 树的结构因为操作纯 JS 对象比直接操作真实 DOM 节点便宜得多。框架通过对比新旧虚拟 DOM 的差异diff找出需要更新的最小范围再一次性更新真实 DOM。面试官问 diff 算法时真正想听到的其实是这几点为什么需要虚拟 DOM直接操作真实 DOM 的性能瓶颈是什么。diff 算法如何降低复杂度传统树 diff 复杂度 O(n^3)React 简化成 O(n)靠的是什么核心简化规则同层比较、key 复用、类型不同直接替换。React 的 diff 思路可以概括为三点只对同一层级的节点做比较如果两个节点类型不同直接替换组件和子树同类型节点通过key来识别复用减少无意义的重建。我个人的建议是如果你找工作要面框架类岗位不要只背概念最好能自己手写一个简略的 diff 函数。比如比较两个对象树差异、针对方括号路径做最小更新亲手写一遍就对“为什么 key 是重要的”有切肤之痛。5.2 Canvas 能做什么、从哪里入手Canvas 是 HTML5 提供的 2D/3D 绘图能力很多人一听就退怯但其实日常需求里 Canvas 最常见的几个场景是图表绘制、图片水印合成、刮刮卡、签名板、游戏开发还有简单的动画效果。入门 Canvas 的路径其实很固定const canvas document.getElementById(myCanvas); const ctx canvas.getContext(2d); ctx.fillStyle #4A90E2; ctx.fillRect(0, 0, 100, 100); ctx.beginPath(); ctx.arc(150, 50, 30, 0, Math.PI * 2); ctx.fill();核心就两步拿到canvas元素再调用getContext(2d)得到绘图上下文。之后你就把它想象成一支笔所有绘制动作都是在这个 2D 画布上描画路径、填充颜色、绘制文字或图片。我的经验是别一上来就追求复杂动画先实现三个小需求画矩形、画圆形、画文字。等你掌握坐标系统和路径组合方式后再去看图表库比如 ECharts 的自定义 series、或者自己实现一个简单的柱状图你会有一种“原来底层就是这些基础绘图命令排列组合”的顿悟。5.3 跨端互相调用从 JavaScript 到原生的思路热词里出现“oc和javascript互相调用”这属于前端跟原生 App 协作的场景。iOS 开发语言是 OC/SwiftAndroid 是 Java/Kotlin前端页面跑在 WebView 里时两边需要通信。常见的通信方案是注入 JavaScript 桥接对象。比如 iOS 的WKWebView支持通过WKScriptMessageHandler注册一个原生对象网页端则用window.webkit.messageHandlers.xxx.postMessage(data)触发原生方法。反向通信则使用evaluateJavaScript来执行网页里的 JavaScript 函数。如果你暂时不涉及跨端开发这个知识的价值在于它能帮你理解“JavaScript 是运行在宿主环境中的”这个底层观点——浏览器是宿主WebView 是宿主Node.js 也是宿主。不同的宿主暴露了不同的能力语言本身不变变化的只是周围的世界。5.4 屏蔽高负载 JavaScript 的思路遇到“屏蔽高负载javascript”这个热词其实大家在讨论怎么做长任务优化。高负载 JS 指的就是执行时间过长、阻塞渲染主线程的脚本典型表现是页面点击没反应、动画卡顿、滚动掉帧。常见的解决思路有三板斧第一拆分长任务。把一个大循环改成分片执行chunking每次执行一部分把剩余部分放到setTimeout或者requestIdleCallback里继续。第二节流和防抖。高频事件处理函数里不要直接做复杂操作先节流防抖减少执行频率。第三移出主线程。用Web Worker处理纯计算型任务。注意Worker里没有 DOM你要是想在主线程里更新 UI需要通过postMessage通信。我自己最常用的是第一招做大数据表格渲染时把一个几万条数据的循环分片处理保证页面不卡死。6. 用 jQuery 看 DOM 操作的心智模型从库到原生热词里有“jquery的dom操作”这说明很多老教材和老项目还在广泛使用 jQuery。虽然新项目基本不推荐再用 jQuery但理解它的封装思路对掌握原生 DOM 操作是很有帮助的。6.1 jQuery 为什么当年那么火jQuery 之所以火遍全世界核心原因就是统一了浏览器差异、封装了常用操作、语法极简。当年 IE 和标准浏览器之间的兼容性问题简直是前端噩梦jQuery 帮我们抹平了这些差异。举个例子jQuery 的$(selector)返回的不是一个普通 DOM 节点而是一个“类数组的 jQuery 对象”。它内部维护了匹配到的 DOM 节点列表同时挂载了丰富的原型方法比如.css()、.text()、.on()、.attr()、.addClass()。这种链式调用的设计当时给人一种“优雅到极致”的感觉。6.2 从 jQuery 到原生 DOM 的思路转换如果你现在在做老项目迁移把 jQuery 代码换成原生 JavaScript我建议按这套映射表来做事jQuery 常用方法原生替代方案$(selector)document.querySelectorAll(selector).addClass(x)element.classList.add(x).removeClass(x)element.classList.remove(x).hide()/.show()element.style.display none / .on(click, fn)element.addEventListener(click, fn).text()element.textContent.html()element.innerHTML注意安全性.val()inputElement.value转换过程中最需要注意的是jQuery 事件绑定内部已经做了事件委托换成原生监听后如果是动态元素你得手动使用事件委托或重新绑定。老项目迁原生我的实操建议是不要追求一次替换全部 API按模块逐步替换每替换完一块就做一轮回归测试。尤其注意那些依赖 jQuery 内部数据缓存、动画排队的功能替换成本往往比想象中大很多。7. 安全是本分DOM 型 XSS 与前端防护前端技术文章不提 XSS 是不完整的。热词里有“dom型xss”我就把这一块展开讲讲。安全问题用不到没啥感觉出事就是大事。7.1 什么是 DOM 型 XSSXSS跨站脚本攻击有三种常见类型存储型、反射型、DOM 型。其中 DOM 型 XSS 的特点是恶意脚本没有经过服务器完全在浏览器端由 JavaScript 修改 DOM 时触发。一个典型例子div idoutput/div script const name getUrlParam(name); // 从 URL 获取参数 document.getElementById(output).innerHTML name; // 直接把参数注入页面 /script如果攻击者构造了一个 URL?nameimg srcx onerroralert(1)当用户打开这个链接时innerHTML会把img标签解析进页面onerror事件就会执行恶意代码。这类攻击防不胜防的原因在于URL 参数看起来无害却可以驱动 DOM 操作变成攻击入口。很多前端工程师只关注接口安全忽略了自己写的那行innerHTML其实也是“输入点”。7.2 防止 DOM 型 XSS 的具体做法最直接的一条原则是能不用innerHTML就不用尽量使用textContent来设置文本内容。const output document.getElementById(output); output.textContent name; // 大于号、小于号会被当作文本显示不会解析成标签如果你实在需要插入 HTML 结构比如富文本编辑器里的内容必须对内容做严格的校验和转义过滤。常用的规避手段是使用白名单策略只允许b、i、a[href]这类安全标签去掉script、iframe、onerror等危险属性。另一个非常隐蔽的入口是element.setAttribute。比如把 URL 参数直接设置到a标签的href上攻击者可以构造javascript:alert(1)这样的伪协议。所以任何从不可信来源拿到的字符串都要经过白名单校验协议头只允许http:、https:、mailto:、tel:之类安全协议。我的习惯是任何从外部进入页面的字符串默认全部当不可信数据。渲染时用textContent属性赋值时白名单校验需要在>const users [{ name: 小明, age: 18 }, { name: 小红, age: 20 }]; console.table(users);console.dir适合打印 DOM 节点展开显示该节点的属性和方法便于查看事件监听器、样式计算等详细信息。console.time/console.timeEnd可以用来标记两段代码之间的执行耗时console.time(渲染耗时); // 模拟渲染代码 for (let i 0; i 10000; i) {} console.timeEnd(渲染耗时); // 输出 渲染耗时: 0.3125ms还有个很好用的能力是debugger关键字。在代码里写一行debugger;代码运行到这一行时会自动暂停浏览器打开开发者工具就能进入断点状态检查当前作用域里的所有变量。实际排错时比反复打印 log 细致得多。8.2 运行时错误的排查流程遇到报错别慌按下面这套流程来第一步看堆栈信息。浏览器控制台会显示报错的文件路径和行号第一行不一定是最底层原因但通常是最直接的线索。第二步复现问题。如果报错只在特定交互里出现先把操作步骤理清楚在可复现的环境里修 bug 的成功率远高于脑补。第三步打断点看变量。在可疑代码处加debugger或者直接在 Sources 面板里点击行号打断点。观察关键变量是什么值、在什么位置变为异常。第四步回头看数据。很多报错的根源不在代码逻辑而是接口返回的数据结构和预期不符。加一层边界判断或者对返回数据结构做个console.table往往能快速定位到是哪一层数据没有处理好。结语写这篇东西的时候我自己也重新梳理了一遍这些年踩过的坑。JavaScript 和 DOM 的知识点确实琐碎但它就像一个程序员的基本功你可以不天天练但不能说不会。遇到类型判断不准确、DOM 查询结果跟预期不符、浮点精度出问题、运行时意外报错能快速定位到原因靠的就是平时这些细枝末节的积累。最后再分享一点个人经验学前端最忌“只看不写”。DOM 操作和类型判断这类技巧你光在文章里读懂没有用真正在浏览器里动手敲一遍、故意写错一次、再调试恢复印象才最深。如果你能把文章里这几个常见的报错场景自己复现一遍再顺手封装一个自己的 DOM 工具函数库那这些知识才算真正是你的了。
RELATED READING

延伸阅读

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