第6章 6.1 DOM 操作:querySelector/event

🎯 开场:为什么你点的外卖和你想的不一样?

你有没有过这种经历——在网上选中一家店,结果送来的东西完全不一样?

我有个朋友点了「红烧肉」,结果送来的是「红烧排骨」。他说他明明看的是红烧肉啊!

后来才发现,他点的是第二个菜,不是第一个。网页上显示的「红烧肉」其实是第二个选项,但他点击了第一个。

这像不像你写网页时的困惑?
- 想操作第二个按钮,结果操作的是第一个
- 想改标题的文字,结果改成了副标题
- 想给某个元素加动画,结果整个页面都动了

问题出在哪? 你没有准确地「选中」你想要的那个元素。

JavaScript 里有一个强大的工具能帮我们精准定位任何网页元素——就是今天要学的 DOM 操作

学完这章,你能:
1. 用 querySelector 像 GPS 一样精准定位任意网页元素
2. 用事件监听让网页「活」起来,响应你的操作
3. 组合使用做出真正能交互的小工具


🧱 基础:DOM 是个什么鬼?

\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n 1. DOM = 网页的「骨架」

先说个生活比喻:

想象你有一栋乐高积木房子。房子有很多零件(墙、门、窗、屋顶),这些零件按照一定顺序拼在一起,形成了整栋房子的结构。

DOM(Document Object Model)就是网页的「乐高结构图」。浏览器把你写的 HTML 标签(如 <div>、<p>、<button>)组织成一棵树,这棵树就叫 DOM 树。

网页结构:
<body>
<header>
<h1>我的网站</h1>
</header>
<main>
<div class="container">
  <p id="intro">欢迎光临</p>
  <button class="btn">点我</button>
</div>
</main>
</body>

用 DOM 树来表示:

body(身体)
├── header(头部)
│   └── h1(标题): "我的网站"
└── main(主体)
└── div.container(容器)
    ├── p#intro(段落): "欢迎光临"
    └── button.btn(按钮): "点我"

为什么要学 DOM? 因为网页上每一个你能看到的东西,都是 DOM 树上的一个「节点」。你想让网页动起来,就得操作这些节点。

2. querySelector:网页元素的「搜索框」

想象你要在淘宝找一个商品。你不会去问客服「帮我找那个红色的上衣」,你会用搜索框搜。

querySelector 就是网页的搜索框,你告诉它「我要找什么」,它帮你找到第一个匹配的元素。

// 找到第一个 class 为 "btn" 的按钮
document.querySelector('.btn')

// 找到第一个 id 为 "title" 的元素
document.querySelector('#title')

// 找到第一个 <button> 标签
document.querySelector('button')

如果页面有多个匹配的元素,但你全都要?那就用 querySelectorAll,它会返回一个「列表」:

// 找到所有 class 为 "item" 的元素
document.querySelectorAll('.item')

一句话解释querySelector 就像 Ctrl+F 搜索,但比 Ctrl+F 精准一万倍。

3. 创建和添加元素:网页的「装修队」

有时候你不想修改已有元素,而是想新增一个元素。就像装修房子,你需要先预制一个家具,再把它搬进去。

// 1. 创建一个新的 <div> 元素
let newDiv = document.createElement('div')

// 2. 给它加点内容
newDiv.textContent = '我是新来的'

// 3. 把它添加到页面里(比如添加到 body 的最后)
document.body.appendChild(newDiv)

一句话解释:创建元素是「造家具」,追加是「搬进房子」。

4. 事件监听:让网页「听话」

你按电梯按钮,电梯会动。你点外卖按钮,外卖会下单。网页也一样——你点击一个按钮,网页应该有所响应。

事件监听就是给网页元素装上一个「监听器」,一旦用户做了什么动作(点击、鼠标悬停、键盘输入),监听器就会触发你设定的函数。

// 获取按钮
let btn = document.querySelector('.btn')

// 给按钮添加「点击事件」监听器
btn.addEventListener('click', function() {
alert('按钮被点击了!')
})

