掌握JavaScript中的迭代器和生成器,順便了解一下async、await的原理

MomentYY發表於2022-04-04

掌握JavaScript中的迭代器和生成器,順便了解一下async、await的原理

前言

相信很多人對迭代器和生成器都不陌生,當提到async和await的原理時,大部分人可能都知道async、await是Promise+生成器的語法糖,其原理具體是怎麼做的呢?下面通過這篇文章帶你詳細瞭解一下迭代器和生成器,以及帶你從生成器一步步推導到async和await。

1.迭代器(Iterator)

1.1.什麼是迭代器?

迭代器是確使使用者在容器物件(連結串列或陣列)上遍訪的物件,使用該介面無需關心物件的內部實現細節。

  • 迭代器的定義可能比較抽象,簡單來說迭代器就是一個物件,可用於幫助我們對某個資料結構(連結串列、陣列)進行遍歷

  • 在JavaScript中,迭代器也是一個具體的物件,並且這個物件必須符合迭代器協議(iterator protocol);

  • 什麼是迭代器協議?

    • 在JavaScript中就是指這個物件必須實現一個特定的next方法,並且next方法有如下要求;
    • next方法可接收0個或者1個引數(在生成器中next可以接收1個引數),並且需返回一個物件,物件包含以下兩個屬性:
      • done:值為Boolean,如果迭代器可以迭代產生下一個值,就為false,如果已經迭代完畢,就為true;
      • value:迭代器返回的值,如果done為false,value一般為undefined;
  • 編寫一個最簡單的迭代器:

    const iterator = {
      next: function() {
        return { done: false, value: 123 }
      }
    }
    

1.2.迭代器的基本使用

明白了迭代器的基本定義,下面就來實現一下符合迭代器協議的物件吧,並且看看其它的一些基本用法。比如,需要通過迭代器訪問一個陣列:

(1)建立一個迭代器物件

const names = ['curry', 'kobe', 'klay']

let index = 0 // 通過一個index來記錄當前訪問的位置
const iterator = {
  next() {
    if (index < names.length) {
      return { done: false, value: names[index++] }
    } else {
      return { done: true, value: undefined }
    }
  }
}

console.log(iterator.next()) // { done: false, value: 'curry' }
console.log(iterator.next()) // { done: false, value: 'kobe' }
console.log(iterator.next()) // { done: false, value: 'klay' }
console.log(iterator.next()) // { done: true, value: undefined }

(2)實現生成迭代器的函式

  • 如果每次需要去訪問一個陣列就去編寫一個對應的迭代器物件肯定是很麻煩的;
  • 可以封裝一個函式,用於生成一個訪問陣列的迭代器;
function createIterator(arr) {
  let index = 0
  return {
    next() {
      if (index < arr.length) {
        return { done: false, value: arr[index++] }
      } else {
        return { done: true, value: undefined }
      }
    }
  }
}
const names = ['curry', 'kobe', 'klay']
// 呼叫createIterator函式,生成一個訪問names陣列的迭代器
const namesIterator = createIterator(names)

console.log(namesIterator.next()) // { done: false, value: 'curry' }
console.log(namesIterator.next()) // { done: false, value: 'kobe' }
console.log(namesIterator.next()) // { done: false, value: 'klay' }
console.log(namesIterator.next()) // { done: true, value: undefined }

1.3.可迭代物件

1.3.1.什麼是可迭代物件?

上面提到了迭代器是一個物件,並且符合迭代器協議,那麼什麼是可迭代物件呢?它與迭代器又有什麼區別?

  • 迭代器是一個符合迭代器協議(iterator protocol)的物件,物件內實現了一個特定的next方法;
  • 而可迭代物件是一個符合可迭代協議(iterable protocol)的物件,物件內實現了一個Symbol.iterator方法,並且該方法返回一個迭代器物件;
  • 所以,可以說可迭代物件包含了迭代器物件,可迭代物件中實現了一個特定方法用於返回迭代器物件;

如下,iteratorObj就是一個可迭代物件:

const iteratorObj = {
  names: ['curry', 'kobe', 'klay'],
  [Symbol.iterator]: function() {
    let index = 0
    return {
      // 注意:這裡的next需要使用箭頭函式,否則this訪問不到iteratorObj
      next: () => {
        if (index < this.names.length) {
          return { done: false, value: this.names[index++] }
        } else {
          return { done: true, value: undefined }
        }
      }
    }
  }
}
// 呼叫iteratorObj中的Symbol.iterator得到一個迭代器
const iterator = iteratorObj[Symbol.iterator]()

