DOM 操作与优化
DOM 操作与优化
DOM (Document Object Model) 是浏览器对 HTML 文档的结构化表示,DOM 操作是前端开发中最常见的操作之一,也是性能瓶颈的主要来源。可以把 DOM 想象成一棵不断被"翻修"的活体建筑:每次改动都不是免费的——墙体挪一寸,整栋楼可能都要重新丈量(重排),墙面刷个新颜色至少也要重新粉刷一遍(重绘)。理解这套"施工成本"模型,是写出流畅前端界面的地基。
本篇会从 DOM 树结构讲起,逐层深入重排/重绘、强制同步布局、批量操作、事件机制、虚拟 DOM,再扩展到现代浏览器提供的一整套"减负"工具:IntersectionObserver、requestIdleCallback、Web Worker、MutationObserver,以及工程中真正落地的虚拟列表方案。每个主题都配代码示例、真实数据和常见坑,尽量做到"看完就能用"。
🌳 DOM 树结构
什么是 DOM 树: 浏览器解析 HTML 后,会在内存里生成一棵由节点(Node)组成的树形结构,这棵树就是 DOM。JavaScript 操作页面内容、结构、样式,本质上都是在操作这棵树。可以类比公司的组织架构图:CEO(document)在最顶层,往下是各部门(元素节点),部门里有具体的人(文本节点),架构图上还可能贴着便签(注释节点)。
DOM 节点类型:
节点类型对照表:
| nodeType 值 | 节点类型 | 常见场景 |
|---|---|---|
| 1 | Element(元素节点) | ` | 3 | Text(文本节点) | 元素内的纯文本 | | 8 | Comment(注释节点) | `` | | 9 | Document(文档节点) | `document` 本身 | | 11 | DocumentFragment(文档片段) | `document.createDocumentFragment()` | DOM 树遍历: DOM 操作 API: 代码示例:遍历 DOM 树的几种写法 性能瓶颈: 为什么 DOM 比 JS 对象慢: DOM 节点不是普通的 JS 对象,它同时挂靠着渲染引擎(负责布局、绘制、合成)里的一整套内部数据结构。访问一个 DOM 属性,背后可能触发 C++ 层的布局计算;而操作一个普通 JS 对象只是内存里的读写。这就是为什么"能用 JS 变量运算就不要来回读 DOM"是一条铁律。 渲染流水线简图: 重排与重绘: 操作成本: 重排 vs 重绘开销对比(示意): | 操作类型 | 触发阶段 | 相对开销 | 典型场景 | |---|---|---|---| | 改变 transform / opacity | 仅合成(Composite) | 最低 | CSS 动画、滑入滑出 | | 改变 color / background-color | Paint | 低 | hover 变色 | | 改变 width / height / margin | Layout + Paint + Composite | 高 | 展开收起、resize | | 增删 DOM 节点 | Layout + Paint + Composite | 高,且影响祖先/兄弟节点 | 列表增删 | | 读取 offsetTop 等布局属性 | 强制同步 Layout | 视上下文而定,可能极高 | 读写交替代码 | 触发重排的常见属性一览: | 类别 | 常见属性/方法 | |---|---| | 尺寸 | width, height, padding, margin, border | | 位置 | top, left, position, float | | 几何查询 | offsetTop/Left/Width/Height, clientTop/Left/Width/Height, scrollTop/Left/Width/Height | | 计算样式 | getComputedStyle()、getBoundingClientRect() | | DOM 结构 | appendChild, removeChild, insertBefore | 概念: Layout Thrashing(布局抖动,也叫强制同步布局 Forced Synchronous Layout)指的是:在同一帧里"写"完样式后立刻去"读"布局属性,导致浏览器不得不打断本该异步、批量执行的布局计算,被迫立刻同步计算一次布局才能把最新的值返回给你。如果这种"写后读"在循环里反复出现,浏览器就会被迫反复布局,性能断崖式下跌。 为什么重要: 浏览器本来的优化策略是把多次样式修改攒起来,在一帧结束时统一计算一次布局(批处理)。但只要你的代码里出现"改样式 → 读 offsetHeight 之类的属性 → 又改样式 → 又读",浏览器为了保证你读到的数字是准确的,只能放弃批处理,退化成同步、逐次计算,等价于把"一次批量作业"拆成了"逐条串行作业"。 原理: 浏览器内部维护了一个"待计算的布局脏标记"。只要你只写不读,脏标记会一直累积,直到一帧结束统一算一次。但一旦你在写入之后读取一个依赖布局的属性,浏览器必须清空脏标记、立刻算一次布局,才能给出正确答案——这个"清空"动作就是强制同步布局。 反例代码(layout thrashing): 修复思路(类似 FastDOM:先读后写,分离两阶段): 真实案例: 早期很多"响应式表格自适应列宽"的实现会在 resize 事件里对每一行都做"读取当前宽度 → 计算 → 写入新宽度",行数一多(几百行)页面就会明显卡顿,用 Chrome DevTools Performance 面板录制会看到一长串紫色的 "Layout" 记录,标注着 "Forced reflow"。改成先统一读、再统一写之后,同样的表格从几百毫秒的阻塞降到几十毫秒。 参考数据(1000 个元素,读写交替 vs 读写分离): | 方案 | 强制同步布局次数 | 大致耗时 | |---|---|---| | 读写交替(反例) | 约 1000 次 | 数百毫秒级 | | 读写分离(先读后写) | 0~1 次 | 几毫秒到十几毫秒级 | 常见坑: 最佳实践: 小结: Layout Thrashing 是"读写交替"造成的隐性性能杀手,肉眼很难从代码逻辑上发现,必须靠 Performance 面板或规范约束(读写分离)来规避。 概念: DocumentFragment 是一个"轻量级、脱离主文档"的容器节点。它不属于当前渲染的 DOM 树,往里面追加多少节点都不会触发页面的重排重绘;只有当你把整个 fragment 一次性 append 到真实 DOM 里时,才会触发一次性的重排重绘。可以把它想象成"装修时先在车库里把家具组装好,一次性搬进客厅",而不是"搬一件进客厅装一件"。 为什么重要: 逐个 appendChild 到已经挂载的 DOM 上,每一次都可能触发浏览器的布局与绘制流程(具体是否每次都同步执行取决于浏览器实现,但至少会累积大量的脏标记和潜在重排开销);数量一大,性能差异非常明显。 批量操作: 代码示例:逐个 append vs DocumentFragment 更进一步:用字符串拼接 + innerHTML(超大批量场景) 参考数据(1000 个 ` | 方案 | 相对耗时(数量级) | 说明 | |---|---|---| | 逐个 appendChild 到真实 DOM | 高(约十倍量级) | 每次都可能触发布局脏标记累积甚至同步布局 | | DocumentFragment 一次性插入 | 低(基准值) | 只有最后一次插入会触发布局 | | innerHTML 字符串拼接一次性赋值 | 与 DocumentFragment 接近或更低 | 依赖浏览器 HTML 解析器优化,但会丢失已绑定的事件监听器需重新绑定 | 真实案例: 一个后台管理系统的表格组件在切换分页时曾经用"clear innerHTML → for 循环 appendChild 500 行"的方式渲染,每次翻页有明显的白屏卡顿;改用 DocumentFragment 批量构建后,翻页耗时从约 300ms 降到 40ms 以内。 常见坑: 最佳实践: 小结: DocumentFragment 的核心价值就是把"N 次可能触发布局的操作"压缩成"1 次",是批量渲染场景下最基础也最有效的优化手段。 缓存 DOM 引用: 避免频繁读取布局属性: 概念:读写分离(Read/Write Batching): 这是 Layout Thrashing 的系统化解法:把一帧内所有需要"读"布局的操作放进一个队列,所有需要"写"布局的操作放进另一个队列,配合 `requestAnimationFrame` 在浏览器下一次绘制之前统一执行——读的时候不会被写污染,写的时候浏览器可以批量合并。 代码示例:用 requestAnimationFrame 做读写批处理 缓存 DOM 引用示例: 常见坑: 最佳实践: 小结: 缓存引用解决的是"重复查询"的浪费,读写分离解决的是"读写交替"的浪费,二者常常配合使用。 事件流: 图示: 事件委托: 代码示例:完整的事件委托实现(含元素查找与数据读取) 参考数据(1000 个可点击子元素): | 方案 | 事件监听器数量 | 内存/绑定开销 | 支持动态元素 | |---|---|---|---| | 逐个绑定 | 1000 | 高 | 否,需手动重新绑定 | | 事件委托(绑定到父元素) | 1 | 低 | 是,天然支持 | 事件处理优化: 代码示例:passive 监听器提升滚动流畅度 常见坑: 最佳实践: 小结: 事件委托是"少绑定、多复用"的思想在事件系统里的体现,配合事件流的捕获/冒泡机制,可以用一个监听器覆盖成百上千个子元素的交互需求。 虚拟 DOM 概念: 一个极简的虚拟节点结构长什么样: 虚拟 DOM 工作原理: Diff 算法: 代码示例:一个极简 Diff 逻辑(帮助理解原理,非生产实现) 为什么 key 很重要:没有 key vs 有 key 的列表更新 真实案例: React 官方文档长期强调"不要用数组下标作为 key",正是因为一旦列表发生排序、插入、删除,index 作为 key 会让 Diff 算法产生大量本不必要的更新,甚至导致表单输入框内容错位(因为 DOM 节点被错误复用)等诡异 bug。 常见坑: 最佳实践: 小结: 虚拟 DOM 用一次内存中的对象比较(Diff),换来对真实 DOM 的最小化、批量化更新,是"以计算换 IO"的经典优化思路。 选择器 API: 性能比较: 为什么选择器匹配是"从右向左": 浏览器解析 CSS 选择器 `.list li a` 时,是先找出所有 ``,再逐个往上判断祖先里是否有 `li`、再判断更上层是否有 `.list`,而不是反过来。这意味着最右边(关键选择器)越具体、命中范围越小,整体匹配就越快;反之如果最右边是 `*` 或标签名很泛的选择器,浏览器要过滤的候选集合就非常大。 代码示例:几种常见查询方式 参考排序(大量 DOM 节点、重复调用场景下的相对速度,数量级示意): | API | 返回值类型 | 是否实时 | 相对速度 | |---|---|---|---| | getElementById | Element \| null | 否(返回单个引用) | 最快 | | getElementsByClassName / getElementsByTagName | HTMLCollection | 是(实时) | 较快 | | querySelector | Element \| null | 否 | 中等,取决于选择器复杂度 | | querySelectorAll | NodeList | 否(静态快照) | 中等偏慢,选择器越复杂越慢 | 常见坑: 最佳实践: 小结: 选择器性能优化的核心是"减少浏览器为你查找元素所做的无谓工作",具体到实践就是"能用 id 就用 id,能缓存就缓存,避免在实时集合上做增删遍历"。 操作 API: 三者对照表: | API | 是否解析 HTML 标签 | 是否触发布局计算 | XSS 风险 | 适用场景 | |---|---|---|---|---| | innerHTML | 是 | 依内容而定 | 有(用户输入需转义) | 需要插入结构化 HTML 片段 | | textContent | 否(原样当纯文本) | 否 | 无 | 只需展示纯文本 | | innerText | 否 | 是(需计算可见性/布局) | 无 | 需要考虑 CSS 可见性的可见文本 | 代码示例: 常见坑: 最佳实践: 小结: 三个 API 看似相似,实则在安全性和性能上差异明显,选型要结合"内容是否可信"和"是否需要保留可见性语义"两个维度判断。 概念: IntersectionObserver 是浏览器原生提供的一套"元素是否进入可视区域"的观察者 API。在它出现之前,判断一个元素是否可见通常要在 scroll 事件里手动调用 `getBoundingClientRect()` 做几何计算——这正是典型的强制同步布局高发区。IntersectionObserver 把这套计算下沉到浏览器引擎内部异步完成,不会阻塞主线程,也不会每次滚动都强制同步布局。 为什么重要: 图片懒加载、无限滚动加载更多、曝光埋点(统计用户到底看没看到某个广告位)都需要"元素进没进入视口"这个信号。用 scroll 事件手写监听,不仅要自己做节流,还要在每次判断时读取布局属性,属于"低效且容易踩坑"的旧方案。 代码示例:图片懒加载 代码示例:无限滚动加载更多 代码示例:曝光埋点 IntersectionObserver vs scroll 事件手写方案对比: | 维度 | scroll 事件 + getBoundingClientRect | IntersectionObserver | |---|---|---| | 是否阻塞主线程 | 是,回调在主线程同步执行 | 否,浏览器内部异步计算,回调批量触发 | | 是否强制同步布局 | 容易触发(读取几何属性) | 不会 | | 需要手动节流/防抖 | 需要 | 不需要 | | 跨 iframe / 复杂裁剪场景 | 需要手写复杂逻辑 | 原生支持 root 参数 | | 兼容性 | 很好(无需新特性) | 现代浏览器广泛支持,老旧浏览器需 polyfill | 常见坑: 最佳实践: 小结: IntersectionObserver 把"元素是否可见"这个高频且容易踩坑的几何判断,交给浏览器引擎异步处理,是现代前端懒加载、埋点、无限滚动的标准方案。 概念: 当一个列表有几千甚至几万条数据时,如果一次性把所有条目都渲染成真实 DOM 节点,浏览器需要维护的节点数量、布局计算量都会暴涨,页面会变得极其卡顿,滚动也会掉帧。虚拟列表(Virtual List / Windowing)的核心思路是:屏幕上能看到多少条,就只渲染多少条(再加一点缓冲),随着用户滚动,动态替换正在渲染的那一小撮 DOM 节点内容,而不是渲染全部数据。 为什么重要: 假设一屏能显示 20 条数据,列表总共有 10000 条。传统做法要创建 10000 个真实 DOM 节点,其中 9980 个用户根本看不见;虚拟列表只需要维护 20~30 个真实节点,其余数据只存在于 JS 数组里,等滚动到时才"复用"这些节点去显示新内容。 原理: 用一个足够高的"占位容器"撑开总的可滚动高度(让原生滚动条表现正常),再用一个"可视窗口"根据当前滚动位置计算出应该显示第几条到第几条数据,把这部分数据渲染到实际可见区域,其余数据不渲染。 代码示例:定高虚拟列表的最小实现 参考数据(10000 条数据,量级示意): | 方案 | 实际渲染的 DOM 节点数 | 首次渲染 | 滚动流畅度 | |---|---|---|---| | 全量渲染 | 10000 | 明显卡顿甚至长时间白屏 | 掉帧严重 | | 虚拟列表(可视区约 20 条 + 缓冲) | 约 20~40 | 几乎瞬时 | 流畅 | 真实案例: 大型在线表格、IM 聊天记录、股票行情长列表等场景普遍采用虚拟列表方案(如 react-window、react-virtualized、vue-virtual-scroller)。类似"无限滚动的新闻流"如果不做虚拟化,滚动几分钟后 DOM 节点数会膨胀到数万个,内存占用和滚动帧率都会明显恶化;引入虚拟列表后,无论滚动多久,DOM 节点数始终维持在几十个的量级。 常见坑: 最佳实践: 小结: 虚拟列表是"用视口大小反推渲染范围"的思路,把 DOM 节点数量和可视区域大小挂钩,而不是和数据总量挂钩,是长列表性能优化的标准答案。 概念: `requestIdleCallback` 是浏览器提供的一个 API,让你把"不那么紧急"的任务安排到浏览器完成了本帧的渲染、布局、绘制等关键工作之后的"空闲时间"里执行,从而不阻塞用户交互和动画的流畅度。 为什么重要: 如果要一次性处理一个非常大的数据集(比如渲染 5000 条列表项、或者做一次大规模的数据统计),全部塞进一个同步任务会长时间占用主线程,期间用户点击、滚动都得不到响应(长任务)。requestIdleCallback 可以把这类任务拆成很多小块,见缝插针地在浏览器空闲时执行。 代码示例:分片渲染大列表 代码示例:不支持 requestIdleCallback 时的降级方案 requestIdleCallback vs setTimeout(fn, 0) vs requestAnimationFrame 对比: | API | 触发时机 | 适用场景 | |---|---|---| | requestAnimationFrame | 浏览器下一次重绘之前 | 动画、需要读写布局的批处理 | | setTimeout(fn, 0) | 宏任务队列,尽快但不保证空闲 | 简单地把任务挪到下一轮事件循环 | | requestIdleCallback | 浏览器判定为"空闲"的时间段 | 低优先级、可拆分的后台任务(统计、预加载、分片渲染) | 常见坑: 最佳实践: 小结: requestIdleCallback 把"不紧急的活"安排在浏览器喘气的间隙里干,用户交互体验因此不受影响,是长任务拆分的重要工具之一。 概念: JavaScript 默认是单线程执行的,主线程既要跑业务逻辑,也要处理渲染、布局、用户交互。如果有一段计算量很大的任务(比如解析一个几万行的 CSV、做复杂的数据聚合、图像像素处理),放在主线程会直接卡住页面,产生"假死"的感觉。Web Worker 允许你在独立的后台线程里执行这些计算,不阻塞主线程,处理完通过消息通信把结果传回来。 为什么重要: 和前面几种优化手段的思路不同,Worker 不是"减少 DOM 操作",而是"把和 DOM 无关的重计算彻底移出主线程"。Worker 里没有 DOM 访问能力,专注于纯计算,计算完成后主线程只需要拿到结果去做少量、轻量的 DOM 更新。 代码示例:用 Worker 处理大数据排序与聚合 Worker vs requestIdleCallback 的分工: | 维度 | requestIdleCallback | Web Worker | |---|---|---| | 运行线程 | 仍在主线程,只是延后到空闲期 | 独立的后台线程 | | 是否能访问 DOM | 能(但仍会占用主线程) | 不能,只能通过消息与主线程通信 | | 适合的任务量级 | 中小型、可拆分的任务 | 计算密集、耗时较长的重任务 | | 是否会阻塞用户交互 | 单次分片仍需控制在几毫秒内,否则也会有感知 | 完全不阻塞主线程交互 | 常见坑: 最佳实践: 小结: Web Worker 解决的是"主线程被计算占满导致页面假死"的问题,与前面 DOM 层面的优化互补,二者结合可以覆盖"渲染"和"计算"两条主线程压力来源。 概念: MutationObserver 是浏览器提供的用于监听 DOM 树变化(节点增删、属性变化、文本变化)的 API,取代了性能极差、已被废弃的 `Mutation Events`。它的回调是异步、批量触发的:短时间内的多次 DOM 变更会被合并成一批,一次性通知给你,而不是每次变更都单独触发一次回调。 为什么重要: 有些场景你无法控制第三方脚本或者其他模块什么时候修改了 DOM(比如某个第三方广告脚本插入了新节点,你需要重新初始化里面的交互逻辑),这时候需要一种"被动感知 DOM 变化"的机制,而不是不断轮询查询 DOM。 代码示例:监听子节点增删 代码示例:监听属性变化(如第三方脚本切换了 class) MutationObserver vs 轮询查询对比: | 维度 | setInterval 轮询查询 DOM | MutationObserver | |---|---|---| | 是否有固定延迟 | 有(取决于轮询间隔) | 变化发生后尽快异步通知,批量合并 | | 空闲时是否消耗资源 | 持续消耗(无论 DOM 是否变化都在查询) | 不消耗,只有真正发生变化才触发 | | 精确度 | 可能错过两次轮询之间的短暂变化 | 能捕获到所有匹配条件的变化记录 | 常见坑: 最佳实践: 小结: MutationObserver 适合"你管不到但又要感知"的 DOM 变化场景,异步批量的设计避免了旧版 Mutation Events 同步触发导致的性能问题。 概念: 防抖和节流都是"控制函数调用频率"的手段,常用于 scroll、resize、input、mousemove 等高频触发的事件。防抖是"停下来一段时间后才执行一次"(如用户停止输入 300ms 后才发起搜索请求);节流是"无论触发多频繁,固定间隔最多执行一次"(如滚动过程中每 100ms 最多更新一次进度条)。 代码示例:防抖实现与应用 代码示例:节流实现与应用 防抖 vs 节流对比: | 维度 | 防抖(Debounce) | 节流(Throttle) | |---|---|---| | 触发时机 | 停止触发一段时间后才执行一次 | 固定间隔内最多执行一次 | | 典型场景 | 搜索联想、表单校验、resize 结束后重排布局 | 滚动进度、拖拽跟随、高频按钮防连点 | | 极端情况 | 如果持续高频触发,函数可能长期不被执行 | 一定会按固定频率执行,不会无限期delay | 常见坑: 最佳实践: 小结: 防抖和节流是控制"事件触发频率"与"实际执行频率"之间关系的两种基本手段,选型取决于业务是否需要"持续反馈"。 概念: 浏览器原生的 `Performance` 对象提供了 `mark` 和 `measure` 方法,可以在代码里手动打点,记录某一段逻辑从开始到结束经过了多长时间,这些数据还会出现在 Chrome DevTools 的 Performance 面板时间线上,方便可视化分析。 代码示例:用 mark/measure 给关键渲染流程打点 代码示例:结合 PerformanceObserver 持续监听长任务 常见性能指标一览: | 指标 | 含义 | 相关 API | |---|---|---| | FP / FCP | 首次绘制 / 首次内容绘制 | PerformanceObserver(paint) | | LCP | 最大内容绘制,衡量主要内容加载速度 | PerformanceObserver(largest-contentful-paint) | | Long Task | 超过 50ms、阻塞主线程的任务 | PerformanceObserver(longtask) | | 自定义业务耗时 | 如某个组件渲染耗时、某个交互响应耗时 | performance.mark / measure | 常见坑: 最佳实践: 小结: Performance API 让性能优化从"凭感觉猜"变成"有数据支撑",是持续性能治理不可缺少的基础设施。 大型应用 DOM 优化: 实施效果(方向性总结,具体数值以实际项目度量为准): DOM 操作: 样式操作: 事件处理: 长列表与大数据: 性能监控: DOM 优化本质上是在和"浏览器渲染流水线"打交道:能不碰真实 DOM 就不碰(缓存、复用),必须碰就尽量攒在一起碰(批量、DocumentFragment、读写分离),高频场景就换个更轻量的机制(事件委托、IntersectionObserver、虚拟列表),实在绕不开的重计算就挪到别的线程(Web Worker、requestIdleCallback)。掌握这套分层思路,遇到任何"页面卡顿"问题时都能顺藤摸瓜找到症结所在。 核心技术一览表: | 技术/方案 | 解决的核心问题 | 一句话记忆点 | |---|---|---| | DocumentFragment | 批量插入节点触发多次重排 | 先在内存里搭好,再一次性搬进 DOM | | 读写分离 / rAF 批处理 | Layout Thrashing(强制同步布局) | 先集中读,再集中写,别掺着来 | | 事件委托 | 大量子元素各自绑定监听器 | 绑一次到父级,靠冒泡分辨来源 | | 虚拟 DOM + Diff | 频繁、细碎的手动 DOM 更新 | 内存里比对,DOM 上只改差异部分 | | 选择器优化 | 查询 DOM 的开销 | 能用 id 就别用复杂选择器 | | textContent | innerHTML 的安全和性能问题 | 纯文本场景无脑用它 | | IntersectionObserver | 手写可见性判断导致的强制同步布局 | 可见性判断交给浏览器异步算 | | 虚拟列表 | 长列表 DOM 节点数量爆炸 | 只渲染看得见的那一小段 | | requestIdleCallback | 非关键任务占用主线程 | 见缝插针,空闲再干 | | Web Worker | 重计算阻塞主线程 | 计算挪去后台,主线程只管渲染 | | MutationObserver | 被动感知外部 DOM 变化 | 变化批量异步通知,不用轮询 | | 防抖/节流 | 高频事件回调执行过多 | 停一停再执行,或按固定节奏执行 | | Performance mark/measure | 优化"靠猜"而非靠数据 | 打点量化,才知道优化有没有效果 | 开发工具: 性能分析工具: 常用社区库: 学习资源:` |
const list = document.getElementById('list');
// childNodes 会包含文本节点(如换行、空格),容易踩坑
console.log(list.childNodes.length); // 可能比想象的多
// children 只返回元素节点,通常是我们想要的
console.log(list.children.length);
// 递归遍历所有元素节点
function walk(node, depth = 0) {
console.log('-'.repeat(depth), node.tagName);
for (const child of node.children) {
walk(child, depth + 1);
}
}
walk(document.body);⚡ DOM 操作性能瓶颈
JavaScript → Style(计算样式)→ Layout(布局/重排)→ Paint(绘制/重绘)→ Composite(合成)
↑______________________________强制同步布局会在这里插队______________________|🔁 重排(Reflow)与重绘(Repaint)
💥 Layout Thrashing(强制同步布局)
// ❌ 反例:读写交替,每次循环都强制同步布局
function badResizeBoxes(boxes) {
for (let i = 0; i < boxes.length; i++) {
// 写:修改宽度
boxes[i].style.width = boxes[i].offsetWidth + 10 + 'px';
// 读:offsetWidth 依赖布局,强制浏览器立刻重新计算
console.log(boxes[i].offsetHeight);
}
// 100 个元素 = 100 次强制同步布局,非常慢
}// ✅ 正确写法:先集中读,再集中写,读写彻底分离
function goodResizeBoxes(boxes) {
// 第一阶段:只读,读的时候样式还没被本轮代码修改过,浏览器可以用缓存值
const widths = boxes.map(box => box.offsetWidth);
// 第二阶段:只写,写操作会被浏览器自动合并,一帧只触发一次布局
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px';
});
}
// 简化版 FastDOM 调度器:把读操作和写操作分别排队,统一在下一帧执行
const fastDomLike = {
reads: [],
writes: [],
scheduled: false,
measure(fn) {
this.reads.push(fn);
this.schedule();
},
mutate(fn) {
this.writes.push(fn);
this.schedule();
},
schedule() {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
const reads = this.reads.splice(0);
const writes = this.writes.splice(0);
reads.forEach(fn => fn()); // 先统一读
writes.forEach(fn => fn()); // 再统一写
this.scheduled = false;
});
}
};
// 使用方式
boxes.forEach(box => {
fastDomLike.measure(() => {
const width = box.offsetWidth;
fastDomLike.mutate(() => {
box.style.width = width + 10 + 'px';
});
});
});🚀 批量操作与 DocumentFragment
// ❌ 不好的做法:循环里直接操作已挂载的真实 DOM
function slowAppend(count) {
const container = document.getElementById('list');
const start = performance.now();
for (let i = 0; i < count; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
container.appendChild(li); // 每次都在真实 DOM 上操作
}
console.log('slowAppend耗时:', performance.now() - start, 'ms');
}
// ✅ 好的做法:先在 DocumentFragment 里构建,最后一次性插入
function fastAppend(count) {
const container = document.getElementById('list');
const fragment = document.createDocumentFragment();
const start = performance.now();
for (let i = 0; i < count; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // 只操作内存中的 fragment
}
container.appendChild(fragment); // 只触发一次重排
console.log('fastAppend耗时:', performance.now() - start, 'ms');
}// 当数据量非常大(如几千条)且不需要为每个节点单独绑定事件时,
// 拼接 HTML 字符串一次性赋值往往比逐个 createElement 更快
function fastestAppend(items) {
const container = document.getElementById('list');
const html = items.map(item => `<li data-id="${item.id}">${item.name}</li>`).join('');
container.innerHTML = html; // 一次性解析并插入
}📌 缓存 DOM 引用与读写分离
// 一个简易的读写调度器:收集本帧内所有的测量(read)和变更(write)任务
class DomScheduler {
constructor() {
this.reads = [];
this.writes = [];
this.pending = false;
}
read(fn) {
this.reads.push(fn);
this._schedule();
}
write(fn) {
this.writes.push(fn);
this._schedule();
}
_schedule() {
if (this.pending) return;
this.pending = true;
requestAnimationFrame(() => this._flush());
}
_flush() {
const reads = this.reads;
const writes = this.writes;
this.reads = [];
this.writes = [];
this.pending = false;
// 关键:先把所有的读操作跑完,此时 DOM 还未被写操作污染
const readResults = reads.map(fn => fn());
// 再把所有写操作跑完,浏览器会把这些写合并为一次布局
writes.forEach((fn, i) => fn(readResults[i]));
}
}
const scheduler = new DomScheduler();
// 使用示例:批量测量一组卡片高度,再统一设置到另一组元素上
const cards = document.querySelectorAll('.card');
const badges = document.querySelectorAll('.badge');
cards.forEach((card, i) => {
scheduler.read(() => card.offsetHeight);
});
badges.forEach((badge, i) => {
scheduler.write((height) => {
badge.style.top = height + 'px';
});
});// ❌ 不好的做法:重复查询DOM
function updateElement() {
document.getElementById('myElement').style.color = 'red';
document.getElementById('myElement').style.fontSize = '16px';
document.getElementById('myElement').style.backgroundColor = 'blue';
}
// ✅ 好的做法:缓存DOM引用
const element = document.getElementById('myElement');
function updateElementOptimized() {
element.style.color = 'red';
element.style.fontSize = '16px';
element.style.backgroundColor = 'blue';
}🎯 事件流三阶段与事件委托
document
│ 捕获 ↓
html
│ 捕获 ↓
body
│ 捕获 ↓
ul.list
│ 捕获 ↓
li (目标阶段:事件在这里触发)
│ 冒泡 ↑
ul.list
│ 冒泡 ↑
body
│ 冒泡 ↑
html
│ 冒泡 ↑
document// ❌ 不好的做法:为每个按钮添加事件监听器
function bindEachButton() {
const buttons = document.querySelectorAll('.item-button');
buttons.forEach(button => {
button.addEventListener('click', function () {
console.log('按钮被点击:', this.dataset.id);
});
});
// 1000 个按钮 = 1000 个监听器,动态新增的按钮还需要重新绑定
}
// ✅ 好的做法:使用事件委托,只绑定一次
function bindDelegated() {
const list = document.getElementById('list');
list.addEventListener('click', function (event) {
// closest 从事件触发点向上找到匹配选择器的祖先,兼容点击到按钮内部图标/文字的情况
const button = event.target.closest('.item-button');
if (!button || !list.contains(button)) return;
console.log('按钮被点击:', button.dataset.id);
});
// 无论后续动态插入多少个 .item-button,都不需要再绑定事件
}
// 一个更通用的事件委托工具函数
function delegate(container, selector, eventType, handler) {
container.addEventListener(eventType, (event) => {
const target = event.target.closest(selector);
if (target && container.contains(target)) {
handler.call(target, event, target);
}
});
}
// 使用
delegate(document.getElementById('list'), '.item-button', 'click', (event, el) => {
console.log('委托点击:', el.dataset.id);
});// ❌ 默认情况下浏览器要等待事件处理函数执行完(确认是否调用了preventDefault)才能开始滚动
window.addEventListener('touchstart', onTouchStart);
// ✅ 声明 passive: true,告诉浏览器该处理函数不会阻止默认滚动行为,
// 浏览器可以立即开始滚动动画,无需等待 JS 执行完毕
window.addEventListener('touchstart', onTouchStart, { passive: true });
window.addEventListener('wheel', onWheel, { passive: true });🔄 虚拟 DOM 与 Diff 算法
// 虚拟 DOM 节点通常就是一个普通对象
const vnode = {
type: 'div',
props: { className: 'card', id: 'card-1' },
children: [
{ type: 'span', props: {}, children: ['Hello'] }
]
};// 简化版:只处理"同类型节点更新属性"和"列表 key 复用"两种核心情况
function diffChildren(oldChildren, newChildren) {
const patches = [];
const oldKeyed = new Map(oldChildren.map((c, i) => [c.key, { node: c, index: i }]));
newChildren.forEach((newChild, newIndex) => {
const matched = oldKeyed.get(newChild.key);
if (matched) {
// key 匹配到旧节点:复用 DOM,只做属性/文本层面的更新,不销毁重建
patches.push({ type: 'UPDATE', oldIndex: matched.index, newIndex, vnode: newChild });
oldKeyed.delete(newChild.key);
} else {
// 没有匹配:这是一个全新节点,需要创建
patches.push({ type: 'CREATE', newIndex, vnode: newChild });
}
});
// 剩下没被复用的旧节点,说明在新列表里被删除了
oldKeyed.forEach(({ index }) => {
patches.push({ type: 'REMOVE', oldIndex: index });
});
return patches;
}// 场景:在列表开头插入一条新数据
// 旧列表:[A, B, C] 新列表:[X, A, B, C]
// ❌ 没有 key(或用 index 当 key):Diff 算法会认为
// 位置0: A→X(更新), 位置1: B→A(更新), 位置2: C→B(更新), 位置3: 新增C
// 结果是所有节点都被"更新"了一遍,即使内容其实没变,白白多做了 3 次属性比对和可能的 DOM patch
// ✅ 使用稳定的唯一 key(如数据自身的 id):Diff 算法能识别出
// A、B、C 是同一批节点只是挪了位置,只需要在最前面插入 X 对应的新节点
// DOM 操作次数从"4 次更新"降为"1 次插入"⚡ DOM API 选择器性能
// 最快:直接按 id 查找,浏览器内部有 id 索引
const el1 = document.getElementById('app');
// 稍慢:CSS 选择器引擎需要解析选择器语法并做匹配
const el2 = document.querySelector('#app');
// 实时集合:DOM 变化时会自动更新集合内容,遍历时如果同时增删元素要小心死循环或漏项
const els1 = document.getElementsByClassName('item');
// 静态快照:一次性查询,之后 DOM 再变化也不会影响这个结果集
const els2 = document.querySelectorAll('.item');
// 反例:在循环里重复查询同一个选择器
for (let i = 0; i < 100; i++) {
document.querySelector('.container').appendChild(document.createElement('span'));
// 每次循环都重新查询 .container,浪费性能
}
// 正例:查询一次,缓存引用
const container = document.querySelector('.container');
for (let i = 0; i < 100; i++) {
container.appendChild(document.createElement('span'));
}📝 innerHTML vs textContent vs innerText
const el = document.getElementById('note');
// 用户输入未经转义直接塞进 innerHTML,存在 XSS 风险
const userInput = '<img src=x onerror="alert(1)">';
el.innerHTML = userInput; // ❌ 危险:会执行恶意脚本
// 纯文本展示,安全且不会被当作 HTML 解析
el.textContent = userInput; // ✅ 安全:原样显示为文字
// 清空子元素的两种方式
el.innerHTML = ''; // 简单但会解绑所有子孙节点上的事件监听器
while (el.firstChild) {
el.removeChild(el.firstChild); // 更可控,可结合业务逻辑决定要不要提前清理监听器
}👀 IntersectionObserver:懒加载与可见性检测
<img data-src="https://example.com/real-image.jpg" class="lazy" alt="示例图片" />const lazyImages = document.querySelectorAll('img.lazy');
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 进入视口后才真正加载图片
img.classList.remove('lazy');
obs.unobserve(img); // 加载完就不再需要观察它,及时释放资源
}
});
}, {
root: null, // null 表示以浏览器视口作为根
rootMargin: '200px', // 提前 200px 开始加载,用户几乎感知不到加载过程
threshold: 0.01 // 只要有 1% 进入视口就触发
});
lazyImages.forEach(img => observer.observe(img));const sentinel = document.getElementById('scroll-sentinel'); // 列表末尾的哨兵元素
let isLoading = false;
const loadMoreObserver = new IntersectionObserver(async (entries) => {
const entry = entries[0];
if (entry.isIntersecting && !isLoading) {
isLoading = true;
await loadNextPage(); // 拉取下一页数据并追加到列表
isLoading = false;
}
}, { rootMargin: '400px' });
loadMoreObserver.observe(sentinel);const adSlots = document.querySelectorAll('.ad-slot');
const exposureObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// threshold 设为 0.5,表示广告至少露出一半才算"真实曝光",避免虚假曝光数据
if (entry.isIntersecting) {
reportExposure(entry.target.dataset.adId);
exposureObserver.unobserve(entry.target); // 一个广告位只上报一次曝光
}
});
}, { threshold: 0.5 });
adSlots.forEach(slot => exposureObserver.observe(slot));📜 虚拟列表:长列表渲染优化
class VirtualList {
constructor({ container, itemHeight, totalCount, renderItem, buffer = 5 }) {
this.container = container; // 可滚动的外层容器,需要设置固定高度和 overflow-y: auto
this.itemHeight = itemHeight; // 每一项的固定高度(px)
this.totalCount = totalCount; // 数据总条数
this.renderItem = renderItem; // (index) => HTMLElement,渲染单条数据
this.buffer = buffer; // 上下多渲染的缓冲行数,减少快速滚动时的白屏
this._setupDom();
this.container.addEventListener('scroll', () => this._onScroll());
this._render();
}
_setupDom() {
// 撑开总高度的占位元素,让滚动条长度符合真实数据总量
this.phantom = document.createElement('div');
this.phantom.style.height = this.itemHeight * this.totalCount + 'px';
this.phantom.style.position = 'relative';
// 实际承载可见内容的容器,用 transform 定位到当前应处的位置
this.content = document.createElement('div');
this.content.style.position = 'absolute';
this.content.style.top = '0';
this.content.style.left = '0';
this.content.style.right = '0';
this.phantom.appendChild(this.content);
this.container.appendChild(this.phantom);
}
_onScroll() {
// 用 requestAnimationFrame 节流滚动回调,避免每个像素滚动都重新渲染
if (this._rafId) return;
this._rafId = requestAnimationFrame(() => {
this._render();
this._rafId = null;
});
}
_render() {
const scrollTop = this.container.scrollTop;
const viewportHeight = this.container.clientHeight;
let startIndex = Math.floor(scrollTop / this.itemHeight) - this.buffer;
let endIndex = Math.ceil((scrollTop + viewportHeight) / this.itemHeight) + this.buffer;
startIndex = Math.max(0, startIndex);
endIndex = Math.min(this.totalCount - 1, endIndex);
// 用 DocumentFragment 批量构建这一小段可见数据,避免逐个 appendChild
const fragment = document.createDocumentFragment();
for (let i = startIndex; i <= endIndex; i++) {
const node = this.renderItem(i);
node.style.position = 'absolute';
node.style.top = i * this.itemHeight + 'px';
node.style.height = this.itemHeight + 'px';
fragment.appendChild(node);
}
// 每次渲染前清空旧内容,只保留当前可视范围内的少量节点
this.content.innerHTML = '';
this.content.appendChild(fragment);
}
}
// 使用示例:渲染 100000 条数据的列表
const list = new VirtualList({
container: document.getElementById('scroll-container'),
itemHeight: 40,
totalCount: 100000,
renderItem(index) {
const div = document.createElement('div');
div.textContent = `第 ${index} 行数据`;
div.className = 'row';
return div;
}
});🧵 requestIdleCallback:利用浏览器空闲时间
function renderLargeListInIdleTime(items, container) {
let index = 0;
function renderChunk(deadline) {
// deadline.timeRemaining() 返回本次空闲期还剩多少毫秒,
// 只要还有时间余量(或浏览器判断任务已超时),就继续渲染下一条
while (index < items.length && (deadline.timeRemaining() > 0 || deadline.didTimeout)) {
const item = items[index];
const div = document.createElement('div');
div.textContent = item.name;
container.appendChild(div);
index++;
}
if (index < items.length) {
// 本次空闲时间用完了,但还有剩余数据,注册下一次空闲回调继续处理
requestIdleCallback(renderChunk, { timeout: 1000 });
}
}
requestIdleCallback(renderChunk, { timeout: 1000 });
}
// 使用:即便 items 有 5000 条,页面在渲染过程中依然可以响应用户输入
renderLargeListInIdleTime(largeDataArray, document.getElementById('big-list'));// Safari 部分版本不支持 requestIdleCallback,降级用 setTimeout 模拟
const ric = window.requestIdleCallback || function (cb) {
const start = Date.now();
return setTimeout(() => {
cb({
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
});
}, 1);
};🛠️ Web Worker:把重计算挪出主线程
// main.js —— 主线程
const worker = new Worker('data-worker.js');
worker.postMessage({ type: 'PROCESS', payload: largeRawDataArray });
worker.onmessage = (event) => {
const { type, result } = event.data;
if (type === 'DONE') {
renderSummary(result); // 只在拿到结果后做一次性、轻量的 DOM 更新
}
};
worker.onerror = (err) => {
console.error('Worker 执行出错:', err.message);
};// data-worker.js —— Worker 线程,没有 window/document,只能做纯计算和消息通信
self.onmessage = function (event) {
const { type, payload } = event.data;
if (type === 'PROCESS') {
const sorted = payload.slice().sort((a, b) => a.value - b.value);
const total = payload.reduce((sum, item) => sum + item.value, 0);
const average = total / payload.length;
self.postMessage({
type: 'DONE',
result: { sorted: sorted.slice(0, 100), total, average }
});
}
};🔍 MutationObserver:监听 DOM 变化
const target = document.getElementById('content');
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1 && node.matches('.lazy-widget')) {
initWidget(node); // 有新的组件节点插入时,自动初始化它
}
});
mutation.removedNodes.forEach(node => {
if (node.nodeType === 1 && node.matches('.lazy-widget')) {
destroyWidget(node); // 节点被移除时做清理
}
});
}
}
});
observer.observe(target, {
childList: true, // 监听子节点增删
subtree: true, // 监听所有后代节点,不只是直接子节点
attributes: false // 是否监听属性变化
});
// 不再需要时记得断开观察,避免持续消耗性能
// observer.disconnect();const box = document.getElementById('status-box');
const attrObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
console.log('class 变化,旧值:', mutation.oldValue, '新值:', box.className);
}
});
});
attrObserver.observe(box, {
attributes: true,
attributeOldValue: true,
attributeFilter: ['class'] // 只关心 class 属性,减少无关回调
});🕹️ 防抖(Debounce)与节流(Throttle)
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 应用:搜索框输入时,用户停止输入 300ms 后才真正发起请求
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce((event) => {
fetchSearchResults(event.target.value);
}, 300));function throttle(fn, interval) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
// 应用:滚动过程中每 100ms 最多更新一次"阅读进度"
window.addEventListener('scroll', throttle(() => {
updateReadingProgress();
}, 100));📊 性能监控与埋点:Performance API
function renderDashboard(data) {
performance.mark('dashboard-render-start');
buildCharts(data);
renderTable(data);
updateSummaryCards(data);
performance.mark('dashboard-render-end');
performance.measure(
'dashboard-render-duration',
'dashboard-render-start',
'dashboard-render-end'
);
const [entry] = performance.getEntriesByName('dashboard-render-duration');
console.log('dashboard 渲染耗时:', entry.duration.toFixed(2), 'ms');
// 上报到自建监控系统或分析平台
reportPerformance('dashboard_render', entry.duration);
// 清理标记,避免长期运行的单页应用中标记无限累积
performance.clearMarks('dashboard-render-start');
performance.clearMarks('dashboard-render-end');
performance.clearMeasures('dashboard-render-duration');
}// 监听超过 50ms 的长任务(Long Task),这类任务会阻塞主线程,影响交互响应
const longTaskObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.warn('检测到长任务:', entry.duration.toFixed(2), 'ms', entry);
reportLongTask(entry.duration);
});
});
longTaskObserver.observe({ entryTypes: ['longtask'] });🏢 真实案例分析
✅ 最佳实践汇总
🧾 总结
🧰 工具与资源