Skip to content

DOM、事件与表单:把页面当作一棵会响应的树 ​

HTML 是文本,DOM 是浏览器解析后提供给程序的对象树。改变 DOM 就可能改变页面,但“修改了一行 JavaScript”不等于“只修改一个像素”:后面还可能发生样式计算、布局与绘制。

先分清节点、元素与文本 ​

元素是节点的一种。标签之间的文字也是节点,源文件中的换行和缩进也可能形成文本节点。因此“第一个子节点”不一定是你眼里的第一个标签。保存下面 HTML,打开控制台:

html
<!doctype html>
<meta charset="utf-8">
<title>一棵小树</title>
<section id="tree">开头<p id="first">甲</p><p id="second">乙</p></section>
<script>
  const tree = document.querySelector('#tree');
  const first = document.querySelector('#first');
  console.log(tree.childNodes.length, tree.children.length); // 3 2
  console.log(tree.firstChild.nodeType); // 3:文本节点
  console.log(tree.firstElementChild === first); // true
  console.log(first.parentElement === tree); // true
  console.log(first.nextElementSibling.id); // second
  console.log(first.previousSibling.textContent); // 开头
  console.log(first.firstChild.nodeType); // 3:甲所在的文本节点
</script>

这棵子树有一个 section、两个 p,以及三个文本节点。childNodes 包含直接的全部子节点,children 只包含直接的子元素;它们都不把孙辈自动摊平。parentNode、firstChild、nextSibling 沿节点关系移动;parentElement、firstElementChild、nextElementSibling 只给元素结果,找不到就返回 null。这里用 nodeType 观察类型,元素的值为 1,文本为 3。

如果给两个 p 之间加换行,childNodes 的数量会改变,children 仍然是 2。这正是处理标签时经常使用元素导航的原因。脚本放在这些标签之后,保证解析器已经建立相应对象;第七章的 defer 和 module 则提供其他安排执行时机的方式。

先让一个控件改变一个元素 ​

复杂表单可以拆成“读取当前值,计算状态,更新节点”。下面的小例子只完成这条链。HTML 中的 class 用于匹配样式规则,与第四章定义对象的 JavaScript class 是不同概念。

html
<!doctype html>
<meta charset="utf-8">
<title>阅读状态</title>
<style>.done { text-decoration: line-through; }</style>
<label><input id="done" type="checkbox">已读</label>
<p id="topic">作用域</p>
<script>
  const checkbox = document.querySelector('#done');
  const topic = document.querySelector('#topic');
  function render() {
    topic.classList.toggle('done', checkbox.checked);
    topic.style.opacity = checkbox.checked ? '0.6' : '1';
  }
  checkbox.addEventListener('change', render);
  render();
</script>

addEventListener 在这里登记函数,发生 change 时浏览器才调用它;传入 render 而不是 render()。最后主动调用一次,用当前控件状态初始化页面。

classList.toggle 的第二个参数明确决定类是否存在,重复 render 不会意外反转状态。classList.add/remove 修改一个类;直接赋值 className 会替换整串类名,容易丢掉其他样式。style 修改行内样式,不能据此读出外部样式表的所有效果;需要最终计算结果时使用 getComputedStyle。固定视觉规则适合写在样式类中,运行时计算值可以赋给 style。

查找结果可能为空,也可能会变化 ​

querySelector 返回第一个匹配元素或 null;querySelectorAll 返回静态 NodeList,之后插入节点不会自动更新这次查询结果。getElementsByClassName 返回的 HTMLCollection 则是动态集合。遍历动态集合同时删除成员,索引会移动,可能跳过节点;必要时先 Array.from 得到快照。

createElement 创建节点,append 插入,remove 移除。再次 append 已存在的节点会移动它,而不是复制它。要复制可用 cloneNode,但通过 addEventListener 注册的监听器不会因此一起复制。

textContent 适合填普通文本;innerHTML 会把字符串解析为 HTML。用户写了 <b>你好</b> 时,文本展示应该显示这些字符,而不是自动授予它修改页面结构的权力。

一个小表单串起节点、输入和事件 ​

保存为 dom.html,直接在浏览器打开。输入名称,按 Enter 或点击按钮,列表会增加一项;点击项内的删除按钮可删除它。

html
<!doctype html>
<meta charset="utf-8">
<title>概念便签</title>
<form id="note-form">
  <label for="topic">概念名称</label>
  <input id="topic" name="topic" required maxlength="40">
  <button>添加</button>
</form>
<p id="status" role="status"></p>
<ul id="notes"></ul>
<script>
  const form = document.querySelector('#note-form');
  const input = document.querySelector('#topic');
  const list = document.querySelector('#notes');
  const status = document.querySelector('#status');

  form.addEventListener('submit', event => {
    event.preventDefault();
    const title = input.value.trim();
    if (!title) {
      status.textContent = '请输入非空白名称';
      input.focus();
      return;
    }
    const item = document.createElement('li');
    const label = document.createElement('span');
    label.textContent = title;
    const remove = document.createElement('button');
    remove.type = 'button';
    remove.dataset.action = 'remove';
    remove.textContent = '删除';
    remove.setAttribute('aria-label', `删除 ${title}`);
    item.append(label, ' ', remove);
    list.append(item);
    form.reset();
    input.focus();
    status.textContent = `已添加 ${title}`;
  });

  list.addEventListener('click', event => {
    if (!(event.target instanceof Element)) return;
    const button = event.target.closest('button[data-action="remove"]');
    if (!button || !list.contains(button)) return;
    button.closest('li').remove();
    input.focus();
    status.textContent = '已删除';
  });
