async/await 与页面状态
学员管理页面读取数据时,结果可能要等一会儿才出现。等待期间,页面仍要响应点击;数据回来后,再显示学员列表或错误提示。本节用本地学员 JSON 练习这条流程,下一节再系统学习 fetch 请求。
本节解决五个问题:
- 异步任务与后续代码按什么顺序执行。
- 怎样在
async函数中使用await等待结果。 - 怎样用
try...catch...finally处理失败并恢复按钮。 - 怎样区分加载、成功、空数据和失败状态。
- 怎样避免重复加载,并确认等待时页面仍能响应。
async 和 await 是 JavaScript 语言语法;setTimeout() 和 fetch() 由运行环境提供。本节先用计时器观察执行顺序,再读取本地数据文件,最后完成学员加载页面。
运行准备
计时器示例可以直接在浏览器打开。读取 JSON 的三个页面需要本地静态服务:在课程目录运行下面的命令,并保持终端开启。
python -m http.server 5500打开以下地址。页面与数据文件都在 .code/p04/c09-async/,因此脚本中的 ./students.json 会从同一目录读取。
http://127.0.0.1:5500/.code/p04/c09-async/03-async-await.html
http://127.0.0.1:5500/.code/p04/c09-async/04-loading-state.html
http://127.0.0.1:5500/.code/p04/c09-async/05-async-student-load.html如果想看清加载期间的提示,可以在开发者工具的 Network 面板选择较慢的网络速度,再点击页面按钮。读取的是课程目录里的文件,不需要启动测试 API。
先观察异步执行顺序
localStorage.getItem() 会在当前语句返回结果。计时器则先安排回调,后面的代码继续执行:
console.log("开始加载");
setTimeout(() => {
console.log("学员数据返回");
}, 1000);
console.log("页面继续响应");Console 依次输出“开始加载”“页面继续响应”“学员数据返回”。1000 表示至少等待 1000 毫秒,还要等当前同步代码执行完。setTimeout() 不会让整个页面停住。
计时器常用方法:
| 方法 | 作用 |
|---|---|
setTimeout(callback, delay) | 延迟一次执行 |
clearTimeout(timerId) | 取消尚未执行的延迟任务 |
setInterval(callback, delay) | 按间隔重复执行 |
clearInterval(timerId) | 停止重复任务 |
回调中的 return 只结束回调,不能成为外层函数一秒后的返回值:
function loadStudents() {
setTimeout(() => {
return [{ id: 1, name: "张三" }];
}, 1000);
}
console.log(loadStudents()); // undefined打开 01-timer-callback.html,点击“开始加载”,观察状态文字一秒后才变为“加载完成:张三”。接下来改用 await 等待浏览器读取数据,不把计时器当作可等待的结果。
在 async 函数中使用 await
给函数声明加上 async,函数内部就能使用 await。这里 fetch() 读取同目录的 JSON 文件;请求地址、响应对象和解析过程会在下一节展开。
async function loadStudents() {
const response = await fetch("./students.json");
if (!response.ok) {
throw new Error("学员数据读取失败");
}
const students = await response.json();
console.log(students);
}
loadStudents();按代码顺序看这两个等待点:
await fetch(...)等浏览器取得响应,保存到response。response.ok为false时主动抛出错误,避免继续读取失败结果。await response.json()等 JSON 解析完成,保存学员数组。console.log(students)在数据可用后执行。
await 暂停的是当前 async 函数。输入框、其他按钮和计时器仍然可以工作。普通函数不能直接在函数体中写 await;需要等待的函数应声明为 async。本课程把等待操作写在函数内部,不使用顶层 await。
事件处理函数也可以声明为 async。打开 03-async-await.html,点击“加载学员”,页面先显示等待提示,随后显示从 students.json 读取的第一名学员。打开 Network 面板,可以看到这次本地文件请求。
处理错误与恢复页面
读取文件可能失败,例如静态服务没有启动、文件路径写错。还可以在读取成功后主动抛出错误,稳定练习失败页面。await 所在代码放进 try;错误交给 catch;按钮恢复放进 finally。
下面是 04-loading-state.html 的核心流程,页面中的按钮、复选框和提示元素均在附件里定义:
async function requestData(shouldFail) {
const response = await fetch("./students.json");
if (!response.ok) {
throw new Error("学员数据读取失败");
}
const students = await response.json();
if (shouldFail) {
throw new Error("模拟加载失败");
}
return students;
}
async function loadData() {
loadButton.disabled = true;
status.textContent = "正在加载...";
try {
const shouldFail = failMode.checked;
const students = await requestData(shouldFail);
status.textContent = `加载成功:${students.map((student) => student.name).join("、")}`;
} catch (error) {
status.textContent = `加载失败:${error.message}`;
} finally {
loadButton.disabled = false;
}
}throw new Error(...) 进入 catch;finally 在成功和失败后都会执行。点击“模拟失败”再加载,确认错误出现且按钮恢复。取消勾选后重试,应能显示学员姓名。
try...catch 要包住 await。只调用异步函数而没有等待它,后续错误不会进入这里的 catch。计时器回调中的错误也不能由包围 setTimeout() 调用的同步 try...catch 捕获。
学员加载案例
完整页面保留四种状态,并让结果可以稳定复现:
| 状态 | 页面效果 |
|---|---|
| 加载中 | 显示加载提示,禁用加载按钮和结果选项 |
| 成功且有数据 | 显示三张学员卡片 |
| 成功但为空 | 显示“暂无学员数据” |
| 失败 | 显示错误与“重新加载”按钮 |
数据来自 students.json。结果选项为“空数据”时,在读取完成后返回空数组;选“失败”时主动抛错。这样不需要修改文件或依赖随机结果,就能反复练习三个分支。
async function readStudents(mode) {
const response = await fetch("./students.json");
if (!response.ok) {
throw new Error("学员数据读取失败,请检查静态服务和文件路径");
}
const students = await response.json();
if (mode === "error") {
throw new Error("模拟服务暂时不可用,请稍后重试");
}
return mode === "empty" ? [] : students;
}加载函数只处理一次任务。resultModeSelect、setLoadingState()、showLoading()、showEmpty()、renderStudents() 和 showError() 在完整附件中定义,运行时使用整个页面:
let isLoading = false;
async function loadStudents() {
if (isLoading) {
return;
}
isLoading = true;
setLoadingState(true);
showLoading();
try {
const students = await readStudents(resultModeSelect.value);
if (students.length === 0) {
showEmpty();
return;
}
renderStudents(students);
} catch (error) {
showError(error.message);
} finally {
isLoading = false;
setLoadingState(false);
}
}isLoading 防止业务函数重复开始;按钮禁用给用户即时反馈。空数据分支的 return 会结束 try,但仍会执行 finally,所以按钮会恢复。
打开 05-async-student-load.html,按顺序检查:
- 选择“成功返回数据”,看到张三、李四、王五三张卡片。
- 选择“成功但没有数据”,看到空数据提示,按钮恢复可点击。
- 选择“加载失败”,看到错误和“重新加载”;切换回成功模式后点击重试,列表恢复。
- 在 Network 面板设置较慢的速度,再次加载。等待期间点击“测试页面响应”,计数仍会增加。
- 等待期间连续点击加载按钮,只开始一次任务;完成或失败后按钮和选项都恢复。
异步排查
页面结果不符合预期时,依次检查:
- 是否通过本地静态服务打开页面,
students.json是否与页面在同一目录。 - 当前函数是否声明为
async,读取结果时是否遗漏await。 try...catch是否覆盖可能失败的等待操作。- Console 是否显示文件路径或 JSON 解析错误,Network 请求是否成功。
finally是否恢复按钮和加载标记。- 是否把空数组误判为加载失败,或因重复点击启动多个任务。
可复制的练习源码
保留 .code/p04/c09-async/ 下的相对目录。01 可以直接打开;03、04、05 与 students.json 需一起通过上面的静态服务运行。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>计时器与回调函数</title>
<style>
body { max-width: 640px; margin: 50px auto; padding: 0 20px; font-family: sans-serif; text-align: center; }
button { padding: 11px 18px; }
#status { margin-top: 22px; font-size: 22px; }
</style>
</head>
<body>
<h1>模拟延迟读取学员</h1>
<button id="load-button" type="button">开始加载</button>
<p id="status">等待操作</p>
<script>
const status = document.querySelector("#status");
document.querySelector("#load-button").addEventListener("click", () => {
status.textContent = "正在加载...";
setTimeout(() => { status.textContent = "加载完成:张三"; }, 1000);
});
</script>
</body>
</html><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>async 与 await</title>
<style>
body { max-width: 660px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
button { padding: 10px 16px; }
pre { padding: 16px; background: #18222d; color: #d9f4e8; }
</style>
</head>
<body>
<h1>按顺序等待异步结果</h1>
<button id="load-button" type="button">加载学员</button>
<pre id="result">等待操作</pre>
<script>
async function loadStudent() {
const result = document.querySelector("#result");
result.textContent = "正在读取学员数据...";
try {
const response = await fetch("./students.json");
if (!response.ok) {
throw new Error("学员数据读取失败");
}
const students = await response.json();
result.textContent = JSON.stringify(students[0], null, 2);
} catch (error) {
result.textContent = `加载失败:${error.message}`;
}
}
document.querySelector("#load-button").addEventListener("click", loadStudent);
</script>
</body>
</html><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>异步错误与页面状态</title>
<style>
body { max-width: 660px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
button { padding: 10px 16px; }
button:disabled { opacity: .5; }
#status { padding: 15px; border-left: 4px solid #1677ff; background: #eef6ff; }
</style>
</head>
<body>
<h1>try...catch...finally</h1>
<label><input id="fail-mode" type="checkbox" /> 模拟失败</label>
<button id="load-button" type="button">加载数据</button>
<p id="status">等待操作</p>
<script>
const loadButton = document.querySelector("#load-button");
const status = document.querySelector("#status");
const failMode = document.querySelector("#fail-mode");
async function requestData(shouldFail) {
const response = await fetch("./students.json");
if (!response.ok) {
throw new Error("学员数据读取失败");
}
const students = await response.json();
if (shouldFail) {
throw new Error("模拟加载失败");
}
return students;
}
async function loadData() {
if (loadButton.disabled) return;
loadButton.disabled = true;
status.textContent = "正在加载...";
try {
const students = await requestData(failMode.checked);
status.textContent = `加载成功:${students.map((student) => student.name).join("、")}`;
} catch (error) {
status.textContent = `加载失败:${error.message}`;
} finally {
loadButton.disabled = false;
}
}
loadButton.addEventListener("click", loadData);
</script>
</body>
</html><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>async/await 与页面状态</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f3f6fa;
color: #303133;
font-family: Arial, "Microsoft YaHei", sans-serif;
}
button,
select {
font: inherit;
}
.page-container {
width: min(980px, calc(100% - 32px));
margin: 40px auto;
}
.page-title {
margin: 0 0 8px;
font-size: 28px;
}
.page-description {
margin: 0 0 22px;
color: #606266;
line-height: 1.7;
}
.panel {
margin-bottom: 18px;
padding: 22px;
border-radius: 10px;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 5%);
}
.control-panel {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 14px;
}
.form-field {
display: grid;
gap: 8px;
}
.form-field label {
color: #606266;
font-size: 14px;
}
.form-field select {
min-width: 170px;
height: 42px;
padding: 0 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #fff;
outline: none;
}
.form-field select:focus {
border-color: #409eff;
box-shadow: 0 0 0 3px rgb(64 158 255 / 12%);
}
.load-button,
.response-button,
.retry-button {
height: 42px;
padding: 0 18px;
border: 0;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.load-button:active,
.response-button:active,
.retry-button:active {
transform: translateY(1px);
}
.load-button {
min-width: 116px;
background: #409eff;
color: #fff;
}
.load-button:hover:not(:disabled) {
background: #66b1ff;
}
.load-button:disabled {
background: #a0cfff;
cursor: not-allowed;
}
.response-button {
background: #ecf5ff;
color: #409eff;
}
.response-button:hover {
background: #d9ecff;
}
.status-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.status-message {
margin: 0;
color: #606266;
line-height: 1.5;
}
.status-message.is-success {
color: #1b9a59;
}
.status-message.is-error {
color: #d93026;
}
.elapsed-time {
flex: 0 0 auto;
color: #909399;
font-size: 13px;
}
.student-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.student-card {
padding: 18px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fafcff;
}
.student-name {
margin: 0 0 10px;
font-size: 18px;
}
.student-info {
margin: 6px 0;
color: #606266;
font-size: 14px;
line-height: 1.5;
}
.state-view {
display: grid;
min-height: 190px;
padding: 28px;
place-items: center;
border: 1px dashed #dcdfe6;
border-radius: 8px;
color: #909399;
text-align: center;
}
.state-content {
display: grid;
justify-items: center;
gap: 12px;
}
.spinner {
width: 34px;
height: 34px;
border: 4px solid #d9ecff;
border-top-color: #409eff;
border-radius: 50%;
animation: rotate 0.8s linear infinite;
}
.state-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 50%;
background: #f5f7fa;
font-size: 24px;
}
.retry-button {
height: 38px;
background: #fef0f0;
color: #d93026;
}
.retry-button:hover {
background: #fde2e2;
}
.tip {
margin: 0;
padding: 14px 16px;
border-left: 4px solid #409eff;
border-radius: 4px;
background: #ecf5ff;
color: #52606d;
line-height: 1.7;
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
@media (max-width: 760px) {
.page-container {
margin: 24px auto;
}
.control-panel {
align-items: stretch;
flex-direction: column;
}
.form-field select,
.load-button,
.response-button {
width: 100%;
}
.student-list {
grid-template-columns: 1fr;
}
.status-line {
align-items: flex-start;
flex-direction: column;
}
}
</style>
</head>
<body>
<main class="page-container">
<h1 class="page-title">模拟加载学员</h1>
<p class="page-description">
选择成功、空数据或失败结果,观察 async/await 与页面状态的配合。
</p>
<section class="panel control-panel">
<div class="form-field">
<label for="result-mode">模拟结果</label>
<select id="result-mode">
<option value="success">成功返回数据</option>
<option value="empty">成功但没有数据</option>
<option value="error">加载失败</option>
</select>
</div>
<button id="load-button" class="load-button" type="button">
加载学员
</button>
<button id="response-button" class="response-button" type="button">
测试页面响应:0
</button>
</section>
<section class="panel">
<header class="status-line">
<p id="status-message" class="status-message" role="status">
请选择一种结果并开始加载
</p>
<span id="elapsed-time" class="elapsed-time"></span>
</header>
<div id="student-view"></div>
</section>
<p class="tip">
在 Network 面板选择较慢的网络速度。加载期间点击“测试页面响应”,计数仍会增加,说明 await 只暂停加载函数,没有冻结网页。
</p>
</main>
<script>
const resultModeSelect = document.querySelector("#result-mode");
const loadButton = document.querySelector("#load-button");
const responseButton = document.querySelector("#response-button");
const statusMessage = document.querySelector("#status-message");
const elapsedTime = document.querySelector("#elapsed-time");
const studentView = document.querySelector("#student-view");
let isLoading = false;
let responseCount = 0;
async function readStudents(mode) {
const response = await fetch("./students.json");
if (!response.ok) {
throw new Error("学员数据读取失败,请检查静态服务和文件路径");
}
const students = await response.json();
if (mode === "error") {
throw new Error("模拟服务暂时不可用,请稍后重试");
}
return mode === "empty" ? [] : students;
}
function showStatus(text, type = "") {
statusMessage.textContent = text;
statusMessage.className = "status-message";
if (type) {
statusMessage.classList.add(`is-${type}`);
}
}
function createStateView(icon, text, state) {
const wrapper = document.createElement("div");
const content = document.createElement("div");
const iconElement = document.createElement("span");
const message = document.createElement("span");
wrapper.classList.add("state-view");
wrapper.dataset.state = state;
content.classList.add("state-content");
iconElement.classList.add("state-icon");
iconElement.textContent = icon;
iconElement.setAttribute("aria-hidden", "true");
message.textContent = text;
content.append(iconElement, message);
wrapper.append(content);
return wrapper;
}
function showLoading() {
const wrapper = document.createElement("div");
const content = document.createElement("div");
const spinner = document.createElement("span");
const text = document.createElement("span");
wrapper.classList.add("state-view");
wrapper.dataset.state = "loading";
content.classList.add("state-content");
spinner.classList.add("spinner");
spinner.setAttribute("aria-hidden", "true");
text.textContent = "正在加载学员数据...";
content.append(spinner, text);
wrapper.append(content);
studentView.replaceChildren(wrapper);
}
function showEmpty() {
studentView.replaceChildren(
createStateView("○", "暂无学员数据", "empty"),
);
}
function showError(errorMessage) {
const errorView = createStateView("!", errorMessage, "error");
const retryButton = document.createElement("button");
retryButton.type = "button";
retryButton.classList.add("retry-button");
retryButton.dataset.action = "retry";
retryButton.textContent = "重新加载";
errorView.querySelector(".state-content").append(retryButton);
studentView.replaceChildren(errorView);
}
function createInfoRow(label, value) {
const row = document.createElement("p");
row.classList.add("student-info");
row.textContent = `${label}:${value}`;
return row;
}
function createStudentCard(student) {
const card = document.createElement("article");
const name = document.createElement("h2");
card.classList.add("student-card");
card.dataset.studentId = String(student.id);
name.classList.add("student-name");
name.textContent = student.name;
card.append(
name,
createInfoRow("班级", student.className),
createInfoRow("手机号", student.mobile),
);
return card;
}
function renderStudents(students) {
const list = document.createElement("div");
const fragment = document.createDocumentFragment();
list.classList.add("student-list");
list.dataset.state = "success";
for (const student of students) {
fragment.append(createStudentCard(student));
}
list.append(fragment);
studentView.replaceChildren(list);
}
function setLoadingState(loading) {
loadButton.disabled = loading;
resultModeSelect.disabled = loading;
loadButton.textContent = loading ? "加载中..." : "加载学员";
}
async function loadStudents() {
if (isLoading) {
return;
}
const mode = resultModeSelect.value;
const startTime = performance.now();
isLoading = true;
setLoadingState(true);
elapsedTime.textContent = "";
showStatus("异步任务已经开始");
showLoading();
try {
const students = await readStudents(mode);
if (students.length === 0) {
showEmpty();
showStatus("加载成功,但没有学员数据");
return;
}
renderStudents(students);
showStatus(`加载成功,共 ${students.length} 名学员`, "success");
} catch (error) {
console.error(error);
showError(error.message);
showStatus(error.message, "error");
} finally {
const duration = Math.round(performance.now() - startTime);
elapsedTime.textContent = `本次耗时约 ${duration} 毫秒`;
isLoading = false;
setLoadingState(false);
}
}
loadButton.addEventListener("click", loadStudents);
responseButton.addEventListener("click", () => {
responseCount += 1;
responseButton.textContent = `测试页面响应:${responseCount}`;
});
studentView.addEventListener("click", (event) => {
const retryButton = event.target.closest('[data-action="retry"]');
if (!retryButton || !studentView.contains(retryButton)) {
return;
}
loadStudents();
});
studentView.replaceChildren(
createStateView("▶", "等待开始加载", "idle"),
);
</script>
</body>
</html>[
{
"id": 1,
"name": "张三",
"className": "Python 1 班",
"mobile": "13800000001"
},
{
"id": 2,
"name": "李四",
"className": "Python 2 班",
"mobile": "13800000002"
},
{
"id": 3,
"name": "王五",
"className": "Web 全栈班",
"mobile": "13800000003"
}
]小结
- 异步任务等待结果时,页面仍能处理其他操作。
- 在
async函数中用await等待数据,再处理后续步骤。 - 用
try...catch...finally显示错误并恢复页面状态。 - 分别处理加载、成功、空数据和失败,并防止重复执行。