当用户点击这个按钮,浏览器就会弹出「按钮被点击了!」的提示框。

你也可以把函数单独定义,让代码更清晰:

function handleClick() {
alert('按钮被点击了!')
}

btn.addEventListener('click', handleClick)

一句话解释addEventListener 就是在元素上安装一个「警报器」,指定「发生什么事」时触发什么「动作」。

5. 事件冒泡:网页的「连锁反应」

这是一个很多新手会踩的坑。

想象你在家里厨房闻到烟味,你爸也闻到了,你妈也闻到了——但实际上只是你的锅里糊了。

事件冒泡就是这个原理:当你在一个子元素上触发事件,这个事件会「向上冒泡」到父元素、祖父元素,一路传到 body 和 document。

// 外层容器
let container = document.querySelector('.container')

// 里面的按钮
let btn = document.querySelector('.btn')

// 给按钮加点击事件
btn.addEventListener('click', function() {
console.log('按钮被点了')
})

// 给容器也加一个点击事件
container.addEventListener('click', function() {
console.log('容器被点了')
})

当你点击按钮,控制台会输出:

按钮被点了
容器被点了

因为点击事件先在按钮上触发,然后「冒泡」到容器。

有时候这是你想要的,比如点击整个卡片都能触发某个动作。有时候这不是你想要的,你需要阻止冒泡:

btn.addEventListener('click', function(event) {
event.stopPropagation() // 阻止事件继续向上冒泡
console.log('按钮被点了')
})

这样点击按钮就只会输出「按钮被点了」,不会触发容器的事件。


🔥 实战:3个小项目

项目 1:点击切换灯的开关(5分钟)

场景:网页上有一个灯的图标,点击一下亮,再点击一下灭。

这是最基础的交互——学会这个你就入门了!

完整可运行代码:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>灯的开关</title>
<style>
    body {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100vh;
        margin: 0;
        background: #1a1a2e;
        font-family: Arial, sans-serif;
    }
    .lamp {
        font-size: 100px;
        cursor: pointer;
        transition: transform 0.2s;
    }
    .lamp:hover {
        transform: scale(1.1);
    }
    .lamp.off::after {
        content: '💡';
    }
    .lamp.on::after {
        content: '🔦';
    }
    p {
        color: white;
        text-align: center;
        margin-top: 20px;
    }
</style>
</head>
<body>
<div>
    <div id="lamp" class="lamp off"></div>
    <p id="status">灯是灭的</p>
</div>

<script>
    // 获取灯和状态文字的元素
    let lampElement = document.querySelector('#lamp')
    let statusText = document.querySelector('#status')

    // 用一个变量记录灯的状态
    let isOn = false

    // 定义切换灯状态的函数
    function toggleLamp() {
        isOn = !isOn  // 取反:开变关,关变开

        if (isOn) {
            // 灯开了
            lampElement.className = 'lamp on'
            statusText.textContent = '灯是亮的'
        } else {
            // 灯关了
            lampElement.className = 'lamp off'
            statusText.textContent = '灯是灭的'
        }
    }

    // 给灯添加点击事件监听
    lampElement.addEventListener('click', toggleLamp)
</script>
</body>
</html>

预期效果
- 初始状态:显示 💡 灯泡图标,文字「灯是灭的」
- 点击一下:显示 🔦 手电筒图标,文字「灯是亮的」
- 再点击一下:又变回 💡 和「灯是灭的」

一句话解释:用一个布尔变量 isOn 记录状态,点击时取反(!isOn),根据状态切换显示。


项目 2:待办清单 To-Do List(15分钟)

场景:你每天有很多事要做,想在网页上记录下来。输入内容,点击添加,就能在列表里看到,还能点击删除。

这个比项目1更进一步——涉及创建元素、循环遍历、删除操作

