DOM 操作:JavaScript 如何控制页面
HTML 搭骨架,CSS 上妆面,但让网页真正「活过来」的是 JavaScript。用户点一个按钮就切换内容、提交表单时即时校验、点赞后数字 +1——这些都是 DOM 操作的功劳。
白物集的仪表盘页面(dashboard.astro)就是一个很好的例子:五个 Tab 切换、用户资料编辑、通知已读标记、封禁表单提交——没跳转页面,全靠 JavaScript 在浏览器里操作 DOM。
DOM 是什么
DOM(Document Object Model)是浏览器把 HTML 解析后生成的一棵树。每个 HTML 标签变成一个节点(Node),节点之间有父子关系。
document
└── html
├── head
│ └── title
└── body
├── header
├── main
└── footer
JavaScript 通过操作这棵树来改变页面——不是刷新页面,是直接在浏览器里改。
第一步:找到你要改的元素
操作 DOM 之前,先找到目标元素。白物集仪表盘里用这几种方式:
// 按 id 找文档定位元素——最快、最精确
const content = document.getElementById('dashboard-content');
const badge = document.getElementById('notif-badge');
// 按 CSS 选择器找——返回第一个匹配
const firstTab = document.querySelector('.dashboard-tab');
// 按 CSS 选择器找所有匹配——返回 NodeList
const tabs = document.querySelectorAll('.dashboard-tab');
用 getElementById 比 querySelector 快,但 querySelector 更灵活(支持任意 CSS 选择器)。日常写代码优先用 getElementById 找唯一元素,用 querySelectorAll 找同类元素集合。
第二步:读取和修改内容
找到元素后,可以读它的内容,也可以写。白物集的登录页面实现了一个 GitHub OAuth 回调处理:
// 读取内容
const hash = window.location.hash;
const params = new URLSearchParams(hash.replace('#', '?'));
const accessToken = params.get('access_token');
// 修改内容——textContent(安全,不解析 HTML)
const nick = document.getElementById('profile-nickname');
nick.textContent = d.user.nickname || '用户';
// 修改内容——innerHTML(渲染 HTML 结构)
content.innerHTML = '<div>加载中...</div>';
textContent 和 innerHTML 的区别:
textContent:纯文本,只改文字内容。即使用户输入<script>alert('xss')</script>,它也只会当作文本显示innerHTML:解析 HTML 标签,可以渲染复杂结构。但如果内容来自用户输入,有 XSS 风险
白物集的实践中,用户生成内容(评论、昵称)用 textContent 或专门的 escapeHtml() 函数处理,只有自己控制的模板字符串才用 innerHTML。
第三步:改变样式
最常见的交互效果就是「点中某个元素、高亮它、其他恢复默认」。白物集仪表盘的 Tab 切换就是这么做的:
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// 先全部恢复默认
tabs.forEach(t => {
t.style.color = 'var(--text-muted)';
t.style.borderBottomColor = 'transparent';
t.style.fontWeight = '400';
});
// 再高亮点中的
tab.style.color = 'var(--text)';
tab.style.borderBottomColor = 'var(--primary)';
tab.style.fontWeight = '600';
});
});
这种「先全部重置、再单独设置」的模式(也叫「排他思想」)在 Tab 切换、手风琴、菜单导航中非常常见。
也可以用 classList 替代直接操作 style——把样式写在 CSS 里,JS 只负责切换 class:
// CSS 里定义 .active 样式
element.classList.add('active'); // 增加
element.classList.remove('active'); // 删除
element.classList.toggle('active'); // 切换
用 class 比用 style 好维护:样式统一在 CSS 里改,JS 只做逻辑。
第四步:事件绑定
「用户点击了怎么办?」——用 addEventListener。白物集登录页面多处用到:
// 表单提交
document.getElementById('login-form')
.addEventListener('submit', async (e) => {
e.preventDefault(); // 阻止页面刷新
const email = document.getElementById('email').value;
// ... 发 API 请求
});
// 按钮点击
const githubBtn = document.getElementById('github-login');
githubBtn.addEventListener('click', async (e) => {
e.preventDefault();
githubBtn.disabled = true;
githubBtn.textContent = '⏳ 跳转中...';
// ... 调 Supabase OAuth
});
几个要点:
e.preventDefault():阻止默认行为(表单不刷新页面、链接不跳转)addEventListener可以绑定多个处理函数,比onclick属性灵活- 事件处理函数里
this指向绑定元素,但用箭头函数时继承外层作用域
事件委托
白物集仪表盘的封禁操作面板用了事件委托——不是给每个按钮单独绑定,而是在父元素上监听一次:
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.mod-action');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'delete-comment') {
// 删除评论
await fetch('/api/comments?id=' + btn.dataset.cid, { method: 'DELETE' });
btn.closest('[style*="border-left:3px"]')?.remove();
} else if (action === 'approve') {
// 放行
await fetch('/api/moderation', { method:'POST', ... });
btn.closest('[style*="border-left:3px"]')?.remove();
}
});
好处:动态生成的按钮不需要重新绑定事件,减少内存占用。
踩坑经验
1. innerHTML 重复渲染丢失状态
用 innerHTML 重新渲染一个区域时,这个区域里的所有事件绑定、输入框状态、焦点都会被清空——因为 DOM 节点被替换了,不是更新。
白物集仪表盘早期在切换 Tab 时直接用 innerHTML 替换整个内容区,导致 Tab 内的子元素事件需要重新绑定。解决方案是对不需要频繁切换的部分用 textContent 或 appendChild 增量更新,而不是全量替换。
2. document.querySelector 返回 null
如果选择器没匹配到任何元素,返回的是 null,不是 undefined。调用 null.textContent 会报 TypeError。
// 安全写法
const el = document.getElementById('notif-badge');
if (el) el.style.display = 'none';
// 或者用可选链
document.getElementById('notif-badge')?.style.display = 'none';
白物集里大量用 document.getElementById('xxx')?.addEventListener(...) 这种模式,因为某些元素在特定 Tab 下才渲染,直接绑定会报错。
3. Astro SSR 中 <script> 多标签合并
Astro 页面里如果用多个 <script> 标签,它们会被合并到同一个作用域。白物集仪表盘早期把 Tab 切换逻辑和加载逻辑写在不同的 <script> 里,结果变量互相覆盖。修正:把关联逻辑放在同一个 <script> 里,或者用 ES module 导出。
下一篇讲异步编程——fetch 怎么调接口、async/await 怎么处理错误、白物集的内容 API 是怎么串联起来的。