console.log(iterator.next()) // { done: false, value: 'curry' }
console.log(iterator.next()) // { done: false, value: 'kobe' }
console.log(iterator.next()) // { done: false, value: 'klay' }
console.log(iterator.next()) // { done: true, value: undefined }
1.3.2.JS內建的可迭代物件

上面的可迭代物件都是由自己實現的,其實在JavaScript中為我們提供了很多可迭代物件,如:String、Array、Map、Set、arguments物件、NodeList(DOM集合)等。

// 1.String
const str = 'abc'

const strIterator = str[Symbol.iterator]()
console.log(strIterator.next()) // { value: 'a', done: false }
console.log(strIterator.next()) // { value: 'b', done: false }
console.log(strIterator.next()) // { value: 'c', done: false }
console.log(strIterator.next()) // { value: undefined, done: true }

// 2.Array
const names = ['curry', 'kobe', 'klay']
console.log(names[Symbol.iterator])

const namesIterator = names[Symbol.iterator]()
console.log(namesIterator.next()) // { value: 'curry', done: false }
console.log(namesIterator.next()) // { value: 'kobe', done: false }
console.log(namesIterator.next()) // { value: 'klay', done: false }
console.log(namesIterator.next()) // { value: undefined, done: true }

// 3.Map/Set
const set = new Set
set.add(10)
set.add(20)
set.add(30)

const setIterator = set[Symbol.iterator]()
console.log(setIterator.next()) // { value: 10, done: false }
console.log(setIterator.next()) // { value: 20, done: false }
console.log(setIterator.next()) // { value: 30, done: false }
console.log(setIterator.next()) // { value: undefined, done: true }
1.3.3.可迭代物件應用場景

可迭代物件在實際應用中特別常見,像一些語法的使用、建立一些物件和方法呼叫都用到了可迭代物件。

  • JS中的語法:for...of、展開語法、解構等。

    • for...of可用於遍歷一個可迭代物件,其原理就是利用迭代器的next函式,如果done為false,就從返回的物件中拿到value返回給我們,而物件不是一個可迭代物件,所以物件不能使用for...of遍歷;

      const num = [1, 2, 3]
      for (const item of num) {
        console.log(item) // 1 2 3
      }
      // 遍歷上面自己定義的可迭代物件iteratorObj也是可以的
      for (const item of iteratorObj) {
        console.log(item) // curry kobe klay
      }
      
      const obj = { name: 'curry', name: 30 }
      for (const key of obj) {
        console.log(key)
      }
      

    • 為什麼陣列能使用展開語法,其原理也是用到了迭代器,在使用...對陣列進行展開時,也是通過迭代器的next去獲取陣列的每一項值,然後存放到新陣列中;

      const names = ['james', 'green']
      // 將陣列和iteratorObj通過擴充套件進行合併
      const newNames = [...names, ...iteratorObj]
      console.log(newNames) // [ 'james', 'green', 'curry', 'kobe', 'klay' ]
      
    • 可迭代物件都是可以使用解構語法的,像陣列、字串為什麼可以使用解構,其原因就在這,原理也是通過迭代器一個個取值然後再賦值給對應變數;

      const str = 'abc'
      const nums = [1, 2, 3]
      
      const [str1, str2, str3] = str
      console.log(str1, str2, str3) // a b c
      
      const [num1, num2, num3] = nums
      console.log(num1, num2, num3) // 1 2 3
      
      const [name1, name2, name3] = iteratorObj
      console.log(name1, name2, name3) // curry kobe klay
      
    • 注意:在擴充套件語法和解構語法中,我們知道陣列可以使用,但是物件也可以使用呀,為什麼沒有提到物件呢?因為物件的擴充套件和解構是在ES9中新增的特性,其原理並不是使用迭代器實現的,只是ECMA提供給我們的一種操作物件的新語法而已;

  • JS建立物件:new Map([Iterable])、new WeakMap([Iterable])、new Set([Iterable])、new WeakSet([Iterable])等。

    // 1.Set
    const set = new Set(iteratorObj)
    console.log(set) // Set(3) { 'curry', 'kobe', 'klay' }
    
    // 2.Array.from
    const names = Array.from(iteratorObj)
    console.log(names) // [ 'curry', 'kobe', 'klay' ]
    
  • JS方法呼叫:Promise.all(Iterable)、Promise.race(Iterable)、Array.from(Iterable)等。

    // 傳入的可迭代物件中的每個值,會使用Promise.resolve進行包裹
    Promise.all(iteratorObj).then(res => {
      console.log(res) // [ 'curry', 'kobe', 'klay' ]
    })
    

