浏览器环境中的 JavaScript
用代码操控网页,让页面"活"起来
🎯 学习目标
学完本章,你将能够:
- 能够使用 DOM API(querySelector、getElementById 等)选择和操作页面元素
- 能够使用 addEventListener 绑定事件,理解事件流(捕获、目标、冒泡)
- 能够使用事件委托优化大量子元素的事件绑定
- 能够使用 fetch API 发起 HTTP 请求,处理响应数据
- 能够使用 Web Storage(localStorage、sessionStorage)持久化客户端数据
你有没有想过
网页上的按钮为什么点击后能弹出对话框?购物车里数量变了总价为什么自动更新?
这些交互的背后,都是 DOM 操作 在起作用。浏览器把 HTML 解析成一棵"节点树",JavaScript 通过操作这棵树来改变页面内容、样式和行为。
这一章,你将学会:选元素、改内容、绑事件、存数据——掌握浏览器端 JS 的核心能力。
Part 1: DOM 树与节点
浏览器把 HTML 解析成节点树,理解这棵树是操控网页的第一步
想要用代码改变页面上的内容,第一步不是写代码,而是理解浏览器眼里的"页面"长什么样。浏览器把每个 HTML 标签、每段文字、每个注释都变成一个"节点",串成一棵树。就像家谱一样——谁是谁的父节点、谁是谁的兄弟节点,关系一目了然。搞清楚这棵树的结构,后面选元素、改内容才有方向。
DOM 节点类型
HTML 中的每个标签、每段文字、每个注释,都是一个"节点"
DOM(Document Object Model)是浏览器提供的一套 API,让你用 JavaScript 操作 HTML 文档。浏览器把 HTML 解析后,会生成一棵由节点组成的树。
| 节点类型 | nodeType 值 | 说明 |
|---|---|---|
| Document | 9 | 文档根节点 |
| Element | 1 | HTML 元素(div、p 等) |
| Text | 3 | 标签之间的文字内容 |
| Comment | 8 | HTML 注释 |
用人话说:DOM 树就像一份家谱。html 是族长,head 和 body 是他的两个孩子,body 下面又有 div、p、span……每个成员就是一个节点。
节点关系
父子、兄弟——和家谱的叫法一模一样
let el = document.querySelector("div");
// 向上找
el.parentNode; // 父节点
// 向下找
el.childNodes; // 所有子节点(含文本、注释)
el.children; // 只返回元素子节点(推荐)
el.firstChild; // 第一个子节点(可能是文本)
el.firstElementChild; // 第一个元素子节点(推荐)
// 横向找兄弟
el.nextSibling; // 下一个兄弟节点
el.nextElementSibling; // 下一个兄弟元素(推荐)
el.previousSibling;
el.previousElementSibling;
childNodes 会包含文本节点(如换行和空格),而 children 只返回元素。绝大多数情况下用 children。
Part 2: 选择与遍历
操作元素的第一步是找到它,CSS 选择器是你的"寻人利器"
知道了 DOM 树的结构,现在要在里面找到目标元素。以前的 getElementById、getElementsByClassName 方法又多又难记。好消息是,现在只需要记住两个:querySelector 找单个,querySelectorAll 找多个。凡是 CSS 能写的选择器,它们都能用。加上 closest 向上找祖先,定位元素不再费劲。
选择元素
querySelector 一招鲜,吃遍天
// 老方法(了解即可)
document.getElementById("app"); // 单个元素
document.getElementsByClassName("item"); // HTMLCollection
document.getElementsByTagName("div"); // HTMLCollection
// 新方法(推荐)
document.querySelector(".container"); // 第一个匹配
document.querySelectorAll("li.item"); // NodeList(可用 forEach)
// CSS 选择器都能用
document.querySelector("#nav > ul > li:first-child");
document.querySelector('[data-id="123"]');
document.querySelector("input[type='email']");
记住两个就够了:querySelector 找单个,querySelectorAll 找多个。它们接受任何 CSS 选择器。
closest 与 matches
一个向上找祖先,一个判断是否匹配
// closest —— 向上查找最近的匹配祖先
let li = document.querySelector("li");
let ul = li.closest("ul"); // 离 li 最近的 ul
let nav = li.closest("nav"); // 离 li 最近的 nav
// matches —— 检查元素是否匹配选择器
if (el.matches(".active")) {
console.log("元素是激活状态");
}
closest 就像问"我最近的 <nav> 祖先是谁"。从当前元素开始,逐级向上查找,找到就返回。
Part 3: 修改内容与样式
找到元素后,文字、属性、class、样式——全部能改
元素找到了,接下来就是动刀。文字内容用 textContent 安全改写,HTML 片段用 innerHTML 灵活替换(但要警惕 XSS)。属性读写有 getAttribute/setAttribute,自定义数据用 dataset。样式控制首选 classList 切换类名,而不是直接写 style。这一节覆盖日常开发中最频繁的 DOM 操作。
内容操作
textContent 安全,innerHTML 灵活但小心 XSS
let el = document.querySelector("#app");
// 纯文本(推荐,安全)
el.textContent = "纯文本内容";
// HTML 内容(灵活,但要注意安全)
el.innerHTML = "<strong>加粗</strong>";
// 包含自身的完整 HTML
el.outerHTML;
// 可见文本(考虑 CSS 样式)
el.innerText;
textContent 设置,或者先做转义。
属性与 data-*
getAttribute 读,setAttribute 写,dataset 管自定义数据
// 通用属性方法
el.getAttribute("href");
el.setAttribute("href", "/new");
el.removeAttribute("disabled");
el.hasAttribute("href");
// DOM 属性(推荐,更简洁)
let img = document.querySelector("img");
img.src = "new.jpg";
img.alt = "描述";
let input = document.querySelector("input");
input.value = "新值";
input.disabled = true;
// data-* 自定义属性
el.dataset.userId = "123"; // HTML: data-user-id="123"
console.log(el.dataset.userId); // "123"
简单规则:标准属性用点语法(img.src),自定义属性用 dataset。
class 与 style
classList 是操作类名的最佳方式
// class 操作
el.classList.add("active");
el.classList.remove("hidden");
el.classList.toggle("dark"); // 有则移除,无则添加
el.classList.contains("active"); // 是否包含
el.classList.replace("old", "new"); // 替换
// 行内样式
el.style.color = "red";
el.style.fontSize = "16px"; // 驼峰写法
el.style.cssText = "color: red; font-size: 16px;";
// 获取计算后的样式
let computed = getComputedStyle(el);
console.log(computed.height);
实际开发中,样式尽量用 CSS class 控制,而不是直接写 style.xxx。这样更好维护。
Part 4: 事件系统
没有事件页面就是"死的",事件让用户操作和代码逻辑连接起来
前面的 DOM 操作都是"主动出击"——代码去改页面。但网页不是静态海报,用户会点击、输入、滚动。怎么让代码"听到"这些操作?答案就是事件系统。点击一个按钮,事件从 window 一路向下(捕获),到达目标,再一路向上冒回来(冒泡)。理解这个传播机制,你就掌握了事件委托、阻止冒泡这些高频技巧。
事件流与监听
捕获→目标→冒泡,三阶段传播
当用户点击一个按钮,事件传播分为三个阶段:
捕获阶段
从 window 一路向下到目标元素。用 addEventListener(event, fn, true) 监听。
目标阶段
到达实际被点击的元素。
冒泡阶段
从目标元素一路向上冒回 window。这是默认监听阶段。
el.addEventListener("click", handler);
el.addEventListener("click", handler, { once: true }); // 只触发一次
el.removeEventListener("click", handler);
Event 对象
事件处理函数收到的那个参数,藏着所有信息
function handler(event) {
console.log(event.type); // "click"
console.log(event.target); // 实际触发的元素
console.log(event.currentTarget); // 绑定事件的元素
event.preventDefault(); // 阻止默认行为
event.stopPropagation(); // 阻止冒泡
event.stopImmediatePropagation(); // 阻止同元素其他监听器
}
event.target 是"谁触发的",event.currentTarget 是"谁在监听"。在事件委托中,两者往往不同。
事件委托
在父元素上监听,代理子元素的事件
事件委托利用冒泡机制:子元素的事件会冒泡到父元素。所以你不需要给每个子元素单独绑定,只需在父元素上监听即可。
// 在 ul 上监听所有 li 的点击
document.querySelector("ul").addEventListener("click", (e) => {
let li = e.target.closest("li");
if (!li) return; // 点的不是 li,忽略
console.log("点击了:", li.textContent);
});
事件委托的好处:1) 动态添加的子元素也能响应事件;2) 减少监听器数量,性能更好。
常见事件类型
鼠标、键盘、表单、滚动——掌握这些够用了
| 类别 | 事件 | 说明 |
|---|---|---|
| 鼠标 | click, dblclick, mouseenter, mousemove | mouseenter 不冒泡,mouseover 冒泡 |
| 键盘 | keydown, keyup | e.key 返回字符,e.code 返回物理键 |
| 表单 | input, change, submit | input 实时触发,change 失焦后触发 |
| 页面 | DOMContentLoaded, load, scroll, resize | DOMContentLoaded 在 DOM 就绪时触发 |
Part 5: 表单与存储
表单是用户输入的入口,Web Storage 是浏览器端的持久化方案
用户在登录框输入账号密码、在搜索框打字——这些交互都要通过表单收集。手动一个个取值太麻烦,FormData 一行搞定。取到值后还得验证,浏览器内置的验证 API 省去大量手写校验。数据存哪?localStorage 关了浏览器还在,sessionStorage 关标签页就清。三者配合,表单处理的完整链路就通了。
表单操作
FormData 一行代码拿到所有字段
let form = document.querySelector("form");
form.addEventListener("submit", (e) => {
e.preventDefault();
let formData = new FormData(form);
for (let [key, value] of formData) {
console.log(key, value);
}
// 转为普通对象
let data = Object.fromEntries(formData);
});
FormData 自动收集表单中所有带 name 属性的字段,不用一个个手动取值。
HTML5 验证 API
浏览器内置验证,省去大量手写校验
let input = document.querySelector("input");
input.setCustomValidity("请输入有效邮箱");
input.checkValidity(); // 触发验证,返回布尔值
input.reportValidity(); // 验证并显示浏览器错误提示
input.validity.valid; // 是否通过验证
input.validity.valueMissing; // 必填字段是否为空
input.validity.typeMismatch; // 类型是否不匹配
input.validity.tooShort; // 是否太短
input.validity.patternMismatch; // 是否不匹配正则
Web Storage
localStorage 持久保存,sessionStorage 关标签清
// localStorage —— 持久存储(关闭浏览器还在)
localStorage.setItem("name", "张三");
localStorage.getItem("name"); // "张三"
localStorage.removeItem("name");
localStorage.clear();
// sessionStorage —— 会话存储(关闭标签页清除)
sessionStorage.setItem("token", "abc123");
// 只能存字符串!对象需要序列化
let user = { name: "张三", age: 25 };
localStorage.setItem("user", JSON.stringify(user));
let saved = JSON.parse(localStorage.getItem("user"));
JSON.stringify,取出来后 JSON.parse。忘了一步就会拿到 "[object Object]"。
Storage 事件
一个标签页改了 localStorage,另一个标签页能收到通知
// 在标签页 A 中监听
window.addEventListener("storage", (e) => {
console.log(e.key); // 变化的键名
console.log(e.oldValue); // 旧值
console.log(e.newValue); // 新值
});
// 在标签页 B 中修改
localStorage.setItem("theme", "dark");
// 标签页 A 会自动触发 storage 事件
这是实现跨标签页同步(比如暗色模式切换)最简单的方式。
Part 6: 常用 Web API
fetch 发请求、定时器做延时、URL 解析地址——日常开发的高频 API
DOM 和事件是基础能力,但真实项目还需要和网络打交道、和时间打交道、和地址栏打交道。fetch 取代了古老的 XMLHttpRequest,语法清爽还支持 async/await。定时器做延时和周期任务,但用完必须清掉否则内存泄漏。URL API 让你告别手动拼字符串。这三个 API 覆盖了日常开发的大部分需求。
fetch 请求
取代 XMLHttpRequest 的现代网络 API
// GET 请求
async function getData() {
let res = await fetch("/api/users");
let data = await res.json();
return data;
}
// POST 请求
async function postData() {
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "张三" })
});
}
fetch 默认不会把 HTTP 4xx/5xx 当作错误。需要手动检查 res.ok 或 res.status。
定时器
延时执行和周期执行,别忘了清除
// 延时执行
let id1 = setTimeout(() => console.log("1秒后"), 1000);
clearTimeout(id1); // 取消
// 周期执行
let id2 = setInterval(() => console.log("每秒"), 1000);
clearInterval(id2); // 取消
// 动画帧(比 setInterval 更流畅)
let id3 = requestAnimationFrame(() => console.log("下一帧"));
cancelAnimationFrame(id3);
useEffect 的清理函数里 clearInterval。
URL API
解析和构造 URL,告别手动拼字符串
let url = new URL("https://example.com/path?name=张三&page=1");
console.log(url.hostname); // "example.com"
console.log(url.searchParams.get("name")); // "张三"
url.searchParams.set("page", "2");
console.log(url.toString());
// "https://example.com/path?name=%E5%BC%A0%E4%B8%89&page=2"
常见问题
问题一:querySelector 返回 null
原因:选择器写错了,或者 JS 执行时 DOM 还没加载。
修复:检查选择器是否正确,确保 script 放在 body 底部或使用 DOMContentLoaded 事件。
问题二:事件绑上了但没反应
原因:JS 执行时元素还不存在,querySelector 返回 null,对 null 调 addEventListener 会报错。
修复:把 JS 放在 </body> 之前,或用 defer 属性。
问题三:localStorage 存对象后取出来是 "[object Object]"
原因:忘了 JSON.stringify,直接存了对象。
修复:存之前 JSON.stringify(obj),取之后 JSON.parse(str)。
交叉观察器
IntersectionObserver —— 高性能监听元素是否进入视口
传统做法是监听 scroll 事件不断计算元素位置,但 scroll 事件触发极其频繁,容易造成卡顿。IntersectionObserver 由浏览器内部优化,只在元素进出视口时才触发回调,性能好得多。
图片懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]')
.forEach(img => observer.observe(img));
无限滚动
const sentinel = document.querySelector('#sentinel');
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreItems(); // 加载下一页数据
}
});
io.observe(sentinel);
跨组件通信
CustomEvent 派发自定义事件,BroadcastChannel 跨标签页通信
CustomEvent 让你在组件之间传递自定义消息,BroadcastChannel 则让不同标签页之间直接对话——比 storage 事件更优雅、更通用。
// CustomEvent —— 创建并派发自定义事件
const event = new CustomEvent("userLogin", {
detail: { userId: 42, name: "张三" }
});
document.dispatchEvent(event);
// 监听自定义事件
document.addEventListener("userLogin", (e) => {
console.log(e.detail.userId); // 42
console.log(e.detail.name); // "张三"
});
// BroadcastChannel —— 跨标签页通信
const channel = new BroadcastChannel("theme_channel");
channel.postMessage({ theme: "dark" }); // 发送
channel.onmessage = (e) => {
document.body.className = e.data.theme; // 接收
};
detail 属性里。 直接在构造函数里传的第二个参数是配置对象,detail 才是携带数据的字段。BroadcastChannel 只在同源的标签页之间生效。
试一试
练习一
任务:写一个页面,包含一个按钮和一个 <div>。点击按钮时,div 的背景色在红色和蓝色之间切换。
// 提示:用 classList.toggle
// 你的代码:
参考答案
<div id="box" style="width:100px;height:100px"></div>
<button id="btn">切换颜色</button>
<script>
const box = document.getElementById("box");
document.getElementById("btn").addEventListener("click", () => {
box.style.backgroundColor =
box.style.backgroundColor === "red" ? "blue" : "red";
});
</script>
练习二
任务:用 localStorage 实现一个"记住我"功能——输入名字后保存,下次打开页面自动显示欢迎语。
// 提示:页面加载时检查 localStorage
// 你的代码:
参考答案
window.addEventListener("DOMContentLoaded", () => {
const saved = localStorage.getItem("username");
if (saved) {
document.getElementById("greeting").textContent = `欢迎回来, ${saved}!`;
}
});
document.getElementById("saveBtn").addEventListener("click", () => {
const name = document.getElementById("nameInput").value;
localStorage.setItem("username", name);
});
练习三
任务:实现图片懒加载。页面中有多个 <img data-src="xxx.jpg"> 元素(没有 src 属性)。当图片进入视口时,把 data-src 的值赋给 src,实现懒加载。要求使用 IntersectionObserver。
// 提示:观察所有 img[data-src],进入视口时赋值 src
// 你的代码:
参考答案
<img data-src="photo1.jpg" alt="图片1">
<img data-src="photo2.jpg" alt="图片2">
<img data-src="photo3.jpg" alt="图片3">
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll("img[data-src]").forEach(img => {
observer.observe(img);
});
</script>
📝 要点总结
本章核心知识点
DOM 操作
选择、创建、修改、删除页面元素
事件处理
事件流三阶段 + addEventListener 绑定
事件委托
利用冒泡机制,父元素代理子元素事件
fetch API
发起网络请求,处理 JSON 数据
Web Storage
localStorage 持久化、sessionStorage 会话级存储
下一章
你已经能操控网页了。下一章学习错误处理与调试——让代码更健壮,让 bug 无处遁形。