Skip to content

async 与 await ​

本页对应 1-11async和await,共 6 段视频,约 1 小时 2 分。

学习目标 ​

  1. 能够根据给定代码判断其同步或异步执行顺序,并说明定时器、事件监听、网络请求等异步代码的结果接收方式。
  2. 能够识别回调函数嵌套形成的回调地狱结构,并说明其在可读性和异常捕获方面的主要问题。
  3. 能够使用 Promise 的 then 链式调用改写多层回调嵌套代码,并通过 return 正确衔接下一个异步任务。
  4. 能够使用 async 声明异步函数,并使用 await 获取 Promise 成功结果,将链式调用代码改写为更简洁的平级写法。
  5. 能够在 async 函数中使用 try...catch 捕获异步请求错误,并读取错误信息进行相应处理。
  6. 能够独立完成“获取省、市、区数据并渲染第一项”的案例,并分别用 Promise 链式调用和 async/await 两种方式实现。

知识点 ​

先明确目标:为什么要区分同步和异步 ​

写 JavaScript 时,代码不一定是“从上到下立刻全部执行完”的。知道哪些代码会原地等待、哪些代码会先跳过、结果什么时候回来,直接决定我们能不能正确写逻辑、能不能看懂控制台打印顺序、能不能调试问题。比如定时器、事件、AJAX 请求,如果误以为它们会像普通赋值一样立刻完成,就会写出顺序错误的代码。

判断时先问自己:这段代码调用后,是否必须原地等结果回来,才能继续往下执行?如果是,就是同步代码;如果调用后耗时,但不阻塞后续代码,而是在将来完成后触发回调函数传递结果,就是异步代码。

同步代码:逐行执行,原地等待结果 ​

同步代码的特点是逐行执行,上一行完成后才执行下一行,需要原地等待结果后再继续向下执行。绝大多数普通语句、变量声明、表达式计算都是同步代码。

怎么做?看下面代码:

javascript
 /**
     * 1. 同步代码
     * */
    console.log(1)
    const num = 1 + 1
    console.log(num)

执行过程很直接:先打印 1,再计算 1 + 1 得到 2 并赋值给 num,最后打印 num,所以输出是 1、2。这里没有任何异步任务,不需要等定时器、事件或网络请求。

易错点:不要把“代码行数多”误认为异步。只要没有定时器、事件注册、AJAX 等异步操作,普通代码仍然按同步顺序执行。

异步代码:调用后耗时,但不阻塞后续执行 ​

异步代码的特点是:调用后可能耗时,但不会阻塞代码继续执行,不必原地等待;将来完成后会触发回调函数,把结果交给回调函数处理。常见异步代码有 setTimeout / setInterval、事件、AJAX(XHR)等。

怎么想?看到“将来才发生”的操作,就要考虑异步:等一秒钟后执行、等用户点击后执行、等服务器响应后执行。它们都不是立刻拿到结果,而是先注册回调,条件满足后再执行回调。

先看定时器示例:

javascript
    /**
     * 2. 同步+异步(定时器)
     * */
    console.log(1)
    setTimeout(() => {
      console.log(2)
    }, 1000)
    console.log(3)

这段代码先打印 1。遇到 setTimeout 时,只是注册了一个定时器,并不会在这里停住等一秒;所以继续执行后面的 console.log(3),先打印 3。大约 1 秒后,定时器完成,触发回调函数,再打印 2。因此顺序是:先 1、3,约 1 秒后 2。

易错点:setTimeout 后面的 1000 是延迟时间,不是让同步代码原地等 1000 毫秒。异步代码“看起来写在中间”,不代表它会在中间立刻执行。

事件和 XHR:注册回调不等于立刻执行 ​

事件监听也是异步代码。注册事件时,只是告诉浏览器“将来满足条件时执行这个回调”,并不会立刻执行。

javascript
    /**
     * 3. 同步+异步(事件)
     * */
    console.log(1)
    document.body.addEventListener('click', () => {
      console.log(2)
    })
    console.log(3)

执行时先打印 1,然后注册点击事件,再打印 3。此时不会打印 2,因为用户还没有点击。只有点击 document.body 后,事件触发,回调函数执行,才打印 2。所以初始顺序是 1、3;点击后输出 2。

XHR 请求同理:

javascript
    /**
     * 4. 同步+异步(XHR)
     * */
    console.log(1)
    const xhr = new XMLHttpRequest()
    xhr.open('get', 'url地址')
    xhr.addEventListener('loadend', () => {
      console.log(2)
    })
    xhr.send()
    console.log(3)

先打印 1。创建 XHR、设置请求方法、注册 loadend 回调、调用 send() 发送请求后,代码不会停在 send() 等服务器响应,而是继续执行,先打印 3。等服务器内容响应回来,触发 loadend 回调,再打印 2。因此顺序是:先 1、3,服务器响应回来后 2。

这里可以结合调试过程理解:如果网络很慢,请求可能很久才回来;如果代码停在请求处不往后走,后续页面交互、事件绑定等逻辑都无法执行,用户体验会很差。所以 XHR 的 loadend 回调属于异步代码,不会阻塞后续执行。

