Skip to content

第八章练习答案 ​

浏览器练习既要解释代码,也要解释环境前提。网络路径、标签页状态与 DOM 位置都可能影响观察结果。

DOM 与表单 ​

第 1 题。 点击内部 span 时 target 可能是 span,closest 会沿自身与祖先寻找符合条件的按钮,contains 再确认它处在委托容器内。只写 event.target.dataset.action 则可能漏掉点击图标的情况。

第 2 题。 required 检查空值,空格构成非空字符串;trim 后再检查落实“非空白名称”的业务规则。客户端代码可被修改或绕过,因此服务端必须独立检查长度、类型、权限等约束。

第 3 题。 eventPhase描述当前分发位置,位于目标时为AT_TARGET即2;注册为捕获监听只决定在哪次相应调用中参与。stopPropagation阻止继续沿路径传播,不停止同一目标上的其他监听器;后者需要stopImmediatePropagation,取消默认动作则是另一职责。

第 4 题。 在表单外加入以下标记;其余片段放在原脚本中,复用 list、input、status:

html
<button id="clear-all" type="button">清空</button>
<output id="count">0</output>
js
const count = document.querySelector('#count');
function updateCount() { count.textContent = String(list.children.length); }
document.querySelector('#clear-all').addEventListener('click', () => {
  for (const item of Array.from(list.children)) item.remove();
  updateCount();
  status.textContent = '已清空';
  input.focus();
});
updateCount();

另外在原添加逻辑的 list.append 后、删除逻辑的 remove 后分别调用 updateCount。先转成数组再移除,避免动态 children 索引移动导致漏删。保留原来的 label.textContent,不改为 innerHTML。依次添加两项、删除一项、清空两次,数量应为 2、1、0、0;标签字符仍作为文本展示。

请求与竞态 ​

第 1 题。 fetch 能获得 404 响应时通常正常兑现,需自行检查 ok/status。无效 JSON 在 response.json 的解析阶段产生拒绝。网络成功、HTTP 成功和数据契约成功是三层不同条件。

第 2 题。 不够。取消是协作机制,已经完成的操作和后续自定义处理未必会被撤销。保存每次调用的编号,渲染前比较当前编号,过期调用即使完成也不能更新界面;错误显示同样需要检查。

第 3 题。 mine===revision决定本次结果是否仍有发布资格,独立于底层是否真正取消。旧错误同样不能成为当前界面错误,因此错误列表应保持为空;失败提示与成功渲染必须遵守相同归属规则。

第 4 题。 保留 createLatestLoader 定义,在支持 Response 的 .mjs 中运行:

js
for (const fail of [false, true]) {
  let settle;
  const shown = [];
  const failures = [];
  const request = () => new Promise((resolve, reject) => {
    settle = () => fail ? reject(new Error('延迟失败'))
      : resolve(new Response('{"title":"延迟结果"}'));
  });
  const loader = createLatestLoader(x => shown.push(x), e => failures.push(e), request);
  const work = loader.load('demo');
  loader.dispose();
  settle();
  await work;
  if (shown.length || failures.length) throw new Error('已离开页面仍被更新');
}

两种完成方式都没有发布权限。用人工完成函数控制顺序,避免“等 100 毫秒希望网络已完成”的不确定性。若 dispose 不增加 revision 且请求忽略取消,成功分支会暴露回归。

存储与历史 ​

第 1 题。 普通对象会转成类似 [object Object] 的字符串,不是 JSON 文本。写入使用 JSON.stringify,读取使用 JSON.parse,并对解析失败和结构不符合预期做降级。序列化还存在类型损失等边界,不适用于任意对象。

第 2 题。 一条请求成功后,同一事务里的后续请求仍可能失败并使事务回滚。整组写入以 transaction complete 为完成依据,abort 表示失败;不要在第一条 put 的 success 就告诉用户全部保存成功。