完整可运行代码:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>我的待办清单</title>
<style>
    body {
        font-family: Arial, sans-serif;
        max-width: 500px;
        margin: 50px auto;
        padding: 20px;
        background: #f5f5f5;
    }
    h1 {
        text-align: center;
        color: #333;
    }
    .input-group {
        display: flex;
        gap: 10px;
        margin-bottom: 20px;
    }
    input {
        flex: 1;
        padding: 12px;
        border: 2px solid #ddd;
        border-radius: 8px;
        font-size: 16px;
    }
    input:focus {
        border-color: #4CAF50;
        outline: none;
    }
    button#addBtn {
        padding: 12px 24px;
        background: #4CAF50;
        color: white;
        border: none;
        border-radius: 8px;
        cursor: pointer;
        font-size: 16px;
    }
    button#addBtn:hover {
        background: #45a049;
    }
    ul {
        list-style: none;
        padding: 0;
    }
    li {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 15px;
        background: white;
        border-bottom: 1px solid #eee;
    }
    li:last-child {
        border-bottom: none;
    }
    .deleteBtn {
        background: #ff4444;
        color: white;
        border: none;
        padding: 6px 12px;
        border-radius: 4px;
        cursor: pointer;
    }
    .deleteBtn:hover {
        background: #cc0000;
    }
    .empty {
        text-align: center;
        color: #999;
        padding: 20px;
    }
</style>
</head>
<body>
<h1>📝 我的待办清单</h1>

<div class="input-group">
    <input type="text" id="todoInput" placeholder="输入新任务...">
    <button id="addBtn">添加</button>
</div>

<ul id="todoList">
    <li class="empty">还没有任务,添加一个吧!</li>
</ul>

<script>
    // 获取需要用到的元素
    let inputElement = document.querySelector('#todoInput')
    let addBtn = document.querySelector('#addBtn')
    let todoList = document.querySelector('#todoList')

    // 定义添加任务的函数
    function addTodo() {
        let text = inputElement.value.trim()  // 获取输入并去掉首尾空格

        // 如果输入为空,提示用户
        if (!text) {
            alert('请输入任务内容!')
            return
        }

        // 先移除「空状态」提示
        let emptyHint = todoList.querySelector('.empty')
        if (emptyHint) {
            emptyHint.remove()
        }

        // 创建新的 li 元素
        let newLi = document.createElement('li')

        // 创建任务文字
        let span = document.createElement('span')
        span.textContent = text

        // 创建删除按钮
        let deleteBtn = document.createElement('button')
        deleteBtn.textContent = '删除'
        deleteBtn.className = 'deleteBtn'

        // 给删除按钮添加点击事件
        deleteBtn.addEventListener('click', function() {
            newLi.remove()  // 删除这个 li

            // 如果列表空了,显示提示
            if (todoList.children.length === 0) {
                let emptyLi = document.createElement('li')
                emptyLi.className = 'empty'
                emptyLi.textContent = '还没有任务,添加一个吧!'
                todoList.appendChild(emptyLi)
            }
        })

        // 组装:把 span 和 deleteBtn 放进 li
        newLi.appendChild(span)
        newLi.appendChild(deleteBtn)

        // 把 li 添加到列表
        todoList.appendChild(newLi)

        // 清空输入框
        inputElement.value = ''
        inputElement.focus()
    }

    // 点击添加按钮触发
    addBtn.addEventListener('click', addTodo)

    // 按回车也能添加
    inputElement.addEventListener('keypress', function(event) {
        if (event.key === 'Enter') {
            addTodo()
        }
    })
</script>
</body>
</html>

预期效果
- 在输入框输入文字,点击「添加」或按回车,任务出现在列表
- 每条任务右边有红色「删除」按钮,点击可删除
- 列表为空时显示提示文字

一句话解释:用 createElement 创建新元素,用 appendChild 添加到列表,删除时用 remove() 移除自身。


项目 3:简易天气预报卡片(15分钟)

场景:从 JSON 数据中读取几个城市的天气,在网页上展示成卡片。点击不同城市,切换显示详情。

这个项目综合了数据渲染、DOM 创建循环、事件委托