易错点:注册事件不等于触发事件;send() 发出请求不等于立刻拿到响应;异步结果不能在 send() 后面直接当同步结果使用。

小结:异步结果靠回调函数接收 ​

回答打印顺序时,核心方法是:先执行同步代码,遇到异步代码先“注册/发起”,不原地等待;等异步任务完成条件满足后,再执行对应回调函数。本节中定时器、事件、XHR 接收结果使用的都是回调函数。

最后记住四个判断点:同步代码逐行执行、原地等待结果;异步代码调用后耗时、不阻塞后续执行;常见异步代码包括 setTimeout / setInterval、事件、AJAX;异步代码的结果依靠回调函数接收。

从需求理解:为什么要认识回调函数地狱 ​

这一节的目标是了解回调函数地狱的概念和缺点。先看一个真实需求:页面打开后,在下拉菜单中展示默认第一个省、第一个城市、第一个地区。

省市区下拉菜单展示第一个省市区

为什么需要:这个需求不是一次请求就能完成。省份列表拿到后,要选第一个省;再拿着省名去请求城市;城市拿到后,再拿着省名和城市名去请求地区。后一次请求依赖前一次请求的结果,所以代码位置必须一层层往里写。

怎么想:先请求省份,在 then 回调里渲染第一个省;再在同一个回调里请求城市,在城市请求的 then 里渲染第一个城市;最后在城市回调里请求地区。也就是说,回调函数里又调用回调函数,一直嵌套下去。

怎么做:按“获取数据 → 取第一项 → 渲染到页面”的步骤写。注意 pname、cname 来自上一层作用域,所以内层请求能直接使用。

易错点:不要在没有拿到省名时就去请求城市;渲染时取的是 list[0],不是整个数组;请求参数名要和接口文档一致。

概念怎么想:回调函数中再调用回调函数 ​

回调函数地狱指在回调函数中嵌套回调函数,一直嵌套下去形成层层缩进的结构。它的缺点有三个:可读性差、异常无法捕获、耦合性严重,牵一发动全身。

为什么需要理解概念:实际开发中使用回调函数时,可能写出这种结构。先认识它,后面才能用 Promise 链式调用、async/await 去改写。

怎么想:看代码不要只看“能不能跑”,还要看结构。第一次请求的结果在第一个 then 里接收;第二次请求写在第一个 then 内部;第三次请求又写在第二个 then 内部。层级越深,阅读和修改越困难。

怎么做:下面代码保留了本节示例,并故意把地区地址改错,用于测试异常捕获:

javascript
// 1. 获取省份数据并展示第1个省
axios({
  url: 'http://hmajax.itheima.net/api/province'
})
  .then(res => {
    // console.log(res)
    const pname = res.data.list[0]
    document.querySelector('.province').innerText = pname
    //  2. 获取第1个省的城市数据,并展示第1个城市
    axios({
      url: 'http://hmajax.itheima.net/api/city',
      params: {
        pname
      }
    })
      .then(res => {
        // console.log(res)
        const cname = res.data.list[0]
        document.querySelector('.city').innerText = cname
        // 3. 获取第1个城市的区数据,并展示第1个区
        axios({
          // url: 'http://hmajax.itheima.net/api/area',
          // 故意改错的地址,测试用
          url: 'http://hmajax.itheima.net/api/area123',
          params: {
            pname, cname
          }
        })
          .then(res => {
            // console.log(res)
            const aname = res.data.list[0]
            document.querySelector('.area').innerText = aname
          }).catch(err => {
            console.dir(err)
          })
      })
  }).catch(err => {
    console.dir(err)
  })

易错点:嵌套层级深时,括号和 .catch 很容易放错位置;不要以为最外层写一个 .catch 就能捕获所有内层异常。

调试过程:异常捕获为什么困难 ​

为什么需要调试:只看概念不容易感受到缺点。把地区请求地址改成错误地址后,控制台会出现异常。此时如果只在外层 then 后面写 .catch,内层请求的错误并不能被最外层捕获。转写稿中的调试结论是:要捕获最里面请求的异常,需要在对应内层 then 后面再写 .catch。

怎么想:每个 then 管理自己这一段异步任务,错误也应贴着对应的 then 处理。三个请求如果都要捕获异常,就要写多个 .catch,维护起来麻烦,这就是“异常捕获困难”。

怎么做:测试时先把地址改错,观察控制台;确认问题后,在内层 then 后补 .catch,用 console.dir(err) 查看错误对象;测试完再把地址恢复正确。

易错点:这里的“异常无法捕获”不是绝对无法捕获,而是最外层捕获不到内层异常,需要逐层处理;改错地址只用于测试,不能留在最终代码中。

小结与易错点 ​

回调函数地狱的结构是:回调函数中嵌套回调函数。主要缺点是可读性差、异常捕获困难、耦合性严重。省市区案例中,城市请求依赖省名,地区请求依赖省名和城市名,所以代码只能层层写进回调里。写的时候要特别注意:请求位置必须在拿到上一层数据之后;pname、cname 的作用域要能覆盖内层;.catch 要放在需要捕获异常的那一层;调试改错地址后要恢复。理解这些,才能体会后续 Promise 链式调用和 async/await 改写代码的价值。