第 3 题。 不是。pushState 修改历史和 URL,不负责渲染,也不会为此次调用自动派发 popstate。应用应主动根据新 URL 更新页面,并在前进后退触发 popstate 时再次恢复。

第 4 题。 第一条请求成功但事务尚未提交,第二条重复键失败默认中止整个事务,所以第一条修改也未提交。put允许对该键覆盖,若整个事务成功,最终值会是第二条内容;这是改变写入契约,不是只隐藏一个报错。

第 5 题。 可以先约束字符串形态,再转换:

js
function chapterFromURL(address) {
  const raw = new URL(address).searchParams.get('chapter');
  return /^(?:[1-9]|10)$/.test(raw ?? '') ? Number(raw) : 1;
}
for (const query of ['?chapter=3', '?chapter=0', '?chapter=2.5', '?chapter=3abc', '']) {
  console.log(chapterFromURL('https://example.test/notes' + query));
}
// 3 1 1 1 1

函数要求可解析的绝对 URL,错误地址由 URL 构造器抛出;参数不合法则按题目降级。重复 chapter 使用 get 的第一个值;若产品禁止重复参数,可先用 getAll 检查数量。初始化和 popstate 都可调用同一解析函数,然后主动渲染。

观察器与合并执行 ​

第 1 题。 ResizeObserver 观察元素尺寸,内容换行、字体加载、父容器变化都可能导致尺寸变化,不一定发生窗口 resize。若只关心 DOM 子节点增加,则是 MutationObserver 的任务。

第 2 题。 不满足。尾沿防抖一直等输入停止,连续触发会不断推迟执行。可设计带最大等待时间的防抖或定期节流,并明确尾部最终保存和离开时清理;不要只把 delay 设成两秒就以为自动满足要求。

第 3 题。 record.target指向同一个活元素,回调时属性已变成b;oldValue按每次变化记录,通用newValue字段不存在。takeRecords取走并清空待交付队列,不能期待同批记录再自动回调一次。

第 4 题。 在 HTML 的 box 外放 <output id="added">0</output>,用下列代码替换 mutations 的创建与 observe,保留原停止函数:

js
let added = 0;
const mutations = new MutationObserver(records => {
  for (const record of records) {
    for (const node of record.addedNodes) {
      if (node instanceof Element && node.matches('p')) added += 1;
    }
  }
  document.querySelector('#added').textContent = String(added);
});
mutations.observe(box, { childList: true });

在控制台执行 box.append(document.createElement('p'), document.createElement('p')),等本轮通知后显示 2。统计记录中的 addedNodes,而不是把回调次数当节点数。输出放在盒子外,且未启用 subtree,因此写日志不会递归统计。这里记录累计添加操作,移动同一 p 回来会再次计数;它不是“当前存在多少个 p”。

Worker 与消息 ​

第 1 题。 Promise 回调仍在当前执行环境运行,密集计算仍占用主线程。Worker 提供独立执行环境,通过消息返回结果;同时有创建与传输成本,不能保证所有小计算都更快。

第 2 题。 转移把缓冲区所有权交给接收端,发送端的 ArrayBuffer 被分离,因此 byteLength 变成零。窗口通信另外需要校验 source,因为同一个可信源也可能有多个窗口;origin 确认源,source 确认具体通信对象,两者与消息字段校验共同构成契约。

第 3 题。 原buffer在第一次转移时已经分离,返回字节不会使原视图重新附着。应从返回消息的buffer创建视图,并按返回对象的所有权继续工作。

第 4 题。 复用原 sum-worker.js,主页面脚本改为:

js
const worker = new Worker('./sum-worker.js');
const pending = new Set(['empty', 'invalid']);
worker.addEventListener('message', event => {
  const { id, total, error } = event.data;
  if (!pending.delete(id)) return;
  console.log(id, error ?? total);
  if (pending.size === 0) worker.terminate();
});
worker.addEventListener('error', () => {
  console.error('Worker 执行失败,未完成:', [...pending]);
  pending.clear();
  worker.terminate();
});
worker.postMessage({ id: 'empty', values: [] });
worker.postMessage({ id: 'invalid', values: [1, '2'] });

