qinghuan
← Java Web / 全部笔记

单元 10 / Java Web

P0 · 必会主线L0 · 零基础

JavaScript、DOM 与异步:让页面响应操作

从按钮事件开始操作页面,再理解 Promise、await 和 fetch,避免旧请求晚返回时覆盖新结果。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能让页面响应事件,并区分网络异常、HTTP 错误与 JSON 解析失败。

学习理由、准备要求与知识点

为什么学它

掌握最小浏览器脚本能力,才能独立观察交互、请求和异步结果。

先读这些内容

知识点清单

  • JavaScript 与 Java 区别
  • let/const 绑定
  • DOM 查询
  • textContent 安全文本
  • 事件监听回调
  • Promise 与 async/await
  • fetch 状态和解析
  • 重复点击和失败恢复

运行前先确认交互机制依据浏览器 DOM、HTML 与 Fetch 标准;JavaScript 与 Java 是不同语言。Java 源码选 OpenJDK 17u jdk-17.0.16+8 的 CompletableFuture 完成阶段,作为跨语言对照;Java 17 算法离线模拟过时响应过滤,不声称实现浏览器事件循环。

01先弄懂它是什么

点击按钮后页面立刻变化,是浏览器运行了监听这个事件的 JavaScript 函数,不是 Java 服务端直接改屏幕。let 变量可以重新赋值,const 不允许重新绑定但不会冻结对象内部。DOM 提供找节点和改文字的方法;普通用户文本应使用 textContent,让它保持为文字,不被重新当成 HTML 解析。

异步表示结果稍后才能得到,等待期间浏览器还能处理其他可运行任务。async 函数返回 Promise,await 暂停的是这个函数后续依赖结果的部分,不是整个页面。不过长时间的同步计算仍会占住主线程;只在函数前加 async,并不会自动把计算移到后台线程。

以搜索框为例,用户先输入 A,再输入 AB。第二次请求可能先返回,而第一次稍后才返回;如果每个成功响应都更新 DOM,页面会被旧结果覆盖。这里的问题不是请求是否成功,而是结果是否仍属于用户最新意图。需要把请求身份、完成状态与界面可见状态分开,再规定谁有权更新当前页面。

文中这几个词,先认一认

事件监听器
提前注册、等对应事件发生时由浏览器调用的函数,例如点击按钮后运行。
Promise
代表未来结果的对象,之后可能成功,也可能失败;具体何时执行后续处理由运行环境调度。
事件循环
浏览器协调任务、微任务和绘制时机的机制,不表示每遇到 await 就新建线程。
过时响应
响应内容本身可能正确,但用户已发出更新的请求,所以它不应再覆盖当前页面。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

每次输入变化时,先读取搜索词,把本次请求编号加一,再显示加载中并发出 fetch;这个递增编号就是这里说的代次。请求发出后,监听函数可以结束,用户继续输入又会得到新编号。响应回来时,把它出发时记下的编号与当前编号比较。只有两者相同,才允许显示结果或结束加载;旧请求无论成功还是失败,都不能改掉新请求的界面状态。

fetch 在拿到 HTTP 响应后通常会正常完成 Promise,即使状态是 404 或 500。因此先检查 response.ok 或具体状态,再按契约解析正文;网络错误、非成功 HTTP 状态、JSON 解析错误和业务拒绝应分层处理。只写 catch 无法自动覆盖所有服务端错误,把所有错误都显示为网络故障也会误导排查。

事件监听器可以先更新 loading,再在异步阶段继续执行,但界面真正何时绘制由浏览器决定。回调里的连续同步工作仍会阻塞其他任务;大量数据整理可拆分、限量或交给合适的 Worker。AbortController 可以中止客户端等待或请求处理的一部分,但服务端是否已执行写入不能只靠浏览器取消结果判断。

先跑通一个最小例子

在空目录保存下面两个文件,直接打开 index.html。脚本通过 defer 等文档解析完成后执行;用一个本地 Promise 模拟异步响应,先观察“处理中”,再观察完成结果。它不向真实服务器发送请求。

index.html · HTML
<!doctype html>
<html lang="zh-CN">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>学习主题表单</title>
<script src="app.js" defer></script>
<main>
  <h1>记录学习主题</h1>
  <form id="study-form">
    <label for="topic">主题</label>
    <input id="topic" name="topic" required maxlength="80">
    <button type="submit">预览结果</button>
  </form>
  <p id="result" role="status">等待输入</p>
</main>
</html>
app.js · JavaScript
const form = document.querySelector('#study-form');
const field = document.querySelector('#topic');
const result = document.querySelector('#result');
const button = form.querySelector('button');
let pending = false;