为什么需要链式调用 ​

回调函数地狱的典型结构是“回调函数中再调用回调函数”:第一次请求的结果在第一个 then 回调里接收,第二次请求又写在这个回调内部,第三次再往里嵌套。转写稿里用省市区案例演示时,代码会不断换行、缩进,可读性变差;异常捕获也麻烦,内层请求出错时,外层一个 catch 往往接不住,需要每一层分别处理。

Promise 链式调用示意图

链式调用的目标不是消灭回调,而是把“层层嵌套”改成“平级串联”:上一个任务结束后,返回下一个异步任务,再在后面继续 .then() 接收结果。

核心机制:then 返回一个新 Promise ​

为什么需要:如果 then() 不返回新 Promise,就无法继续串联下一环任务。

怎么想:把 then() 看成“登记上一环任务完成后的处理函数”,它执行完会生成一个新的 Promise。这个新 Promise 管什么任务,由 then() 回调函数的返回值决定。

怎么做:转写稿中先验证 then() 的返回值:

js
const p = getPName()
const p2 = p.then(res => {
  console.log(res) // 省份数据
})

console.log(p2 instanceof Promise) // true
console.log(p === p2) // false

这说明 p2 是新生成的 Promise,不是原来的 p。接着在回调中 return 下一个异步任务:

js
function getPName() {
  return new Promise(resolve => {
    setTimeout(() => resolve('省份数据'), 2000)
  })
}

function getCName() {
  return new Promise(resolve => {
    setTimeout(() => resolve('城市数据'), 2000)
  })
}

getPName()
  .then(res => {
    console.log('第一次异步任务结果:', res)
    return getCName() // 返回下一个异步任务
  })
  .then(res => {
    console.log('第二次异步任务结果:', res)
  })

易错点:then() 回调里只是调用异步函数、却没有 return,下一个 then() 就不会等待它。比如只写 getCName(),第二个 then() 拿到的是 undefined,而不是城市数据。另一个易错点是误以为 then() 返回原 Promise;转写稿用 p === p2 打印出 false,就是为了纠正这一点。

怎么写:把省市区嵌套改成链式 ​

为什么需要:省市区请求有先后依赖,必须拿到省份才能查城市,拿到城市才能查地区。

怎么想:每一步 then() 中做两件事:使用当前结果、return 下一个请求。最后一个 then() 只处理最终结果。

怎么做:结合转写稿的 axios 案例,结构可写成:

js
let pName // 第一个 then 中赋值,后面还要用

axios.get('省份列表接口').then(res => {
  pName = res.data.list[0]
  // 渲染省份
  return axios.get('城市列表接口', { params: { pName } })
}).then(res => {
  const cName = res.data.list[0]
  // 渲染城市
  return axios.get('地区列表接口', { params: { pName, cName } })
}).then(res => {
  const areaName = res.data.list[0]
  // 渲染地区
})

转写稿调试时发现:pName 如果在第一个 then() 里用 const 声明,后面第二个、第三个 then() 拿不到它,因为链式调用中的回调并不是嵌套作用域。解决方式是把 pName 提到外层声明,第一个 then() 中只赋值。这个细节很容易错。

易错点:忘记 return axios.get(...);把 params 写成 perms;把 pName 声明在错误作用域;在 then() 里写渲染代码后忘了返回下一个请求。

调试与执行顺序 ​

转写稿中的验证顺序是:先打印省份数据,再打印城市数据。若在第一个 then() 中 return getCName(),第二个 then() 才会等城市请求完成;若没有 return,第二个 then() 不会等待。链式调用后代码是平级的,没有层层嵌套,可读性比回调函数地狱更高。

小结 ​

  1. 什么是 Promise 链式调用?
    利用 then() 返回新 Promise 的特性,继续串联下一环任务,直到结束。

  2. then() 回调中 return 的值传给哪里?
    传给 then() 生成的新 Promise 对象;如果返回的是 Promise,下一个 then() 会等待它。

  3. 链式调用有什么用?
    解决回调函数嵌套问题,把嵌套写法改成链式写法。

从回调地狱到链式调用:为什么需要改写 ​

为什么需要:回调函数里再调用回调函数,会形成层层嵌套的“回调地狱”。转写稿里的省市区案例就是典型:先请求省份,在它的回调里请求城市,再在城市回调里请求地区。逻辑一多,换行和缩进越来越深,可读性差,异常捕获也麻烦。

怎么想:不要继续往回调里塞回调,而是把每个异步任务都交给一个 Promise 管理,再用 then 把任务串起来。这样代码从“嵌套结构”变成“平级链条”。

怎么做:先拿到省份 Promise,在 then 中处理省份数据,并 return 获取城市的 Promise;下一个 then 再处理城市数据,并 return 获取地区的 Promise。核心是“上一个 then 返回下一个异步任务”。

Promise链式调用示意图

