技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

Async/Await 如何通过同步的方式实现异步

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/12-Async Await 如何通过同步的方式实现异步
本文目录12 个章节
  1. 一、同步与异步
  2. 二、回调与回调地狱
  3. 三、协程:一个有用但需要限定的类比
  4. 四、Iterator 迭代器协议
  5. 五、Generator 生成器
  6. 六、Generator + Promise 处理异步
  7. 七、Async/Await
  8. 八、串行与并发
  9. 九、请求取消与资源清理
  10. 十、现代兼容性说明
  11. 总结
  12. 参考资料

Async/Await 如何通过同步的方式实现异步

Category(分类): JavaScript Status: 已更新

原文从同步/异步、回调地狱、协程、Iterator、Generator 一路讲到 async/await。本文保留这条学习路径,修复原文代码抓取造成的注释粘连、全角标点、迭代器副作用和错误处理问题,并补充现代 Promise 时序、顶层 await、并发控制和请求取消。

原文最后更新:2020-12-14 历史文章:Async/Await 如何通过同步的方式实现异步

一、同步与异步

在一个 ECMAScript agent 的单个执行上下文中,一段 JavaScript job 会连续执行到完成,当前调用栈上的代码不会被另一个 JavaScript job 抢占。这个模型常被简化成“JavaScript 单线程”,但不能据此说整个浏览器或 Node.js 只有一个线程:

  • 浏览器可以使用 Worker、SharedWorker 等独立执行环境;
  • Node.js 和浏览器宿主可以在其他线程处理 I/O、编解码或调度;
  • 不同 agent 之间不能直接共享普通 JavaScript 对象;
  • 异步 API 的完成通知会在之后的任务或 Promise job 中继续执行。

下面的循环会阻塞当前执行环境:

function blockingWork() {
  const start = performance.now()

  for (let i = 0; i < 1e8; i += 1) {
    // 模拟 CPU 密集型工作
  }

  console.log(`blocked for ${Math.round(performance.now() - start)}ms`)
}

function quickWork() {
  console.log('quick work')
}

blockingWork()
quickWork()

网络请求、定时器、文件 I/O 等操作通常由宿主提供异步接口。调用者发起操作后可以继续执行其他 JavaScript,待结果准备好时,宿主再把后续回调安排到相应队列。

二、回调与回调地狱

传统回调风格可能这样发送请求:

requestUser('/api/user', (error, user) => {
  if (error) {
    handleError(error)
    return
  }

  requestOrders(user.id, (error, orders) => {
    if (error) {
      handleError(error)
      return
    }

    renderOrders(orders)
  })
})

function handleError(error) {
  console.error(error)
}

当任务逐层依赖时,回调会形成嵌套,错误处理、取消和资源清理也容易重复,这就是通常所说的“回调地狱”。Promise 可以把成功/失败链条扁平化,async/await 则提供了更接近同步控制流的写法。

Node.js 的回调 API 也遵循类似模式:

import { rename } from 'node:fs'

rename('旧文件.txt', '新文件.txt', error => {
  if (error) {
    throw error
  }
  console.log('重命名完成')
})

回调本身不是异步的关键;关键在于宿主何时调用它,以及调用结果如何进入事件循环或 Promise job 队列。

三、协程:一个有用但需要限定的类比

协程通常指可以暂停、恢复,并在多个逻辑任务之间协作切换的控制流结构。它可以运行在同一个线程上,但“协程比线程轻量”是实现层面的常见特征,不是所有语言或运行时的统一保证。

在 JavaScript 教学中,可以把 Generator 看作一种可暂停/恢复的执行机制,把异步操作的完成结果作为恢复时传入的值:

生产者:生产数据 → yield,把控制权交给消费者
消费者:消费数据 → yield,把控制权交给生产者

但需要区分:

  • Iterator 是遍历协议,不是协程;
  • Generator 同时实现可迭代协议和迭代器协议,并提供暂停/恢复;
  • async/await 的标准语义由 AsyncFunction 和 Await 定义,并不是规范要求编译成 Generator;
  • “async/await 是 Generator 的语法糖”可以作为历史转译或教学类比,但不能当成完整的实现事实。

