Skip to content

HTML DOM与事件(Day14 2/2) ​

本页对应 Day14_DOM,共 6 段视频,约 1 小时 35 分。

学习目标 ​

  • 能够使用 table.rows、insertRow()、deleteRow()、tr.rowIndex、tr.cells、insertCell()、deleteCell()、cell.cellIndex 完成表格行与单元格的增删,并为删除按钮绑定删除所在行的功能。
  • 能够使用 new Image() 创建图片元素,设置 src、width、height,并通过 appendChild() 将其插入页面。
  • 能够分别使用 HTML 标签属性、DOM 元素对象属性、addEventListener() 三种方式监听事件,并说明同一事件多次监听时三者的生效差异。
  • 能够分别解除三种方式绑定的事件监听,并正确使用 removeEventListener() 及同一函数引用解除通过 addEventListener() 绑定的事件。
  • 能够描述事件捕获、目标、冒泡三个阶段的触发过程,并说明事件回调函数默认在冒泡阶段执行以及 addEventListener() 第三个参数对执行阶段的影响。
  • 能够说明事件回调函数中 this 指向被监听元素,并使用 this 解决循环为多个元素绑定事件时的索引错乱问题。

知识点 ​

表格元素的专用方法:table、tr、td ​

为什么需要:用 createElement 创建 tr、td 再 appendChild 也能做表格,但代码多、层级容易写错。DOM 给表格元素提供了专用方法,能把“创建并插入”合成一步。
怎么想:先分清三层:table 管行,tr 管单元格,td/th 只记自己在行内的位置。
怎么做:table.rows 获取所有行集合;table.insertRow(index) 添加一行,table.deleteRow(index) 删除一行;tr.rowIndex 是本行索引,tr.cells 是本行单元格集合;tr.insertCell(index) 添加单元格,tr.deleteCell(index) 删除单元格;td.cellIndex 是同一行内的索引。
易错点:insertRow 返回新创建的 tr,insertCell 返回新创建的 td,要接住返回值再设内容;不传索引通常加到末尾;索引从 0 开始,且表头行和 tbody 里的行会一起参与 rowIndex 排序。

js
const table = document.querySelector('#table');
const nameInp = document.querySelector('#name');
const addressInp = document.querySelector('#address');
const phoneInp = document.querySelector('#phone');
const btn = document.querySelector('#btn');

btn.onclick = function () {
  const tr = table.insertRow();          // 返回新行
  tr.insertCell().innerHTML = tr.rowIndex;
  tr.insertCell().innerHTML = nameInp.value;
  tr.insertCell().innerHTML = addressInp.value;
  tr.insertCell().innerHTML = phoneInp.value;
  tr.insertCell().innerHTML = '<button class="deleteBtn">删除</button>';
};

删除时要拿“当前按钮所在行”的索引。调试中容易先想到按钮集合的 index,但删一行后按钮集合与行集合就对不上了。可靠做法是从按钮往上找:btn.parentElement.parentElement 得到 tr,再用 tr.rowIndex:

js
document.querySelectorAll('.deleteBtn').forEach(btn => {
  btn.onclick = function () {
    const tr = this.parentElement.parentElement;
    table.deleteRow(tr.rowIndex);
  };
});

快速创建图片元素 ​

为什么需要:图片很常用,new Image() 比 createElement('img') 更直接。
怎么想:它创建出来的仍是图片元素,设置 src、宽高后还要挂到页面上才可见。
怎么做:

js
const img = new Image();
img.src = 'xxx.jpg';
img.width = 200;
img.height = 100;
document.body.appendChild(img);

易错点:width/height 这里成为元素自带属性,不等同于写 CSS 样式;不 appendChild 就不会显示。

三种事件监听方式及区别 ​

为什么需要:交互要等用户点击、右击、按键等动作发生后再执行代码,而不是页面一加载就执行。
怎么想:把事件理解成“在某个元素上埋监听,用户触发动作时执行回调”。
怎么做:第一种写 HTML 属性:onclick="...";第二种写元素对象属性:btn.onclick = function(){};第三种用标准方法:btn.addEventListener('click', fn)。
易错点:前两种同一个事件写多次,前者只有前面的生效,后者后面的覆盖前面的;addEventListener 事件名不带 on,同一事件可绑定多个回调,都会执行。多人协作时更推荐第三种,避免互相覆盖。

js
btn1.onclick = function () { console.log('btn1'); };
btn2.onclick = function () { console.log('btn2'); };
btn3.addEventListener('click', function () { console.log('btn3-a'); });
btn3.addEventListener('click', function () { console.log('btn3-b'); });