易错点:不要把三个请求写成并列调用,因为它们有数据依赖;也不要在 then 里只调用请求却忘记 return,否则下一个 then 接不到这个 Promise。

then 为什么能链式调用:返回值决定下一个 Promise ​

为什么需要:只有理解 then 的返回值,才知道链式调用为什么能成立。转写稿明确:then 方法会返回一个新生成的 Promise 对象,所以后面可以继续点 then。

怎么想:then 回调里 return 什么,就会影响新 Promise 的状态和结果。若 return 一个 Promise,下一个 then 就会等待它成功并拿到结果。

怎么做:可以用两个模拟异步函数验证:

js
function getPName() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('省份数据'), 2000)
  })
}

function getCName() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('城市数据'), 2000)
  })
}

getPName()
  .then((res) => {
    console.log(res) // 省份数据
    return getCName() // 返回下一个异步任务
  })
  .then((res) => {
    console.log(res) // 城市数据
  })

易错点:忘记 return getCName(),下一个 then 的 res 就是 undefined;then 返回的是新 Promise,不是原来那个 Promise,转写稿中用 P === P2 测试得到 false。

用链式调用改写省市区案例:怎么做 ​

为什么需要:实际案例中省、市、区必须按顺序获取,前一个结果决定后一个请求参数。用链式调用既能保留顺序,又能去掉嵌套。

怎么想:页面打开先请求省份;第一个 then 中保存并渲染省份,然后 return 城市请求;第二个 then 中保存并渲染城市,再 return 地区请求;第三个 then 中渲染地区。

怎么做:核心结构如下,接口地址按实际文档填写:

js
let pName // 转写稿中改为全局变量,方便后续使用

axios({ url: '省份接口' })
  .then((res) => {
    pName = res.data.list[0]
    // 渲染省份
    return axios({ url: '城市接口', params: { pName } })
  })
  .then((res) => {
    const cName = res.data.list[0]
    // 渲染城市
    return axios({ url: '地区接口', params: { pName, cName } })
  })
  .then((res) => {
    const aName = res.data.list[0]
    // 渲染地区
  })

易错点:第三个请求需要 pName 时,若 pName 声明在第一个 then 内部,后面的平级 then 访问不到。转写稿的调试过程就是:控制台报 pName is not defined,于是把 const pName 改成全局 let pName,并在第一个 then 中赋值,问题解决。

调试过程:从报错看链式调用的关键点 ​

为什么需要:链式调用写错时,报错往往指向作用域或返回值,需要按控制台提示定位。

怎么想:先看请求是否按顺序发出,再看每个 then 是否 return 了下一个 Promise,最后检查变量作用域是否覆盖到使用位置。

怎么做:转写稿中先完成省份渲染;再把城市请求写在第一个 then 内并 return 出去,后面接第二个 then;地区请求同理写在第二个 then 内并 return,最后接第三个 then。保存后依次看到省、市、区数据渲染。

易错点:链式调用不是“把代码写在同一行”就算完成,关键是每个 then 的返回值要正确;另外,回调地狱原本异常捕获困难,链式调用中也要注意在合适位置处理失败,不能只写成功分支。

小结 ​

  1. Promise 链式调用如何解决回调函数地狱?
答案
  • then 的回调函数中 return Promise 对象,影响当前新 Promise 对象的值

从回调地狱到 async/await 的动机 ​

为什么需要:回调函数里再调用回调函数,会形成“回调函数地狱”。转写稿里用“获取省、市、区”的案例演示:第一次请求的结果在第一个 then 回调里接收,第二次请求写在它内部,第三次再往里嵌套。这样虽然能拿到数据,但层层嵌套、换行缩进多,可读性差;异常捕获也麻烦,外层一个 .catch 捕获不到内层错误,往往要在每个 then 后分别写 .catch。

怎么想:Promise 链式调用已经把“嵌套”改成“链式”,在 then 回调中 return 下一个 Promise 对象,就可以继续 .then。但链式写法仍有链式结构,变量作用域也不如平铺代码直观。async 和 await 的目标,是让我们在 async 函数内用近似同步代码的方式等待 Promise 成功结果,不再刻意写 then 链。

怎么做:本节笔记给出的核心结构是:

javascript
async function func() {
  const res1 = await Promise对象1
  const res2 = await Promise对象2
  const res3 = await Promise对象3
}
func()

易错点:await 不是“让整个程序停住”,而是暂停当前 async 函数内部后续代码,等后面的 Promise 成功后再把结果赋给左侧变量;外部其他代码仍可继续执行。不要把它理解成阻塞页面。

async 函数和 await 的基本语法 ​

为什么需要:如果只用 then,每次取结果都要写回调函数;多个异步任务连续执行时,代码结构不够平直。async/await 让“等待结果”这一步更简洁。

怎么想:先声明一个 async 函数,在函数体内部写 await Promise对象。代码执行到 await 时,会等待后面的 Promise 成功,并把成功结果赋值给左侧变量;普通同步代码照常从上往下写。转写稿中演示时,先写 async function func(),再在内部用 await axios(...) 之类的请求结果,随后直接 console.log 或渲染页面。