四、Iterator 迭代器协议

1. 可迭代对象与迭代器

一个对象要能被 for...of、展开语法等使用,通常需要提供 [Symbol.iterator]() 方法。该方法返回一个迭代器;迭代器需要提供 next(),每次返回一个 IteratorResult 对象:

{
  value: any,
  done: boolean
}

valuedone 不都要求显式存在:完成时可以省略 valuedone 省略时按假值处理。但为了教学清晰,通常会把它们都写出来。

2. 自定义一个不修改原数组的可迭代对象

function createArrayIterable(items) {
  const values = Array.from(items)

  return {
    [Symbol.iterator]() {
      let index = 0

      return {
        next() {
          if (index >= values.length) {
            return { value: undefined, done: true }
          }

          return {
            value: values[index++],
            done: false
          }
        }
      }
    }
  }
}

const iterable = createArrayIterable(['人月', '神话'])
const iterator = iterable[Symbol.iterator]()

console.log(iterator.next()) // { value: '人月', done: false }
console.log(iterator.next()) // { value: '神话', done: false }
console.log(iterator.next()) // { value: undefined, done: true }
console.log([...iterable]) // ['人月', '神话']

原文通过 arr.length++shift() 制造结束条件,会修改输入数组并让实现难以理解;上面的索引版本保持输入不变。

不要为了让普通对象可迭代而修改 Object.prototype[Symbol.iterator]

// 不要这样做:这会污染所有普通对象的原型
// Object.prototype[Symbol.iterator] = function* () {}

如果确实需要迭代一个对象,只给该对象定义自己的迭代器:

const user = {
  name: 'amap',
  team: 'sharetrip',
  *[Symbol.iterator]() {
    yield ['name', this.name]
    yield ['team', this.team]
  }
}

for (const entry of user) {
  console.log(entry)
}

3. 手动消费数组迭代器

function printIterable(iterable) {
  const iterator = iterable[Symbol.iterator]()

  while (true) {
    const result = iterator.next()
    if (result.done) {
      break
    }
    console.log(result.value)
  }
}

printIterable([0, 1, 2, 3])

五、Generator 生成器

生成器函数使用 function* 声明。调用它不会立即执行函数体,而是返回一个既是迭代器又是可迭代对象的 Generator:

function* numbers() {
  yield 1
  yield 2
  return 3
}

const generator = numbers()

console.log(generator.next()) // { value: 1, done: false }
console.log(generator.next()) // { value: 2, done: false }
console.log(generator.next()) // { value: 3, done: true }
console.log(generator.next()) // { value: undefined, done: true }

yield 可以暂停执行;下一次 next(value) 传入的值,会成为上一个 yield 表达式的结果。yield* 可以把控制委托给另一个可迭代对象,不仅限于另一个 Generator:

function* another() {
  yield '人月神话'
}

function* gen() {
  yield* another()

  const a = yield 'hello'
  const b = yield a
  yield b
}

const g = gen()
console.log(g.next()) // { value: '人月神话', done: false }
console.log(g.next()) // { value: 'hello', done: false }
console.log(g.next('world')) // { value: 'world', done: false }
console.log(g.next('!')) // { value: '!', done: false }
console.log(g.next()) // { value: undefined, done: true }

六、Generator + Promise 处理异步

Generator 不会自动等待 Promise,需要调用方或执行器负责在 Promise 完成后调用 next()

function* loadData() {
  const first = yield Promise.resolve({ a: 1 })
  console.log('first:', first)

  const second = yield Promise.resolve({ b: 2 })
  console.log('second:', second)
}

const generator = loadData()
const firstStep = generator.next()

firstStep.value
  .then(first => {
    const secondStep = generator.next(first)
    return secondStep.value
  })
  .then(second => {
    generator.next(second)
  })