form.addEventListener('submit', async (event) => {
  event.preventDefault(); // 接管表单提交,避免默认整页导航。
  if (pending) return; // 同一次异步处理未完成时不重复启动。
  const title = field.value.trim();
  if (!title) { result.textContent = '请输入非空主题'; return; }
  pending = true;
  button.disabled = true;
  result.textContent = '处理中…';
  try {
    const message = await new Promise(resolve => {
      setTimeout(() => resolve(`准备学习:${title}`), 500);
    });
    result.textContent = message; // 当作文本显示,用户内容不进入 HTML 解析器。
  } catch {
    result.textContent = '处理失败,请重试';
  } finally {
    pending = false;
    button.disabled = false; // 成功或失败都恢复交互状态。
  }
});

核对运行结果输入 Java 并提交,会先显示“处理中…”,约半秒后显示“准备学习:Java”。只输入空格时提示非空主题。输入 <b>Java</b> 会作为普通文本显示,不创建粗体节点。模拟完成不表示内容已经保存到数据库。

正在加载流程图…

完成顺序可以不同于发起顺序。过滤过时结果保证界面意图,不自动取消旧请求或撤销服务端副作用。
查看流程图源码(Mermaid)
sequenceDiagram
 participant U as 用户
 participant P as 页面状态
 participant S as 服务端
 U->>P: 输入 A,生成代次 1
 P->>S: 请求 1
 U->>P: 输入 AB,生成代次 2
 P->>S: 请求 2
 S-->>P: 响应 2
 P->>P: 当前代次为 2,展示新结果
 S-->>P: 响应 1
 P->>P: 代次过旧,忽略界面写入

按顺序看,每一步做了什么

  1. 注册事件函数,读取 DOM 输入并生成稳定的本次代次。
  2. 发起异步请求,明确加载、成功与失败状态。
  3. 收到响应后先检查 HTTP 层,再解析业务内容。
  4. 提交界面更新前核对代次,旧成功和旧失败都不能覆盖新请求。

03对照实现代码

CompletableFuture 如何检查前序状态再执行转换 (新标签页)↗

OpenJDK 17u,jdk-17.0.16+8 · java.util.concurrent.CompletableFuture#uniApplyStage、UniApply#tryFire

真实文件和方法已核验。下面重新编写一个完成状态转换模型,仅保留等待、成功、失败传播与转换函数异常四种分支;未实现并发、依赖注册、执行器或 Promise 微任务调度。Java CompletableFuture 与浏览器 Promise 的执行规则不能直接等同。

核心机制代码 · java
import java.util.function.Function;
class SourceModel {
  record Outcome<T>(boolean done, T value, Throwable error) {}
  static <T,R> Outcome<R> transform(Outcome<T> input, Function<T,R> fn) {
    if (!input.done()) return new Outcome<>(false,null,null); // 前序未完成,不能计算
    if (input.error()!=null) return new Outcome<>(true,null,input.error()); // 失败继续传播
    try {
      R value=fn.apply(input.value()); // 只有成功值才进入普通转换函数
      return new Outcome<>(true,value,null);
    } catch (RuntimeException failure) {
      return new Outcome<>(true,null,failure); // 转换本身也可能失败
    }
  }
}

thenApply 进入 uniApplyStage,真实实现会根据前序是否已完成决定立即处理或安排依赖动作。UniApply.tryFire 首先检查源结果及相关引用,条件不足时不会凭空执行转换。这对应异步组合的核心约束:依赖结果的计算必须在状态可用后才推进。

当源结果表达失败时,普通成功转换函数不会被当作恢复函数调用,失败会进入后续阶段。若源成功但转换函数抛异常,后续阶段同样失败。教学模型显式区分这两条来源,帮助解释解析 JSON 出错与网络请求失败为何可能最后都进入错误处理,但诊断原因不同。

Java 的非 Async 与 Async API 是否指定执行器,会影响动作在哪个执行线程执行;浏览器 Promise 反应按它自己的任务和微任务规则调度。因此这段源码用于对照完成依赖和失败传播,不用于证明 JavaScript 的 then 会像某个 Java 方法一样在调用者线程立刻运行。

异步阶段成功,只表示这次计算有了结果,不知道它是否仍适合当前页面。过滤旧搜索结果,要另外记录请求代次或管理取消。把“请求成功”与“这个结果还能不能更新界面”分开判断,才能解决并未抛异常的乱序覆盖。

代码先调用谁,再调用谁

  1. 调用 thenApply 表达结果转换依赖。
  2. uniApplyStage 判断前序状态并建立后续阶段。
  3. UniApply.tryFire 在条件满足后传播失败或执行函数。
  4. 后续阶段完成,调用方仍需判断结果是否适用于当前业务状态。