怎么做:按笔记结构,把原来的 then 改成 await。例如连续三个异步任务可以平铺为 res1、res2、res3,后面直接使用这些变量。转写稿的省市区案例最终就是:先 await 省份请求,取第一个省并渲染;再 await 城市请求,取第一个城市并渲染;再 await 区请求,取第一个区并渲染。代码没有回调嵌套,也没有链式 .then。

易错点:第一,await 要写在 async 函数内,按本节语法不要在普通函数里直接使用。第二,忘记写 await 时,拿到的是 Promise 对象,不是成功结果。转写稿专门删掉 await 测试,打印出来是 Promise 对象,后续再取属性就会报错。第三,await 后面通常等待 Promise;普通同步代码不需要 await。

用 await 替代 then 提取成功结果 ​

为什么需要:then 的作用是注册回调,从 Promise 成功状态中取结果;await 可以直接把成功结果赋给变量,减少回调层级。

怎么想:把“Promise对象.then(res => { ... })”理解为“等待这个 Promise 成功,然后把结果交给 res”。用 await 后,就是把结果直接赋给左侧变量,后续逻辑继续平铺。

怎么做:笔记中的写法是:

javascript
async function func() {
  const res1 = await Promise对象1
  const res2 = await Promise对象2
  const res3 = await Promise对象3
}
func()

这段代码表达的是:res1 拿到第一个 Promise 成功结果后,再执行第二个 await;res2 拿到后再执行第三个。转写稿调试时也看到,加上 await 后能正常打印省份、城市、地区数据;不加 await 则拿到 Promise 对象。

易错点:await 只负责等待成功结果;失败处理不能靠它“自动解决”。本节笔记的小结也强调:await 的作用是替代 then 方法来提取 Promise 对象成功状态的结果。失败场景要用下一部分的 try/catch。

用 try/catch 捕获 async 函数中的错误 ​

为什么需要:await 等待 Promise 时,如果请求失败,错误需要被捕获,否则会中断当前 async 函数后续代码。转写稿中把第二个请求 URL 后面故意加 123,控制台出现 404,message 为 not find,说明请求失败。

怎么想:async 函数内部可以使用普通的 try...catch。把需要执行的异步代码放进 try,出错后进入 catch,通过 catch (err) 拿到错误信息。

怎么做:转写稿的结构是:try 中放原来的请求和渲染逻辑;catch 中打印 err。调试时先不改错,页面数据正常渲染,catch 不执行;再把 URL 改错,catch 执行并打印错误。错误发生后,try 中后续代码不再继续,所以页面上只渲染了第一份数据,第二、第三份没有渲染。

易错点:try 里没有错误时,catch 不会执行;错误一旦发生,try 内后续语句会中断。不要以为 catch 会“修复”请求,它只是接收并处理错误信息。

小结 ​

为什么需要:记住 await 的定位,避免把它当成普通函数或阻塞语句。怎么想:async 声明函数,await 等待 Promise 成功结果。怎么做:在 async 函数内用 await 取代 then 提取结果;失败用 try/catch。易错点:不加 await 得到 Promise 对象;错误会中断后续代码。

await 的作用是什么?

替代 then 方法来提取 Promise 对象成功状态的结果。

为什么需要 try catch 捕获错误 ​

写代码时,错误并不总是“语法错误”。更多时候是运行过程中才出现的问题,例如请求地址写错、服务器返回 404、某个数据取不到。如果不管它,代码会中断,控制台会报错,页面后续渲染也可能停掉。

try...catch 的作用就是:把可能出错的代码放进 try 里,出错后不要让程序直接“炸掉”,而是立刻跳到 catch 中处理错误。本节笔记的目标是了解用 try catch 捕获同步流程的错误;放到 async 函数里,就是用它包住 await 相关代码,捕获异步请求失败等错误。

怎么想:先明确分工——try 放“要执行的语句”,catch 放“出错后要执行的语句”。catch 的参数 error 用来接收错误信息。

怎么做:保留本节笔记中的基本结构:

javascript
try {
  // 需要被执行的语句
} catch (error) {
  // error 接收错误信息
  // try 有错误时执行的语句
}

易错点:try 里没有错误时,catch 不会执行;不要以为写了 catch 就一定会打印。

先想清楚:try 里报错后会立刻跳转 ​

转写稿里反复强调一句话:try 里报错的代码会立刻跳转到 catch 中。也就是说,try 中一旦某一行出错,它后面的代码就不再继续执行,而是直接把错误信息带到 catch 的 error 参数里。

为什么需要:理解这个跳转过程,才能解释“为什么页面只渲染了第一份数据,后面的数据没有渲染”。在调试中,第二个请求地址被故意改错后,try 中后续代码中断,所以第二、第三份数据没有继续处理。

怎么想:把 try 看成一个“受保护区域”。区域里正常就顺序执行;区域里出错就立即退出,进入 catch。

怎么做:在 catch 中先打印错误,确认是否捕获到:

javascript
try {
  // 需要被执行的语句
} catch (error) {
  console.dir(error)
}

