浏览器环境中的 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 值说明
Document9文档根节点
Element1HTML 元素(div、p 等)
Text3标签之间的文字内容
Comment8HTML 注释

用人话说: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 vs children: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;
永远不要用 innerHTML 插入用户输入的内容。 这会导致 XSS 攻击。用户输入的内容应该用 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, mousemovemouseenter 不冒泡,mouseover 冒泡
键盘keydown, keyupe.key 返回字符,e.code 返回物理键
表单input, change, submitinput 实时触发,change 失焦后触发
页面DOMContentLoaded, load, scroll, resizeDOMContentLoaded 在 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"));
localStorage 只能存字符串。 存对象前必须 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);
用完定时器一定要清除! 否则组件销毁后定时器还在跑,会导致内存泄漏和错误。React 中要在 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"

常见问题

遇到 "Cannot read property of null" 先检查选择器。 这说明 querySelector 没找到元素。

问题一: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);
为什么比 scroll 事件更好? scroll 事件每秒触发几十次,需要手动节流;IntersectionObserver 由浏览器在内部优化,只在真正需要时触发回调,不阻塞主线程。

跨组件通信

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;    // 接收
};
CustomEvent 的数据必须放在 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 无处遁形。

1 / X