擴充套件:現在我們都知道了for...of可用於遍歷一個可迭代物件,如果在遍歷過程中終端了呢?因為使用break、continue、return、throw都是可以中斷遍歷的,既然for...of遍歷的原理是基於迭代器的,那麼在for...of中進行中斷操作,一定是可以被迭代器監聽到的,上面說了,在迭代器中有一個next方法,其實還可以指定一個return方法,如果遍歷過程中斷了,就會去呼叫return方法,注意return方法也要返回和next方法一樣的物件。這種情況就稱之為迭代器的中斷

const iteratorObj = {
  names: ['curry', 'kobe', 'klay'],
  [Symbol.iterator]: function() {
    let index = 0
    return {
      next: () => {
        if (index < this.names.length) {
          return { done: false, value: this.names[index++] }
        } else {
          return { done: true, value: undefined }
        }
      },
      return() {
        console.log('哎呀,我被中斷了!')
        return { done: true, value: undefined }
      }
    }
  }
}

for (const item of iteratorObj) {
  console.log(item)
  if (item === 'kobe') break
}

1.3.4.自定義可迭代類

上面提到了物件不是一個可迭代物件,所以物件不能使用for...of遍歷,如果我們想要實現通過for...of遍歷物件呢?那麼可以自己實現一個類,這個類的例項化物件是可迭代物件。

  • 實現一個Person類,並且Person類中實現了Symbol.iterator方法用於返回一個迭代器;
  • Person類的例項化物件p中包含一個friends陣列,通過for...of遍歷p物件時,可以將friends陣列的每一項遍歷出來;
class Person {
  constructor(name, age, friends) {
    this.name = name
    this.age = age
    this.friends = friends
  }

  [Symbol.iterator]() {
    let index = 0
    return {
      next: () => {
        if (index < this.friends.length) {
          return { done: false, value: this.friends[index++] }
        } else {
          return { done: true, value: undefined }
        }
      }
    }
  }
}

簡單看一下效果:

const p = new Person('curry', 30, ['kobe', 'klay', 'green'])
for (const name of p) {
  console.log(name) // kobe klay green
}

2.生成器(Generator)

2.1.什麼是生成器?

生成器是ES6中新增的一種控制函式執行的方案,它可以幫助我們控制函式的暫停和執行。生成器是一種特殊的迭代器,所以生成器也是一個物件,並且可以呼叫next方法。那麼怎麼建立一個生成器物件呢?

建立生成器物件需要使用生成器函式,生成器函式和普通函式不一樣,主要有以下特點:

  • 生成器函式的宣告需要在function後加上一個符號*
  • 在生成器函式中可以使用yield關鍵字來分割函式體程式碼,控制函式的執行;
  • 生成器函式呼叫的返回值就是生成器物件了;

2.2.生成器的基本使用

實現一個生成器函式,該函式的執行可以通過返回的生成器物件進行控制。

function* generatorFn() {
  console.log('函式開始執行~')

  console.log('函式第一段程式碼執行...')
  yield
  console.log('函式第二段程式碼執行...')
  yield
  console.log('函式第三段程式碼執行...')
  yield
  console.log('函式第四段程式碼執行...')

  console.log('函式執行結束~')
}

// 呼叫generatorFn獲取生成器
const generator = generatorFn()

generator.next()
console.log('------------------------')
generator.next()
console.log('------------------------')
generator.next()
console.log('------------------------')
generator.next()

2.3.生成器next方法的返回值

上面說到了生成器是一種特殊的迭代器,那麼呼叫next方法肯定也是有返回值的,並且返回值是一個包含done、value屬性的物件。

function* generatorFn() {
  console.log('函式第一段程式碼執行...')
  yield
  console.log('函式第二段程式碼執行...')
  yield
  console.log('函式第三段程式碼執行...')
  yield
  console.log('函式第四段程式碼執行...')
}

// 呼叫generatorFn獲取生成器
const generator = generatorFn()

console.log(generator.next())
console.log('------------------------')
console.log(generator.next())
console.log('------------------------')
console.log(generator.next())
console.log('------------------------')
console.log(generator.next())

從列印結果可以看出來,next返回的物件中value是沒有值的,當執行到最後一段程式碼後,done的值就為true了:

如果需要指定next返回值中的value,那麼可以通過在yield後面跟上一個值或者表示式,就可以將對應的值傳遞到next返回物件value中了。

function* generatorFn() {
  console.log('函式第一段程式碼執行...')
  yield 10
  console.log('函式第二段程式碼執行...')
  yield 20
  console.log('函式第三段程式碼執行...')
  yield 30
  console.log('函式第四段程式碼執行...')
}

// 呼叫generatorFn獲取生成器
const generator = generatorFn()

console.log(generator.next())
console.log('------------------------')
console.log(generator.next())
console.log('------------------------')
console.log(generator.next())
console.log('------------------------')
console.log(generator.next())

觀察以上列印結果,在執行完第四段程式碼後,呼叫的next返回值為{ value: undefined, done: true },原因是後面已經沒有yield了,而且當函式沒有指定返回值時,最後會預設執行return undefined

2.4.生成器next方法的引數傳遞

在前面介紹迭代器定義時,提到迭代器的next可以傳遞0個或1個引數,而可以傳遞1個引數的情況就是生成器的next可以傳遞一個引數,而給每一段程式碼傳遞過去的引數可以通過yield來接收。

function* generatorFn(value) {
  console.log('函式第一段程式碼執行...', value)
  const value1 = yield 10

  console.log('函式第二段程式碼執行...', value1)
  const value2 = yield 20

  console.log('函式第三段程式碼執行...', value2)
  const value3 = yield 30

  console.log('函式第四段程式碼執行...', value3)
}

// 呼叫generatorFn獲取生成器
const generator = generatorFn('引數0')

console.log(generator.next())
console.log('------------------------')
console.log(generator.next('引數1'))
console.log('------------------------')
console.log(generator.next('引數2'))
console.log('------------------------')
console.log(generator.next('引數3'))

next引數傳遞解釋

  • next中傳遞的引數是會被上一個yield接收的,這樣可以方便下面程式碼使用這個引數,所以給next傳遞引數,需要從第二個next開始傳遞;
  • 如果第一段程式碼需要使用引數呢?可以在呼叫生成器函式時傳遞引數過去,供第一段程式碼使用;

2.5.生成器的return方法

return方法也可以給生成器函式傳遞引數,但是呼叫return後,生成器函式就會中斷,之後再呼叫next就不會再繼續生成值了。

function* generatorFn() {
  console.log('函式第一段程式碼執行...')
  yield

  console.log('函式第二段程式碼執行...')
  yield

  console.log('函式第三段程式碼執行...')
  yield

  console.log('函式第四段程式碼執行...')
}

// 呼叫generatorFn獲取生成器
const generator = generatorFn()

console.log(generator.next())
console.log('------------------------')
console.log(generator.next())
console.log('------------------------')
console.log(generator.return(123))
console.log('------------------------')
console.log(generator.next())
console.log(generator.next())
console.log(generator.next())
console.log(generator.next())
console.log(generator.next())

上面的執行return方法,相當於函式內部執行了return:

function* generatorFn() {
  console.log('函式第一段程式碼執行...')
  yield

  console.log('函式第二段程式碼執行...')
  const value = yield
  return value

  console.log('函式第三段程式碼執行...')
  yield

  console.log('函式第四段程式碼執行...')
}

// 呼叫generatorFn獲取生成器
const generator = generatorFn()

console.log(generator.next())
console.log('------------------------')
console.log(generator.next())
console.log('------------------------')
console.log(generator.next(123))
console.log('------------------------')
console.log(generator.next())
console.log(generator.next())
console.log(generator.next())
console.log(generator.next())
console.log(generator.next())

2.6.生成器的throw方法

throw方法可以給生成器函式內部丟擲異常。

  • 生成器呼叫throw方法丟擲異常後,可以在生成器函式中進行捕獲;
  • 通過try catch捕獲異常後,後續的程式碼還是可以正常執行的;
function* generatorFn() {
  console.log('函式第一段程式碼執行...')
  yield 10

  console.log('函式第二段程式碼執行...')
  try {
    yield 20
  } catch (err) {
    console.log(err)
  }

  console.log('函式第三段程式碼執行...')
  yield 30

  console.log('函式第四段程式碼執行...')
}

// 呼叫generatorFn獲取生成器
const generator = generatorFn()

console.log(generator.next())
console.log('------------------------')
console.log(generator.next())
console.log('------------------------')
console.log(generator.throw('err message'))
console.log('------------------------')
console.log(generator.next())

2.7.生成器替換迭代器