易错点:不要认为 try 里出错后还会继续执行下一行;实际是中断并跳转。

在 async 函数里怎么写 try catch ​

转写稿说明:使用 async 关键字声明函数后,就可以在异步代码中使用普通的 try catch 代码块。写法不是新的语法,仍然是普通 try...catch,只是放在 async 函数内部,包住 await 的代码。

为什么需要:await 等待的 Promise 如果失败,需要有地方接收错误;try...catch 就是统一处理入口。

怎么想:函数用 async 声明;需要执行的请求逻辑放进 try;出错处理放进 catch。例如结构可以写成:

javascript
async function getData() {
  try {
    const res = await axios.get('/接口地址')
    console.log(res)
  } catch (error) {
    console.dir(error)
  }
}

怎么做:先把原来的请求逻辑剪切到 try 里,再在 catch 中通过 error 获取错误信息。转写稿中的调试就是:结构改完后,页面数据仍能正常渲染,因为 try 里没有出错,catch 不执行;随后把第二次请求的 URL 后面随便加上 123 制造错误,控制台出现错误输出,说明 catch 执行了。

易错点:async 函数里仍然写的是 try catch,不是别的关键字;catch 的参数名可以叫 error,也可以叫 err,关键是它接收错误信息。

调试过程:看 catch 是否执行、错误信息是什么 ​

转写稿的调试过程很值得还原:先只改结构、不改错,确认页面正常,控制台没有 catch 输出;再把第二个请求地址改错,保存后到控制台观察。结果出现错误打印,并且对应行号指向 catch 里的输出,说明 try 中出错后确实进入了 catch。

接着查看错误对象:服务器返回的信息在 response.data 里,message 的值是 not find,表示找不到;状态码 status 是 404,也表示资源找不到。这两个信息是对应的。

为什么需要:捕获错误不只是“知道错了”,还要读出错误原因,方便定位是地址错、参数错还是服务器问题。

怎么做:在 catch 中先用 console.dir(error) 或 console.log(error) 输出,再展开 response、data、message、status 查看。

易错点:不要只看“报错了”三个字;要确认错误对象里的 message 和 status 是否一致。转写稿中 not find 与 404 对上,才说明判断正确。

易错点汇总 ​

第一,try 中没有错误时,catch 不会执行,所以控制台没有输出是正常的。第二,try 中一旦出错,后面的代码不再执行,页面只完成出错前的渲染。第三,catch 的参数 error 是错误信息入口,不要写成没有参数的 catch 后还奇怪为什么拿不到信息。第四,async 函数中如果不写 await,拿到的是 Promise 对象而不是成功结果;本节虽然重点讲错误捕获,但调试时也要注意 await 是否写对。第五,本节笔记的小结是“捕获同步流程的代码报错信息”,而在 async 函数中,try catch 可以包住 await 代码,把等待过程中的失败交给 catch 处理。掌握这套结构后,请求出错时就能在 catch 中统一接收并查看错误信息,而不是让代码直接中断。

课件图示 ​

课件第16页:async函数和await 定义: async 函数是使用async关键字声明的

课件第12页:总结 总结 1. Promise的链式调用: then方法会返回一个新的Prom

课件第14页:Promise-链式调用-解决回调地狱 需求:展示数据到下拉框中(使用Promi

课件第11页:Promise-链式调用 Promise链式调用: 每一个then方法还会返回一

课件第18页:async函数和await-错误捕获 定义: 使用async/await 关键字

课件第27页:定义:每个代理都是由事件循环驱动的,事件循环负责收集用事件(包括用户事件以及其他

课件第29页:总结 总结 1. (宏)任务: 浏览器执行的异步代码: script标签,定时器

课件第7页:回调函数地狱: 在回调函数中调用回调函数,形成的代码结构称之为回调函数地狱 缺点

代码示例 ​

示例一:同步代码与异步代码的执行顺序 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>同步代码与异步代码</title>
</head>
<body>
  <button id="btn">点我</button>

  <script>
    /* 1. 同步代码:逐行执行,原地等待结果 */
    console.log(1)
    const num = 1 + 1
    console.log(num) // 2

    /* 2. 同步 + 异步(定时器):先打印 1、3,1 秒后打印 2 */
    console.log(1)
    setTimeout(() => {
      console.log(2)
    }, 1000)
    console.log(3)

    /* 3. 同步 + 异步(事件):先打印 1、3,点击后才打印 2 */
    console.log(1)
    document.body.addEventListener('click', () => {
      console.log(2)
    })
    console.log(3)

    /* 4. 同步 + 异步(XHR):先打印 1、3,服务器响应回来后才打印 2 */
    console.log(1)
    const xhr = new XMLHttpRequest()
    xhr.open('get', 'http://hmajax.itheima.net/api/province')
    xhr.addEventListener('loadend', () => {
      console.log(2)
    })
    xhr.send()
    console.log(3)
  </script>
</body>
</html>

要点解释:

  • setTimeout(() => {...}, 1000) 只是“登记”了一个 1 秒后的任务,代码不会停在原地等,而是继续往下走,所以先输出 3,1 秒后才输出 2。
  • addEventListener('click', ...) 只是注册事件,回调函数要满足触发条件(点击)才会执行,不点就永远不打印 2;注册事件同样属于异步代码。
  • xhr.send() 把请求发给服务器后立刻返回,后面的 console.log(3) 马上执行;loadend 回调要等服务器响应回来才触发,此时才打印 2。
  • 易错点:把“异步”理解成“慢”,以为定时器或请求会阻塞后面的代码;实际上异步代码不会阻塞后续执行,结果统一靠回调函数接收。

示例二:Promise 链式调用 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Promise 链式调用</title>
</head>
<body>
  <script>
    /* 用定时器模拟一个耗时 2 秒的异步任务,返回 Promise 对象 */
    function getPName() {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve('省份数据')
        }, 2000)
      })
    }

    function getCName() {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve('城市数据')
        }, 2000)
      })
    }

    /* then 返回的是一个“新”的 Promise 对象,可以继续 .then 串下去 */
    const p = getPName()
    const p2 = p.then(res => {
      console.log(res) // 省份数据
      return getCName() // 返回值决定新 Promise 管理的异步任务
    })
    console.log(p === p2) // false,p2 是新生成的 Promise 对象

    /* 链式调用写法:没有回调函数嵌套 */
    getPName()
      .then(res => {
        console.log(res) // 第一次异步任务的结果:省份数据
        return getCName() // 返回下一个异步任务
      })
      .then(res => {
        console.log(res) // 第二次异步任务的结果:城市数据
      })
  </script>