输出分别为 empty 的 0、invalid 的“需要有限数字数组”。id 对应结果,不能只按“第几条消息”判断任务。Service Worker 更新实验的 waiting 阶段仍由 v1 响应;主动启用且 controllerchange 后由 v2 响应。只看到 update Promise 完成不能通过该项验证。

Web Components ​

第 1 题。 本例从 shadow 内部按钮发出,自定义事件默认 composed 为 false,无法穿过该边界到达外部 document。bubbles 只决定是否冒泡,不能代替 composed。

第 2 题。 元素移除再接入会再次调用 connectedCallback,多次注册新函数监听器可能让一次点击执行多次。使用 AbortController 或保留引用后移除旧监听。template.content 是实际片段,直接插入会移动节点;cloneNode(true) 为各实例建立各自结构。

第 3 题。 升级为已存在的元素建立自定义行为,其对象身份仍保留;构造是实例初始化,连接表示是否接入文档,是不同生命周期。相同实例可以多次连接,因此外部监听注册与清理必须支持重复连接。

第 4 题。 在原 HTML 增加 <button id="restore" type="button">恢复</button>,在原脚本末尾保存实例并注册恢复:

js
const notice = document.querySelector('study-notice');
let dismissals = 0;
document.addEventListener('notice-dismiss', () => {
  console.log('关闭次数', ++dismissals);
});
document.querySelector('#restore').addEventListener('click', () => {
  if (!notice.isConnected) document.body.append(notice);
});

三次关闭应输出 1、2、3。原 connectedCallback 的 abort 与 disconnectedCallback 的清理保留;每次接入只保留当前监听器。保存实例让 remove 之后还能重新插入,并验证同一对象的连接生命周期。

文件与实时通信 ​

第 1 题。 Blob.size 表示字节数,字符串 length 表示 UTF-16 码元数;UTF-8 中一个汉字通常占多个字节,二者测量的对象不同。上传大小限制应检查文件字节大小,不用文件名或文本字符数猜测。

第 2 题。 对象 URL 保留到 Blob 的映射,长期创建却不释放会增加资源占用。但设置 src 后加载可能尚未完成,立即 revoke 会破坏使用机会。根据生命周期,在预览被替换、移除或组件销毁时释放;保留仍需要的下载和另存操作。

第 3 题。 单向进度可以使用 SSE,提交命令单独 fetch;如果协议需要频繁双向消息或二进制通信,可考虑 WebSocket。选择依据是方向、消息形态和恢复要求,而不是“实时”两个字。无论哪种方案,都需要服务端支持、身份权限校验,以及断线后的重复和丢失处理。

第 4 题。 连接恢复只表示可以继续传输,重复可能导致再次应用,缺口可能意味着状态不完整;应用需按协议去重、补发或获取快照。字节块可切在字符中间,共用流模式decoder可保留未完成字节,逐块新建decoder会丢失这些上下文。

第 5 题。 保留原函数定义,运行:

js
const receiver = makeProgressReceiver();
const messages = [
  { id: 1, percent: 20 }, { id: 3, percent: 60 },
  { id: 2, percent: 40 }, { id: 3, percent: 60 },
  { id: 3, percent: 99 }
];
const results = messages.map(message => receiver.accept(message));
console.log(results); // accepted, gap, accepted, accepted, duplicate
console.log(receiver.read()); // { last: 3, percent: 60 }

第一次 3 没有应用,因此补上 2 后必须重发 3;重复 3 的不同内容也不覆盖状态。此处手动提供补发顺序,验证的是接收规则,不意味着 EventSource 自动替业务补齐了缺口。

用心记录每一步成长