完整可运行代码:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>天气预报</title>
<style>
    body {
        font-family: Arial, sans-serif;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        min-height: 100vh;
        padding: 30px;
        margin: 0;
    }
    h1 {
        text-align: center;
        color: white;
        margin-bottom: 30px;
    }
    .container {
        display: flex;
        flex-wrap: wrap;
        gap: 20px;
        justify-content: center;
        max-width: 1000px;
        margin: 0 auto;
    }
    .city-card {
        background: white;
        border-radius: 16px;
        padding: 25px;
        width: 200px;
        cursor: pointer;
        transition: transform 0.2s, box-shadow 0.2s;
        box-shadow: 0 4px 15px rgba(0,0,0,0.1);
    }
    .city-card:hover {
        transform: translateY(-5px);
        box-shadow: 0 8px 25px rgba(0,0,0,0.2);
    }
    .city-card.selected {
        border: 3px solid #667eea;
    }
    .city-name {
        font-size: 20px;
        font-weight: bold;
        color: #333;
        margin-bottom: 10px;
    }
    .weather-icon {
        font-size: 50px;
        text-align: center;
        margin: 15px 0;
    }
    .temperature {
        font-size: 28px;
        color: #ff6b6b;
        text-align: center;
        font-weight: bold;
    }
    .weather-desc {
        text-align: center;
        color: #666;
        margin-top: 5px;
    }
    .detail-panel {
        background: white;
        border-radius: 16px;
        padding: 30px;
        max-width: 500px;
        margin: 30px auto;
        text-align: center;
        display: none;
    }
    .detail-panel.show {
        display: block;
    }
    .detail-title {
        font-size: 24px;
        margin-bottom: 20px;
    }
    .detail-info {
        display: grid;
        grid-template-columns: repeat(2, 1fr);
        gap: 15px;
    }
    .detail-item {
        background: #f8f8f8;
        padding: 15px;
        border-radius: 8px;
    }
    .detail-label {
        color: #999;
        font-size: 12px;
    }
    .detail-value {
        font-size: 18px;
        color: #333;
        font-weight: bold;
    }
</style>
</head>
<body>
<h1>🌤️ 天气预报</h1>

<div class="container" id="cityContainer">
    <!-- 城市卡片会动态生成 -->
</div>

<div class="detail-panel" id="detailPanel">
    <h2 class="detail-title" id="detailTitle">城市详情</h2>
    <div class="detail-info" id="detailInfo"></div>
</div>

<script>
    // 模拟的天气数据(实际项目中可能来自API)
    let weatherData = [
        {
            city: '北京',
            temp: 26,
            humidity: 45,
            wind: 12,
            pressure: 1013,
            icon: '☀️',
            desc: '晴朗',
            aqi: 58
        },
        {
            city: '上海',
            temp: 28,
            humidity: 72,
            wind: 8,
            pressure: 1008,
            icon: '🌤️',
            desc: '多云',
            aqi: 42
        },
        {
            city: '广州',
            temp: 32,
            humidity: 85,
            wind: 15,
            pressure: 1005,
            icon: '🌧️',
            desc: '中雨',
            aqi: 35
        },
        {
            city: '成都',
            temp: 24,
            humidity: 68,
            wind: 6,
            pressure: 1015,
            icon: '⛅',
            desc: '阴天',
            aqi: 78
        }
    ]

    let container = document.querySelector('#cityContainer')
    let detailPanel = document.querySelector('#detailPanel')
    let detailTitle = document.querySelector('#detailTitle')
    let detailInfo = document.querySelector('#detailInfo')

    // 遍历数据,生成每个城市的卡片
    weatherData.forEach(function(cityData) {
        // 创建卡片容器
        let card = document.createElement('div')
        card.className = 'city-card'
        card.dataset.city = cityData.city  // 记住这个城市名

        // 创建卡片内容
        card.innerHTML = `
            <div class="city-name">${cityData.city}</div>
            <div class="weather-icon">${cityData.icon}</div>
            <div class="temperature">${cityData.temp}°C</div>
            <div class="weather-desc">${cityData.desc}</div>
        `

        // 给卡片添加点击事件
        card.addEventListener('click', function() {
            // 移除其他卡片的选中状态
            document.querySelectorAll('.city-card').forEach(function(c) {
                c.classList.remove('selected')
            })
            // 给当前卡片添加选中状态
            card.classList.add('selected')

            // 显示详情面板
            showDetail(cityData)
        })

        // 把卡片添加到容器
        container.appendChild(card)
    })

    // 显示详情面板的函数
    function showDetail(data) {
        detailTitle.textContent = data.city + ' 详细天气'
        detailInfo.innerHTML = `
            <div class="detail-item">
                <div class="detail-label">湿度</div>
                <div class="detail-value">${data.humidity}%</div>
            </div>
            <div class="detail-item">
                <div class="detail-label">风速</div>
                <div class="detail-value">${data.wind} km/h</div>
            </div>
            <div class="detail-item">
                <div class="detail-label">气压</div>
                <div class="detail-value">${data.pressure} hPa</div>
            </div>
            <div class="detail-item">
                <div class="detail-label">空气质量指数</div>
                <div class="detail-value">${data.aqi}</div>
            </div>
        `
        detailPanel.classList.add('show')
    }