解除事件监听 ​

为什么需要:一次性按钮、广告按钮等场景,触发后要取消监听。
怎么想:前两种本质是把函数赋给元素属性,解除时用空值覆盖;第三种必须移除“同一个函数引用”。
怎么做:

js
btn1.onclick = null;
btn2.onclick = null;

function handler() { console.log('btn3'); }
btn3.addEventListener('click', handler);
btn3.removeEventListener('click', handler);

易错点:removeEventListener 不能传一个临时匿名函数,必须传添加时的同一个函数;回调函数加括号会立即执行,传参时不能加 ()。

事件流:捕获、目标、冒泡 ​

为什么需要:父子元素都监听点击时,必须知道谁先触发。
怎么想:用户点某个元素后,浏览器先从文档层层向下找到目标元素,这叫捕获;找到目标元素是目标阶段;再从目标元素层层向上回到文档,这叫冒泡。事件回调默认在冒泡阶段执行。
怎么做:addEventListener('click', fn, true) 第三参数为 true 时改为捕获阶段触发;前两种监听方式不能这样调整。
易错点:目标元素不一定监听了事件;捕获和冒泡会经过很多元素,只有刚好监听了事件的元素才执行回调。

回调函数中的 this ​

为什么需要:一组元素绑定同一逻辑时,要知道“当前点的是谁”。
怎么想:事件回调里的 this 指向监听事件的元素,三种方式结论一致。
怎么做:可用 this 代替循环变量:

js
const lis = document.querySelectorAll('#books li');
lis.forEach(function (li) {
  li.onclick = function () {
    this.classList.toggle('active');
  };
});

易错点:用 for 循环绑定事件时,回调里的 i 是全局变量,点击发生时循环早已结束,i 往往变成最后的值,导致报错或全选最后一个;此时用 this 或 forEach 的形参更稳。

表格元素的专用属性与方法 ​

为什么需要:用 createElement('tr')、createElement('td') 再层层 appendChild 也能做表格,但代码长、层级容易写错。表格元素自带专用方法,能把“创建并插入”合成一步。

怎么想:先分清三层:table 管行,tr 管单元格,td/th 只关心自己在行内的位置。table.rows 取所有行;tr.cells 取本行所有单元格。

怎么做:

js
const table = document.querySelector('#table');
const tr = table.insertRow();      // 返回新 tr,默认加到末尾
const td1 = tr.insertCell();       // 返回新 td
td1.innerHTML = tr.rowIndex;       // 行索引从 0 开始,表头行也参与

删除时,按钮在 td 里,先 parentElement.parentElement 找到 tr,再用 rowIndex:

js
btn.onclick = function () {
  const tr = this.parentElement.parentElement;
  table.deleteRow(tr.rowIndex);
};

易错点:insertRow(0) 不一定插到 tbody 第一行,因为 thead 和 tbody 的 tr 会一起排索引;不传参数才默认加到最后。rowIndex 从 0 开始,表头行也会占一个索引。

快速创建图片元素 ​

为什么需要:创建图片很常用,new Image() 比 createElement('img') 更直接。

怎么想:它创建出来的仍是图片元素,设置 src 后还要挂到页面上才可见。

怎么做:

js
const img = new Image();
img.src = 'demo.png';
img.width = 200;
img.height = 100;
document.body.appendChild(img);

易错点:width/height 在这里是元素自带属性,不是 CSS 样式;目前转写稿中明确提到可用 new 创建的特殊元素是 Image,不要随意类推到所有元素。

三种事件监听方式 ​

为什么需要:事件让代码“等用户动作发生后再执行”,而不是页面一加载就执行。要给元素绑定点击、右击、键盘等动作,就需要监听。

怎么想:三种写法本质都是“事件名 + 回调函数”,区别在绑定位置和重复绑定规则。

怎么做:

html
<button id="btn1" onclick="console.log('btn1')">按钮1</button>
js
const btn2 = document.querySelector('#btn2');
btn2.onclick = function () { console.log('btn2'); };

const btn3 = document.querySelector('#btn3');
btn3.addEventListener('click', function () { console.log('btn3'); });

易错点:第一种把事件写成标签属性,同名事件写两次只有前面生效;第二种写成元素对象属性,同名事件后写覆盖前写;第三种 addEventListener 可对同一事件绑定多个回调,事件名不带 on,写 click 而不是 onclick。

