《javascript高階程式設計》學習筆記 | 11.3.非同步函式

小謳發表於2021-09-29
關注前端小謳,閱讀更多原創技術文章

非同步函式

  • ES8 新增非同步函式(async/await),是 ES6 期約模式在 ECMAScript 函式中的應用
  • 同步方式的程式碼執行非同步

相關程式碼 →

非同步函式

  • ES8 對函式進行了擴充套件,新增 2 個關鍵字asyncawait

async

  • async關鍵字用於宣告非同步函式,可用在函式宣告函式表示式箭頭函式方法
async function foo() {} // 用在函式宣告
let bar = async function () {} // 用在函式表示式
let baz = async () => {} // 用在箭頭函式
class Qux {
  async qux() {} // 用在方法
}
  • async關鍵字讓函式具有非同步特性,程式碼仍同步求值,引數或閉包也具有普通 JS 函式的正常行為
async function foo() {
  console.log(1)
}
foo()
console.log(2)
/* 
  1,foo()函式先被求值
  2
*/
  • 非同步函式return返回的值,會被Promise.resolve()包裝成期約物件,呼叫非同步函式始終返回該期約物件

    • return關鍵字返回的是實現thenable介面的物件(callback、期約),該物件由提供給then()的處理程式解包
    • return關鍵字返回的是常規的值,返回值被當作已解決的期約(無return關鍵字,返回值被當作 undefined)
async function foo() {
  return 'foo' // 返回原始值
}
console.log(foo()) // Promise {<fulfilled>: "foo"},被當作已解決的期約
foo().then((result) => console.log(result)) // 'foo'

async function bar2() {
  return ['bar'] // 返回沒有實現thenable介面的物件
}
console.log(bar2()) // Promise {<fulfilled>: ['bar']},被當作已解決的期約
bar2().then((result) => console.log(result)) // ['bar']

async function baz2() {
  const thenable = {
    then(callback) {
      callback('baz')
    },
  }
  return thenable // 返回實現了thenable介面的非期約物件
}
console.log(baz2()) // Promise {<pending>}
baz2().then((result) => console.log(result)) // 'baz',由then()解包

async function qux() {
  return Promise.resolve('qux') // 返回解決的期約
}
console.log(qux()) // Promise {<pending>}
qux().then((result) => console.log(result)) // 'qux',由then()解包

async function rejectQux() {
  return Promise.reject('qux') // 返回拒絕的期約
}
console.log(rejectQux()) // Promise {<pending>}
rejectQux().then(null, (result) => console.log(result)) // 'qux',由then()解包
// Uncaught (in promise) qux
rejectQux().catch((result) => console.log(result)) // 'qux',由catch()解包
  • 非同步函式中丟擲錯誤會返回拒絕的期約
async function foo() {
  console.log(1)
  throw 3
}
foo().catch((result) => console.log(result)) // 給返回的期約新增拒絕處理程式
console.log(2)
/* 
  1,foo()函式先被求值
  2
  3
*/
  • 非同步函式中拒絕期約的錯誤(非“返回拒絕的期約”)不會被非同步函式捕獲
async function foo() {
  Promise.reject(3) // 拒絕的期約(非返回)
}
foo().catch((result) => console.log(result)) // catch()方法捕獲不到
// Uncaught (in promise) 3,瀏覽器訊息佇列捕獲

await

  • 使用await關鍵字可以暫停非同步函式程式碼執行,等待期約解決
let p = new Promise((resolve, reject) => {
  setTimeout(resolve, 1000, 3)
})
p.then((x) => console.log(x)) // 3

// 用async/await重寫
async function foo() {
  let p = new Promise((resolve, reject) => {
    setTimeout(resolve, 1000, 3)
  })
  console.log(await p)
}
foo() // 3
  • await會嘗試解包物件的值(與yield類似),然後將該值傳給表示式,而後非同步恢復執行非同步函式
async function foo() {
  console.log(await Promise.resolve('foo')) // 將期約解包,再將值傳給表示式
}
foo()

async function bar2() {
  return await Promise.resolve('bar')
}
bar2().then((res) => console.log(res)) // 'bar'

async function baz2() {
  await new Promise((resolve, reject) => {
    setTimeout(resolve, 1000)
  })
  console.log('baz')
}
baz2() // 'baz'(1000毫秒後)
  • await根據等待的值,執行不同的操作

    • 若等待的值是實現thenable介面的物件(callback、期約),該物件由await解包
    • 若等待的值是常規值,該值被當作已解決的期約(然後再由await來解包)
async function foo() {
  console.log(await 'foo') // 等待原始值,被當作已解決的期約Promise.resolve('foo'),再由await解包
}
foo() // 'foo'

async function bar2() {
  console.log(await ['bar']) // 等待值是沒有實現thenable介面的物件,被當作已解決的期約再由await解包
}
bar2() // ["bar"]