</body>
</html>

要点解释:

  • .then() 方法会返回一个新生成的 Promise 对象,所以可以在后面继续 .then(),这就是链式调用的根本原因。
  • .then() 回调函数里 return 的值,会传给新生成的那个 Promise 对象:return getCName() 就是让新 Promise 去管理“获取城市数据”这个异步任务,于是下一个 .then() 的 res 拿到的就是城市数据。
  • 不 return 下一个 Promise,下一个 .then() 就接不到任何有用结果——这是最常见的漏写点。
  • p === p2 打印 false,说明新对象不是原来那个 Promise;链式调用解决的是回调函数嵌套,代码是平级的,可读性更好。

示例三:async / await + try catch 获取省市区 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>async 和 await 解决回调地狱</title>
</head>
<body>
  <div class="province"></div>
  <div class="city"></div>
  <div class="area"></div>

  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
  <script>
    async function func() {
      try {
        // 1. 获取省份数据并展示第 1 个省
        const res1 = await axios({
          url: 'http://hmajax.itheima.net/api/province'
        })
        const pname = res1.data.list[0]
        document.querySelector('.province').innerText = pname

        // 2. 获取第 1 个省的城市数据,展示第 1 个城市
        const res2 = await axios({
          url: 'http://hmajax.itheima.net/api/city',
          params: { pname }
        })
        const cname = res2.data.list[0]
        document.querySelector('.city').innerText = cname

        // 3. 获取第 1 个城市的区数据,展示第 1 个区
        const res3 = await axios({
          url: 'http://hmajax.itheima.net/api/area',
          params: { pname, cname }
        })
        const aname = res3.data.list[0]
        document.querySelector('.area').innerText = aname
      } catch (err) {
        // try 里报错会立刻跳到 catch,err 就是错误信息
        console.dir(err)
      }
    }
    func()
  </script>
</body>
</html>

要点解释:

  • await 只能写在 async 函数内部,它替代了 .then():等待后面的 Promise 对象成功,并把成功结果赋值给左侧变量,所以 res1 直接就是 axios 的响应对象,可以 res1.data.list[0] 取值。
  • 易错点:漏写 await 时,res3 拿到的是一个 Promise 对象,res3.data 是 undefined,会直接报错——这是本节最典型的错误。
  • try { ... } catch (err) { ... } 捕获的是这段同步流程里的错误:try 中某一行报错(例如接口地址写错),后面的代码立刻不再执行,直接跳进 catch,err 里就是错误信息。
  • pname、cname 在同一个函数作用域里声明,后面直接就能用,不需要像链式调用那样靠 return 传值,也不用改成全局变量。
  • 页面里用类名相同的 div 承载文本(笔记演示时是给下拉框的 option 赋值),核心逻辑完全一致:innerText 赋值即可,不用拼标签。

常见错误 ​

1. await 写在普通函数里,控制台直接报语法错误

  • 现象:Uncaught SyntaxError: await is only valid in async functions,整段代码一行都不执行。
  • 原因:await 只能在 async 修饰的函数体内出现,写在外层普通函数或全局顶层(旧环境)都会解析失败。
  • 修法:给最外层函数加上 async;如果不想改结构,就退回 .then() 写法。注意 async 加在函数声明上,不是加在 await 那一行。

2. await 漏写,变量里拿到的是 Promise 对象

  • 现象:res.data 报 Cannot read properties of undefined,或者打印出来是 Promise {<pending>}。
  • 原因:axios(...) 本身返回 Promise,不写 await 时赋值给变量的就是 Promise 本身,不是响应结果。
  • 修法:const res = await axios(...),每个取结果的调用点都要检查是否带 await;调试时先 console.log(res) 确认它是 Promise 还是数据。

