JavaScript 学习笔记:DOM 操作与事件的基本套路
学会语法之后我发现真正卡住我的是「怎么让页面上的东西动起来」。这篇笔记把 DOM 操作和事件处理整理成固定的几步, 以后写小练习直接照着套就行。
一、选中元素:先想清楚选几个
入门阶段我只需要记住两个方法,其余的基本不用碰:
// 只取第一个匹配的元素,找不到返回 null
var box = document.querySelector('.card');
// 取全部匹配元素,返回 NodeList(找不到时是空的 NodeList,不是 null)
var items = document.querySelectorAll('.post-item');
这里有个我踩过好几次的坑:querySelectorAll 返回的结果不会自动更新。
如果页面后来动态插入了新元素,需要重新查询一次,否则拿到的还是旧的列表。
遍历 NodeList 我习惯先转成数组,兼容性更省心:
Array.prototype.forEach.call(items, function (el) {
// 对每个元素做处理
});
二、修改内容:textContent 与 innerHTML 的区别
| 写法 | 行为 | 什么时候用 |
|---|---|---|
el.textContent = '文本' |
原样写入文本,标签不会被解析 | 显示用户输入或动态数据,首选 |
el.innerHTML = '<b>文本</b>' |
字符串会被当成 HTML 解析 | 只在内容完全由自己控制时使用 |
笔记里要特别标红一句:永远不要把用户输入的内容直接写进
innerHTML。 我之前练习时把输入框的值直接拼进去,输入<img src=x onerror=alert(1)>就真的弹窗了, 这就是最典型的 XSS。改成textContent之后同样的输入只会显示成普通文字。
三、改样式的三种方式
- 切换 class(推荐):样式统一写在 CSS 文件里,JS 只负责开关。
- 行内样式:适合随数据变化的值,比如进度条宽度。
- 修改 CSS 自定义属性:需要动态换主题色时很方便。
// 1. 切换 class
menu.classList.toggle('open');
// 2. 行内样式(注意是驼峰命名)
bar.style.width = percent + '%';
// 3. 自定义属性
document.documentElement.style.setProperty('--brand', '#2f6f8f');
这个网站的移动端菜单就是用第一种方式做的:CSS 里写 .nav-links.open { display: flex; },
JS 只做一次 toggle,同时更新 aria-expanded 让读屏软件知道菜单展开了。
四、事件:冒泡、捕获与委托
点击一个按钮时,事件其实走了三段路:
- 捕获阶段:从
window一路往下传到目标元素; - 目标阶段:在目标元素上触发;
- 冒泡阶段:再从目标元素一路往上传回
window。
addEventListener(type, fn, false) 中第三个参数为 false(默认)表示在冒泡阶段处理。
冒泡最实用的地方是事件委托:给父元素绑一个监听器,就能处理所有子元素(包括以后新加的)的点击。
list.addEventListener('click', function (e) {
var item = e.target.closest('li'); // 找到真正被点的那一项
if (!item || !list.contains(item)) return;
item.classList.toggle('done');
});
对比一下:如果给每个 li 都绑一次监听器,100 项就是 100 个监听器;
委托之后只有 1 个,而且新增的项目自动生效。
另外两个常用小点:
e.preventDefault():阻止默认行为,比如表单提交、链接跳转。e.stopPropagation():阻止继续冒泡,要少用,容易让上层逻辑失效。
五、一个小练习:今日学习打卡
把上面的知识点串起来,我写了一个不到 40 行的打卡小工具,数据存在浏览器本地:
(function () {
'use strict';
var KEY = 'study-checkin';
var list = document.getElementById('task-list');
var input = document.getElementById('task-input');
var addBtn = document.getElementById('add-btn');
var count = document.getElementById('done-count');
// 读取本地记录,解析失败时退回空数组
function load() {
try {
return JSON.parse(localStorage.getItem(KEY)) || [];
} catch (err) {
return [];
}
}
function save(data) {
localStorage.setItem(KEY, JSON.stringify(data));
}
function render() {
var data = load();
list.textContent = ''; // 清空用 textContent,安全且高效
var done = 0;
data.forEach(function (task, index) {
var li = document.createElement('li');
li.textContent = task.name; // 内容一律用 textContent 写入
if (task.done) {
li.classList.add('done');
done += 1;
}
li.setAttribute('data-index', index);
list.appendChild(li);
});
count.textContent = '已完成 ' + done + ' / ' + data.length + ' 项';
}
addBtn.addEventListener('click', function () {
var name = input.value.trim();
if (!name) return;
var data = load();
data.push({ name: name, done: false });
save(data);
input.value = '';
render();
});
// 事件委托:只给 ul 绑一次监听器
list.addEventListener('click', function (e) {
var li = e.target.closest('li');
if (!li) return;
var data = load();
var i = Number(li.getAttribute('data-index'));
data[i].done = !data[i].done;
save(data);
render();
});
render();
})();
写完之后我给自己列了三条检查项:
- 刷新页面数据还在吗?(
localStorage生效) - 输入
<script>alert(1)</script>会执行吗?(不会,因为用了textContent) - 新增的任务点击能切换状态吗?(能,因为用了事件委托)
六、几个还要继续弄明白的问题
- 频繁触发的事件(滚动、输入)到底什么时候该做节流、什么时候该做防抖;
localStorage、sessionStorage、Cookie 的容量与生命周期差异;- 异步:回调、Promise、
async/await三者的写法转换; - 为什么直接操作 DOM 很慢,文档片段
DocumentFragment能优化到什么程度。
这些留到下一篇笔记再整理。