手写层层 .then() 比较繁琐,所以可以编写一个自动执行器。可靠的执行器需要处理普通值、Promise 拒绝、Generator 内部异常和最终返回值:

function run(generatorFunction) {
  return new Promise((resolve, reject) => {
    let iterator

    try {
      iterator = generatorFunction()
    } catch (error) {
      reject(error)
      return
    }

    function step(method, value) {
      let result

      try {
        result = iterator[method](value)
      } catch (error) {
        reject(error)
        return
      }

      if (result.done) {
        resolve(result.value)
        return
      }

      Promise.resolve(result.value).then(
        nextValue => step('next', nextValue),
        error => step('throw', error)
      )
    }

    step('next')
  })
}

run(function* () {
  const first = yield Promise.resolve({ a: 1 })
  const second = yield Promise.resolve({ b: 2 })
  return { first, second }
}).then(result => {
  console.log(result) // { first: { a: 1 }, second: { b: 2 } }
});

如果 Generator 没有在 try...catch 中捕获被 throw() 传入的异常,执行器会让外层 Promise 拒绝。这个执行器只用于理解历史实现,生产代码应直接使用原生 Promise 和 async/await

七、Async/Await

1. 基本写法

async function 的调用结果始终是 Promise;正常 return 的值会成为 fulfillment value,函数内未捕获的异常会成为 rejection reason:

async function loadUser() {
  const response = await fetch('/api/user')

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }

  return response.json()
}

loadUser()
  .then(user => console.log(user))
  .catch(error => console.error(error))

原文用 Generator 执行器与 async/await 对比,这个类比可以帮助理解控制流:

async function loadPair() {
  const first = await Promise.resolve({ a: 1 })
  console.log(first)

  const second = await Promise.resolve({ b: 2 })
  console.log(second)

  return 'done'
}

loadPair().then(value => {
  console.log(value) // done
})

2. await 不只对 Promise 生效

原文说“只有 await 后面是 Promise 才会异步执行,其他值同步执行”,这不准确。await 会对操作数进行 Promise 化,并让当前 async 函数暂停;即使操作数是普通值,后续也会在之后的 Promise job 中恢复:

async function demo() {
  console.log('inside: before')
  const value = await 1
  console.log('inside: after', value)
}

console.log('outside: before')
demo()
console.log('outside: after')

// outside: before
// inside: before
// outside: after
// inside: after 1

await 还会吸收 thenable:

const thenable = {
  then(resolve) {
    resolve('thenable value')
  }
}

async function readThenable() {
  return await thenable
}

在普通 classic script 中,await 通常只能位于 async 函数;在支持的 ES Module 中还可以使用顶层 await:

// module.mjs
const config = await fetch('/config.json').then(response => response.json())
export default config

顶层 await 会影响该模块及依赖它的模块的评估顺序,应谨慎用于启动关键路径。

3. 错误处理

最直接的方式是使用 try...catch

async function loadData() {
  try {
    const response = await fetch('/api/data')
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }
    return await response.json()
  } catch (error) {
    console.error('load failed', error)
    throw error
  }
}

如果一个函数需要把错误和值一起返回,可以使用明确的 helper:

async function to(promise) {
  try {
    return [null, await promise]
  } catch (error) {
    return [error, undefined]
  }
}

async function asyncTask() {
  const [error1, result1] = await to(fetch('/api/one'))
  if (error1) {
    throw new Error('加载第一个接口失败', { cause: error1 })
  }

  const [error2, result2] = await to(fetch('/api/two'))
  if (error2) {
    throw new Error('加载第二个接口失败', { cause: error2 })
  }

  return { result1, result2 }
}

检查错误对象而不是用 if (!result) 判断结果,否则 0false 和空字符串等合法值会被误认为失败。若使用 helper,请明确参数是 Promise;如果参数是函数,应先调用它。

八、串行与并发

1. 有依赖关系时串行

第二个请求需要第一个请求的结果时,应按顺序等待:

async function fetchJson(url) {
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }
  return response.json()
}