在前面實現了一個生成迭代器的函式,實現過程還需要進行判斷,並自己返回對應的物件,下面就用生成器來實現一個生成迭代器的函式。

  • 方式一:根據陣列元素的個數,執行yield;

    function* createIterator(arr) {
      let index = 0
      yield arr[index++]
      yield arr[index++]
      yield arr[index++]
    }
    
  • 方式二:遍歷陣列,執行yield;

    function* createIterator(arr) {
      for (const item of arr) {
        yield item
      }
    }
    
  • 方式三:執行yield*,後面可以跟上一個可迭代物件,它會依次迭代其中每一個值;

    function* createIterator(arr) {
      yield* arr
    }
    

測試一下以上三種方法,執行結果都是一樣的:

const names = ['curry', 'kobe', 'klay']
const iterator = createIterator(names)

console.log(iterator.next()) // { value: 'curry', done: false }
console.log(iterator.next()) // { value: 'kobe', done: false }
console.log(iterator.next()) // { value: 'klay', done: false }
console.log(iterator.next()) // { value: undefined, done: true }

3.非同步請求的處理方案

在進行非同步請求時,如果出現了這樣一個需求,下一次的請求需要拿到上一次請求的結果。如下是使用Promise封裝的一個request方法。

function request(url) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(url)
    }, 300)
  })
}

實現上面的需求可以怎麼做呢?

3.1.方案一:使用Promise的then進行巢狀呼叫

  • 在then中拿到結果後,再去做下一次請求,以此類推;
  • 缺點:形成了回撥地獄;
request('/aaa').then(res => {
  request(res + '/bbb').then(res => {
    request(res + '/ccc').then(res => {
      console.log(res) // /aaa/bbb/ccc
    })
  })
})

3.2.方案二:使用Promise的then的返回值

  • 雖然可以解決回撥地獄問題,但是閱讀性不佳;
request('/aaa').then(res => {
  return request(res + '/bbb')
}).then(res => {
  return request(res + '/ccc')
}).then(res => {
  console.log(res) // /aaa/bbb/ccc
})

3.3.方案三:使用Promise和Generator處理

function* getRequestData() {
  const res1 = yield request('/aaa')
  const res2 = yield request(res1 + '/bbb')
  const res3 = yield request(res2 + '/ccc')
  console.log(res3)
}
  • 手動執行生成器的next方法;

    const generator = getRequestData()
    generator.next().value.then(res => {
      generator.next(res).value.then(res => {
        generator.next(res).value.then(res => {
          generator.next(res) // /aaa/bbb/ccc
        })
      })
    })
    

  • 自動執行生成器的next方法:如果手動執行巢狀層級過多的話是不方便的,那麼可以藉助遞迴的思想實現一個自動執行生成器的函式;

    function autoGenerator(generatorFn) {
      const generator = generatorFn()
    
      function recursion(res) {
        const result = generator.next(res)
        // 如果done值為true,說明結束了
        if (result.done) return result.value
        // 沒有結束,繼續呼叫Promise的then
        result.value.then(res => {
          recursion(res)
        })
      }
    
      recursion()
    }
    
    autoGenerator(getRequestData) // /aaa/bbb/ccc
    
  • 使用第三方庫來執行生成器:像自動執行生成器函式,早就已經有第三方庫幫助我們實現了,如co

    const co = require('co')
    co(getRequestData) // /aaa/bbb/ccc
    

3.4.方案四:使用async和await

async和await是我們解決非同步回撥的最終解決方案,它可以讓我們非同步的程式碼,看上去是同步執行的。

async function getRequestData() {
  const res1 = await request('/aaa')
  const res2 = await request(res1 + '/bbb')
  const res3 = await request(res2 + '/ccc')
  console.log(res3)
}

getRequestData() // /aaa/bbb/ccc

4.async和await的原理

相信從上面講述的四個非同步請求的處理方案中,就可以看出來async、await和生成器的關係了。

  • 將生成器函式的*換成async
  • 將生成器函式中的yield換成await
  • 兩種方案所體現出的效果和程式碼書寫形式幾乎差不多;

總結

  • async和await的原理其實就是Promise+生成器實現的;
  • 為什麼async、await能夠讓非同步程式碼看上去是同步執行的,其原因就在於生成器的next方法可以對函式內程式碼的執行進行控制,當上一次請求拿到結果後,再去執行下一次next;
  • 所以為什麼說async和await只是Promise+生成器的語法糖,其原理就在這;

相關文章