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 之后同样的输入只会显示成普通文字。

三、改样式的三种方式

  1. 切换 class(推荐):样式统一写在 CSS 文件里,JS 只负责开关。
  2. 行内样式:适合随数据变化的值,比如进度条宽度。
  3. 修改 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 能优化到什么程度。

这些留到下一篇笔记再整理。

本文为范亚鹏的个人学习记录,示例代码均为本人练习所作,可能存在不准确之处,仅供学习参考。转载请注明出处与本站「优你维」(fanyapeng.cn)。