async function baz2() {
  const thenable = {
    then(callback) {
      callback('baz')
    },
  }
  console.log(await thenable) // 等待值是實現了thenable介面的非期約物件,由await解包
}
baz2() // 'baz'

async function qux() {
  console.log(await Promise.resolve('qux')) // 等待值是解決的期約
}
qux() // 'qux'
  • 等待會丟擲錯誤的同步操作,會返回拒絕的期約
async function foo() {
  console.log(1)
  await (() => {
    throw 3 // 丟擲錯誤的同步操作
  })()
}
foo().catch((result) => console.log(result)) // 給返回的期約新增拒絕處理程式
console.log(2)
/* 
  1
  2
  3
*/
  • 拒絕的期約使用await,會釋放錯誤值(將拒絕期約返回)
async function foo() {
  console.log(1)
  await Promise.reject(3) // 對拒絕的期約使用await,將其返回(後續程式碼不再執行)
  console.log(4) // 不執行
}
foo().catch((result) => console.log(result)) // 給返回的期約新增拒絕處理程式
console.log(2)
/* 
  1
  2
  3
*/

await 的限制

  • 必須非同步函式中使用
  • 不能在頂級上下文(如<script>標籤或模組)中使用
  • 可以定義並立即呼叫非同步函式
  • 非同步函式的特質不會擴充套件到巢狀函式
async function foo() {
  console.log(await Promise.resolve(3)) // 必須在非同步函式中使用
}
foo() // 3
;(async function () {
  console.log(await Promise.resolve(3)) // 3,立即呼叫的非同步函式表示式
})()

const syncFn = async () => {
  console.log(await Promise.resolve(3)) // 在箭頭函式中使用,箭頭函式前一樣要加async
}
syncFn() // 3

function foo() {
  // console.log(await Promise.resolve(3)) // 不允許在同步函式中使用
}

async function foo() {
  // function bar() {
  //   console.log(await Promise.resolve(3)) // 錯誤:非同步函式不會擴充套件到巢狀函式
  // }
  async function bar() {
    console.log(await Promise.resolve(3)) // 需要在bar前加async
  }
}

停止和恢復執行

  • async/await真正起作用的是awaitasync只是識別符號)

    • JS 在執行時碰到await關鍵字,會記錄在哪裡暫停執行
    • 等到await右邊的值可以用時,JS 向訊息佇列推送任務,該任務恢復非同步函式的執行
    • 即使await右邊跟著一個立即可用的值,函式也會暫停,且其餘部分會被非同步求值
// async只是識別符號
async function foo() {
  console.log(2)
}
console.log(1)
foo()
console.log(3)
/* 
  1
  2
  3
*/

// 遇到await -> 記錄暫停 -> await右邊的值可用 -> 恢復執行非同步函式
async function foo() {
  console.log(2)
  await null // 暫停,且後續操作變為非同步
  // 為立即可用的值null向訊息佇列中新增一個任務
  console.log(4)
}
console.log(1)
foo()
console.log(3)
/* 
  1
  2
  3
  4
*/
  • 如果await後面是一個期約,則會有兩個任務被新增到訊息佇列並被非同步求值

    • 第一個任務是等待期約的返回值,第二個任務是拿到返回值後執行程式
    • tc39 對await後面是期約的情況做過 1 次修改,await Promise.resolve()不再生成 2 個非同步任務,而只是 1 個
async function foo() {
  console.log(2)
  console.log(await Promise.resolve(8))
  console.log(9)
}

async function bar2() {
  console.log(4)
  console.log(await 6)
  console.log(7)
}

console.log(1)
foo()
console.log(3)
bar2()
console.log(5)
/*
  書本順序:1 2 3 4 5 6 7 8 9
  瀏覽器順序:1 2 3 4 5 8 9 6 7(tc39做過1次修改)
*/

非同步函式策略

實現 sleep()

  • 可以利用非同步函式實現類似JAVAThread.sleep()的函式,在程式中加入非阻塞的暫停
function sleep(delay) {
  return new Promise((resolve) => setTimeout(resolve, delay)) // 設定延遲,延遲後返回一個解決的期約
}
async function foo() {
  const t0 = Date.now()
  await sleep(1500) // 暫停約1500毫秒
  console.log(Date.now() - t0)
}
foo() // 1507

利用平行執行

  • 按順序等待 5 個隨機的超時
async function randomDelay(id) {
  const delay = Math.random() * 1000 // 隨機延遲0-1000毫秒
  return new Promise((resolve) =>
    setTimeout(() => {
      console.log(`${id} finished`)
      resolve()
    }, delay)
  )
}

async function foo() {
  const t0 = Date.now()
  await randomDelay(0)
  await randomDelay(1)
  await randomDelay(2)
  await randomDelay(3)
  await randomDelay(4)
  console.log(`${Date.now() - t0} ms elapsed`)
}
foo()
/* 
  0 finished
  1 finished
  2 finished
  3 finished
  4 finished
  3279 ms elapsed
*/

