Skip to content

async/await 与页面状态 ​

学员管理页面读取数据时,结果可能要等一会儿才出现。等待期间,页面仍要响应点击;数据回来后,再显示学员列表或错误提示。本节用本地学员 JSON 练习这条流程,下一节再系统学习 fetch 请求。

本节解决五个问题:

  1. 异步任务与后续代码按什么顺序执行。
  2. 怎样在 async 函数中使用 await 等待结果。
  3. 怎样用 try...catch...finally 处理失败并恢复按钮。
  4. 怎样区分加载、成功、空数据和失败状态。
  5. 怎样避免重复加载,并确认等待时页面仍能响应。

async 和 await 是 JavaScript 语言语法;setTimeout() 和 fetch() 由运行环境提供。本节先用计时器观察执行顺序,再读取本地数据文件,最后完成学员加载页面。

运行准备 ​

计时器示例可以直接在浏览器打开。读取 JSON 的三个页面需要本地静态服务:在课程目录运行下面的命令,并保持终端开启。

bash
python -m http.server 5500

打开以下地址。页面与数据文件都在 .code/p04/c09-async/,因此脚本中的 ./students.json 会从同一目录读取。

text
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() 会在当前语句返回结果。计时器则先安排回调,后面的代码继续执行:

js
console.log("开始加载");

setTimeout(() => {
    console.log("学员数据返回");
}, 1000);

console.log("页面继续响应");

Console 依次输出“开始加载”“页面继续响应”“学员数据返回”。1000 表示至少等待 1000 毫秒,还要等当前同步代码执行完。setTimeout() 不会让整个页面停住。

计时器常用方法:

方法作用
setTimeout(callback, delay)延迟一次执行
clearTimeout(timerId)取消尚未执行的延迟任务
setInterval(callback, delay)按间隔重复执行
clearInterval(timerId)停止重复任务

回调中的 return 只结束回调,不能成为外层函数一秒后的返回值:

js
function loadStudents() {
    setTimeout(() => {
        return [{ id: 1, name: "张三" }];
    }, 1000);
}

console.log(loadStudents()); // undefined

打开 01-timer-callback.html,点击“开始加载”,观察状态文字一秒后才变为“加载完成:张三”。接下来改用 await 等待浏览器读取数据,不把计时器当作可等待的结果。

在 async 函数中使用 await ​

给函数声明加上 async,函数内部就能使用 await。这里 fetch() 读取同目录的 JSON 文件;请求地址、响应对象和解析过程会在下一节展开。

js
async function loadStudents() {
    const response = await fetch("./students.json");

    if (!response.ok) {
        throw new Error("学员数据读取失败");
    }

    const students = await response.json();
    console.log(students);
}

loadStudents();

按代码顺序看这两个等待点:

  1. await fetch(...) 等浏览器取得响应,保存到 response。
  2. response.ok 为 false 时主动抛出错误,避免继续读取失败结果。
  3. await response.json() 等 JSON 解析完成,保存学员数组。
  4. console.log(students) 在数据可用后执行。

await 暂停的是当前 async 函数。输入框、其他按钮和计时器仍然可以工作。普通函数不能直接在函数体中写 await;需要等待的函数应声明为 async。本课程把等待操作写在函数内部,不使用顶层 await。

事件处理函数也可以声明为 async。打开 03-async-await.html,点击“加载学员”,页面先显示等待提示,随后显示从 students.json 读取的第一名学员。打开 Network 面板,可以看到这次本地文件请求。

处理错误与恢复页面 ​

读取文件可能失败,例如静态服务没有启动、文件路径写错。还可以在读取成功后主动抛出错误,稳定练习失败页面。await 所在代码放进 try;错误交给 catch;按钮恢复放进 finally。

下面是 04-loading-state.html 的核心流程,页面中的按钮、复选框和提示元素均在附件里定义:

js
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。结果选项为“空数据”时,在读取完成后返回空数组;选“失败”时主动抛错。这样不需要修改文件或依赖随机结果,就能反复练习三个分支。

js
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() 在完整附件中定义,运行时使用整个页面:

js
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,按顺序检查:

  1. 选择“成功返回数据”,看到张三、李四、王五三张卡片。
  2. 选择“成功但没有数据”,看到空数据提示,按钮恢复可点击。
  3. 选择“加载失败”,看到错误和“重新加载”;切换回成功模式后点击重试,列表恢复。
  4. 在 Network 面板设置较慢的速度,再次加载。等待期间点击“测试页面响应”,计数仍会增加。
  5. 等待期间连续点击加载按钮,只开始一次任务;完成或失败后按钮和选项都恢复。

异步排查 ​

页面结果不符合预期时,依次检查:

  1. 是否通过本地静态服务打开页面,students.json 是否与页面在同一目录。
  2. 当前函数是否声明为 async,读取结果时是否遗漏 await。
  3. try...catch 是否覆盖可能失败的等待操作。
  4. Console 是否显示文件路径或 JSON 解析错误,Network 请求是否成功。
  5. finally 是否恢复按钮和加载标记。
  6. 是否把空数组误判为加载失败,或因重复点击启动多个任务。

可复制的练习源码 ​

保留 .code/p04/c09-async/ 下的相对目录。01 可以直接打开;03、04、05 与 students.json 需一起通过上面的静态服务运行。

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: 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>
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>
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>
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>
json
[
  {
    "id": 1,
    "name": "张三",
    "className": "Python 1 班",
    "mobile": "13800000001"
  },
  {
    "id": 2,
    "name": "李四",
    "className": "Python 2 班",
    "mobile": "13800000002"
  },
  {
    "id": 3,
    "name": "王五",
    "className": "Web 全栈班",
    "mobile": "13800000003"
  }
]

小结 ​

  1. 异步任务等待结果时,页面仍能处理其他操作。
  2. 在 async 函数中用 await 等待数据,再处理后续步骤。
  3. 用 try...catch...finally 显示错误并恢复页面状态。
  4. 分别处理加载、成功、空数据和失败,并防止重复执行。