# JavaScript 为什么会先显示加载状态

原文: https://yishto.com/learn/web-interface/javascript-runtime-and-async/

作者: [Yishto](https://yishto.com/about/)

通过搜索请求解释同步执行、微任务与 await，并用请求身份处理加载终态和过期响应覆盖问题。

首次发布: 2026-11-10
内容更新: 2026-11-10

搜索按钮被点击后，页面先显示加载，结果稍后出现。这段等待不是 JavaScript 在一行代码里同时做所有事情：网络等平台能力在处理外部工作，完成后的回调再进入执行队列。界面能否及时反馈，取决于同步代码是否让出执行机会以及结果如何更新状态。

本课使用资料搜索案例解释任务、微任务、Promise 和过期结果。离线例子只列出简化队列顺序，不运行浏览器、网络或真实计时器，也不把 Python 的调度行为当成 JavaScript 行为。

## 同步执行先走完当前调用

在同一个 JavaScript 事件循环上，正在执行的同步代码通常运行到完成，其他回调不能任意插进当前调用栈。点击处理函数如果进行很长的纯计算，按钮、输入和绘制都可能等待。把函数声明为 async，不会自动把其中的计算送到其他线程。

平台能异步等待网络或计时器，再安排相应回调执行。定时器指定的延迟也不是精确的执行时刻：当前任务、其他排队任务与平台策略都会影响实际开始时间。不能凭一个零延迟定时器声称它立即执行。

Web Worker 等机制可以把合适的计算放到另一个执行环境，但涉及消息、数据传递与生命周期。普通资料搜索首先需要合理组织请求与状态；只有确认主线程计算造成阻塞，再评估拆分计算或工作线程。

## Promise 回调和 await 的含义

Promise 表达未来的完成或失败。它的反应回调通过微任务调度；在典型浏览器事件循环中，当前同步任务结束后会处理微任务，再进入后续任务。不断添加微任务也会拖延其他工作，因此 Promise 并不是无限拆分计算的免费性能方案。

await 暂停当前 async 函数的后续执行，等待值完成，但调用者仍可以继续处理其他工作。await 前面的同步部分已经运行，后面继续执行仍需要调度。不要把暂停一个函数理解为暂停整个浏览器，也不要把它理解为自动并行。

对外部请求必须处理失败。网络连接失败、HTTP 错误和成功响应中的字段错误属于不同层；fetch 的 Promise 完成不代表 HTTP 状态一定成功。先检查请求结果，再按契约解析数据，最后更新 loading、success、empty 或 error 状态。

## 加载状态需要覆盖每条退出路径

点击搜索后先记录当前查询，再进入 loading。成功且有数据显示结果，成功但为空显示 empty，失败显示 error 并保留输入。若只在成功分支取消加载，拒绝路径会留下永不结束的 spinner。

清理逻辑通常适合处理结束动作，但不能让旧请求的清理覆盖新请求的 loading。连续查询时，每次请求需要自己的身份，只有当前有效请求可以修改当前界面。否则第一次失败的 finally 可能让第二次请求看起来已结束。

闭包捕获的是作用域中的绑定，某次异步回调使用哪个值要结合变量声明、赋值与调用时机判断。可以在发请求前保存本次查询与请求序号，避免回调从可变的全局输入里重新取值。不要笼统地说所有闭包都会保存一份独立数据副本。

## 用离线队列模型观察顺序

示例把同步输出 start 和 end 放在结果列表，再处理 promise 微任务，最后处理 timer 任务。数组只是合成队列；真实浏览器还包含渲染机会、多个任务来源、嵌套调度和更多平台规则。

```python
tasks = ["timer"]
microtasks = ["promise"]
observed = ["start", "end"]
while microtasks:
    observed.append(microtasks.pop(0))
while tasks:
    observed.append(tasks.pop(0))
assert observed == ["start", "end", "promise", "timer"]
print(observed)
```

输出是 `['start', 'end', 'promise', 'timer']`。断言验证这个模型选择的顺序，帮助解释为什么 Promise 回调不会打断当前同步段。它没有测量耗时，无法证明页面会在 promise 前还是后绘制，也不能证明所有浏览器任务共享一个简单 FIFO。

在真实浏览器做对应观察时，可以记录同步日志、一个已完成 Promise 的回调和定时器回调。把实际日志顺序与机制比较，但将计时器延迟和绘制单独观察。没有运行过浏览器实验，就不能把这个 Python 输出称为浏览器实测结果。

## 防止较旧结果覆盖最新查询

假设先查“索引”，随后查“事务”。第一请求晚返回时，如果直接赋值，页面会显示旧查询的资料。可以为每次查询增加序号，只允许与当前序号一致的结果提交状态；也可以用取消机制减少无用请求，再保留身份检查。

取消不意味着服务器一定停止了所有工作，也不保证已完成的回调从未进入队列。页面卸载、筛选变化与重试同样要让过期结果失效。请求 ID 应是应用控制的信息，不以返回顺序推断请求的新旧。

测试时让“旧慢、新快”与“旧快、新慢”都发生，并加入旧请求失败、新请求成功的情况。验收关注最终状态属于哪个查询，而不是哪个网络调用最先结束。存在版本化服务数据时，还需区分请求序号与服务端数据版本。

搜索框每次输入都发请求还可能造成无用负载。可以采用明确的提交动作，或按产品需要延迟触发；这种策略减少请求次数，却不代替过期结果检查。延迟触发时还要处理输入清空、离开页面和再次输入，避免旧计时回调发出已经无意义的查询。

请求身份与数据身份应分开记录。查询 ID 用来控制谁可以更新本次页面，资料 ID 用来确定结果代表哪条记录；把两者混用，会在重试或重复查询时产生错误判断。错误消息也应属于当前查询，不能沿用上一轮失败。

## 练习：设计可控的搜索状态机

写出 idle、loading、success、empty、error 的合法转换，定义查询 ID 和结果所属 ID。用可手动完成的教学请求替身模拟两次查询，不需要连接真实搜索服务。分别完成旧请求、新请求，观察每一步状态。

验收要求：空结果结束加载；失败保留查询并可重试；旧成功不覆盖新结果；旧失败不关闭新加载；组件离开后旧回调不再更新可见状态。再加入一段大同步计算，观察真实浏览器响应是否受阻，明确 async 声明无法改变这段计算的执行环境。记录顺序与行为，避免没有测量就给出性能倍数。

## 资料来源与延伸阅读

- [JavaScript guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide)
- [JavaScript execution model](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model)

资料核验：2026-11-10。教学示例不代表线上实测。

## 相关文章

- [流式输出与结构化结果：两种不同的可靠性](https://yishto.com/learn/models/streaming-and-structured-output/)
- [Flutter 应用怎样管理页面和异步状态](https://yishto.com/learn/application-platforms/flutter-reading-library/)