3. 链式调用中第二个 then 拿不到上一个的结果

  • 现象:第二个 .then(res => ...) 里的 res 是 undefined,或者直接跳过。
  • 原因:上一个 then 的回调用了花括号却没 return 下一个 Promise(axios(...) 前面少写 return),新 Promise 就变成 fulfilled 值为 undefined。
  • 修法:回调体用简写形式直接返回,或显式写 return axios(...);改完看缩进——.then 要和上一个 .then 平级,不是缩进到里面。

4. 多个 then 里复用变量报 is not defined

  • 现象:回调地狱改链式调用后,第三个 then 里用 pname 报未定义;改 async/await 后又莫名其妙好了。
  • 原因:链式调用中每个 then 回调是独立作用域,是平级的,后面的 then 访问不到前面 then 里 const 声明的变量。
  • 修法:要么在最外层用 let 声明变量(链式调用场景),要么改用 async/await —— 所有 await 都在同一个函数体内,变量天然共享。

5. 用 try/catch 包 axios().then() 却捕不到错误

  • 现象:接口地址故意写错,catch 块没反应,控制台反而是未捕获的 rejection。
  • 原因:try/catch 只能捕获同步流程抛出的错误;.then() 里的失败是异步 reject,早已跳出 try 的执行栈。
  • 修法:把 Promise 用 await 摊平成同步写法再包 try,即 try { const res = await axios(...) } catch(err) {...};坚持用 then 就补 .catch()。

6. try 范围包小了,出错后页面只渲染了一半

  • 现象:三个下拉框只出了第一个,后面两个空白,catch 里也没打印。
  • 原因:try 只包住了第一个 await,后面两个请求在 try 之外,出错后既不渲染也没人捕获;或者反过来,try 包住了整段但 catch 里只写了空语句。
  • 修法:把整条 await 业务链一起放进 try,catch 里必须 console.dir(err) + 给用户提示;改完故意把某个 URL 改错一次验证 catch 真的会走。

练习 ​

1. 写出打印顺序

javascript
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')

说明为什么 B 和 C 的顺序是这样(提示:一个进宏任务队列,一个进微任务队列)。

2. 改写回调地狱 把下面这段改写成 async/await 版本,要求不出现任何嵌套 then:

javascript
axios({url:'/api/province'}).then(res => {
  const pname = res.data.list[0]
  axios({url:'/api/city', params:{pname}}).then(res2 => {
    const cname = res2.data.list[0]
    axios({url:'/api/area', params:{pname, cname}}).then(res3 => {
      render(res3.data.list[0])
    })
  })
})

3. 找 bug

javascript
async function getData() {
  const res = axios({url:'/api/province'})
  document.querySelector('.province').innerText = res.data.list[0]
}
getData()

说出至少两处问题并改正。

4. 找 bug(链式调用)

javascript
axios({url:'/api/province'})
  .then(res => {
    const pname = res.data.list[0]
    axios({url:'/api/city', params:{pname}})
  })
  .then(res => {
    console.log(res.data.list[0])
  })

说明第二个 then 会打印什么,并给出两种修法。

5. 用 Promise 包装定时器 实现 delay(ms),使其可以被 await:await delay(1000); console.log('1秒后')。要求不用 setTimeout 直接返回、不能返回 undefined。

6. 错误处理补全 给练习 2 的 async/await 版本加上错误处理,要求:请求失败时 catch 能拿到错误对象,并且控制台打印出服务器返回的 message 与状态码。


原视频对照 ​

#视频时长
1同步代码和异步代码09:14
2回调函数地狱14:01
3Promise-链式调用11:01
4Promise-链式调用-解决回调地狱11:34
5async函数和await11:27
6async函数和await-错误捕获04:59

小结 ​

  1. 判断一段代码会不会"插队",只看它是不是回调函数 / Promise 回调形式——是就排到后面去,和它写在第几行无关。
  2. 回调地狱的本质不是"回调太多",而是异步结果只能出现在回调里,导致后续依赖只能往里缩进;所以解法的核心是"把结果提到同一层",嵌套和链式都只是手段。
  3. 链式调用的成败取决于一个 return:上一个 then 返回什么,下一个 then 就收到什么,忘了 return 整条链就断在半路。
  4. async/await 之所以是终极方案,是因为它把变量拉回了同一个函数作用域,链式调用中最容易踩的跨 then 取变量问题直接消失。
  5. await 只是"等结果",不是"吞错误"——不加 try/catch,失败时依然是未捕获的 rejection,页面会静默地停在半截。
  6. try/catch 只对同步流程有效,遇到 Promise 必须先 await 摊平,这条规律同样适用于任何"包了却不生效"的排查。
  7. 三种写法都要会:真实项目里 .then() 和 async/await 常常混用,读得懂旧代码、写得清新代码,才算真正掌握。
  8. 调试异步问题的第一动作永远是 console.log 打出那个变量——先确认它是数据还是 Promise,能省掉一半排查时间。