解除事件监听 ​

为什么需要:一次性按钮、广告关闭、权限变化等场景,需要“触发一次后不再触发”。

怎么想:前两种监听本质是元素对象属性,赋空即可覆盖;第三种必须用 removeEventListener,并传入同一个函数引用。

怎么做:

js
btn2.onclick = null;

function handler() { console.log('btn3'); }
btn3.addEventListener('click', handler);
btn3.removeEventListener('click', handler);

易错点:removeEventListener 不能传一个“长得一样”的匿名函数,必须是同一个函数引用;所以回调要先起名或存变量。

事件流:捕获、目标、冒泡 ​

为什么需要:点击子元素时,父元素上的事件也可能触发,必须理解触发顺序,才能解释“为什么点了绿色却先执行橙色”。

怎么想:事件从 document 层层向下找到目标元素,叫捕获;找到目标后,再从目标层层向上,叫冒泡。回调默认在冒泡阶段执行。

怎么做:

js
box2.addEventListener('click', function () {
  console.log('橙色被点击');
});
box3.addEventListener('click', function () {
  console.log('黄色被点击');
});
// 第三个参数 true:改为捕获阶段触发
box2.addEventListener('click', fn, true);

易错点:目标元素不一定是绑了事件的元素;捕获和冒泡都会经过祖先元素,只有刚好绑了事件且处于对应阶段,回调才执行。前两种监听方式不能调整阶段,默认走冒泡。

回调函数中的 this ​

为什么需要:一组 li 都要绑定点击事件,回调执行时要知道“到底点的是哪一个”。

怎么想:事件回调里的 this 指向监听事件的元素。用 forEach 时每个回调有形参,各自独立;用 for 循环时若引用外层 i,点击时循环早已结束,i 已变成最终值。

怎么做:

js
const items = document.querySelectorAll('#book-list li');
items.forEach(function (item) {
  item.onclick = function () {
    this.classList.toggle('active');
  };
});

用 for 循环时也可在回调里用 this,而不是用循环变量 i。

易错点:转写稿中的调试现象是:for 循环里回调输出 i 全是 6,因为回调要等点击才执行,那时循环已结束;再用 items[6] 会找不到元素而报错。this 能稳定拿到当前被点击的 li。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