04动手写一个例子

最新请求获准更新界面的代次状态机

顺序接收 start 与 complete 事件。start 生成递增请求 ID 并进入 loading;只有最新 ID 的第一次完成能更新界面,旧响应和重复完成被忽略,未发起的 ID 被拒绝。模拟单个页面组件的单线程事件处理,不涉及网络或多线程竞争。

输入 1
start1,start2,success2(B),success1(A)
输出 1
data:B
输入 2
start1,start2,error1(E)
输出 2
loading
输入 3
start1,error1(E)
输出 3
error:E
输入 4
无事件
输出 4
idle

当前界面只关心最新一次意图,因此不必保存全部历史响应。维护 latest、finished 和 view 三个状态,开始新请求时递增 latest、清除 finished 并进入 loading。

完成事件先验证 ID 属于已发起范围。旧 ID 无权更新当前页面;等于 latest 但已经完成,也应忽略,防止重复回调覆盖第一次结果。只有最新且尚未完成的事件能原子地改变本模型的状态。

这里的原子性来自按顺序处理事件的题目前提,不是把这些 Java 字段直接放进多线程就安全。main 自测正常完成、倒序返回、旧失败、最新失败、重复回调、空状态和无效 ID;计数溢出另行拒绝。

为什么这样写是对的view 的已完成内容只能来自 latest 对应请求的第一次完成;开始新请求后任何旧 ID 都不能改变 loading 或新结果。

时间 每个事件 O(1)额外空间 O(1)

状态机只保存最新代次、完成标志和当前显示值;不保存所有在途请求。字符串内容的分配成本另按其长度计算。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 39 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
public class Main {
  static class Gate {
    long latest=0;
    boolean finished=false;
    String view="idle";
    long start() {
      latest=Math.incrementExact(latest); // 溢出时拒绝,不能重复使用旧代次
      finished=false;
      view="loading";
      return latest;
    }
    void complete(long id,String value,boolean error) {
      if(id<=0 || id>latest || value==null) throw new IllegalArgumentException();
      if(id!=latest || finished) return; // 旧响应或重复响应都不能覆盖页面
      finished=true;
      view=(error?"error:":"data:")+value; // 此题单线程处理检查与写入
    }
  }
  static void eq(String expected,String actual) {
    if(!expected.equals(actual)) throw new AssertionError(actual);
  }
  public static void main(String[] args) {
    Gate empty=new Gate(); eq("idle",empty.view);
    Gate normal=new Gate(); normal.complete(normal.start(),"A",false); eq("data:A",normal.view);
    Gate reverse=new Gate(); long old=reverse.start(),fresh=reverse.start();
    reverse.complete(fresh,"B",false); reverse.complete(old,"A",false); eq("data:B",reverse.view);
    Gate staleError=new Gate(); long one=staleError.start(); staleError.start();
    staleError.complete(one,"offline",true); eq("loading",staleError.view);
    Gate failed=new Gate(); failed.complete(failed.start(),"bad",true); eq("error:bad",failed.view);
    Gate duplicate=new Gate(); long id=duplicate.start();
    duplicate.complete(id,"first",false); duplicate.complete(id,"second",false); eq("data:first",duplicate.view);
    try { new Gate().complete(1,"x",false); throw new AssertionError("应拒绝未发起 ID"); }
    catch(IllegalArgumentException expected) { /* 无效输入已拒绝 */ }
    Gate overflow=new Gate(); overflow.latest=Long.MAX_VALUE;
    try { overflow.start(); throw new AssertionError("应拒绝代次溢出"); }
    catch(ArithmeticException expected) { /* 极值边界已验证 */ }
    System.out.println("PASS 8 request gate tests");
  }
}

这些特殊情况也要试一试

  • 最新请求失败也应结束它自己的 loading,不能只允许成功更新。
  • 旧请求失败不应把新请求页面改成错误态。
  • 组件销毁后还需独立的生命周期检查;代次不能自动证明组件仍挂载。
进阶选读 · 生产拓展:多分页与局部结果的版本管理

场景搜索支持分页、自动补全和局部缓存更新,仅用一个全局最新代次会错误丢弃仍有效的分页结果。

做法按查询身份、分页游标和组件生命周期分层标记版本,定义哪些结果可合并、哪些必须替换。取消用于节约资源,版本校验仍守住提交结果的边界。

复杂度单个结果校验可保持 O(1) 查表,但需要 O(q+p) 保存活跃查询与分页状态;合并排序结果的成本取决于具体数据结构。