</script>
</body>
</html>

预期效果
- 页面加载后显示 4 个城市的天气卡片
- 点击某个城市卡片,该卡片高亮,底部弹出详细面板显示更多信息
- 有悬停动画效果

一句话解释:用 forEach 遍历数据批量生成卡片,点击时调用 showDetail 显示详情。


💪 进阶:5个新人必踩的坑

坑1:找不到元素?因为页面还没加载完!

错误写法:

let btn = document.querySelector('.btn')
btn.addEventListener('click', handleClick)
// 报错:Cannot read property 'addEventListener' of null

为什么?因为 JavaScript 执行时,页面还没加载完,那个按钮根本不存在!

正确写法:

// 写法1:把 script 放到 body 最后
// </body> 前面写 <script>...</script>

// 写法2:用 DOMContentLoaded 事件
document.addEventListener('DOMContentLoaded', function() {
let btn = document.querySelector('.btn')
btn.addEventListener('click', handleClick)
})

坑2:querySelector 和 querySelectorAll 的返回值不一样

错误理解:

let btn = document.querySelector('.btn')
// 以为 btn 是数组,可以用 btn[0]
btn[0].addEventListener('click', handleClick) // 报错!

正确理解:

// querySelector 返回第一个匹配元素(单个),直接用
let singleBtn = document.querySelector('.btn')
singleBtn.addEventListener('click', handleClick) // ✅

// querySelectorAll 返回所有匹配的「NodeList(像数组)」
let allBtns = document.querySelectorAll('.btn')
allBtns.forEach(function(btn) {  // 用 forEach 遍历
btn.addEventListener('click', handleClick)
})

坑3:修改 innerHTML 会丢失之前的事件监听

错误做法:

container.innerHTML = '<button>新按钮</button>'
// 之前给旧按钮加的事件监听,全没了!

正确做法:

// 不要用 innerHTML 整体覆盖,改用 createElement
let newBtn = document.createElement('button')
newBtn.textContent = '新按钮'
newBtn.addEventListener('click', handleClick)
container.appendChild(newBtn)  // 追加而不是覆盖

坑4:事件委托的父元素选错了

错误写法:

// 给 1000 个 li 加点击事件,累死浏览器
document.querySelectorAll('li').forEach(function(li) {
li.addEventListener('click', handleClick)
})

正确写法(事件委托):

// 把事件监听放在父元素上,让子元素的点击「冒泡」上来
document.querySelector('ul').addEventListener('click', function(event) {
// event.target 是实际被点击的元素
if (event.target.tagName === 'LI') {
    handleClick(event.target)
}
})

坑5:在循环里绑定事件,结果闭包变量共享

错误代码:

for (var i = 0; i < 3; i++) {
document.querySelectorAll('.btn')[i].addEventListener('click', function() {
    alert('点击了按钮 ' + i)  // 永远弹出 "点击了按钮 3"
})
}

正确写法:

// 用 let 代替 var,或者用 forEach
document.querySelectorAll('.btn').forEach(function(btn, i) {
btn.addEventListener('click', function() {
    alert('点击了按钮 ' + (i + 1))  // 正确!
})
})