async function loadSequential() {
  const user = await fetchJson('/api/user')
  const orders = await fetchJson(`/api/users/${user.id}/orders`)
  return { user, orders }
}

2. 无依赖关系时并发

多个操作互不依赖时,先创建全部 Promise,再用 Promise.all() 等待:

async function loadConcurrent(urls) {
  const requests = urls.map(url => fetchJson(url))
  const results = await Promise.all(requests)
  return results
}

map() 在这里的作用是同步创建 Promise,不是让 map 自己等待。Promise.all() 会按输入顺序返回结果;如果一个 Promise 拒绝,整体会尽快拒绝,但不会自动取消其他请求。

3. 循环中的 async 函数

forEach 不会等待 async 回调,也不会把回调的 Promise 返回给调用者:

async function wrong(items) {
  items.forEach(async item => {
    await save(item)
  })
  console.log('可能早于保存完成')
}

需要串行时使用 for...of

async function saveSequential(items) {
  for (const item of items) {
    await save(item)
  }
}

需要并发时使用 Promise.all(items.map(...))

async function saveConcurrent(items) {
  await Promise.all(items.map(item => save(item)))
}

如果操作很多,还要考虑并发上限,使用队列或限制并发的工具,而不是一次性创建无限数量的请求。

异步迭代器可以使用 for await...of

async function readStream(stream) {
  for await (const chunk of stream) {
    console.log(chunk)
  }
}

4. 一个 URL 数组的完整示例

async function loadAll(urls) {
  const responses = await Promise.all(
    urls.map(async url => {
      const response = await fetch(url)
      if (!response.ok) {
        throw new Error(`${url}: HTTP ${response.status}`)
      }
      return response.json()
    })
  )

  return responses
}

async function loadOneByOne(urls) {
  const results = []

  for (const url of urls) {
    const response = await fetch(url)
    if (!response.ok) {
      throw new Error(`${url}: HTTP ${response.status}`)
    }
    results.push(await response.json())
  }

  return results
}

原文:Generator 与 Async/Await 的关系示意

九、请求取消与资源清理

Promise 本身没有通用的取消方法。Fetch 可以使用 AbortController

async function loadWithCancel(url, signal) {
  const response = await fetch(url, { signal })
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }
  return response.json()
}

const controller = new AbortController()
const promise = loadWithCancel('/api/data', controller.signal)

// 需要取消时:controller.abort()
promise.catch(error => {
  if (error.name !== 'AbortError') {
    console.error(error)
  }
})

取消请求、清理事件监听器和释放定时器属于应用层生命周期管理,不能因为使用了 async/await 就自动完成。

十、现代兼容性说明

现代浏览器、Node.js、Deno 和 Bun 对 async/await 的支持已经很广泛。需要兼容旧运行环境时,应区分:

  • 语法转译:Babel、TypeScript 等把代码转为目标语法;
  • 运行时补充:Promise、迭代器等 API 可能需要 polyfill;
  • 宿主 APIfetch、AbortController、顶层 await 等还要查看目标环境;
  • 打包策略:不同目标浏览器可以生成不同构建结果。

不能把 es6-shim 当成 async/await 语法的转换器,也不要把 Babel 统称为 polyfill。

总结

  • 同步代码会阻塞当前 JavaScript 执行环境,异步 API 通过宿主调度后续任务;
  • Iterator 是遍历协议,Generator 是可暂停/恢复的迭代器;
  • async/await 可以用 Generator + Promise 近似解释,但标准语义并不等于 Generator 转换;
  • await 会统一处理普通值、Promise 和 thenable,并在之后恢复 async 函数;
  • 独立任务使用 Promise.all 并发,有依赖关系时按顺序等待;
  • forEach 不等待 async 回调,map 应与 Promise.all 配合;
  • fetch 的 HTTP 404/500 通常仍然兑现,需要检查 response.ok
  • Promise 不自动取消请求,需要 AbortController 或其他业务协议;
  • 文章中的 Generator 执行器和手写代码适合学习原理,生产代码优先使用原生 API。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS