DOM、事件与表单:把页面当作一棵会响应的树
HTML 是文本,DOM 是浏览器解析后提供给程序的对象树。改变 DOM 就可能改变页面,但“修改了一行 JavaScript”不等于“只修改一个像素”:后面还可能发生样式计算、布局与绘制。
先分清节点、元素与文本
元素是节点的一种。标签之间的文字也是节点,源文件中的换行和缩进也可能形成文本节点。因此“第一个子节点”不一定是你眼里的第一个标签。保存下面 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 是不同概念。
<!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 或点击按钮,列表会增加一项;点击项内的删除按钮可删除它。
<!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,点击“记录路径”,观察页面上的五行输出:
<!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 事件。
练习
在删除按钮里加入一个 span 图标,为什么本例委托仍能找到按钮?
为什么输入框填入三个空格时仍需自己的校验?为什么客户端校验不能替代服务端校验?
为什么目标上的捕获监听显示eventPhase为2?stopPropagation为什么没有阻止同目标的后续监听器?
给便签列表增加“清空”按钮和当前条数。要求添加、删除、清空后数量正确;对空列表再次清空仍为 0;名称为
<b>测试</b>时显示原字符。先独立完成再看答案。