调试技巧:console.log 是你的好朋友

let btn = document.querySelector('.btn')
console.log('找到的按钮:', btn)  // 看元素到底有没有找到
console.log('按钮的文字是:', btn.textContent)

btn.addEventListener('click', function(event) {
console.log('事件对象:', event)  // 看事件的详细信息
console.log('点击的元素:', event.target)
})

打开浏览器控制台(F12),你就能看到这些输出,比猜错哪里出了问题高效 100 倍。


✏️ 练习题

练习 1(1分钟):改改看

  • 输入:把项目1里的 emoji 换一下(💡 换成 🕯️,🔦 换成 ✨)
  • 预期输出:点击后显示不同的 emoji
  • 提示:只改 CSS 里 ::after 的内容

练习 2(2分钟):加个判断

  • 输入:在项目1的 toggleLamp 函数里,加一个判断:只有灯是灭的时候,才播放声音(用 console.log('播放开灯音效') 模拟)
  • 预期输出:灯灭→开时打印「播放开灯音效」,灯开→灭时不做声
  • 提示:在 if (isOn) 里面加 console.log

练习 3(3分钟):换个数据源

  • 输入:把项目2的待办数据改成你自己的 3 个任务
  • 预期输出:页面加载时直接显示你的 3 个任务(不需要手动输入)
  • 提示:在页面加载时用 JavaScript 动态创建 3 个 li,而不是等用户输入

练习 4(4分钟):串联两个项目

  • 输入:给项目2的每个待办项添加「完成」按钮,点击后划掉这条任务(加个删除线样式)
  • 预期输出:点击完成按钮,任务文字有删除线效果
  • 提示:用 event.target.classList.add('completed') 给元素加 class,配合 CSS .completed { text-decoration: line-through; }

练习 5(5分钟):分析报错

  • 输入:以下代码为什么会报错?怎么修?
let title = document.querySelector('#title')
title.addEventListener('click', function() {
title.textContent = '被点击了'
})
title.remove()
  • 预期输出:能运行,不报错
  • 提示:考虑一下,remove 之后,还能调用 title.addEventListener 吗?

作业:做一个「学生成绩管理页面」

需求描述:班主任想用一个网页方便地查看和管理学生成绩。

功能点
1. 显示一个学生列表(至少5个学生,每个有姓名、分数)
2. 点击「添加学生」可以新增一条记录
3. 每个学生右边有「删除」按钮,点谁删谁
4. 分数低于 60 分的,显示红色高亮

加分项(选做):
1. 点击学生姓名可以修改名字(输入框变成可编辑)
2. 添加「按分数排序」功能

验收标准
- 能跑起来(复制到 HTML 文件里直接用)
- 添加和删除功能正常工作
- 低分红色高亮正常显示
- 代码有适当注释

提交方式:把代码贴到评论区,或者 GitHub 链接甩过来!


📚 总结 + 资源

本文学了 3 件事:
1. DOM 是网页的骨架,用 querySelector 能像 GPS 一样精准定位任意元素
2. 事件监听让网页「活」起来,addEventListener('click', fn) 最常用
3. 事件冒泡是双刃剑——善用 stopPropagation() 阻止它,善用委托优化性能

延伸学习资源:
- MDN 官方文档(中文):https://developer.mozilla.org/zh-CN/docs/Web/API/Document/querySelector
- 《JavaScript 高级程序设计》:红宝书,体系完整,值得反复看
- 视频:B 站「渡一教育」JS 教程第 6-8 集,讲得很细

互动钩子:

你在工作中有没有遇到过「明明想点 A,结果 B 触发了」的场景?是因为事件冒泡还是选择器写错了?评论区聊聊,老粉优先回复!


下章预告:
学会了选中元素和绑定事件,但如果我想让一个按钮响应很多种操作(点一下、双击、长按、拖拽……)怎么办?下一章我们来聊聊事件处理与委托,让你的网页交互从「单机版」升级成「多功能键盘」!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。