// 用for迴圈重寫
async function foo() {
  const t0 = Date.now()
  for (let i = 0; i < 5; i++) {
    await randomDelay(i)
  }
  console.log(`${Date.now() - t0} ms elapsed`)
}
foo()
/* 
  0 finished
  1 finished
  2 finished
  3 finished
  4 finished
  3314 ms elapsed
*/
  • 不考慮順序時,可以先一次性初始化所有期約,分別等待結果(獲得平行加速)
async function foo() {
  const t0 = Date.now()

  // 一次性初始化所有期約
  const p0 = randomDelay(0)
  const p1 = randomDelay(1)
  const p2 = randomDelay(2)
  const p3 = randomDelay(3)
  const p4 = randomDelay(4)

  // 分別等待結果,延遲各不相同
  await p0
  await p1
  await p2
  await p3
  await p4

  console.log(`${Date.now() - t0} ms elapsed`)
}
foo()
/* 
  4 finished
  3 finished
  1 finished
  0 finished
  2 finished
  870 ms elapsed,大幅度降低總耗時
*/

// 用陣列和for迴圈再次包裝
async function foo() {
  const t0 = Date.now()
  const promises = Array(5)
    .fill(null)
    .map((item, i) => randomDelay(i))

  for (const p of promises) {
    await p
  }
  console.log(`${Date.now() - t0} ms elapsed`)
}
foo()
/* 
  1 finished
  3 finished
  0 finished
  4 finished
  2 finished
  806 ms elapsed
*/
  • 儘管期約未按順序執行,但await按順序收到每個期約的值
async function randomDelay(id) {
  const delay = Math.random() * 1000 // 隨機延遲0-1000毫秒
  return new Promise((resolve) =>
    setTimeout(() => {
      console.log(`${id} finished`)
      resolve(id)
    }, delay)
  )
}
async function foo() {
  const t0 = Date.now()
  const promises = Array(5)
    .fill(null)
    .map((item, i) => randomDelay(i))

  for (const p of promises) {
    console.log(`awaited ${await p}`)
  }
  console.log(`${Date.now() - t0} ms elapsed`)
}
foo()
/* 
  1 finished
  4 finished
  0 finished
  awaited 0
  awaited 1
  2 finished
  awaited 2
  3 finished
  awaited 3
  awaited 4
  833 ms elapsed
*/

序列執行期約

  • 使用async/await期約連鎖
function addTwo(x) {
  return x + 2
}
function addThree(x) {
  return x + 3
}
function addFive(x) {
  return x + 5
}
async function addTen(x) {
  for (const fn of [addTwo, addThree, addFive]) {
    x = await fn(x)
  }
  return x
}
addTen(9).then((res) => console.log(res)) // 19
  • 將函式改成非同步函式,返回期約
async function addTwo(x) {
  return x + 2
}
async function addThree(x) {
  return x + 3
}
async function addFive(x) {
  return x + 5
}
addTen(9).then((res) => console.log(res)) // 19

棧追蹤與記憶體管理

  • 超時處理執行拒絕期約時,錯誤資訊包含巢狀函式的識別符號(被呼叫以建立最初期約例項的函式)棧追蹤資訊中不應該看到這些已經返回的函式

    • JS 引擎會在建立期約時,儘可能保留完整的呼叫棧,丟擲錯誤時棧追蹤資訊會佔用記憶體,帶來一些計算和儲存成本
function fooPromiseExecutor(resolve, reject) {
  setTimeout(reject, 1000, 'bar')
}
function foo() {
  new Promise(fooPromiseExecutor)
}
foo()
/* 
  Uncaught (in promise) bar
  setTimeout (async) // 錯誤資訊包含巢狀函式的識別符號
  fooPromiseExecutor // fooPromiseExecutor函式已返回,不應該在棧追蹤資訊中看到
  foo
*/
  • 換成非同步函式,已經返回的函式不會出現在錯誤資訊中,巢狀函式(在記憶體)中儲存指向包含函式的指標,不會帶來額外的消耗
async function foo() {
  await new Promise(fooPromiseExecutor)
}
foo()
/* 
  Uncaught (in promise) bar
  foo
  async function (async)
  foo
*/

總結 & 問點

  • async 關鍵字的用法是什麼?根據函式內返回值的不同,非同步函式的返回值有哪些情況?
  • await 關鍵字的用法是什麼?根據等待值的不同,呼叫非同步函式有哪些情況?其使用有哪些限制?
  • JS 執行時遇到 await 關鍵字會怎樣?函式的其餘部分會在何時恢復執行?
  • 寫一段程式碼,用非同步函式實現在程式中加入非阻塞的暫停
  • 寫一段程式碼,用非同步函式平行執行多個期約,隨機設定這些期約的延遲,並計算期約全部完成後的使用的時間
  • 寫一段程式碼,用非同步函式做期約連鎖

相關文章