1. 表格相关元素:insertRow / insertCell / deleteRow ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>表格相关元素</title>
  <style>
    table { border-collapse: collapse; }
    td, th { border: 1px solid #999; padding: 6px 12px; }
    .deleteBtn { color: red; }
  </style>
</head>
<body>
  <input type="text" id="name" placeholder="姓名">
  <input type="text" id="address" placeholder="地址">
  <input type="text" id="phone" placeholder="电话">
  <button id="btn">添加</button>

  <table id="table">
    <thead>
      <tr>
        <th>序号</th><th>姓名</th><th>地址</th><th>电话</th><th>操作</th>
      </tr>
    </thead>
    <tbody></tbody>
  </table>

<script>
  // 1. 获取相关元素
  const tableBooks = document.querySelector('#table');
  const nameInp    = document.querySelector('#name');
  const addressInp = document.querySelector('#address');
  const phoneInp   = document.querySelector('#phone');
  const btn        = document.querySelector('#btn');

  // 2. 点击添加
  btn.onclick = function () {
    // insertRow() 不传参 → 添加到最后;返回的就是新创建的 TR
    const tr = tableBooks.insertRow();

    // insertCell() 不传参 → 加到行末;返回值就是新创建的 TD
    tr.insertCell().innerHTML = tr.rowIndex;      // 行号,从 0 开始,表头是 0
    tr.insertCell().innerHTML = nameInp.value;
    tr.insertCell().innerHTML = addressInp.value;
    tr.insertCell().innerHTML = phoneInp.value;
    tr.insertCell().innerHTML = '<a href="javascript:;" class="deleteBtn">删除</a>';

    bindDelete();   // 新增的删除按钮也要能点
  };

  // 3. 给每个删除按钮绑定事件
  function bindDelete() {
    const deleteBtns = tableBooks.querySelectorAll('.deleteBtn');
    deleteBtns.forEach(function (btn) {
      btn.onclick = function () {
        // 删除按钮 → 父元素 TD → 父元素 TR
        const tr = this.parentElement.parentElement;
        tableBooks.deleteRow(tr.rowIndex);   // 按行索引删除一行
      };
    });
  }
</script>
</body>
</html>

要点

  • table.insertRow() 一个方法顶 createElement('tr') + appendChild 两步,返回值就是新 TR,可以继续操作;TR 的父元素是 tbody,不是 table,所以不能用 table.appendChild(tr)。
  • insertRow() 的参数是行位置索引。传 0 会插到 thead 那行前面——因为 thead 的 TR 和 tbody 的 TR 是一起排号的;不传参就加到最后。
  • tr.insertCell() 同理,返回新 TD;不传参加到行末,传 0 会插到最前面,所以写第二个单元格要传 1,别再传 0。
  • tr.rowIndex 是本行索引(表头行是 0,tbody 第一行是 1),不是“第几个删除按钮”。删除一行后按钮集合的下标会错位,所以不能用按钮在集合里的 index 当行索引。
  • 删除按钮取所在行:this.parentElement.parentElement 拿到 TR,再用 rowIndex 交给 deleteRow()。

2. 三种事件监听方式与解除监听 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>三种事件监听方式与解除监听</title>
  <style>
    #list { list-style: none; padding: 0; width: 635px; }
    #list li { padding: 10px; border-bottom: 1px solid #999; cursor: pointer; }
    #list li.active { background: #900; color: #fff; }
  </style>
</head>
<body>
  <!-- 方式一:事件作为标签的属性,事件名要带 on -->
  <button id="btn1" onclick="console.log('btn1 被点击了')">按钮01</button>
  <button id="btn2">按钮02</button>
  <button id="btn3">按钮03</button>
  <button id="btn4">解除事件监听</button>

  <ul id="list">
    <li>刘备</li>
    <li>关羽</li>
    <li>张飞</li>
  </ul>

<script>
  const btn1 = document.querySelector('#btn1');
  const btn2 = document.querySelector('#btn2');
  const btn3 = document.querySelector('#btn3');
  const btn4 = document.querySelector('#btn4');

  // 方式二:事件名作为元素对象的属性
  btn2.onclick = function () {
    console.log('btn2 被点击了', this.id);   // this 指向监听事件的元素
  };

  // 方式三:addEventListener,事件名不带 on,回调必须具名才能解除
  function handler() {
    console.log('btn3 被点击了', this.id);
  }
  btn3.addEventListener('click', handler);

  // 解除监听
  btn4.onclick = function () {
    btn1.onclick = null;                          // 前两种:置空即覆盖掉原回调
    btn2.onclick = null;
    btn3.removeEventListener('click', handler);   // 第三种:必须传同一个函数
  };

  // this 的应用:给一组 li 绑定点击选中
  const items = document.querySelectorAll('#list li');
  for (var i = 0; i < items.length; i++) {
    items[i].onclick = function () {
      this.classList.toggle('active');   // this 就是被点击的那个 li
    };
  }
</script>
</body>
</html>

要点

  • 方式一和方式二本质相同(标签属性映射成元素对象的属性),所以解除方式也相同:元素.onclick = null;同一事件写两遍,方式一只有前面的生效,方式二只有后面的生效(覆盖)。
  • 方式三 addEventListener('click', fn) 事件名不带 on;同一事件绑定多个回调会全部执行,多人协作时不会互相覆盖。
  • 解除第三种监听必须 removeEventListener('click', 同一个函数引用):匿名函数解除不了,写一模一样的函数也不行(函数是对象类型,写一遍就是新的一个),只能先把函数赋给变量/起名字再传进去。回调传函数名时不加小括号,加了就自己调用了。
  • 三种方式回调里的 this 规则一样:都指向监听事件的那个元素,可以用它直接取到当前元素。
  • 在 for 循环里给一组元素绑定事件时,不要在回调里用 items[i]:var 声明的 i 是全局变量,回调执行时循环早已结束,i 恒等于 length,会报错。改用 this 拿当前元素就完全没问题。

3. 事件流:捕获 / 目标 / 冒泡 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>事件流</title>
  <style>
    #box1 { padding: 100px; width: 800px; background: #f00; }
    #box2 { padding: 100px; background: #f80; }
    #box3 { padding: 100px; background: #ff0; }
    #box4 { height: 100px; background: #080; }
  </style>
</head>
<body>
  <div id="box1">
    <div id="box2">
      <div id="box3">
        <div id="box4"></div>
      </div>
    </div>
  </div>

<script>
  const box2 = document.querySelector('#box2');
  const box3 = document.querySelector('#box3');

  // 第三个参数为 true → 回调在【捕获阶段】执行
  box2.addEventListener('click', function () {
    console.log('橙色的元素被点击了');
  }, true);

  // 不写第三个参数 → 默认 false,回调在【冒泡阶段】执行
  box3.addEventListener('click', function () {
    console.log('黄色的元素被点击了');
  });
</script>
</body>
</html>

要点

  • 事件触发分三阶段:捕获(从 document 层层向下找目标元素)→ 目标阶段(找到目标元素那一刻,承上启下)→ 冒泡(从目标元素层层向上回到 document)。也有人只分捕获和冒泡两段。
  • 点最内层的绿块,相当于也点了黄、橙、红(目标元素是绿块,它没有监听事件也照样是目标);但回调默认只在冒泡阶段执行,所以经过黄、橙时才会打印——先黄后橙。
  • 红块没监听事件,捕获和冒泡照样经过它,只是什么都不干;这个过程每次点击都会发生,跟有没有监听事件无关。
  • addEventListener 第三个参数默认为 false(冒泡阶段执行),设成 true 就在捕获阶段执行:所以本例点绿块会先打印橙色、再打印黄色。
  • 只有 addEventListener 能调整执行阶段,onclick 和标签属性写法都改不了。

常见错误 ​

  1. 现象:table.insertRow(0) 后新行跑到表头里 → 原因:把 thead 和 tbody 中的 tr 分开编号了 → 修法:按整张表所有 tr 的统一索引理解,必要时用 table.rows 验证位置。
  2. 现象:连续 insertCell(0) 后单元格顺序反了 → 原因:每次都插到第 0 位,后面的被后移 → 修法:按目标顺序使用递增索引,或省略参数追加到末尾。
  3. 现象:删除按钮点一次后,后续删除错行或报错 → 原因:用按钮集合的 index 当行号,删除后集合与行索引错位 → 修法:从当前按钮向上找到所在 tr,用该行的 rowIndex,或直接用 this 定位当前行。
  4. 现象:removeEventListener 解除不了事件 → 原因:添加和移除时传入的不是同一个函数引用,匿名函数每次都是新函数 → 修法:把回调函数保存为具名函数或变量,添加和移除使用同一引用。
  5. 现象:for 循环给一组元素绑定事件后,点击每个元素都拿到最后一个索引,甚至报错 → 原因:回调执行时循环早已结束,变量是共享的全局变量 → 修法:回调中改用 this 获取当前监听元素,或用 let/闭包保存当次索引。
  6. 现象:给同一元素同一事件绑定多次,结果与预期不一致 → 原因:三种监听方式对重复绑定的处理不同,前两种会覆盖,第三种可叠加 → 修法:明确需求后统一监听方式;需要多人协作叠加时用 addEventListener,需要覆盖时用属性方式。

练习 ​

  1. 实现一个表格:输入姓名、地址、电话,点击“添加”用 insertRow/insertCell 新增一行,并显示当前行号;每行带“删除”按钮,点击删除当前行。
  2. 用 new Image() 创建一个图片元素,设置 src、width、height 后挂到 body,再对比用 createElement('img') 的写法。
  3. 对同一个按钮分别用三种方式监听 click,观察重复绑定、执行顺序和解除监听时的差异,并写出你的选择理由。
  4. 写一个父子嵌套结构,分别给父、子元素监听 click,通过 addEventListener 第三个参数验证冒泡与捕获的执行顺序。
  5. 用 for 循环给一组 li 绑定点击事件,实现点击项高亮;要求分别用 this 和闭包两种方式修正索引问题。

原视频对照 ​

#视频时长
1HTMLDOM-表格相关元素23:19
2HTMLDOM-快速创建新图片01:57
3三种事件监听方式18:22
4解除事件的监听12:51
5事件流22:26
6事件回调函数中的this16:48

小结 ​

  1. 表格相关 API 的核心是按“表—行—单元格”的层级操作,索引位置要特别谨慎。
  2. 行索引应优先从当前行元素获取,不要用按钮集合索引代替行索引。
  3. 动态增删表格时,先定位当前操作对象,再调用对应删除或插入方法。
  4. 图片可用 new Image() 快速创建,但创建后仍需挂载到页面结构才可见。
  5. 事件监听三种方式都能用,但覆盖规则、解除方式和协作场景不同。
  6. 需要解除监听时,必须保留并复用同一个回调函数引用。
  7. 事件回调中的 this 指向监听事件的元素,是解决批量绑定定位问题的可靠方式。
  8. 默认回调在冒泡阶段执行,理解捕获、目标、冒泡顺序有助于排查嵌套元素事件问题。