好处与代价细粒度版本提高并行利用率,却增加状态清理与一致性规则;全局代次简单,但只适合整块结果替换。

05项目里怎么用

快速重复点击可在页面禁用按钮或合并请求,但写操作仍需要服务端幂等和资源权限。浏览器超时或取消不保证服务端没有执行,不能把重新启用按钮直接等同于允许再次扣款。读取搜索可以忽略旧结果,订单提交则要用稳定业务标识查询状态。

错误展示应区分需要重试、重新登录、修正输入或联系支持的情况。日志记录请求关联标识和阶段,不记录完整凭据。对于 fetch,先处理响应状态再解析正文,避免 HTML 错误页引发 JSON 异常后只显示一个无法定位的提示。

加载和错误状态也要让键盘用户和辅助技术知道:按钮是否在等待、结果是否已更新、失败后怎样重试,都需要明确表达。大列表要控制一次同步处理的工作量,必要时分批显示或安排合适后台计算。看输入延迟和长任务记录,比给所有函数加 async 更能找到卡顿原因。

容易踩的坑

  • 注册监听器时误把函数调用结果传入,导致行为立即执行或监听器缺失。
  • 认为 fetch 对所有 4xx/5xx 都会自动进入 catch。
  • 把 await 当作冻结整个页面或自动新建线程。
  • 只过滤旧成功而遗漏旧失败,仍会发生过时状态覆盖。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
忽略过时代次简单地守住页面最新意图。旧请求可能继续消耗网络与服务端资源。搜索、详情切换等整块读取结果替换。
客户端取消加版本校验兼顾资源节约和最终界面正确性。取消存在时序边界,不能撤销所有服务端副作用。频繁输入或组件切换。
合并与缓存请求减少相同请求的重复工作。需要定义缓存键、有效期和多调用方生命周期。多组件读取相同稳定资源。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01await 为什么不等于后台线程?

展开参考答案与推导

它表达当前异步函数在结果可用后继续执行,具体工作由宿主 API 与运行时安排。连续同步计算仍可占住主线程,不能靠 async 关键字自动迁移。

02成功响应为何也可能不应该更新页面?

展开参考答案与推导

响应可能属于旧查询意图。传输成功与结果仍适用于当前界面是不同条件,需要请求身份或版本校验。

03Java CompletableFuture 能否证明浏览器 Promise 的调度细节?

展开参考答案与推导

不能。两者可用于比较依赖完成与异常传播,但执行线程、微任务和宿主事件循环规则不同,应分别查对应规范。

换个情况,还会处理吗

01先搜索 A 后搜索 AB,A 最后返回覆盖结果,怎样修复?

展开参考答案与推导

发起时保存查询代次,所有成功和失败处理在更新界面前检查是否仍为最新;取消旧请求可节省资源,但不代替提交前校验。

02服务返回 500,代码没有进入 catch,是否 fetch 故障?

展开参考答案与推导

通常不是。拿到 HTTP 响应可使 fetch 正常完成,需检查 ok 或状态,再决定是否抛业务错误或展示失败;同时区分正文解析失败。

03点击后页面卡两秒,即使函数是 async,怎么看?

展开参考答案与推导

检查 await 前后同步计算和 DOM 操作的长任务,测量主线程占用;拆分工作或采用合适的 Worker,而不是继续增加无意义的 await。

写代码试一试

01给代次状态机增加组件销毁事件。

输入 / 条件
start1,destroy,success1(A)
输出 / 预期
不更新已销毁组件

提示 有效代次与组件存活是两个条件。

展开参考答案与推导

增加 active 标志或生命周期代次,销毁时失效;完成事件同时验证组件身份与请求代次,重新挂载不能沿用旧授权。

02怎样验证旧失败不会结束新 loading?

输入 / 条件
start1,start2,error1(E)
输出 / 预期
loading

提示 错误分支也使用统一门控。

展开参考答案与推导

在错误路径先比较请求代次,不直接修改 loading 或 error 字段;给它写独立自测,避免仅成功路径正确。

03为 fetch 处理流程划分错误类型。

输入 / 条件
网络拒绝、HTTP 404、无效 JSON
输出 / 预期
网络错误、资源响应错误、解析错误

提示 各阶段分别检查。

展开参考答案与推导

先等待 fetch,检查 HTTP 状态,再按媒体类型解析内容;捕获时保留阶段信息,必要时转成统一但可诊断的错误结构。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能让页面响应事件,并区分网络异常、HTTP 错误与 JSON 解析失败。

本篇重点加练