</script>

这里监听 submit,因此鼠标和键盘提交走同一条路径。把逻辑只写在按钮 click 中,容易漏掉其他提交途径。原生 button 自带键盘交互,比给 div 添一堆补丁更省事。label 的 for 与 input 的 id 对应,帮助用户和辅助技术确认输入含义。

required 可以阻止空字符串提交,却不会把全空格当成空字符串,所以仍检查 trim 后的结果。浏览器校验服务于交互,服务端仍必须校验:用户完全可以绕开这个页面发请求。

事件沿着哪条路走 ​

点击按钮时,事件先沿祖先方向向目标传播(捕获阶段),经过目标,再按事件类型决定是否向祖先冒泡。普通 click 会冒泡。addEventListener('click', fn, { capture: true }) 注册捕获监听器,默认是非捕获监听器。

event.target 是经过相应规则暴露的事件目标;event.currentTarget 是当前正在执行监听器的节点。在本例的列表监听器中,target 可能是按钮内部新增的图标,currentTarget 则是列表。因此用 closest 寻找相关按钮,而不硬猜 target 一定是 button。

这就是事件委托:祖先处理后代事件,新插入的列表项也能生效。不冒泡的事件不能直接套这个思路;例如可以用冒泡的 focusin 替代某些 focus 委托需求,或使用捕获监听。

preventDefault 阻止默认行为,例如表单导航;stopPropagation 阻止继续传播。二者互不替代。过度停止传播会让外层埋点、快捷操作等监听器收不到事件。被动监听器 passive: true 中也不能指望 preventDefault 生效。

把传播路径实际打印出来 ​

上一段便签使用了冒泡,但仅说“先里面、后外面”会漏掉捕获阶段。保存下面完整 HTML,点击“记录路径”,观察页面上的五行输出:

html
<!doctype html>
<meta charset="utf-8">
<title>事件经过哪里</title>
<section id="panel"><button id="trigger" type="button">记录路径</button></section>
<pre id="trace"></pre>
<script>
  const panel = document.querySelector('#panel');
  const trigger = document.querySelector('#trigger');
  const trace = document.querySelector('#trace');
  function record(label) {
    return event => {
      trace.textContent += `${label} target=${event.target.id} current=${event.currentTarget.id} phase=${event.eventPhase}\n`;
    };
  }
  panel.addEventListener('click', event => {
    trace.textContent = '';
    record('外层捕获')(event);
  }, true);
  trigger.addEventListener('click', record('目标捕获'), true);
  trigger.addEventListener('click', record('目标监听一'));
  trigger.addEventListener('click', record('目标监听二'));
  panel.addEventListener('click', record('外层冒泡'));
</script>

输出依次是外层捕获、目标捕获、目标监听一、目标监听二、外层冒泡。target 都指向 trigger,而 currentTarget 随当前监听器改变;阶段分别是 1、2、2、2、3。目标上的捕获监听仍属于“在目标”阶段,不会因为注册时写了 true 就显示阶段 1。

浏览器在分发时构造事件路径,再沿路径调用相应监听器。一个元素不必因为自己发生点击才执行监听器,它可能只是目标的祖先;委托就是利用这一点把处理放到稳定的容器上。同节点、同阶段的多个监听器通常按注册顺序调用。

接着在“目标监听一”的处理器里调用 stopPropagation:外层冒泡不再出现,但同一目标上的“目标监听二”仍可执行。若需要连同当前目标上后续监听器一起停止,则是 stopImmediatePropagation。两者都不自动取消按钮、链接、表单的默认动作;preventDefault 也不自动停止其他监听器。

只在确实需要改变交互时拦截默认行为,并检查事件是否可取消。尤其不要把 stopPropagation 当作所有委托冲突的通用补丁,否则外层无法观察本应共享的行为。

通过 dispatchEvent 主动派发事件会同步调用监听器,然后返回;这不等于模拟完整的真人交互,其 isTrusted 为 false,也不能凭此获得文件选择等需要用户激活的权限。手动触发一个 input 事件同样不替你填写输入值。

输入值、属性与组合输入 ​

input 的 value 属性反映当前输入;HTML value 特性更接近初始值,两者并不始终同步。复选框读 checked,不要读 value 来判断是否勾选。FormData(form) 根据可成功提交的控件收集数据:需要 name,disabled 控件和未选中的复选框不会像普通文本框一样提供值。

input 适合跟随编辑变化,change 更接近一次变更确认。中文输入法会经历组合输入;对实时搜索可检查 event.isComposing,并在 compositionend 后处理最终值,避免每个拼音中间态都触发搜索。程序直接设置 value 通常不会自动触发 input 事件。

练习 ​

  1. 在删除按钮里加入一个 span 图标,为什么本例委托仍能找到按钮?

  2. 为什么输入框填入三个空格时仍需自己的校验?为什么客户端校验不能替代服务端校验?

  3. 为什么目标上的捕获监听显示eventPhase为2?stopPropagation为什么没有阻止同目标的后续监听器?

  4. 给便签列表增加“清空”按钮和当前条数。要求添加、删除、清空后数量正确;对空列表再次清空仍为 0;名称为 <b>测试</b> 时显示原字符。先独立完成再看答案。

查看本节答案

用心记录每一步成长