第九章:浏览器环境:DOM 深度剖析 摘要 : 欢迎来到 JavaScript 应用最广泛、最核心的舞台——浏览器。在此前的章节中,我们已经完全掌握了 JavaScript 语言本身的内部原理。从本章开始,我们将把这些能力应用于与用户直接交互的界面。我们将深度剖析文档对象模型 (DOM) ,理解浏览器是如何将一份静态的 HTML 文档,转化为一个我们可以用代码动态操控的、活生生的对象树。您将学会如何精准地查找、遍历、修改、创建和删除页面上的任何元素,并掌握这一切操作背后的性能原理。
在本章中,我们将系统地学习 DOM 操作的每一个环节:
首先,我们将从 DOM 的核心概念 出发,理解其树形结构与节点类型。 接着,我们将学习如何 捕获和遍历 页面上的元素,这是所有操作的第一步。 然后,我们将掌握如何 修改元素的内容、属性与样式 。 紧接着,我们将学习如何 动态地创建和销毁元素 ,为页面赋予生命。 最后,我们将深入 DOM 的性能原理 ,学习如何编写高效、不卡顿的交互代码。 9.1. DOM 基础:文档树与节点 承上启下 : 在我们能够用 JavaScript “做”任何事之前,我们必须先理解我们操作的 对象 是什么。当浏览器加载一个 HTML 文件时,它做的不仅仅是显示文本。它在内存中进行了一项至关重要的工作:将这份纯文本的 HTML 代码,解析成一个 JavaScript 可以理解和操作的、结构化的对象模型。这个模型,就是 DOM。
9.1.1. DOM 到底是什么? DOM (Document Object Model) 的核心思想是,将一份 HTML 文档表示为一个倒置的 树形结构 (Node Tree) 。
文档的每一个部分——无论是整个 <html>
标签、一个 <p>
元素、一段文本,甚至是一行注释——都被视为这个树上的一个 节点 (Node) 。 这些节点之间存在着清晰的层级关系,就像一个家谱:<html>
是根节点,它有 head
和 body
两个子节点;body
又可以有 div
, p
等多个子节点。 通过这种方式,原本无序的文本标记,就被转换成了一个 JavaScript 可以通过标准 API 进行访问和修改的、严谨的对象集合。
9.1.2. Node
接口与节点类型 DOM 树中的所有节点,无论是什么类型,都继承自一个共同的 Node
接口。这意味着它们都共享一些通用的属性和方法。我们可以通过 nodeType
和nodeName
这两个属性来区分不同类型的节点。
节点类型 nodeType
常量nodeType
值nodeName
返回值示例 元素节点 (Element) Node.ELEMENT_NODE
1 大写的标签名 DIV
, P
, BODY
文本节点 (Text) Node.TEXT_NODE
3 #text
<h1>
标签内的文字注释节点 (Comment) Node.COMMENT_NODE
8 #comment
`` 文档节点 (Document) Node.DOCUMENT_NODE
9 `#document` `document` 对象本身
代码示例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > </head > <body > <div id ="container" > Hello World </div > <script > const container = document .getElementById ("container" ); console .log ( `容器: type=${container.nodeType} , name=${container.nodeName} ` , ); const firstChild = container.firstChild ; console .log ( `第一个子节点: type=${firstChild.nodeType} , name=${firstChild.nodeName} ` , ); </script > </body > </html >
1 2 容器: type =1, name=DIV 第一个子节点: type =3, name=#text
textContent
: 这是一个非常实用的 Node
属性,它会返回该节点及其所有后代节点中的 纯文本内容 ,并忽略所有 HTML 标签。9.1.3. document
对象:一切的起点 document
对象是整个 DOM 树的根节点,也是 JavaScript 与页面交互的 唯一入口 。它作为 window
对象的一个属性被全局提供,我们可以直接使用它来访问文档的任何部分。
document
对象提供了许多便捷的属性,用于快速访问文档中的关键节点和信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 console .log ("documentElement:" , document .documentElement );console .log ("head:" , document .head );console .log ("body:" , document .body );console .log ("title:" , document .title );document .title = "New Page Title" ;console .log ("New title:" , document .title );console .log ("URL:" , document .URL );console .log ("Images count:" , document .images .length );console .log ("Forms count:" , document .forms .length );
1 2 3 4 5 6 7 8 documentElement: <html lang="en" >...</html> head : <head >...</head>body: <body>...</body> title: My Page New title: New Page Title URL: [当前页面的完整地址] Images count: 0 Forms count: 0
9.2. 元素的查找与遍历 要操作页面,首先必须精准地找到我们想要操作的那个元素。
9.2.1. 元素查找 API 方法 描述 返回值 querySelector(selector)
(推荐) 使用 CSS 选择器查找 第一个 匹配的元素。单个元素或 null
querySelectorAll(selector)
(推荐) 使用 CSS 选择器查找 所有 匹配的元素。静态 NodeList
getElementById(id)
通过 id
属性查找元素,速度最快 。 单个元素或 null
getElementsByClassName(className)
通过 class
名称查找。 动态 HTMLCollection
getElementsByTagName(tagName)
通过标签名查找。 动态 HTMLCollection
示例 1: querySelector
和 getElementById
1 2 3 4 5 6 7 8 9 10 11 <div id ="box1" > 盒子1</div > <div class ="item" > 项目A</div > <script > const box = document .querySelector ('#box1' ); console .log ('通过 querySelector 找到:' , box.textContent ); const boxById = document .getElementById ('box1' ); console .log ('通过 getElementById 找到:' , boxById.textContent ); </script >
1 2 通过 querySelector 找到: 盒子1 通过 getElementById 找到: 盒子1
示例 2: querySelectorAll
和 getElementsByClassName
1 2 3 4 5 6 7 8 9 10 11 12 13 <ul > <li class ="fruit" > 苹果</li > <li class ="fruit" > 香蕉</li > <li > 面包(不是水果)</li > </ul > <script > const fruits = document .querySelectorAll ('.fruit' ); const fruitClass = document .getElementsByClassName ("fruit" ) console .log (fruitClass) console .log ('querySelectorAll 找到的水果数量:' , fruits.length ); fruits.forEach (fruit => console .log (`水果: ${fruit.textContent} ` )); </script >
1 2 3 4 HTMLCollection(2) [li.fruit, li.fruit] querySelectorAll 找到的水果数量: 2 水果: 苹果 水果: 香蕉
9.2.2. 集合类型深度辨析: HTMLCollection
vs. NodeList
这是 DOM 操作中一个非常重要的原理性知识。getElementsBy...
系列方法返回的是 动态的 HTMLCollection
,而 querySelectorAll
返回的是 静态的 NodeList
。
HTMLCollection
(动态/实时) : 如果在获取集合后,DOM 结构发生了变化(如新增或删除了匹配的元素),这个集合会 自动更新 以反映这些变化。NodeList
(静态快照) : 它是在执行 querySelectorAll
时对 DOM 拍下的一张“照片”。之后 DOM 的任何变化都 不会影响 这个 NodeList
的内容。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <div id ="container" > <p class ="item" > 项目 1</p > <p class ="item" > 项目 2</p > </div > <button id ="add-btn" > 添加新项目</button > <script > const container = document .getElementById ('container' ); const addBtn = document .getElementById ('add-btn' ); const liveCollection = container.getElementsByClassName ('item' ); const staticNodeList = container.querySelectorAll ('.item' ); console .log ('获取时,动态集合长度:' , liveCollection.length ); console .log ('获取时,静态集合长度:' , staticNodeList.length ); addBtn.onclick = () => { const newItem = document .createElement ('p' ); newItem.className = 'item' ; newItem.textContent = '新增项目' ; container.append (newItem); console .log ('--- DOM 变化后 ---' ); console .log ('动态集合长度自动更新为:' , liveCollection.length ); console .log ('静态集合长度保持不变:' , staticNodeList.length ); }; </script >
1 2 3 4 5 获取时,动态集合长度: 2 index.html:65 获取时,静态集合长度: 2 index.html:73 --- DOM 变化后 --- index.html:74 动态集合长度自动更新为: 3 index.html:75 静态集合长度保持不变: 2
9.2.3. 元素遍历 API 一旦获取了一个元素,我们就可以基于它来查找其亲属元素。
元素节点遍历 (推荐) 这组 API 只会返回 元素节点 ,自动忽略了元素间空白导致的文本节点,是日常开发的首选。
属性 描述 parentElement
返回父 元素 children
返回一个包含所有子 元素 的 HTMLCollection
firstElementChild
返回第一个子 元素 lastElementChild
返回最后一个子 元素 nextElementSibling
返回下一个兄弟 元素 previousElementSibling
返回上一个兄弟 元素
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 <div id ="container" > <p class ="item" > 项目 1</p > <p class ="item" > 项目 2</p > </div > <button id ="add-btn" > 添加新项目</button > <script > const container = document .querySelector ("#container" ); const container_child = container.children ; console .log (container_child); const container_parent = container.parentElement ; console .log (container_parent); const container_frist_child = container.firstElementChild ; console .log (container_frist_child); const container_last_child = container.lastElementChild ; console .log (container_last_child); const container_next_sibling = container.nextElementSibling ; console .log (container_next_sibling); const container_previous_sibling = container.previousElementSibling ; console .log (container_previous_sibling); </script >
通用节点遍历 (含陷阱) 这组 API 会返回 所有类型 的节点,包括元素、文本、注释等。在格式化的 HTML 代码中,元素间的换行和缩进会被视为空白 文本节点 ,使用这组 API 容易踩坑。
属性 描述 parentNode
返回父 节点 childNodes
返回一个包含所有子 节点 的 NodeList
firstChild
返回第一个子 节点 lastChild
返回最后一个子 节点 nextSibling
返回下一个兄弟 节点 previousSibling
返回上一个兄弟 节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <div id ="container" > <p class ="item" > 项目 1</p > <p class ="item" > 项目 2</p > </div > <button id ="add-btn" > 添加新项目</button > <script > const container = document .querySelector ("#container" ); const container_parent_node = container.parentNode ; console .log (container_parent_node); const container_childNodes = container.childNodes ; console .log (container_childNodes); </script >
9.3. 元素的操作:内容、属性与样式 承上启下 :上一节我们学会了如何精准地“捕获”到页面元素。现在,是时候学习如何“改造”它们了。本节将聚焦于最常见的 DOM 操作:修改元素内部的 内容 ,管理它的 属性 ,以及动态切换它的 样式 。我们将通过一系列小型实战场景,逐一攻克这些核心操作。
9.3.1. 微场景(一):动态更新用户资料卡 核心痛点 : 假设我们从服务器获取了最新的用户信息,需要将其动态地展示在一个用户资料卡上。这涉及到安全地更新文本内容、修改图片地址以及绑定自定义数据。
1 2 3 4 5 6 7 8 9 10 11 <style > .user-card { border : 1px solid #ccc ; padding : 15px ; width : 250px ; border-radius : 8px ; font-family : sans-serif; } .user-card img { width : 80px ; height : 80px ; border-radius : 50% ; float : left; margin-right : 15px ; } .user-card h3 { margin : 0 0 10px ; } .user-card p { color : #666 ; } </style > <div id ="profile-card" class ="user-card" data-user-id ="1" > <img id ="profile-avatar" src ="https://picsum.photos/200/300" alt ="用户头像" > <h3 id ="profile-name" > 旧用户名</h3 > <p id ="profile-bio" > 这是一段旧的个人简介。</p > </div >
操作一:更新文本内容 (textContent
vs innerHTML
) 我们需要将获取到的用户名和简介更新到页面上。这里有两个主要的属性可用:
element.textContent
: (安全/推荐) 它会将所有内容都作为纯文本处理。任何传入的 HTML 标签都会被直接显示为字符串,而不会被浏览器解析。这是更新文本内容的 最佳实践 ,能从根本上杜绝 XSS 跨站脚本攻击。element.innerHTML
: (危险/慎用) 它会将传入的字符串作为 HTML 解析。虽然功能强大,但如果内容来源不可信(如用户输入),恶意脚本(<script>
, onerror
)就可能被执行,导致严重的安全漏洞。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > .user-card { border : 1px solid #ccc ; padding : 15px ; width : 250px ; border-radius : 8px ; font-family : sans-serif; } .user-card img { width : 80px ; height : 80px ; border-radius : 50% ; float : left; margin-right : 15px ; } .user-card h3 { margin : 0 0 10px ; } .user-card p { color : #666 ; } </style > </head > <body > <div id ="profile-card" class ="user-card" data-user-id ="1" > <img id ="profile-avatar" src ="https://picsum.photos/200/300" alt ="用户头像" > <h3 id ="profile-name" > 旧用户名</h3 > <p id ="profile-bio" > 这是一段旧的个人简介。</p > <button onclick ="updateProfile()" > 更新</button > </div > </body > <script > const newData = { name : "Prorise 博客" , bio : "一个追求极致的技术笔记平台。" , avatarUrl : "https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/avatar.png" }; const nameEL = document .getElementById ("profile-name" ); const bioEL = document .getElementById ("profile-bio" ); function updateProfile ( ) { nameEL.textContent = newData.name ; bioEL.textContent = newData.bio ; } </script > </html >
操作二:管理 HTML 属性 (setAttribute
& dataset
) 接下来,我们需要更新头像的 src
属性,并可能需要读取或更新绑定在元素上的自定义数据(如用户 ID)。
标准属性 (getAttribute
/setAttribute
) : 用于读写 href
, src
, class
, id
等 HTML 标准属性。自定义数据属性 (dataset
) : (推荐) 现代前端开发的最佳实践是,将与业务逻辑相关的自定义数据,通过 data-*
属性存储在 HTML 中。JavaScript 中可以通过 element.dataset
对象来方便地访问它们(data-user-id
会被自动转换为 dataset.userId
)。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > .user-card { border : 1px solid #ccc ; padding : 15px ; width : 250px ; border-radius : 8px ; font-family : sans-serif; } .user-card img { width : 80px ; height : 80px ; border-radius : 50% ; float : left; margin-right : 15px ; } .user-card h3 { margin : 0 0 10px ; } .user-card p { color : #666 ; } </style > </head > <body > <div id ="profile-card" class ="user-card" data-user-id ="1" > <img id ="profile-avatar" src ="https://picsum.photos/200/300" alt ="用户头像" > <h3 id ="profile-name" > 旧用户名</h3 > <p id ="profile-bio" > 这是一段旧的个人简介。</p > <button onclick ="updateProfile()" > 更新</button > </div > </body > <script > const newData = { name : "Prorise 博客" , bio : "一个追求极致的技术笔记平台。" , avatarUrl : "https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/avatar.png" }; const nameEL = document .getElementById ("profile-name" ); const bioEL = document .getElementById ("profile-bio" ); const cardEl = document .getElementById ('profile-card' ); const avatarEl = document .getElementById ('profile-avatar' ); const userId = cardEl.dataset .userId ; console .log (`资料卡的用户 ID 为: ${userId} ` ); function updateProfile ( ) { nameEL.textContent = newData.name ; bioEL.textContent = newData.bio ; avatarEl.setAttribute ('src' , newData.avatarUrl ); console .log ("头像 URL 已更新。" ); } </script > </html >
9.3.2. 微场景(二):实现动态的视觉反馈 核心痛点 (Why) : 在 Web 应用中,我们需要为用户的操作提供即时的视觉反馈。例如,点击一个按钮后,它的状态应该改变;或者,当用户在输入框中输入时,根据内容的有效性,输入框的边框应该变色。直接在 JavaScript 中逐行修改 style
属性会导致逻辑和表现的强耦合,难以维护。
解决方案 : 通过 JavaScript 切换 CSS 类名 。我们将不同状态的样式预先定义在 CSS 类中,JavaScript 只负责添加或移除这些类,实现行为与表现的分离。element.classList
是完成此任务的完美工具。
classList
方法描述 .add('class')
添加一个类。 .remove('class')
移除一个类。 .toggle('class')
如果类存在则移除,不存在则添加。非常适合做状态切换。 .contains('class')
检查是否包含指定的类,返回布尔值。
示例一:状态切换按钮 (使用 toggle
) 这是 toggle
方法最经典的应用场景,用一行代码实现两种状态的切换。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > .follow-btn { border : 1px solid #ccc ; padding : 10px 20px ; background-color : #fff ; border-radius : 15px ; cursor : pointer; font-size : 16px ; font-weight : bold; color : #333 ; transition : all 0.3s ease; } .following { background-color : #ff03ea ; color : #fff ; } </style > </head > <body > <button id ="follow-btn" class ="follow-btn" onclick ="follow()" > 关注</button > </body > <script > function follow ( ) { const followBtn = document .getElementById ('follow-btn' ); followBtn.classList .toggle ('following' ); } </script > </html >
示例二:表单输入验证反馈 (使用 add
, remove
) 这个场景更清晰地展示了如何根据不同的逻辑条件,精确地添加和移除类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > .input-field { padding : 8px ; border : 1px solid #ccc ; border-radius : 4px ; transition : border-color 0.3s ease; } .input-field .valid { border-color : #2e7d32 ; } .input-field .invalid { border-color : #d32f2f ; } .feedback-msg { font-size : 12px ; height : 16px ; margin-top : 4px ; } .feedback-msg .valid { color : #2e7d32 ; } .feedback-msg .invalid { color : #d32f2f ; } </style > </head > <body > <input type ="password" id ="password-input" class ="input-field" placeholder ="请输入至少8位的密码" > <p id ="feedback-msg" class ="feedback-msg" > </p > </body > <script > const passwordInput = document .getElementById ('password-input' ); const feedbackMsg = document .getElementById ('feedback-msg' ); passwordInput.addEventListener ("input" , () => { const value = passwordInput.value ; if (value.length >= 8 ) { passwordInput.classList .add ('valid' ); passwordInput.classList .remove ('invalid' ); feedbackMsg.textContent = '密码强度符合要求' ; feedbackMsg.classList .add ('valid' ); feedbackMsg.classList .remove ('invalid' ); } else { passwordInput.classList .add ('invalid' ); passwordInput.classList .remove ('valid' ); feedbackMsg.textContent = '密码长度不能少于8位' ; feedbackMsg.classList .add ('invalid' ); feedbackMsg.classList .remove ('valid' ); } }) </script > </html >
9.3.3 微场景(三):实现一个可拖拽的卡片 (操作行内样式) 核心痛点 : 有时,我们需要根据用户的实时交互(如鼠标移动)来计算并设置元素的样式,例如一个可拖拽元素的 left
和 top
值。这些值是高度动态、实时计算的,不适合预先定义在 CSS 类中。
解决方案 : 在这种场景下,直接操作元素的 element.style
对象是最直接有效的方式。
element.style
属性返回一个对象,它对应于该元素的 HTML style
行内属性。我们可以像操作普通 JS 对象一样,给它的属性赋值来动态修改样式。
注意 :
CSS 属性名中的连字符 (-
) 需要转换为驼峰命名法(例如 background-color
变为 backgroundColor
)。 element.style
只能 读取 到行内样式,无法获取来自 <style>
标签或外部 CSS 文件的样式。重要信息 : 如果你是新手,不懂 Dom 的事件的话,可以先继续往后看,后续的章节我们会详细介绍
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 <style > #draggable-box { position : absolute; width : 100px ; height : 100px ; background-color : #007bff ; color : white; cursor : grab; display : flex; align-items : center; justify-content : center; user-select : none; } #draggable-box :active { cursor : grabbing; } </style > <div id ="draggable-box" > 拖动我</div > <script > const box = document .getElementById ('draggable-box' ); let isDragging = false ; let offsetX, offsetY; box.addEventListener ('mousedown' , (e ) => { isDragging = true ; offsetX = e.clientX - box.offsetLeft ; offsetY = e.clientY - box.offsetTop ; box.style .cursor = 'grabbing' ; }); document .addEventListener ('mousemove' , (e ) => { if (isDragging) { box.style .left = `${e.clientX - offsetX} px` ; box.style .top = `${e.clientY - offsetY} px` ; } }); document .addEventListener ('mouseup' , () => { isDragging = false ; box.style .cursor = 'grab' ; }); </script >
9.4. 动态构建:创造与销毁元素 承上启下 : 前面我们学会了如何查找和修改 已存在 的元素。但 Web 应用的魅力在于其 动态性 ——根据用户的操作或数据的变化,实时地在页面上创建全新的内容,或移除不再需要的部分。本节,我们将聚焦于 DOM 操作中最激动人心的部分:从无到有地创造元素,并将其注入页面,以及在适当的时候销毁它们。
9.4.1. 从零到一:创建新节点 在将一个新元素添加到页面之前,我们首先需要在内存中将它“创造”出来。这主要通过两个方法完成。
方法 描述 document.createElement(tagName)
创建一个指定标签名(如 'div'
, 'p'
)的 元素节点 。 document.createTextNode(text)
创建一个包含指定文本的 文本节点 。
这两个方法创建出的节点,仅仅是存在于 JavaScript 的内存中,与页面上的 DOM 树还没有任何关系。我们可以像操作普通 JS 对象一样,先对它进行各种设置。
示例 : 创建一个结构完整的、待插入的段落元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const newParagraph = document .createElement ('p' );newParagraph.className = 'comment' ; newParagraph.id = 'comment-1' ; const textContent = document .createTextNode ('这是一条新的评论内容。' );newParagraph.append (textContent); console .log ('内存中创建的元素:' , newParagraph);
1 内存中创建的元素: <p class="comment" id ="comment-1" >这是一条新的评论内容。</p>
9.4.2. 放入页面:插入节点 当节点在内存中准备就绪后,我们就可以使用插入 API 将它“挂载”到 DOM 树的指定位置。
现代节点插入 API 这组 API 更灵活、更直观,并且允许一次性插入多个节点,老版本的 API 我们就不学习了
方法 描述 parentElement.append(...nodes)
在 parentElement
的 最后一个子节点之后 插入一个或多个节点。 parentElement.prepend(...nodes)
在 parentElement
的 第一个子节点之前 插入一个或多个节点。 element.before(...nodes)
在 element
之前 插入一个或多个兄弟节点。 element.after(...nodes)
在 element
之后 插入一个或多个兄弟节点。
示例 : 实现一个简单的评论发布功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Elegant Comment Box</title > <style > .comment-box-container { margin : 0 auto; background-color : #ffffff ; border-radius : 12px ; box-shadow : 0 6px 20px rgba (0 , 0 , 0 , 0.08 ); padding : 24px ; width : 100% ; max-width : 550px ; box-sizing : border-box; } .comment-textarea { width : 100% ; height : 120px ; padding : 12px ; border : 1px solid #ccd0d5 ; border-radius : 8px ; font-size : 16px ; line-height : 1.4 ; resize : vertical; box-sizing : border-box; outline : none; transition : border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; } .comment-textarea :focus { border-color : #007bff ; box-shadow : 0 0 0 3px rgba (0 , 123 , 255 , 0.25 ); } .send-button { background-color : #007bff ; color : white; border : none; border-radius : 8px ; padding : 10px 20px ; font-size : 16px ; font-weight : 600 ; cursor : pointer; outline : none; transition : background-color 0.2s ease-in-out; } .comment-list ul {padding : 0 ;} .comment-list ul li { list-style : none; } .comment-content { background-color : #f0f2f5 ; padding : 12px ; border-radius : 8px ; margin-top : 12px ; } #comment-content { font-size : 16px ; line-height : 1.4 ; margin : 0 ; } </style > </head > <body > <div class ="comment-box-container" > <textarea class ="comment-textarea" placeholder ="在此输入您的评论..." > </textarea > <div style ="display: flex; justify-content: flex-end; margin-top: 16px;" > <button class ="send-button" onclick ="sendComment()" > 发送</button > </div > <div class ="comment-list" style ="margin-top: 16px;" > <ul > </ul > </div > </div > </body > <script > function sendComment ( ) { const comment = document .querySelector ('.comment-textarea' ).value ; if (!comment.trim ()) return ; const commentList = document .querySelector ('.comment-list ul' ); const newComment = document .createElement ('li' ); newComment.innerHTML = ` <div class="comment-content"> <p>末尾评论:${comment} </p> </div> ` ; commentList.append (newComment); const headerComment = document .createElement ('li' ); headerComment.innerHTML = ` <div class="comment-content" style="background-color: #e3f2fd;"> <p><strong>最新评论:</strong>${comment} </p> </div> ` ; commentList.prepend (headerComment); const beforeElement = document .createElement ('div' ); beforeElement.innerHTML = '<p style="color: #666; font-size: 14px;">--- 评论分隔线 ---</p>' ; newComment.before (beforeElement); const afterElement = document .createElement ('div' ); afterElement.innerHTML = '<p style="color: #666; font-size: 12px;">评论时间: ' + new Date ().toLocaleString () + '</p>' ; newComment.after (afterElement); document .querySelector ('.comment-textarea' ).value = '' ; } </script > </html >
9.4.3. 完成使命:移除与替换节点 现代节点移除/替换 API (2025 推荐) 这组 API 直接在目标元素上调用,非常直观。
方法 描述 element.remove()
将 element
从其父节点中 移除 。 element.replaceWith(...nodes)
用一个或多个新节点 替换 掉 element
。
示例 : 为每条评论添加删除功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Elegant Comment Box</title > <style > .comment-box-container { margin : 0 auto; background-color : #ffffff ; border-radius : 12px ; box-shadow : 0 6px 20px rgba (0 , 0 , 0 , 0.08 ); padding : 24px ; width : 100% ; max-width : 550px ; box-sizing : border-box; } .comment-textarea { width : 100% ; height : 120px ; padding : 12px ; border : 1px solid #ccd0d5 ; border-radius : 8px ; font-size : 16px ; line-height : 1.4 ; resize : vertical; box-sizing : border-box; outline : none; transition : border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; } .comment-textarea :focus { border-color : #007bff ; box-shadow : 0 0 0 3px rgba (0 , 123 , 255 , 0.25 ); } .send-button { background-color : #007bff ; color : white; border : none; border-radius : 8px ; padding : 10px 20px ; font-size : 16px ; font-weight : 600 ; cursor : pointer; outline : none; transition : background-color 0.2s ease-in-out; } .comment-list ul {padding : 0 ;} .comment-list ul li { list-style : none; } .comment-content { background-color : #f0f2f5 ; padding : 12px ; border-radius : 8px ; margin-top : 12px ; position : relative; } #comment-content { font-size : 16px ; line-height : 1.4 ; margin : 0 ; } .delete-button { position : absolute; top : 8px ; right : 8px ; background-color : #dc3545 ; color : white; border : none; border-radius : 4px ; padding : 4px 8px ; font-size : 12px ; cursor : pointer; } .delete-button :hover { background-color : #c82333 ; } </style > </head > <body > <div class ="comment-box-container" > <textarea class ="comment-textarea" placeholder ="在此输入您的评论..." > </textarea > <div style ="display: flex; justify-content: flex-end; margin-top: 16px;" > <button class ="send-button" onclick ="sendComment()" > 发送</button > </div > <div class ="comment-list" style ="margin-top: 16px;" > <ul > </ul > </div > </div > </body > <script > function deleteComment (element ) { element.closest ('li' ).remove (); } function replaceComment (element ) { const replacementComment = document .createElement ('li' ); replacementComment.innerHTML = ` <div class="comment-content" style="background-color: #ffeaa7;"> <p><em>此评论已被替换</em></p> <button class="delete-button" onclick="deleteComment(this)">删除</button> </div> ` ; element.closest ('li' ).replaceWith (replacementComment); } function sendComment ( ) { const comment = document .querySelector ('.comment-textarea' ).value ; if (!comment.trim ()) return ; const commentList = document .querySelector ('.comment-list ul' ); const newComment = document .createElement ('li' ); newComment.innerHTML = ` <div class="comment-content"> <p>末尾评论:${comment} </p> <button class="delete-button" onclick="deleteComment(this)">删除</button> </div> ` ; commentList.append (newComment); const headerComment = document .createElement ('li' ); headerComment.innerHTML = ` <div class="comment-content" style="background-color: #e3f2fd;"> <p><strong>最新评论:</strong>${comment} </p> <button class="delete-button" onclick="deleteComment(this)">删除</button> <button class="delete-button" onclick="replaceComment(this)" style="right: 60px;">替换</button> </div> ` ; commentList.prepend (headerComment); const beforeElement = document .createElement ('div' ); beforeElement.innerHTML = '<p style="color: #666; font-size: 14px;">--- 评论分隔线 ---</p>' ; newComment.before (beforeElement); const afterElement = document .createElement ('div' ); afterElement.innerHTML = '<p style="color: #666; font-size: 12px;">评论时间: ' + new Date ().toLocaleString () + '</p>' ; newComment.after (afterElement); document .querySelector ('.comment-textarea' ).value = '' ; } </script > </html >
9.5. DOM 事件基础:监听与响应 我们已经学会了如何用 JavaScript 像上帝一样创造、修改和销毁页面元素。但目前,这些操作都只能在代码加载时执行一次。如何让页面“活”起来,响应用户的点击、鼠标的移动和键盘的输入?答案就是 事件 。本节,我们将开启 DOM 交互的核心——事件监听,对于事件我们通常不需要去特殊记忆,在什么场景需要用到就去查一下就可以了
一个 事件 (Event) ,是浏览器通知我们“某件事发生了”的信号。例如:
用户点击了一个按钮 (click
事件) 鼠标指针移入一张图片 (mouseover
事件) 用户在输入框中按下了键盘 (keydown
事件) 整个页面资源加载完毕 (load
事件) 而 事件监听 ,就是我们编写一段代码(称为“事件监听器”或“事件处理器”),并将其“绑定”到某个元素上,告诉浏览器:“当这个元素上发生这种事件时,请执行我这段代码。”
9.5.1. 事件监听器的演进 在历史上,为元素绑定事件监听器的方式经历了几个阶段的演进。
阶段一:HTML on-* 属性 最早的方式是直接在 HTML 标签上使用 on<事件名>
属性。
1 <button onclick ="alert('你点击了按钮!'); console.log('这是一个已废弃的用法。');" > 不要这样用</button >
缺点 : 这种方式严重违反了“结构与行为分离”的原则,将 JavaScript 代码和 HTML 标记混杂在一起,极难维护。
阶段二:DOM0 级事件处理 (element.on*
) 通过直接给元素的 on<事件名>
属性赋值一个函数,可以实现行为与结构的分离。
1 2 3 4 const btn = document .querySelector ('#my-btn' );btn.onclick = function ( ) { console .log ('DOM0 方式绑定的点击事件。' ); };
缺点 : 一个元素的一个事件只能绑定一个处理器 。如果对同一个 btn.onclick
多次赋值,后面的会 覆盖 前面的,导致逻辑丢失。
9.5.2. 现代事件监听:addEventListener
addEventListener
是 W3C DOM2 级事件规范中定义的方法,也是当今所有现代浏览器支持的 标准事件监听方式 。
核心语法 : element.addEventListener(type, listener, [options/useCapture]);
type
: 事件类型字符串,没有 on
前缀 ,例如 'click'
, 'mouseover'
, 'keydown'
。listener
: 事件触发时要执行的函数(回调函数)。第三个参数(可选): 通常是一个布尔值,用于指定是在“捕获”阶段还是“冒泡”阶段执行(我们将在后续章节深入讲解事件流)。默认为 false
(冒泡阶段)。 核心优势 :
可以为一个事件绑定多个监听器 ,它们会按照添加的顺序依次执行,互不覆盖。提供了更精细的控制,如通过 removeEventListener
移除监听。 示例一:为一个按钮添加多个点击事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <button id ="multi-listener-btn" > 点我试试</button > <script > const btn = document .getElementById ('multi-listener-btn' ); btn.addEventListener ('click' , () => { console .log ('监听器 1 被触发了!' ); }); btn.addEventListener ('click' , (event ) => { console .log (`监听器 2 被触发了!事件类型是: ${event.type} ` ); }); </script >
1 2 监听器 1 被触发了! 监听器 2 被触发了!事件类型是: click
移除事件监听器: removeEventListener
要移除一个事件监听器,必须使用 removeEventListener
,并且需要满足两个条件:
事件类型、监听函数、以及第三个参数必须与添加时 完全一致 。 因此,被移除的监听函数 不能是匿名函数 ,必须是一个有引用的函数(如命名函数或函数变量)。 示例二:一个只触发一次的事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > </head > <body > <button id ="once-btn" > click me</button > </body > <script > const btn = document .getElementById ('once-btn' ); function handleFirstClick ( ) { alert ("click" ); btn.removeEventListener ("click" , handleFirstClick); } btn.addEventListener ("click" , handleFirstClick); </script > </html >
点击按钮,控制台会输出消息。再次点击,将不再有任何反应,因为监听器已被成功移除。
9.6. DOM 事件(下):事件流与事件对象 9.6.1. 事件流:捕获与冒泡 核心原理 : 当您点击一个深层嵌套的按钮时,您不仅仅是点击了那个按钮。从根节点 <html>
到那个按钮,路径上的所有祖先元素,实际上都“感知”到了这次点击。事件在 DOM 树中传播的顺序,就称为 事件流 (Event Flow) 。
W3C 标准规定,事件流分为三个阶段:
捕获阶段 : 事件从文档的根节点(window
-> document
-> <html>
)开始,逐级 向下 传播,直到达到真正的目标元素。目标阶段 : 事件到达目标元素。冒泡阶段 : 事件从目标元素开始,逐级 向上 冒泡,直到再次回到文档的根节点。默认情况下,我们使用 addEventListener
绑定的事件监听器,只会在 冒泡阶段 被触发。
addEventListener
的第三个参数 :
element.addEventListener('click', handler, false);
(或不写): handler
在 冒泡阶段 执行。element.addEventListener('click', handler, true);
: handler
在 捕获阶段 执行。我们来看一个在实际开发中非常常见的场景,这个场景经常因为对事件流理解不清而导致 bug。场景:一个可以点击的卡片,但卡片内有一个功能独立的“删除”按钮。
目标 :点击卡片空白处,触发“查看详情”;点击卡片内的“删除”按钮,只 触发“删除”操作。常见的坑 :点击“删除”按钮后,不仅触发了“删除”,还意外地触发了“查看详情”。下面是这个问题的 错误实现 ,它清晰地暴露了不处理事件冒泡所带来的问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 <!DOCTYPE html > <html > <head > <title > 事件冒泡问题演示</title > <style > body { font-family : sans-serif; display : flex; justify-content : center; align-items : center; height : 100vh ; background-color : #f0f0f0 ; margin : 0 ; } .card { width : 250px ; padding : 20px ; border : 1px solid #ccc ; border-radius : 8px ; background-color : white; box-shadow : 0 2px 5px rgba (0 ,0 ,0 ,0.1 ); cursor : pointer; transition : transform 0.2s ; } .card :hover { transform : translateY (-5px ); } .card-header { display : flex; justify-content : space-between; align-items : center; margin-bottom : 10px ; } .delete-btn { padding : 5px 10px ; border : none; background-color : #e74c3c ; color : white; border-radius : 5px ; cursor : pointer; } </style > </head > <body > <div id ="product-card" class ="card" > <div class ="card-header" > <h4 > 商品卡片</h4 > <button id ="delete-button" class ="delete-btn" > 删除</button > </div > <p > 点击卡片空白处查看详情。</p > </div > <script > const card = document .getElementById ('product-card' ); const deleteBtn = document .getElementById ('delete-button' ); card.addEventListener ('click' , () => { alert ('事件冒泡了!触发了卡片的点击事件 -> 准备跳转详情页...' ); }); deleteBtn.addEventListener ('click' , () => { alert ('删除了!触发了删除按钮的点击事件 -> 正在执行删除操作...' ); }); </script > </body > </html >
你会发现,两个事件都被触发了。这是因为 click
事件在“删除”按钮上触发后,会继续向 上冒泡 到它的父元素 div#product-card
,从而也触发了绑定在卡片上的监听器。这显然不是我们想要的结果。
现在,我们用 event.stopPropagation()
来修正这个问题。这个方法可以阻止事件继续向上冒泡。
1 2 3 4 5 6 deleteBtn.addEventListener ('click' , (event ) => { event.stopPropagation (); alert ('删除了!触发了删除按钮的点击事件 -> 正在执行删除操作...' ); });
现在点击删除按钮您将看不到上述的问题了!
9.6.2. 事件对象 (Event Object
) 当一个事件发生时,浏览器会自动创建一个 事件对象 (通常命名为 event
或 e
),并将其作为唯一的参数传递给事件监听函数。这个对象包含了关于该事件的所有详细信息,是我们与事件交互的关键。
核心属性/方法 描述 event.target
事件的真正发起者 。在事件流中,它始终是那个被用户直接交互的、最深层的元素(例如,被点击的按钮或链接)。event.currentTarget
当前正在执行监听器的那个元素 。也就是你调用 addEventListener
时绑定的那个元素。event.type
事件的类型(如 'click'
, 'keydown'
)。 event.preventDefault()
(方法) 阻止元素的 默认行为 。例如,阻止 <a>
标签的跳转,或 <form>
的提交。event.stopPropagation()
(方法) 阻止事件继续传播 (通常指阻止冒泡)。事件流将在此处停止,不会再触发父级元素的同类型事件监听器。
场景示例:一个可交互的待办事项列表
想象一个待办事项列表,我们希望实现以下功能:
事件委托 :只在父容器 <ul>
上设置一个点击监听器来管理所有子项,而不是给每个 <li>
或按钮都单独绑定事件,这样更高效。点击待办事项的 文本 ,可以将其标记为“已完成”(添加删除线)。 点击每项后面的 “移除”链接 ,应该 只删除 该项,而 不触发 “标记完成”的逻辑,并且 不刷新 页面。 这个场景完美地展示了上述所有概念的协同工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > .todo-list { border : 1px solid #ccc ; padding : 10px ; width : 300px ; list-style : none; } .todo-list li { padding : 8px ; margin : 5px 0 ; background-color : #f0f0f0 ; display : flex; justify-content : space-between; align-items : center; cursor : pointer; } .todo-list li .completed .text { text-decoration : line-through; color : #888 ; } .todo-list .remove-btn { color : red; text-decoration : none; font-size : 12px ; } </style > </head > <body > <ul id ="todo-list" class ="todo-list" > <li > <span class ="text" > 学习 JavaScript 事件流</span > <a href ="#" class ="remove-btn" > 移除</a > </li > <li > <span class ="text" > 写一个很棒的例子</span > <a href ="#" class ="remove-btn" > 移除</a > </li > <li > <span class ="text" > 休息一下</span > <a href ="#" class ="remove-btn" > 移除</a > </li > </ul > </body > <script > const list = document .getElementById ('todo-list' ); list.addEventListener ('click' , (event ) => { console .log ('--- 事件触发 ---' ); console .log ('event.target:' , event.target .tagName , `(class: ${event.target.className} )` ); console .log ('event.currentTarget:' , event.currentTarget .tagName ); if (event.target .classList .contains ('remove-btn' )) { event.preventDefault (); event.stopPropagation (); const itemToRemove = event.target .parentElement ; itemToRemove.remove (); } else { const targetLi = event.target .closest ('li' ); if (targetLi) { targetLi.classList .toggle ('completed' ); } } }) </script > </html >
9.7. 高级事件模式:事件委托 在上一节的待办事项列表中,我们只给父元素 <ul>
添加了一个事件监听器,却成功地管理了所有 <li>
子项的点击事件,包括它们的完成状态和删除按钮。这种高效的模式,就是 事件委托 。
核心痛点 : 想象一下,一个拥有 1000 个列表项的 <ul>
。如果我们为每一个 <li>
都单独调用 addEventListener
,会发生什么?
性能问题 : 内存中会创建 1000 个独立的监听器函数,占用大量内存,可能导致页面响应变慢。动态内容问题 : 如果我们通过 JavaScript 动态地向这个列表中添加一个新的 <li>
,新添加的项 不会 自动绑定上事件监听器,我们需要手动再次绑定,代码会变得非常复杂。解决方案 : 事件委托 正是为了解决这两个问题而生。它的核心原理是利用我们上一节学到的 事件冒泡 机制。我们不再给每个子元素单独设置监听器,而是只给它们的共同父元素设置一个监听器。当任何一个子元素被点击时,事件会冒泡到父元素,父元素的监听器就会被触发。
实现的关键 : 在父元素的监听器中,我们通过 event.target
属性,就能精确地判断出事件的真正来源是哪个子元素,然后执行相应的逻辑。
实战场景:一个动态的、可交互的标签选择器 我们将构建一个标签选择器。初始时有几个标签,用户可以点击“添加新标签”来动态增加更多标签。我们希望实现:
无论何时,点击任何一个标签,都能在 alert 打印出它的内容。 整个过程只使用 一个 事件监听器。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > #tag-container { border : 1px solid #ccc ; padding : 10px ; width : 400px ; } .tag { display : inline-block; padding : 5px 10px ; margin : 5px ; background-color : #007bff ; color : white; border-radius : 15px ; cursor : pointer; transition : background-color 0.2s ; } .tag :hover { background-color : #0056b3 ; } #add-tag-btn { margin-top : 10px ; } </style > </head > <body > <div id ="tag-container" > <span class ="tag" > JavaScript</span > <span class ="tag" > HTML</span > <span class ="tag" > CSS</span > </div > <button id ="add-tag-btn" > 添加新标签</button > </body > <script > const tagContainer = document .getElementById ('tag-container' ); const addTagBtn = document .getElementById ('add-tag-btn' ); let tagCounter = 0 ; tagContainer.addEventListener ('click' , (event ) => { if (event.target .classList .contains ('tag' )) { const tagName = event.target .textContent ; alert (`你点击了标签: ${tagName} ` ); } }); addTagBtn.addEventListener ('click' , () => { tagCounter++; const newTag = document .createElement ('span' ); newTag.className = 'tag' ; newTag.textContent = `新标签 ${tagCounter} ` ; tagContainer.append (newTag); }); </script > </html >
9.8. 精准定位:获取元素尺寸与位置 (重点) 在前端开发中,我们不仅能改变元素,更需要能够 读取 它在页面布局中的精确信息。无论是实现“Tooltip 提示”、“无限滚动加载”还是“拖拽功能”,都离不开对元素尺寸和位置的精确掌控。
然而,DOM 提供了多套看似相似的 API,它们的参照物和计算方式各不相同,极易混淆。本章将彻底厘清这些概念,并把它们分为两大核心任务:
测量盒子 :这个元素到底有多大?(涉及 offsetWidth
, clientHeight
, scrollHeight
)定位盒子 :这个元素在页面的什么位置?(涉及 getBoundingClientRect
, offsetTop
/offsetLeft
)我们将通过一个个具体的实战场景,来掌握这些关键的 API。
9.8.1. 测量盒子:元素有多大? 在定位一个元素之前,我们首先要知道它自身的尺寸信息。DOM 提供了三套核心的“测量尺”,分别用于不同的场景。
属性 包含内容 一句话解释 offsetWidth
/offsetHeight
内容 + padding
+ border
视觉尺寸 :元素在屏幕上占据的完整空间。clientWidth
/clientHeight
内容 + padding
内部尺寸 :元素内部可供内容显示的区域大小,不含边框和滚动条。scrollHeight
/scrollWidth
所有 内容(包括被隐藏的)内容总尺寸 :如果把所有内容平铺开,它所需要的总高度/宽度。
实战场景:实现“无限滚动加载”
核心痛点 : 当列表内容非常多时,一次性加载会非常慢。最佳体验是当用户滚动到列表底部时,再自动加载下一页数据。这就需要我们精确判断“用户是否已滚动到底部”。
解决方案 : 判断滚动条是否触底,完美地诠释了上述三个尺寸属性的协作关系。我们需要用到:
element.scrollHeight
: 内容的总高度。element.clientHeight
: 容器的可视高度。element.scrollTop
: 内容已经被向上卷去的距离。当 “已滚动距离 (scrollTop
)” + “可视区高度 (clientHeight
)” ≈ “内容总高度 (scrollHeight
)” 时,我们就可以判定用户已经滚动到了底部。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > #scroll-list { position : relative; height : 300px ; width : 300px ; overflow-y : scroll; border : 1px solid #625f5f ; padding : 10px ; } #scroll-list p { margin : 0 0 10px ; padding : 5px ; background : #f4f4f4 ; } #loading-indicator { display : none; text-align : center; color : #999 ; margin-top : 10px ; } </style > </head > <body > <div id ="scroll-list" > </div > </body > <script > const list = document .getElementById ('scroll-list' ); let itemCounter = 1 ; let isLoading = false ; let loading; function createLoadingIndicator ( ) { loading = document .createElement ('div' ); loading.id = 'loading-indicator' ; loading.textContent = '加载更多中...' ; loading.style .display = 'none' ; loading.style .textAlign = 'center' ; loading.style .color = '#999' ; loading.style .marginTop = '10px' ; } function loadMoreItems ( ) { isLoading = true ; if (!loading) { createLoadingIndicator (); } list.append (loading); loading.style .display = "block" ; setTimeout (() => { for (let i = 0 ; i < 10 ; i++) { const newItem = document .createElement ('p' ); newItem.textContent = `Item ${itemCounter + i} ` ; list.append (newItem); } itemCounter += 10 ; loading.style .display = "none" ; isLoading = false ; }, 1000 ); } loadMoreItems (); list.addEventListener ('scroll' , () => { const scrollPosition = list.scrollTop + list.clientHeight ; if (scrollPosition >= list.scrollHeight - 5 && !isLoading) { loadMoreItems (); } }) </script > </html >
9.8.2. 定位盒子:元素在哪里? 知道了元素多大,下一步就是确定它的位置。这也是最容易混淆的地方。DOM 提供了两套主要的“定位系统”:一套是基于 浏览器视口 的现代方案,另一套是基于 父元素 的传统方案。
A. 现代首选:相对于浏览器视口定位 核心痛点 : 当鼠标悬停在一个元素上时,我们希望在它旁边弹出一个“提示框”(Tooltip)。这个提示框的位置必须根据目标元素 当前在浏览器窗口中的位置 来动态计算,无论页面滚动到哪里。
解决方案 : 这种相对于 浏览器视口 (viewport) 的定位需求,最现代、最直接的工具就是 element.getBoundingClientRect()
。
它返回一个 DOMRect
对象,包含 left
, top
, right
, bottom
, width
, height
等相对于 视口左上角 的精确坐标。
最大特点 :它的返回值是 动态的 。当页面滚动时,元素相对于视口的位置会改变,getBoundingClientRect()
返回的 top
和 left
值也会随之改变。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 <style > .container { position : relative; padding-top : 50px ; padding-left : 100px ; height : 300px ; } #tooltip-target { padding : 10px 20px ; border : 1px solid #666 ; cursor : pointer; } #tooltip { position : absolute; display : none; background-color : #333 ; color : white; padding : 8px ; border-radius : 4px ; pointer-events : none; } </style > <div class ="container" > <button id ="tooltip-target" > 悬停在我身上</button > <div id ="tooltip" > 这是一个动态计算位置的提示框。</div > </div > <script > const target = document .getElementById ('tooltip-target' ); const tooltip = document .getElementById ('tooltip' ); target.addEventListener ('mouseenter' , () => { const rect = target.getBoundingClientRect (); tooltip.style .top = `${rect.bottom + window .scrollY + 5 } px` ; tooltip.style .left = `${rect.left + window .scrollX} px` ; tooltip.style .display = 'block' ; }); target.addEventListener ('mouseleave' , () => { tooltip.style .display = 'none' ; }); </script >
注意 :getBoundingClientRect()
获取的是相对于视口的坐标。如果你的提示框是 position: absolute
并且其定位父级是 <body>
,那么设置 .style.top
时需要加上 window.scrollY
,才能将其从“视口坐标”转换为“文档坐标”。
B. 传统方案:相对于父元素定位 核心痛点 : 在复杂的布局中,我们常常需要将一个元素(如弹出的下拉菜单)精确地定位在某个已定位的父容器 内部,而不是相对于整个浏览器窗口。
解决方案 : 这正是 offset
家族属性的用武之地。
offsetParent
: 一个至关重要的概念,它指的是在 DOM 树中,离当前元素最近的、CSS position
属性 不为 static
的祖先元素。offsetLeft
/ offsetTop
: 元素的外边框边缘,到其 offsetParent
内边距边缘的距离。最大特点 :它的值是 静态的 ,基于 DOM 结构和 CSS 布局决定,不会因为页面滚动而改变 。getBoundingClientRect
vs offsetTop/Left
核心区别
特性 element.getBoundingClientRect().top
element.offsetTop
参照物 浏览器视口 offsetParent
是否随滚动变化 ✅ 会 ❌ 不会 常见用途 Tooltip、判断元素是否可见、吸顶效果 在已定位的容器内布局子元素(如自定义下拉菜单)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > </head > <style > #wrapper { position : relative; width : 400px ; height : 150px ; border : 2px solid green; padding : 10px ; margin-left : 50px ; } #menu-button { position : absolute; top : 20px ; left : 30px ; } #dropdown-menu { position : absolute; display : none; border : 1px solid #ccc ; background : white; list-style : none; padding : 0 ; margin : 0 ; } </style > <body > <div id ="wrapper" > <button id ="menu-button" > 点击展开菜单</button > <ul id ="dropdown-menu" > <li > 选项 1</li > <li > 选项 2</li > </ul > </div > </body > <script > const button = document .getElementById ('menu-button' ); const menu = document .getElementById ('dropdown-menu' ); button.addEventListener ('click' , () => { console .log (`按钮的 offsetParent 是: #${button.offsetParent.id} ` ); console .log (`按钮的 offsetTop: ${button.offsetTop} , offsetLeft: ${button.offsetLeft} ` ); console .log (`按钮的 offsetHeight: ${button.offsetHeight} ` ); const menuTop = button.offsetTop + button.offsetHeight ; const menuLeft = button.offsetLeft ; menu.style .top = `${menuTop} px` ; menu.style .left = `${menuLeft} px` ; menu.style .display = 'block' ; }) </script > </html >
9.8.3. 动态定位:捕获鼠标的位置 掌握了元素的静态位置信息后,我们还需要处理动态的交互——鼠标。当用户点击、移动鼠标时,我们需要知道鼠标指针的精确坐标,才能将元素定位与用户操作关联起来。
event
对象提供了多套坐标属性,它们的 坐标系参照物 也各不相同。
坐标属性 参照物 描述 clientX
/ clientY
浏览器视口 最常用 。鼠标指针相对于浏览器 当前可见窗口 左上角的坐标。pageX
/ pageY
文档 鼠标指针相对于整个 HTML 文档左上角的坐标。pageY = clientY + 页面垂直滚动距离
。 offsetX
/ offsetY
目标元素 鼠标指针相对于触发事件的那个元素的 内边距(padding)左上角 的坐标。 screenX
/ screenY
电脑屏幕 鼠标指针相对于用户 整个显示器屏幕 左上角的坐标。不常用。
9.8.4. 综合实战:实现电商平台“图片放大镜”效果 承上启下 : 理论学习的最终目的是应用于实战。为了将本章所学的 元素查找
、样式操作
、事件监听
以及 元素几何定位
等核心知识点融会贯通,我们将从零开始,用原生 JavaScript 复刻一个在电商平台中极其常见的高级交互——“图片放大镜” 。
这个案例将完美展现我们如何协同运用多个 DOM API 来构建一个复杂的、有状态的 UI 组件。它综合运用了:
事件监听 : mouseenter
, mouseleave
, mousemove
捕捉用户意图。几何定位 : getBoundingClientRect()
获取中图容器的位置,结合 event.clientX/Y
计算出鼠标在容器内的 相对坐标 。尺寸测量 : offsetWidth/Height
用于计算遮罩层移动的边界。样式操作 : 实时更新遮罩层 (layer
) 和大图背景 (large
) 的位置。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > .goods-image { width : 480px ; height : 400px ; position : relative; display : flex; font-family : sans-serif; } .middle { width : 400px ; height : 400px ; background : #f5f5f5 ; position : relative; border-radius : 8px ; overflow : hidden; cursor : move; } .middle img { width : 100% ; height : 100% ; object-fit : cover; } .small { width : 80px ; margin-left : 12px ; list-style : none; padding : 0 ; } .small li { width : 68px ; height : 68px ; margin-bottom : 12px ; cursor : pointer; border : 2px solid #e8e8e8 ; border-radius : 4px ; overflow : hidden; transition : all 0.2s ; } .small li :hover , .small li .active { border-color : #007bff ; } .small li img { width : 100% ; height : 100% ; object-fit : cover; } .layer { width : 200px ; height : 200px ; background : rgba (0 , 0 , 0 , 0.2 ); left : 0 ; top : 0 ; position : absolute; pointer-events : none; border-radius : 4px ; backdrop-filter : blur (2px ); display : none; } .large { position : absolute; top : 0 ; left : 412px ; width : 400px ; height : 400px ; z-index : 500 ; box-shadow : 0 4px 15px rgba (0 , 0 , 0 , 0.1 ); background-repeat : no-repeat; background-size : 800px 800px ; background-color : #f8f8f8 ; border : 1px solid #e8e8e8 ; border-radius : 8px ; display : none; } </style > </head > <body > <div class ="goods-image" > <div class ="middle" > <img src ="https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop" alt ="" /> <div class ="layer" > </div > </div > <ul class ="small" > <li class ="active" > <img src ="https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop" alt ="" /> </li > <li > <img src ="https://images.unsplash.com/photo-1434389677669-e08b4cac3105?w=400&h=400&fit=crop" alt ="" /> </li > <li > <img src ="https://images.unsplash.com/photo-1445205170230-053b83016050?w=400&h=400&fit=crop" alt ="" /> </li > </ul > <div class ="large" > </div > </div > </body > <script > const middleBox = document .querySelector ('.goods-image .middle' ); const middleImg = document .querySelector ('.middle img' ); const layer = document .querySelector ('.middle .layer' ); const largeBox = document .querySelector ('.goods-image .large' ); const smallList = document .querySelector ('.goods-image .small' ); largeBox.style .backgroundImage = `url(${middleImg.src} )` ; smallList.addEventListener ('mouseover' , (event ) => { let targetLi = null ; if (event.target .tagName === 'IMG' ) { targetLi = event.target .parentElement ; } if (targetLi) { const currentActive = smallList.querySelector ('.active' ); if (currentActive) { currentActive.classList .remove ('active' ); } targetLi.classList .add ('active' ); const newImgSrc = targetLi.querySelector ('img' ).src ; middleImg.src = newImgSrc; largeBox.style .backgroundImage = `url(${newImgSrc} )` ; } }); middleBox.addEventListener ('mouseenter' , () => { layer.style .display = 'block' ; largeBox.style .display = 'block' ; }); middleBox.addEventListener ('mouseleave' , () => { layer.style .display = 'none' ; largeBox.style .display = 'none' ; }); middleBox.addEventListener ("mousemove" , (event ) => { const rect = middleBox.getBoundingClientRect (); const mouseX = event.clientX - rect.left ; const mouseY = event.clientY - rect.top ; let layerX = mouseX - layer.offsetWidth / 2 ; let layerY = mouseY - layer.offsetHeight / 2 ; const maxX = middleBox.offsetWidth - layer.offsetWidth ; const maxY = middleBox.offsetHeight - layer.offsetHeight ; layerX = Math .max (0 , Math .min (layerX, maxX)); layerY = Math .max (0 , Math .min (layerY, maxY)); layer.style .left = `${layerX} px` ; layer.style .top = `${layerY} px` ; const scale = 2 ; largeBox.style .backgroundPosition = `${-layerX * scale} px ${-layerY * scale} px` ; }) </script > </html >
案例总结 这个看似复杂的交互,完全是由我们本章学习的基础知识构建而成。通过这个案例,我们可以深刻体会到,只有精准地获取和计算元素的尺寸与坐标,才能构建出像素级完美的动态交互效果。掌握本章内容,是您从基础 DOM 操作迈向高级 UI 开发的关键一步。
9.9. DOM 性能优化:重绘与回流 我们已经掌握了各式各样强大的 DOM API,可以随心所欲地改变页面。但一个不容忽视的问题是:DOM 操作是昂贵的 。不恰当的、频繁的 DOM 操作是导致页面卡顿、动画掉帧、用户体验下降的头号元凶。
本节,我们将通过一个可视化的性能对比实验,来深入理解其背后的原理,并学会如何写出高性能的 DOM 操作代码。
9.9.1. 实战场景:瀑布流布局的初始定位 核心痛点 : 假设我们需要为一个包含 1000 个图片卡的瀑布流布局进行初始定位。每个卡片的位置都需要通过 JavaScript 动态计算并设置。
让我们用两种截然不同的方式来实现这个需求,并直观地感受它们的性能差异。
版本一:性能灾难 (循环内“读写”交错) 在这个版本中,我们在循环内部对每个元素进行“写”操作(设置 style.top
),然后又立即进行一次“读”操作(item.offsetTop
,即使这个读取没有实际用途)。这种“写后即读”的模式是性能杀手。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > #container { position : relative; width : 100% ; border : 1px solid #ccc ; height : 300px ; overflow-y : scroll; } .item { position : absolute; width : 100px ; height : 30px ; border : 1px solid steelblue; background : aliceblue; text-align : center; line-height : 30px ; } </style > </head > <body > <div id ="controls" > <button id ="bad-btn" > 运行性能灾难版 (1000个元素)</button > <div id ="log-output" > 等待操作...</div > </div > <div id ="container" > </div > </body > <script > const container = document .getElementById ('container' ); const badBtn = document .getElementById ('bad-btn' ); const log = document .getElementById ('log-output' ); badBtn.addEventListener ('click' , () => { container.innerHTML = '' ; log.textContent = '开始渲染 (性能灾难版)...' ; const startTime = performance.now (); for (let i = 0 ; i < 1000 ; i++) { const item = document .createElement ('div' ); item.className = 'item' ; item.style .top = (i * 32 ) + 'px' ; item.textContent = `Item ${i} ` ; container.append (item); const uselessValue = item.offsetTop ; } const endTime = performance.now (); log.textContent = `[性能灾难版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2 ) } ms` ; }); </script > </html >
点击按钮,你会发现页面可能会卡顿数秒! 这就是“强制同步布局”的威力。每次循环,浏览器都不得不执行一次完整的“计算布局 -> 渲染”流程,1000 个元素就意味着近 1000 次回流。
9.9.2. 性能瓶颈的根源:浏览器的渲染队列 要理解为什么上述代码如此之慢,我们必须了解浏览器为了优化性能所做的一项重要工作:异步渲染队列 。
当你通过 JS 修改 DOM 样式时(“写”操作),浏览器并不会立即执行渲染。它会将这些操作先存放到一个队列中。然后,在某个合适的时机(通常是当前 JS 任务执行完毕后),浏览器会批量处理这个队列里的所有修改,计算一次布局(回流),然后绘制一次屏幕(重绘)。这种“攒一批再干”的模式极大地提升了性能。
然而,如果你在“写”操作之后,立即进行“读”操作(如获取 offsetTop
, clientWidth
等需要精确布局信息的属性),就打破了这个机制。为了给你一个准确的值,浏览器必须 立即清空队列,强制执行回行和重绘 。
回流 : 当修改影响了元素的 几何属性 (尺寸、位置、边距等),浏览器需重新计算页面上所有受影响元素的位置和大小。这是最昂贵的操作。重绘 : 当修改只影响元素的 外观样式 (颜色、背景等)而不影响布局时,浏览器只需重新“粉刷”受影响的区域。核心原理 : 回流必然导致重绘,重绘不一定导致回流 。而 在“写”操作后立即“读”,会强制触发回流 。我们的优化目标就是避免这种强制同步行为。
9.9.3. 解决方案:读写分离与批量操作 策略一:彻底分离“读”和“写” 解决方案 : 遵循一个简单的黄金法则——先集中读取所有你需要的信息,再集中进行所有 DOM 的写入操作 。
在这个版本中,我们先在一个循环里计算出所有元素的目标 top
值并存储在一个数组中。这个过程完全不涉及 DOM 操作。然后,在第二个循环里,我们一次性地将所有样式应用到 DOM 元素上,中间不进行任何读取。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > #container { position : relative; width : 100% ; border : 1px solid #ccc ; height : 300px ; overflow-y : scroll; } .item { position : absolute; width : 100px ; height : 30px ; border : 1px solid steelblue; background : aliceblue; text-align : center; line-height : 30px ; } </style > </head > <body > <div id ="controls" > <button id ="bad-btn" > 运行性能优化版 (1000个元素)</button > <div id ="log-output" > 等待操作...</div > </div > <div id ="container" > </div > </body > <script > const container = document .getElementById ('container' ); const badBtn = document .getElementById ('bad-btn' ); const log = document .getElementById ('log-output' ); badBtn.addEventListener ('click' , () => { container.innerHTML = '' ; log.textContent = '开始渲染 (高性能版)...' ; const startTime = performance.now (); const positions = []; for (let i = 0 ; i < 1000 ; i++) { positions.push (i * 32 ); } for (let i = 0 ; i < 1000 ; i++) { const item = document .createElement ('div' ); item.className = 'item' ; item.style .top = positions[i] + 'px' ; item.textContent = `Item ${i} ` ; container.appendChild (item); } const endTime = performance.now (); log.textContent = `[高性能版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2 ) } ms` ; }); </script > </html >
点击按钮,你会发现渲染几乎是瞬时完成的! 因为所有的“写”操作都被浏览器有效地缓存和批量处理了,整个过程可能只触发了 一次回流 。
策略二:使用 DocumentFragment
进行批量插入 当需要创建并插入大量新元素时,DocumentFragment
是一个绝佳的工具。它是一个存在于内存中的、临时的 DOM 容器。
工作原理 : 先将所有新创建的元素添加到这个“内存碎片”中,这个过程 完全不会触发回流和重绘 。最后,再一次性地将这个完整的“碎片”插入到真实 DOM 中。效果 : 将上千次独立的插入操作,优化为 仅仅一次 对真实 DOM 的修改。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > #container { position : relative; width : 100% ; border : 1px solid #ccc ; height : 300px ; overflow-y : scroll; } .item { position : absolute; width : 100px ; height : 30px ; border : 1px solid steelblue; background : aliceblue; text-align : center; line-height : 30px ; } </style > </head > <body > <div id ="controls" > <button id ="fragment-btn" > 运行DocumentFragment优化版 (1000个元素)</button > <div id ="log-output" > 等待操作...</div > </div > <div id ="container" > </div > </body > <script > const container = document .getElementById ('container' ); const fragmentBtn = document .getElementById ('fragment-btn' ); const log = document .getElementById ('log-output' ); fragmentBtn.addEventListener ('click' , () => { container.innerHTML = '' ; log.textContent = '开始渲染 (DocumentFragment优化版)...' ; const startTime = performance.now (); const fragment = document .createDocumentFragment (); for (let i = 0 ; i < 1000 ; i++) { const item = document .createElement ('div' ); item.className = 'item' ; item.style .top = (i * 32 ) + 'px' ; item.textContent = `Item ${i} ` ; fragment.append (item); } container.append (fragment); const endTime = performance.now (); log.textContent = `[DocumentFragment优化版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2 ) } ms` ; }); </script > </html >
9.9.4. 深入理解:回流与重绘 我们在前面已经知道,在“写”操作后立即“读”,会强制浏览器清空渲染队列,触发回流。现在,让我们更系统地定义这两个概念。
浏览器的渲染过程可以大致分为:
解析 HTML 构建 DOM 树。 解析 CSS 构建 CSSOM 树。 将 DOM 和 CSSOM 合并,生成 渲染树 。 根据渲染树,计算每个节点在屏幕上的确切位置和大小,这个过程称为 布局 或 回流 。 根据计算好的布局信息,将节点绘制到屏幕上,这个过程称为 绘制 或 重绘 。 页面首次加载时,至少会经历一次回流和重绘。而我们后续的 DOM 和 CSSOM 操作,则会触发后续的回流与重绘。
什么是回流 回流 是指当渲染树中的一部分因为元素的规模尺寸、布局、隐藏等改变而需要重新构建的过程。可以把它想象成对网页的“骨架”或“蓝图”进行重新计算。一个节点的回流可能会导致其所有子节点以及 DOM 中紧随其后的同级节点、甚至父节点的“连锁反应”。
常见触发回流的操作 :
页面初始加载 :这是不可避免的第一次回流。添加或删除可见的 DOM 元素 。元素位置改变 :position
, top
, left
等。元素尺寸改变 :width
, height
, padding
, border
, margin
等。元素内容改变 :例如,文本数量或图片大小的改变,导致元素尺寸变化。字体大小改变 。调整浏览器窗口大小 (resize
事件)。获取需要计算的 DOM 属性 :这就是我们之前遇到的“性能杀手”。当你读取如 offsetTop
, offsetLeft
, offsetWidth
, offsetHeight
, scrollTop
, scrollLeft
, clientTop
, clientWidth
, getComputedStyle()
等属性时,浏览器为了返回精确值,必须立即执行回流。什么是重绘 重绘 是指当渲染树中的一些元素需要更新属性,而这些属性只影响元素的外观、风格,而不会影响其布局时,所发生的过程。可以把它想象成只对网页的某个部分重新“上色”或“化妆”,而不需要动其骨架。
常见触发重绘的属性 :
color
border-style
/ border-radius
background
相关属性 (background-color
, background-image
等)visibility
text-decoration
outline
相关属性box-shadow
核心关系 : 回流必然导致重绘,但重绘不一定导致回流 。比如,改变元素的 width
,既改变了布局(回流),也需要重新绘制它(重绘)。而改变元素的 background-color
,只影响外观,不影响布局,所以只会触发重绘,性能开销小得多。我们的首要优化目标是 尽量避免和减少回流 。
9.9.5. DOM 性能优化黄金法则总结 基于以上原理,我们可以总结出一些简单实用的高性能 DOM 操作法则:
读写分离,集中操作 :这是最重要的法则。在修改 DOM 之前,先通过循环或其它方式将所有需要读取的值(如元素尺寸、位置)缓存到变量中。然后,在另一个集中的步骤中,完成所有的“写”操作(修改样式、增删元素)。
使用 CSS class 合并样式变更 :不要逐条修改 style
属性,这可能导致多次回流。更好的做法是预先定义好一个 CSS 类,然后一次性地用 className
或 classList.add()
来切换样式。
1 2 3 4 5 6 7 8 el.style .width = '100px' ; el.style .height = '100px' ; el.style .border = '1px solid red' ; el.classList .add ('new-style' );
批量操作 DOM,善用 DocumentFragment
:如前所述,当需要添加多个元素时,先将它们添加到 DocumentFragment
中,最后一次性追加到真实 DOM,将多次回流合并为一次。
对复杂动画使用 absolute
或 fixed
定位 :将需要执行动画的元素脱离文档流(position: absolute/fixed
)。这样,它的变化只会影响自身和一个小的图层,而不会引起整个页面的回流,极大地提升动画性能。
谨慎使用 display: none
:使用 display: none
隐藏元素会触发回流,而使用 visibility: hidden
只会触发重绘,因为它虽然不可见,但仍在布局中占据空间。根据需求选择合适的方式。
使用虚拟 DOM :现代前端框架(如 Vue, React)的性能法宝之一。它们通过在内存中维护一个轻量的 JavaScript 对象树(虚拟 DOM)来模拟真实 DOM。当状态变更时,它们会计算出新旧虚拟树的差异(Diff),然后只将这些最小化的差异批量应用到真实 DOM 上,从而最大限度地减少了直接、昂贵的 DOM 操作。
9.9.6. 控制执行时机:定时器、防抖与节流 除了减少单次操作的开销,控制 操作的频率 也是性能优化的关键,尤其是在处理高频触发的事件(如 resize
, scroll
, input
)时。
基础工具:定时器 JavaScript 提供了 setTimeout
和 setInterval
两个函数,它们向任务队列中添加定时任务,让我们能够延迟或周期性地执行代码。
setTimeout(callback, delay)
: 指定 callback
函数在 delay
毫秒之后 执行一次 。它返回一个定时器 ID,可用于 clearTimeout()
取消。
1 2 3 4 5 6 const timerId = setTimeout (() => { console .log ("这段代码在1秒后执行" ); }, 1000 );
this
指向问题 : 如果 setTimeout
的回调函数是一个对象的方法,该方法内部的 this
将默认指向全局对象(在浏览器中是 window
),而不是该对象本身。可以使用箭头函数或 .bind()
来修正 this
指向。
setInterval(callback, delay)
: 指定 callback
函数 每隔 delay
毫秒就执行一次。它同样返回一个 ID,用于 clearInterval()
停止。
下面的例子使用 setInterval
实现一个简单的淡出动画。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 <!DOCTYPE html > <html lang ="en" > <head > <title > SetInterval Animation</title > <style > #someDiv { width : 100px ; height : 100px ; background : steelblue; opacity : 1 ; transition : opacity 0.5s ; } </style > </head > <body > <div id ="someDiv" > </div > <script > const div = document .getElementById ('someDiv' ); let opacity = 1 ; const fader = setInterval (function ( ) { opacity -= 0.05 ; if (opacity > 0 ) { div.style .opacity = opacity; } else { opacity = 0 ; div.style .opacity = opacity; clearInterval (fader); } }, 30 ); </script > </body > </html >
动画性能警示 :虽然 setInterval
可以实现动画,但它不是最佳选择。它的执行时机不精确,且与浏览器渲染刷新率无关,可能导致掉帧。现代 Web 动画的首选是 requestAnimationFrame
,它能确保动画函数在浏览器下一次重绘之前执行,从而实现更流畅、更高性能的动画效果。
实战进阶:防抖 想象一个场景:监听窗口的 scroll
事件来打印滚动条位置。
1 2 3 4 window .addEventListener ('scroll' , () => { console .log ('滚动条位置:' , window .scrollY ); });
如果你快速滚动页面,会发现控制台疯狂输出,函数执行频率极高!如果这个回调函数内部有复杂的 DOM 操作,将导致严重的性能问题。
防抖 的策略是:对于在短时间内连续触发的事件,只执行最后一次 。就像电梯关门,只要有人在指定时间内进来,关门计时器就重置,直到最后一个人进来后计时结束,门才关闭。
实现防抖 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function debounce (fn, delay ) { let timer = null ; return function (...args ) { if (timer) { clearTimeout (timer); } timer = setTimeout (() => { fn.apply (this , args); }, delay); } }
应用防抖 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 <!DOCTYPE html > <html > <head > <title > Debounce Demo</title > <style > body { height : 2000px ; font-family : sans-serif; } #log-output { position : fixed; top : 10px ; left : 10px ; background : #fff ; border : 1px solid #ccc ; padding : 10px ; } </style > </head > <body > <div id ="log-output" > <p > <strong > 未防抖:</strong > <span id ="log-raw" > 0</span > </p > <p > <strong > 防抖 (500ms):</strong > <span id ="log-debounced" > 0</span > </p > </div > <script > function debounce (fn, delay ) { let timer = null ; return function (...args ) { if (timer) { clearTimeout (timer); } timer = setTimeout (() => { fn.apply (this , args); }, delay); } } const logRaw = document .getElementById ('log-raw' ); const logDebounced = document .getElementById ('log-debounced' ); let rawCount = 0 ; let debouncedCount = 0 ; function updateLog ( ) { logDebounced.textContent = ++debouncedCount; } const debouncedUpdate = debounce (updateLog, 500 ); window .addEventListener ('scroll' , () => { logRaw.textContent = ++rawCount; debouncedUpdate (); }); </script > </body > </html >
动手试试 :快速滚动上面的沙箱页面,你会看到“未防抖”的计数器飞速增长,而“防抖”的计数器只有在你停止滚动半秒后才会更新。这对于搜索框输入建议、窗口 resize 事件等场景非常有用。
实战进阶:节流 防抖在某些场景下并不完美。比如,如果我们想在拖拽或滚动时实时更新某个元素的位置,防抖会导致只有在停止时才更新,体验不佳。这时就需要 节流 。
节流 的策略是:在指定的时间间隔内,事件处理函数 最多只执行一次 ,确保函数以一个可控的频率被调用。就像技能冷却,放了一次技能后,必须等 CD 转好才能放第二次。
实现节流 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function throttle (fn, delay ) { let canRun = true ; return function (...args ) { if (!canRun) { return ; } canRun = false ; setTimeout (() => { fn.apply (this , args); canRun = true ; }, delay); } }
应用节流 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 <!DOCTYPE html > <html > <head > <title > Throttle Demo</title > <style > body { height : 2000px ; font-family : sans-serif; } #log-output { position : fixed; top : 10px ; left : 10px ; background : #fff ; border : 1px solid #ccc ; padding : 10px ; } </style > </head > <body > <div id ="log-output" > <p > <strong > 未节流:</strong > <span id ="log-raw" > 0</span > </p > <p > <strong > 节流 (500ms):</strong > <span id ="log-throttled" > 0</span > </p > </div > <script > function throttle (fn, delay ) { let canRun = true ; return function (...args ) { if (!canRun) { return ; } canRun = false ; setTimeout (() => { fn.apply (this , args); canRun = true ; }, delay); } } const logRaw = document .getElementById ('log-raw' ); const logThrottled = document .getElementById ('log-throttled' ); let rawCount = 0 ; let throttledCount = 0 ; function updateLog ( ) { logThrottled.textContent = ++throttledCount; } const throttledUpdate = throttle (updateLog, 500 ); window .addEventListener ('scroll' , () => { logRaw.textContent = ++rawCount; throttledUpdate (); }); </script > </body > </html >
动手试试 :持续上下滚动上面的沙箱页面,你会看到“未节流”的计数器依然飞速增长,而“节流”的计数器会以大约每半秒一次的稳定频率进行更新,既保证了响应性,又避免了性能浪费。
防抖与节流的应用场景总结 使用防抖 :
搜索框输入建议 :用户输完一串字符后才发送请求,而不是每输入一个字母都发。文本编辑器自动保存 :用户停止打字一段时间后才执行保存。窗口 resize
事件 :当用户调整完窗口大小后,再重新计算布局。使用节流 :
DOM 元素拖拽 :在拖拽过程中,按一定频率更新元素位置。页面滚动事件 :如实现滚动加载、高亮导航栏等,需要持续响应但又不能过于频繁。游戏中的射击 :按住开火键,子弹以固定的频率射出。核心区别 :防抖是“你别急,等你停了我再做”,强调 最终结果 ;节流是“你慢点,按我的节奏来”,强调 过程中的平均响应
9.10. 本章核心原理与高频面试题 核心原理速查 分类 关键项 / 概念 核心描述与最佳实践 核心模型 DOM (文档对象模型) 浏览器将 HTML 解析成的、可用 JS 操控的 树形节点结构 。 元素查找 querySelector
/ querySelectorAll
(推荐) 使用 CSS 选择器查找,返回单个元素或 静态 NodeList
,功能强大且行为可预测。内容操作 textContent
vs innerHTML
优先使用 textContent
以避免 XSS 安全风险。仅在确切需要解析 HTML 时才使用 innerHTML
。样式操作 element.classList
(推荐) 通过 add/remove/toggle
操作 CSS 类,实现行为与表现的分离,比直接操作 style
对象更优。结构变更 现代 API (append
, remove
等) 比 appendChild
, removeChild
等传统 API 更简洁、功能更强(例如支持多个参数)。 事件模型 事件流 (捕获与冒泡)事件在 DOM 树中从外到内(捕获),再从内到外(冒泡)的传播过程。 事件委托 (核心模式) 在父元素上监听,利用事件冒泡和 event.target
处理子元素事件,高效且支持动态内容。几何定位 getBoundingClientRect()
(推荐) 获取元素相对于 视口 的位置和尺寸,是实现 Tooltip、可视区判断等高级交互的首选。性能优化 回流 与 重绘 回流(计算布局)代价极高。应避免频繁触发,如在循环中读取布局属性。 DocumentFragment
(核心工具) 在内存中进行批量 DOM 操作的容器,最后一次性插入真实 DOM,可将多次回流优化为一次。
高频面试题与陷阱 面试官深度追问
2025-08-29
我
查找:这是所有操作的基础。主要是通过 querySelector
和 querySelectorAll
使用 CSS 选择器来精准地找到一个或一批元素。
我
修改:找到元素后,我们可以修改它的内容(通过 textContent
或 innerHTML
)、HTML 属性(通过 setAttribute
或 dataset
)以及 CSS 样式(主要通过 classList
)。
我
结构变更:这指的是动态地改变页面结构,包括创建新元素 (createElement
)、将元素添加到页面 (append
)、以及从页面移除元素 (remove
)。 我
事件处理:为元素添加事件监听器 (addEventListener
),以响应用户的交互,这是让页面“活”起来的关键。 很好。那你能解释一下什么是“事件代理”或“事件委托”吗?它主要解决了什么问题?
我
事件委托是一种利用事件冒泡机制的 DOM 事件处理模式。它的核心思想是,不给大量的子元素逐一绑定事件监听器,而是只给它们的共同父元素绑定一个监听器。
我
当某个子元素被触发事件时,这个事件会沿着 DOM 树向上冒泡,最终被父元素的监听器捕获。在父元素的监听函数中,我们可以通过检查 event.target
属性,来判断事件的真正来源是哪个子元素,然后执行相应的逻辑。
我
它主要解决了两个核心问题:第一是性能问题,极大地减少了事件监听器的数量,节省了内存;第二是动态内容问题,对于后续通过 JS 动态添加到父容器中的新子元素,这个委托的监听器依然对它们有效,无需重新绑定。
非常好。最后一个问题,如果让你写一个函数,判断一个元素是否完全出现在了浏览器的可视区域内,你会怎么实现?
我
我会使用 element.getBoundingClientRect()
方法来实现。这个方法返回一个对象,包含了元素相对于浏览器视口的 top
, bottom
, left
, right
等坐标。
我
一个元素完全可见,必须同时满足四个条件:它的顶部必须在视口的上边界之下,它的底部必须在视口的下边界之上,它的左边必须在视口的左边界之右,它的右边必须在视口的右边界之左。
第十章:浏览器对象模型 (BOM) 与现代 Web API 摘要 : 在第九章,我们征服了文档本身(DOM)。现在,我们将把视野扩大到承载文档的整个 浏览器环境 。本章将深入探讨 浏览器对象模型 (BOM) ,这是一套让我们能够与浏览器窗口、导航历史、用户屏幕乃至客户端存储进行交互的强大 API。我们将从一个个真实且重要的开发场景出发,学习如何控制页面跳转、让网站拥有“记忆”、感知用户环境,并最终掌握一系列让网页功能比肩原生应用的 2025+ 现代 Web API。
在本章中,我们将围绕“赋予网页更多能力”这一核心,探索以下主题:
首先,我们将理解 window
对象 作为全局上下文的“上帝视角”。 接着,我们将学习如何通过 页面导航与历史管理 ,构建现代单页应用(SPA)的基石。 然后,我们将深入 客户端数据持久化 方案,让网页拥有“记忆”用户的能力。 紧接着,我们将学习如何进行 环境感知与特性检测 ,编写出更健壮的跨平台代码。 最后,我们将探索一系列 现代 Web API ,为我们的网页赋予调用系统分享、安全读写剪贴板等原生能力。 10.1. window
对象:作为全局上下文的“上帝视角” 核心内容 : 在我们与浏览器进行任何交互之前,必须先认识 JavaScript 在浏览器环境中的“根”对象——window
。它身兼二职:既是 BOM 的核心,代表着整个浏览器窗口;又是所有 JavaScript 代码执行的 全局作用域 (Global Scope) 。
理解 window
作为全局作用域,意味着在脚本顶层声明的 var
变量和函数,都会自动成为 window
对象的属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <script > var globalVar = "我是一个全局变量" ; function globalFunc ( ) { console .log ("我是一个全局函数" ); } console .log ('通过 window 访问变量:' , window .globalVar ); window .globalFunc (); let blockScopedVar = "我不会出现在 window 上" ; console .log ('window 上有 blockScopedVar 吗?' , window .blockScopedVar ); </script >
1 2 3 通过 window 访问变量: 我是一个全局变量 我是一个全局函数 window 上有 blockScopedVar 吗? undefined
window
对象还提供了一些基础的、用于与用户进行简单模态交互的方法。
方法 描述 返回值 alert(message)
弹出一个带有一段信息和一个“确定”按钮的警告框。 undefined
confirm(message)
弹出一个带有信息、一个“确定”和一个“取消”按钮的对话框。 true
(用户点击确定) 或 false
(用户点击取消)prompt(message, [default])
弹出一个带有一段信息和一个文本输入框的对话框。 用户输入的字符串,或 null
(用户点击取消)
示例:一个简单的用户确认流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <button id ="delete-btn" > 删除重要文件</button > <p id ="status-msg" > </p > <script > const deleteBtn = document .getElementById ('delete-btn' ); const statusMsg = document .getElementById ('status-msg' ); deleteBtn.addEventListener ('click' , () => { const isConfirmed = window .confirm ('你确定要删除这个重要文件吗?此操作不可逆!' ); if (isConfirmed) { const password = window .prompt ('请输入你的管理员密码以确认删除:' ); if (password === '123456' ) { statusMsg.textContent = '文件已成功删除。' ; window .alert ('操作成功!' ); } else if (password !== null ) { statusMsg.textContent = '密码错误,删除操作已取消。' ; } else { statusMsg.textContent = '用户取消了操作。' ; } } else { statusMsg.textContent = '删除操作已取消。' ; } }); </script >
alert
, confirm
, prompt
都会 阻塞 JavaScript 的执行和页面的渲染,直到用户与之交互。它们在现代 Web 开发中应谨慎使用,通常只用于简单的调试或需要强行中断用户流程的场景。更复杂的交互应使用自定义的 HTML/CSS 模态框组件。
10.2. 页面导航与历史管理:构建单页应用 (SPA) 的基石 核心场景 : 在传统网站中,每次点击链接都会导致整个页面的白屏、刷新和重新加载,这种体验稍显缓慢。而现代 Web 应用(如 GitHub, Gmail)感觉更像桌面程序,它们可以在不刷新整个页面的情况下,流畅地切换内容并同步更新浏览器地址栏中的 URL。
这种流畅的体验是如何实现的?答案就藏在 location
和 history
这两个 BOM 对象中。它们是所有现代前端框架(如 Vue Router, React Router)实现“客户端路由”的底层基石。
10.2.1. location
对象:URL 的编程接口 window.location
对象提供了对当前页面 URL 的详细信息的访问,并允许我们通过代码来触发页面跳转。
读取 URL 信息 location
对象将一个完整的 URL 精确地分解为多个部分,方便我们读取。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <p > 当前页面的 URL 信息:</p > <pre id ="location-output" > </pre > <script > const output = document .getElementById ('location-output' ); const info = ` href: ${location.href} (完整 URL) protocol: ${location.protocol} (协议) hostname: ${location.hostname} (主机名) port: ${location.port} (端口) host: ${location.host} (主机名 + 端口) pathname: ${location.pathname} (路径) search: ${location.search} (查询字符串) hash: ${location.hash} (锚点) ` ; output.textContent = info.trim (); </script >
sandbox
环境下的 URL 可能比较特殊,但这些属性的分解方式在真实 URL 中是完全一致的。
修改 URL 以实现跳转 方法/属性 描述 对历史记录的影响 location.href = '...'
(常用) 将页面导航到新的 URL。在历史记录中 新增 一条记录。 location.assign('...')
功能与设置 href
完全相同。 在历史记录中 新增 一条记录。 location.replace('...')
用新的 URL 替换 当前页面。 不 在历史记录中新增记录,用户无法通过“后退”按钮返回。location.reload()
重新加载当前页面。 -
10.2.2. history
对象:与浏览器会话历史交互 window.history
对象允许我们与浏览器的会话历史进行交互。
基础导航 这三个方法模拟了用户点击浏览器“前进”、“后退”按钮的行为。
history.back()
: 后退一步。history.forward()
: 前进一步。history.go(delta)
: 移动到指定位置。history.go(-1)
等同于 back()
,history.go(1)
等同于 forward()
。SPA 路由核心:pushState
与 replaceState
这正是实现 SPA “无刷新”导航的魔法所在。这两个方法可以在 不触发页面刷新的前提下,动态地修改浏览器地址栏的 URL ,并管理会话历史。
history.pushState(state, title, url)
: 向会话历史栈中 推入 一个新的状态。history.replaceState(state, title, url)
: 替换 当前的历史状态。参数 :
state
: 一个与新历史记录相关联的 JavaScript 对象。当用户通过前进/后退导航到这个状态时,popstate
事件会携带这个对象。title
: 一个简短的标题,目前大部分浏览器会忽略此参数。url
: 新的历史记录的 URL。必须与当前页面同源。popstate
事件 : 当用户点击浏览器的前进/后退按钮,或者通过代码调用 history.back()
等方法导致活动历史记录发生变化时,window
会触发 popstate
事件。
综合示例:从零实现一个最简 SPA 路由器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > nav a { margin : 0 10px ; cursor : pointer; color : blue; text-decoration : underline; } #app-root { margin-top : 20px ; padding : 15px ; border : 1px solid #ccc ; } </style > </head > <body > <nav > <a data-path ="/" > 主页</a > <a data-path ="/about" > 关于我们</a > <a data-path ="/contact" > 联系方式</a > </nav > <div id ="app-root" > </div > </body > <script > const appRoot = document .getElementById ('app-root' ); function render (path ) { let content = '' ; switch (path) { case '/about' : content = '<h1>关于我们</h1><p>我们是一个追求极致的笔记平台</p>' ; break ; case '/contact' : content = '<h1>联系方式</h1><p>请发送邮件至 3381292732@qq.com</p>' ; break ; default : content = '<h1>主页</h1><p>欢迎来到我们的网站!</p>' ; } appRoot.innerHTML = content; } document .querySelector ("nav" ).addEventListener ("click" , (e ) => { if (e.target .tagName === "A" ) { e.preventDefault (); const path = e.target .dataset .path ; history.pushState ({ path : path }, '' , path); render (path); } }); window .addEventListener ('popstate' , (e ) => { const path = e.state ? e.state .path : '/' ; render (path); }); render (location.pathname ); </script > </html >
10.3. 客户端数据持久化:让网页拥有“记忆” 核心场景 : HTTP 协议本身是 无状态 的,这意味着服务器默认不会“记住”你的上一次访问。然而,现代 Web 应用充满了需要“记忆”的场景:
用户关闭了浏览器,下次打开时,网站依然保持着他的登录状态。 用户将商品加入了购物车,刷新页面后,商品依然还在。 用户填写一个复杂的表单,中途不小心关闭了标签页,重新打开后,之前填写的内容还在。 为了解决这些问题,浏览器提供了多种客户端存储技术,让我们的网页能够在用户的设备上持久化数据。
10.3.1. Web Storage API:现代客户端存储方案 Web Storage API 是 HTML5 引入的,旨在提供比 Cookie 更简单、更强大的客户端存储方案。它分为 localStorage
和 sessionStorage
两种。
localStorage
: 永久的本地存储localStorage
用于 永久性 地在本地存储数据。除非用户手动清除浏览器缓存或代码主动删除,否则数据将永远存在,即使关闭浏览器或电脑重启。
API 方法 描述 setItem(key, value)
存储一个键值对。 getItem(key)
根据键读取一个值。 removeItem(key)
根据键删除一个键值对。 clear()
清空所有存储的数据。
核心原理 : localStorage
只能存储 字符串 。如果要存储对象或数组,必须先用 JSON.stringify()
将其转换为 JSON 字符串,读取时再用 JSON.parse()
解析回来。
示例:实现一个可记忆的主题切换器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <style > body .dark-theme { background-color : #333 ; color : #eee ; } #theme-switcher { padding : 10px ; } </style > </head > <body > <button id ="theme-switcher" > 切换主题</button > <p > 当前主题会永久保存在你的浏览器中。</p > </body > <script > const switcherBtn = document .getElementById ('theme-switcher' ); function applyTheme (theme ) { if (theme === 'dark' ) { document .body .classList .add ('dark-theme' ); } else { document .body .classList .remove ('dark-theme' ); } } const savedTheme = localStorage .getItem ('theme' ); if (savedTheme) { applyTheme (savedTheme); } switcherBtn.addEventListener ('click' , () => { const currentTheme = document .body .classList .contains ('dark-theme' ) ? 'dark' : 'light' ; const newTheme = currentTheme === 'dark' ? 'light' : 'dark' ; applyTheme (newTheme); localStorage .setItem ('theme' , newTheme); }); </script > </html >
请尝试点击“切换主题”按钮,然后点击 sandbox
右上角的“重新运行”图标(模拟刷新页面),你会发现主题状态被成功保留了。
sessionStorage
: 基于会话的临时存储sessionStorage
的 API 与 localStorage
完全相同 ,但其生命周期完全不同:
生命周期 : sessionStorage
中存储的数据只在 当前浏览器标签页的会话期间 有效。一旦该标签页或浏览器被关闭,数据就会被清除。作用域 : 数据只在当前标签页可见,在另一个标签页中打开同一个网站,也无法访问到。核心应用场景 : sessionStorage
非常适合存储一些 一次性的、临时的 会话数据,例如防止用户在填写多步骤表单时不小心刷新页面而导致数据丢失。
10.3.2. Cookie
: 与服务器通信的信使 Cookie
是最传统的客户端存储技术。与 Web Storage 的主要区别在于,Cookie
的核心使命是 在客户端和服务器之间传递信息 。
核心原理 : 一旦为一个域名设置了 Cookie,那么在后续每一次向该域名发送 HTTP 请求时,浏览器都会 自动 在请求头中带上这些 Cookie。服务器可以读取这些 Cookie 来识别用户、维持会话等。
Cookie
属性描述 Expires
/ Max-Age
设置 Cookie 的过期时间。 Path
指定 Cookie 生效的路径。 Domain
指定 Cookie 生效的域名。 Secure
(安全) 设置后,Cookie 只会在 HTTPS 连接中被发送。HttpOnly
(安全) 设置后,此 Cookie 无法被 JavaScript 访问 (document.cookie
),只能由服务器读写。这是防止 XSS 攻击窃取用户会话 Cookie 的关键防御手段。SameSite
(安全) 控制 Cookie 是否随跨站请求发送,是防御 CSRF 攻击的核心机制。
JavaScript 操作 :
1 2 3 4 5 6 7 8 9 10 11 12 const expiresDate = new Date (Date .now () + 7 * 24 * 60 * 60 * 1000 ).toUTCString ();document .cookie = `username=Prorise; expires=${expiresDate} ; path=/; SameSite=Lax` ;const cookies = document .cookie .split ('; ' ).reduce ((acc, cookie ) => { const [key, value] = cookie.split ('=' ); acc[key] = value; return acc; }, {}); console .log ('当前页面的 Cookies:' , cookies);
1 当前页面的 Cookies: { username: "Prorise" }
10.3.3. 存储方案对比与选型 特性 localStorage
sessionStorage
Cookie
生命周期 永久 单个会话(标签页) 可设置过期时间 容量大小 5MB ~ 10MB 5MB ~ 10MB 约 4KB 与服务器通信 不参与 不参与 自动 随每次请求发送API 易用性 非常简单 (setItem
, getItem
) 非常简单 (setItem
, getItem
) 繁琐,需手动解析字符串 核心应用场景 长期用户设置,离线数据 临时表单数据,单页应用状态 身份认证令牌 (Token) ,会话 ID选型建议 :
需要长期保留在客户端,且不常与服务器交互的数据(如用户偏好设置、主题) -> 使用 localStorage
。仅在单次浏览会话中需要暂存的数据(如复杂表单的草稿) -> 使用 sessionStorage
。需要与服务器进行身份验证或状态保持的数据(如登录令牌 Session ID) -> 使用 Cookie
,并务必设置好 HttpOnly
, Secure
, SameSite
等安全属性。10.4. 环境感知与特性检测:编写健壮的跨平台代码 核心场景 : 一个专业的 Web 应用,不应假设它运行在何种设备或浏览器上。用户的设备可能是高性能台式机,也可能是低性能手机;浏览器可能是最新的 Chrome,也可能是其他内核的浏览器。为了提供一致且可靠的用户体验,我们的代码必须具备“感知”其运行环境并作出相应调整的能力。本节,我们将学习如何利用 navigator
和 screen
对象来实现这一点。
10.4.1. navigator
对象:探测浏览器与设备能力 window.navigator
对象是一个信息仓库,存储了关于浏览器本身及其所在环境的详细信息。
传统方式:用户代理嗅探 (User Agent Sniffing) 过去,开发者常常通过解析 navigator.userAgent
这个包含了浏览器、引擎、操作系统等信息的长字符串,来判断用户正在使用的浏览器。
1 2 3 4 5 6 7 8 9 const ua = navigator.userAgent ;console .log ('你的 User Agent:' , ua);if (ua.includes ('Chrome' )) { console .log ('检测到 Chrome 浏览器。' ); } else if (ua.includes ('Firefox' )) { console .log ('检测到 Firefox 浏览器。' ); }
1 2 你的 User Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 检测到 Chrome 浏览器。
不要这样做! 用户代理嗅探是一种 脆弱且已被废弃 的做法。原因在于:
User Agent 字符串非常复杂且可能被用户或插件修改; 新的浏览器层出不穷,维护一个准确的检测列表几乎不可能。 现代最佳实践:特性检测 (Feature Detection) 现代 Web 开发遵循一个核心原则:我们不应该关心“用户在用什么浏览器”,而应该关心“用户的浏览器支持什么功能”。 这就是特性检测。我们通过检查一个特定的 API 或属性是否存在,来决定是否使用它。
示例:安全地使用现代 API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const copyBtn = document .getElementById ('copy-btn' );copyBtn.addEventListener ('click' , () => { if (navigator.clipboard && navigator.clipboard .writeText ) { navigator.clipboard .writeText ('这是通过现代 API 复制的!' ) .then (() => alert ('已成功复制到剪贴板!' )) .catch (err => console .error ('复制失败:' , err)); } else { alert ('你的浏览器不支持自动复制功能,请手动复制。' ); } }); function updateOnlineStatus ( ) { const statusEl = document .getElementById ('status' ); statusEl.textContent = navigator.onLine ? '设备在线' : '设备离线' ; statusEl.style .color = navigator.onLine ? 'green' : 'red' ; } window .addEventListener ('online' , updateOnlineStatus);window .addEventListener ('offline' , updateOnlineStatus);updateOnlineStatus ();
10.4.2. screen
对象:获取屏幕信息 window.screen
对象提供了关于用户显示器屏幕的信息,这对于需要进行窗口管理或收集分析数据的应用非常有用。
属性 描述 screen.width
/ height
用户屏幕的 完整 宽度和高度(以像素为单位)。 screen.availWidth
/ availHeight
用户屏幕的 可用 宽度和高度,即减去了操作系统界面组件(如 Windows 任务栏或 macOS 菜单栏)后的空间。 screen.colorDepth
返回屏幕的颜色深度(通常是 24 或 32)。 screen.pixelDepth
返回屏幕的像素深度(现代设备上通常与 colorDepth
相同)。
示例:获取屏幕信息并打开一个适配的窗口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <button id ="open-window-btn" > 打开一个适配窗口</button > <pre id ="screen-info" > </pre > <script > const infoEl = document .getElementById ('screen-info' ); const openBtn = document .getElementById ('open-window-btn' ); const screenInfo = ` 完整分辨率: ${screen.width} x ${screen.height} 可用空间: ${screen.availWidth} x ${screen.availHeight} 颜色深度: ${screen.colorDepth} bits ` ; infoEl.textContent = screenInfo.trim (); openBtn.addEventListener ('click' , () => { const popupWidth = screen.availWidth * 0.8 ; const popupHeight = screen.availHeight * 0.8 ; window .open ( 'https://prorise666.site' , '/' , `width=${popupWidth} ,height=${popupHeight} ` ); }); </script >
window.open
可能会被浏览器的弹出窗口拦截器阻止。在实际应用中,通常需要由明确的用户操作(如点击)来触发。
10.5. 2025+ 现代 Web API 精选 承上启下 : 传统的网页能力有限,但随着 Web 平台的发展,浏览器正不断地向 JavaScript 开放更多与操作系统底层交互的能力。这些现代 Web API 正在模糊网页与原生应用 (Native App) 之间的界限,让 Web 应用能够实现更丰富、更强大的功能。本节将精选介绍几个在 2025 年极具实用价值的现代 API。
10.5.1. Clipboard API (剪贴板 API) 核心痛点 : 传统的 document.execCommand('copy')
方法是同步的,API 不友好,且在安全策略日益收紧的现代浏览器中,其行为变得不可靠。我们需要一种更安全、更强大的方式来与系统剪贴板交互。
解决方案 : 现代的 Clipboard API
(navigator.clipboard
) 是基于 Promise
的、异步的,并且与浏览器的权限系统紧密集成,提供了更安全、更强大的读写能力。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 <style > #clipboard-demo textarea { width : 95% ; height : 60px ; margin-bottom : 10px ; } #clipboard-demo button { margin-right : 10px ; } </style > <div id ="clipboard-demo" > <textarea id ="text-to-copy" > 这是你想要复制的文本。</textarea > <button id ="copy-btn" > 复制</button > <button id ="paste-btn" > 粘贴</button > <p id ="clipboard-status" > </p > </div > <script > const textEl = document .getElementById ('text-to-copy' ); const copyBtn = document .getElementById ('copy-btn' ); const pasteBtn = document .getElementById ('paste-btn' ); const statusEl = document .getElementById ('clipboard-status' ); copyBtn.addEventListener ('click' , () => { navigator.clipboard .writeText (textEl.value ) .then (() => { statusEl.textContent = '状态:已成功复制到剪贴板!' ; console .log ('复制成功' ); }) .catch (err => { statusEl.textContent = '状态:复制失败,请检查浏览器权限。' ; console .error ('复制失败:' , err); }); }); pasteBtn.addEventListener ('click' , () => { navigator.clipboard .readText () .then (clipboardText => { textEl.value = clipboardText; statusEl.textContent = '状态:已成功从剪贴板粘贴!' ; console .log ('粘贴成功' ); }) .catch (err => { statusEl.textContent = '状态:粘贴失败,请检查浏览器权限。' ; console .error ('粘贴失败:' , err); }); }); </script >
出于安全考虑,浏览器通常会在首次调用剪贴板 API 时向用户请求权限。此外,读取剪贴板的操作通常要求页面处于激活状态。
10.5.2. Page Visibility API (页面可见性 API) 核心痛点 : 用户打开了 20 个浏览器标签页,我们的页面在后台不可见,但页面上的轮播图动画、轮询服务器的请求却仍在消耗着用户的 CPU、电量和网络资源。
解决方案 : Page Visibility API
允许我们的页面知道自己当前是否对用户可见。我们可以通过监听 document
上的 visibilitychange
事件,并检查 document.hidden
(布尔值) 属性,来智能地暂停或恢复耗时任务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <style > #spinner { width : 50px ; height : 50px ; border : 5px solid #f3f3f3 ; border-top : 5px solid #3498db ; border-radius : 50% ; animation : spin 2s linear infinite; } @keyframes spin { 0% { transform : rotate (0deg ); } 100% { transform : rotate (360deg ); } } #spinner .paused { animation-play-state : paused; } </style > <div id ="spinner" > </div > <p id ="visibility-status" > 当前页面可见,动画正在播放。</p > <script > const spinner = document .getElementById ('spinner' ); const status = document .getElementById ('visibility-status' ); document .addEventListener ('visibilitychange' , () => { if (document .hidden ) { spinner.classList .add ('paused' ); status.textContent = '页面已隐藏,动画暂停以节省资源。' ; console .log ('页面隐藏于' , new Date ().toLocaleTimeString ()); } else { spinner.classList .remove ('paused' ); status.textContent = '当前页面可见,动画正在播放。' ; console .log ('页面恢复于' , new Date ().toLocaleTimeString ()); } }); </script >
请尝试切换到另一个浏览器标签页,等待几秒钟,然后再切换回来,观察动画和文本的变化。
10.5.3. Web Share API (网页分享 API) 核心痛点 : 在移动设备上,用户希望像分享原生 App 内容一样,方便地将网页分享到微信、短信或任何其他 App。传统的做法是为每个社交平台都做一个分享按钮,体验差且不完整。
解决方案 : Web Share API
(navigator.share()
) 允许网页调用操作系统的 原生分享对话框 ,让用户可以选择任意已安装的应用进行分享。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <button id ="share-btn" > 分享本页</button > <p id ="share-result" > </p > <script > const shareBtn = document .getElementById ('share-btn' ); const shareResult = document .getElementById ('share-result' ); shareBtn.addEventListener ('click' , async () => { if (!navigator.share ) { shareResult.textContent = '你的浏览器不支持 Web Share API。' ; return ; } const shareData = { title : 'Prorise 技术笔记' , text : '快来看看这个超棒的笔记平台!' , url : 'https://prorise.site' }; try { await navigator.share (shareData); shareResult.textContent = '感谢你的分享!' ; } catch (err) { shareResult.textContent = `分享失败: ${err.message} ` ; } }); </script >
Web Share API
必须在 安全上下文 (HTTPS) 中,并且通常需要由 明确的用户操作 (如 click
事件)来触发。在 sandbox
中可能无法调用,但在真实的移动端浏览器上会弹出原生分享界面。
10.5.4. Permissions API (权限 API) 核心痛点 : 在需要使用地理位置、通知等敏感功能前,如果我们能预先知道用户当前的授权状态(是“已授权”、“已拒绝”还是“需要询问”),就可以提供更友好的用户体验,而不是唐突地弹出一个权限请求框。
解决方案 : Permissions API
(navigator.permissions.query()
) 提供了一种标准化的方式来查询用户对特定功能的授权状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > </head > <body > <button id ="geo-btn" > 请求地理位置</button > <p id ="permission-status" > 权限状态:未知</p > </body > <script > const geoBtn = document .getElementById ('geo-btn' ); const statusEl = document .getElementById ('permission-status' ); async function checkGeoPermission ( ) { try { const permissionStatus = await navigator.permissions .query ({ name : 'geolocation' }); console .log (permissionStatus.state ) statusEl.textContent = `权限状态: ${permissionStatus.state} ` ; if (permissionStatus.state === 'prompt' ) { navigator.geolocation .getCurrentPosition ( (position ) => { statusEl.textContent = `权限已授予,位置: ${position.coords.latitude} , ${position.coords.longitude} ` ; }, (error ) => { statusEl.textContent = `权限被拒绝或出错: ${error.message} ` ; } ); } permissionStatus.onchange = () => { statusEl.textContent = `权限状态已变为: ${permissionStatus.state} ` ; }; } catch (err) { statusEl.textContent = `无法查询权限: ${err.message} ` ; } } geoBtn.addEventListener ('click' , checkGeoPermission); </script > </html >