重点加练异步状态与用户重复操作,检验请求生命周期和界面生命周期是否一致。

  • Promise 与 async/await
  • 重复点击和失败恢复

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    给出 let 计数、const 对象与 Java 局部变量的独立样例,比较重新赋值、对象字段修改及运行入口;不得仅按语法相似推断两种语言机制。

    提交内容
    语言与绑定行为对照、最小运行记录。
    怎样算完成
    • 绑定和对象内容分开
    • 结论由各自运行环境验证

    练到的知识点JavaScript 与 Java 区别 · let/const 绑定

  2. 作业 02编程实践

    创建一个本地页面,按钮读取输入文本并用 textContent 显示;输入含 <b>课程</b>、空串和中文,给每次点击记录序号,不使用 innerHTML 处理用户文本。

    提交内容
    HTML/JS、自写输入和重复点击测试、浏览器证据。
    怎样算完成
    • DOM 查询失败有明确处理
    • 输入按文本合同展示
    • 监听不会重复注册累积

    练到的知识点DOM 查询 · textContent 安全文本 · 事件监听回调

综合应用

  1. 作业 03代码推演

    用 Promise 和 setTimeout 模拟两次异步加载,设延迟分别为 80ms 与 10ms;先写出可确定与不可确定的执行关系,再记录 await 前后事件。

    提交内容
    异步时间线和实际事件记录。
    怎样算完成
    • 调用顺序与完成顺序分开
    • 不将 await 理解成独立工作线程

    练到的知识点Promise 与 async/await

  2. 作业 04排错实验

    使用本地模拟请求函数分别返回成功 JSON、HTTP 错误、非法 JSON 和拒绝的 Promise;检验页面加载状态、错误提示和下一次点击恢复。

    提交内容
    页面代码、自写四类失败与边界测试、运行证据。
    怎样算完成
    • 状态错误与解析错误分开
    • 失败后可继续操作
    • 旧请求不会随意覆盖新状态

    练到的知识点fetch 状态和解析 · 重复点击和失败恢复

  3. 作业 05面试场景

    搜索框先输入 Java 后输入 JVM,第一次请求较慢;组件在响应前又被移除。为最新结果、取消和销毁三个事件建立页面更新合同。

    提交内容
    事件序列、状态图与验收案例。
    怎样算完成
    • 更新资格由合同确定
    • 取消与完成竞态都被考虑

    练到的知识点DOM 查询 · 事件监听回调 · Promise 与 async/await · 重复点击和失败恢复

  4. 作业 06编程实践

    实现本地模拟“保存笔记”按钮,保存中按合同禁用重复提交,失败后允许重试;使用可控 Promise 注入先失败后成功,不发送真实请求。

    提交内容
    HTML/JS、自写连续点击/失败恢复/完成测试及运行证据。
    怎样算完成
    • 按钮状态对应实际任务
    • 重试产生的操作次数可查

    练到的知识点Promise 与 async/await · 重复点击和失败恢复

  5. 作业 07代码推演

    两个 Promise 中一个成功一个失败,程序需要同时等待并展示各自结果。写出按顺序 await 与共同等待时的观察合同,注明错误在哪一层处理。

    提交内容
    执行关系图和可控样例记录。
    怎样算完成
    • 完成顺序与展示次序分开
    • 失败不会被未定义地吞掉

    练到的知识点Promise 与 async/await

  6. 作业 08设计评审

    用户保存过程中编辑了表单,保存响应随后返回。定义当前编辑内容、已保存版本和成功提示的关系,列出响应更新页面的必要条件。

    提交内容
    版本状态图和交互验收序列。
    怎样算完成
    • 编辑状态与请求快照分开
    • 提示含义可由数据证据验证

    练到的知识点重复点击和失败恢复

进阶挑战 · 掌握前置后选做

  1. 作业 09编程实践

    完成状态机后:实现带明确重试按钮的异步资料面板,最多允许一个最新请求更新视图;用可控 Promise 测试乱序成功、旧失败及卸载后完成。

    提交内容
    JavaScript 实现、自写确定性事件测试和浏览器运行证据。
    怎样算完成
    • 不依赖真实网络时序
    • 重试不会造成监听或状态泄露
    • 界面状态和请求生命周期可追踪

    练到的知识点事件监听回调 · Promise 与 async/await · fetch 状态和解析 · 重复点击和失败恢复

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    保存功能增加“用户离线时排队,恢复后提交”。重新评审重复点击、旧响应与重试的语义,暂不实现浏览器离线存储。

    提交内容
    事件序列与提交合同。
    怎样算完成
    • 每次编辑的身份可追踪
    • 联网前后的成功判据明确

    练到的知识点Promise 与 async/await · 重复点击和失败恢复

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