JavaScript 使用单线程的执行模型,若使用同步逻辑执行向服务器请求数据之类的耗时操作,会使页面在请求结束之前都没有响应。

解决这一问题可以用回调函数。回调函数会在请求结束后被调用,因此不会阻塞 UI。但当回调逻辑需要嵌套多层时,回调函数的写法需要很多错误处理的模板代码,同时由于嵌套过深,得到一坨称为 callback hell 的丑陋代码。

利用 generators 和 promises 可以写出优雅的异步代码。

Generators

普通函数从开始执行到结束,只有一个返回值。

Generator 函数可以接收一系列请求,并为每个请求生成相应的值。

  • 我们需要显式地向 Generator 函数发送请求索要值,Generator 函数会生成一个对应的值作为响应或告知已经没有值可以生成了。
  • Generator 函数生成一个值后,并没有结束执行,而是进入暂停的状态,当它再次收到对值的请求时,又能从暂停处恢复执行

定义一个 Generator 函数只需要 function 关键字后跟上星号 *,这样函数体内就可以使用关键字 yield 来生成单个的值。

  • 可以用 for-of(语法糖)循环消费 Generator 函数生成的值。
function* DigitGenerator() {
    yield 1
    yield 2
}
for (let digit of DigitGenerator()) {
    console.log(digit) // 1 2
}

const digitIterator = DigitGenerator()
let digit
while(!(digit = digitIterator.next()).done) {
    console.log(digit.value) // 1 2
}

用迭代器控制 Generator 函数

调用一个生成器函数并不会执行它,而是会创建一个称为迭代器(iterator)的对象,通过该迭代器可以与 Generator 函数通信,控制 Generator 函数的执行。

  • 调用迭代器的 next 方法后,Generator 函数内的代码会被执行,直到遇上一个 yield 关键字,然后暂停执行,这个过程不会造成阻塞。
  • Generator 函数等待下一个请求将它唤醒,唤醒后从暂停处继续执行。
  • 调用 next 的结果是得到一个对象value 字段是生成的值,done 字段指示 Generator 函数是否还有更多的值可以生成。
function* DigitGenerator() {
    yield 1
    yield 2
}
const digitIterator = DigitGenerator()
const result1 = digitIterator.next() // {value: 1, done: false}
const result2 = digitIterator.next() // {value: 2, done: false}
const result3 = digitIterator.next() // {value: undefined, done: true}

yield* 操作符用在迭代器上时,可以将一个 Generator 函数的执行委托(delegate)给另一个 Generator 函数。

function* DigitGenerator() {
    yield 1
    yield* LetterGenerator()
    yield 2
}
function* LetterGenerator() {
    yield "a"
    yield "b"
}
for (let a of DigitGenerator()) {
    console.log(a) // 1 a b 2
}

Generator 使用示例

利用 Generator 函数为对象生成唯一 ID:

function* IdGenerator(){ // 避免了全局变量的使用
    let id = 0
    while(true) {
        yield ++id
    }
}
const idIterator = IdGenerator()
const o1 = { id: idIterator.next().value }
const o2 = { id: idIterator.next().value }

利用 Generator 函数遍历 DOM:

function* DomTraversal(ele, tags) {
    tags.push(ele.nodeName)
    yield ele
    ele = ele.firstElementChild
    while (ele) {
        yield* DomTraversal(ele, tags) // *
        ele = ele.nextElementSibling
    }
    tags.pop()
}
const dom = document.querySelector("html")
let tags = []
for (let ele of DomTraversal(dom, tags)) {
    // console.log(tags)
    console.log(ele.nodeName)
}

// 递归写法
let levels = 0
function traversalDOM(ele, tags, cb) {
    tags.push(cb(ele))
    // console.log(tags)
    ele = ele.firstElementChild
    while (ele) {
        traversalDOM(ele, tags, cb)
        ele = ele.nextElementSibling
    }
    tags.pop()
}
const dom = document.querySelector("html")
let tags = []
traversalDOM(dom, tags, function(ele) {
    console.log(ele.nodeName)
    return ele.nodeName
})

与运行中 Generator 函数交换数据

我们可以向 Generator 函数发送数据,实现双向通信。

  1. 最简单的向 Generator 函数发送数据的方式是像调用普通函数一样用参数传递;通过 Generator 函数传入的参数和正常函数传参没有区别。

  2. 通过迭代器的 next 方法传入的参数会成为上一个 yield 表达式的值,从而实现向运行中的 Generator 函数传递参数的功能。

    • 第一次调用 next 方法时,由于不存在上一个 yield 表达式,此时无法通过 next 传递值。
    function* NameGenerator(data) {
        const n = yield `${data}` // "Your name is" 成为 yield `${data}` 表达式的值
        yield `${n} ${data}`
    }
    const nameGenerator = NameGenerator("John")
    
    const result1 = nameGenerator.next('abc')           // result1: {value: 'John', done: false} // 第一次调用 next,'abc' 被丢弃
    const result2 = nameGenerator.next("Your name is")  // result2: {value: 'Your name is John', done: false}
    
  3. 还可以利用迭代器的 throw 方法抛出一个错误的方式向 Generator 函数传递值。

    function* NameGenerator() {
        try {
            yield "ho"
            console.log("The expected exception didn't occur") // shouldn't be reached
        } catch (e) {
            console.log(e)
        }
    }
    const nameGenerator = NameGenerator()
    const result1 = nameGenerator.next() // ho
    nameGenerator.throw("throw you an error")
    

Generator 原理

Generator 函数的机制类似状态机

  1. 暂停作为开始(suspended start);
    • 通过函数调用语句开始生命周期,返回一个迭代器,此时内部代码尚未被执行,执行代码的位置从 Generator 函数回到它的调用者。
    • 对于普通函数,程序的执行回到它的调用者之后,函数的执行上下文就会出栈并被丢弃,但由于存在迭代器对 Generator 函数的引用,Generator 函数的执行上下文尽管已出栈,但仍能被访问到,并未被丢弃;这种机制和闭包类似,正是它决定了 Generator 函数的特性。
  2. 执行(executing);
    • 从初始处或上一次暂停处开始执行。
    • 调用 next 方法时,不会创建一个新的执行上下文并入栈,而是激活迭代器引用的 Generator 函数的执行上下文并将其重新入栈,并从初始处或上一次暂停处恢复执行,从而进入执行状态。
  3. 遇上 yield 后暂停(suspended yield);
    • Generator 函数的执行上下文再次出栈,但迭代器仍保持对它的引用,执行代码的位置从 Generator 函数回到迭代器。
  4. 完成(completed)。
    • 代码执行完毕或遇上 return 语句即进入此状态。
function* DigitGenerator() {
    yield 1
    return 2
}
const digitIterator = DigitGenerator()
const result1 = digitIterator.next() //   {value: 1, done: false}
const result2 = digitIterator.next() // * {value: 2, done: true}
const result3 = digitIterator.next() //   {value: undefined, done: true}

Promises

Promise 是一种内置对象,用作当下还没有但将来某个时间会有的值的占位符

  • Promise 构造器可以创建一个 promise,构造器的参数是一个执行函数(executor function)。

  • 执行函数有两个参数 resolvereject,它们是内置函数

    • 创建 promise 对象时会立刻调用执行函数。
    • 若希望 promise 解析成功,就手动调用 resolve,否则就调用 reject
  • Promise 对象的 then 方法接受一个成功回调和一个失败回调作为参数。

    • 成功回调在 promise 解析成功(resolve 函数被调用)时被调用。
    • 失败回调在 promise 解析失败(reject 被调用或出现未被处理的抛错)时被调用。
    • then 方法返回的是一个新的 promise
    const aPromise = new Promise((resolve, reject) => {
        resolve("resolved")
    })
    aPromise.then(res => {
        console.log(res) // resolved
    }, err => {
        console.log("shouldn't be an error")
    })
    
    Promise.resolve().then(() => { console.log("resolved immediately") }) // resolved immediately
    

回调函数的问题

错误处理困难是回调函数的第一个问题:由于调用回调函数的代码与注册回调函数用来处理耗时逻辑的代码通常不在事件队列的同一(step)中,导致语言内置的结构(如 try-catch 语句)不能用于回调函数。

// inapplicable
try {
    getJSON("data.json", () => { /* handle results*/ })
} catch (e) { /* handle errors */ }

不能使用 try-catch 的结果是错误会丢失,为此许多库会定义报告错误的规范。

  • Node.js 中,回调函数通常接受两个参数,errdata,若过程中有错误发生,err 的值会是一个非 null 值。

第二个问题是一系列互相依赖的异步回调逻辑导致代码嵌套过深、难以理解,要插入新的步骤十分困难,再加上错误处理的逻辑,使代码更加复杂。

getJSON("data.json", (err, data) => {
    getJSON(data[0].url, (err, detail) => {
        getJSON(detail[0], (err, info) => {
            /* logic */
        })
    }) // pyramid of doom
})

对于需要并行执行多个互不相关的耗时操作并汇总所有执行结果的情况,我们无法预知结果的返回顺序,只能每次都去判断所要的数据是否齐全,导致很多模板代码的产生,这是回调函数的第三个问题。

promise 提供了一种新的解决方法。

promise 概览

一个 promise 的生命周期会经历多个状态:

  1. pending (unresolved)
    • promise 的初始状态,此时我们没有任何关于 promised 值的信息。
    • 若程序执行过程中 promise 的 resolve 方法被调用,promise 的状态转为 fulfilled,此时我们可以成功获取 promised 值。
    • 若出现抛错,promise 转为 rejected 状态,这种情况不能获取到 promised 值。
  2. fulfilledrejected
    • promise 的状态变成 fulfilled 或 rejected 之后,状态就不能切换了(fulfilled 和 rejected 之间不能互相切换),并将保持在那个状态,此时称 promise 已完成解析(resolved)。
const delayedPromise = new Promise((resolve, reject) => {
    console.log("delayedPromise executor")      // 1st printed
    setTimeout(() => {
        console.log("resolving delayedPromise") // 6th
        resolve("500ms delayed")                // 7th
    }, 500)
})
console.log("delayedPromise created")           // 2nd
delayedPromise.then(msg => {
    console.log(msg)                            // 7th
})
const immediatePromise = new Promise((resolve, reject) => {
    console.log("immediatePromise executor")    // 3rd
    resolve(`"immediate"`)                      // 5th
})
immediatePromise.then(msg => {
    console.log(msg)                            // 5th
})
console.log("end")                              // 4th

显式调用执行函数中的 reject 会使 promise 失败;在处理 promise 过程中出现未处理的抛错也会使 promise 失败。

catch 方法也可以用来处理 promise 失败的情况。

const promise = new Promise((resolve, reject) => {
    reject("Explicitly reject a promise")
})
promise.then(
    () => console.log("shouldn't be called"),
    err => console.log(err) // Explicitly reject a promise
)

// implicitly rejection case
const promise2 = new Promise((resolve, reject) => {
    undeclaredVariable++
})
promise2.then(() => console.log("shouldn't be called"))
       .catch((err) => console.log(err)) 
// ReferenceError: undeclaredVariable is not defined
//     at <anonymous>:2:5
//     at new Promise (<anonymous>)
//     at <anonymous>:1:18

promise 使用示例

function getJSON(url) { // *
    return new Promise((resolve, reject) => {
        const request = new XMLHttpRequest()
        request.open("GET", url)
        // * 注册事件处理器供浏览器调用
        request.onload = function() {
            try { // catch json parse error
                if (this.status === 200) {
                    resolve(JSON.parse(this.response))
                } else {
                    reject(`${this.status} ${this.statusText}`)
                }
            } catch (e) {
                reject(e.message)
            }
        }
        request.onerror = function() {
            reject(`${this.status} ${this.statusText}`)
        }
        request.send()
    })
}

const url = "https://5af71a21c222a90014dbda4f.mockapi.io/api/v1/records"
getJSON(url).then(data => console.log(data))
            .catch(e => console.log(e))

链式 promise

then 方法返回返回一个新的 promise,因此可以串联任意多个 then 方法。

getJSON("data.json")
    .then(data => getJSON(data[0].url))
    .then(detail => getJSON(detail[0]))
    .then(msg => console.log(msg))
    .catch(err => console.log(err)) // Catches promise rejections in any of the steps

当只关心一系列操作的最终结果时,可以只用一个 catch 捕捉过程中的任意一个错误,而不用在每个 then 中提供错误回调。

并行 promise 处理

Promise.all 接受一个 promise 的数组并返回一个新的 promise。

  • 新的 promise 在所有传入的 promise 都解析成功后才被认定为成功,否则就是失败。

  • 所有 promise 的值也是按照传入的顺序以数组的形式返回。

    Promise.all([getJSON("data1.json"),
                getJSON("data2.json"),
                getJSON("data3.json")]).then(results => {
                    const res1 = results[0], res2 = results[1], res3 = results[2]
                }).catch(err => {
                    console.log(err)
                })
    

Promise.race 在传入的一组 promise 中有一个完成解析,它返回的那个新的 promise 就完成解析。

Promise.all([getJSON("dataA.json"),
             getJSON("dataB.json"),
             getJSON("dataC.json")]).then(result => {
                 console.log(result)
             }).catch(err => {
                 console.log(err)
             })

结合 Generator 和 Promise

同步写法的优点是简洁,可以利用语言内置结构如 try-catch(错误处理更清晰)和 for 循环。

try {
    const instruction1 = syncGetJSON("step1.json")
    const instruction2 = syncGetJSON("step2.json")
    const instruction3 = syncGetJSON("step3.json")
} catch (e) {
    console.log(e)
}

结合 Generator 和 Promise 实现同步写法的思路:

  1. 异步代码放在 Generator 函数里;

  2. 执行到异步逻辑时,创建一个 promise 来表示异步操作的结果,并将结果 yield,避免阻塞;

  3. 当 promise 完成解析后,再调用 next 方法恢复 Generator 函数的执行。

    function handle(iteratorResult) {
        if (iteratorResult.done) { return }
        const iteratorValue = iteratorResult.value
        if (iteratorValue instanceof Promise) {
            // step1.json 请求结束后,控制流就离开 async 的调用,不会造成阻塞。
            // * step1.json 返回的 promise resolve 的结果赋给 instruction1,并接着执行 yield getJSON(instruction1.url),实现恢复迭代器执行的效果,直至执行完毕。
            iteratorValue.then(res => handle(iterator.next(res)))   
                            .catch(err => iterator.throw(err)) // * 抛出异步请求报出的错误
        }
    }
    function async(generator) {
        const iterator = generator()
        try {
            handle(iterator.next())
        } catch (e) {
            iterator.throw(e)
        }
    }
    // 发起调用
    async(function* () {
        try {
            // getJSON 返回 Promise
            const instruction1 = yield getJSON("step1.json")
            const instruction2 = yield getJSON(instruction1.url)
            const result = yield getJSON(instruction2.url)
        } catch (e) {
            console.log(e)
        }
    })
    

async, await

asyncawait 进一步简化了结合 Generator 和 Promise 所需的模板代码。

  • async 放在 function 关键字前面,表示函数依赖异步的值。

  • await 放在异步操作的调用前面,JavaScript 引擎会无阻塞地等待异步操作的结果。

    (async function (){
        try {
            const instruction1 = await getJSON("step1.json")
            const instruction2 = await getJSON(instruction1.url)
            const result = await getJSON(instruction2.url)
            console.log(result)
        }
        catch (e) {
            console.log("Error: ", e)
        }
    })()
    
    (async function sleep(ms) {
        console.log("start sleeping")           // 1st (switch control without blocking)
        await new Promise(resolve => setTimeout(resolve, ms))
        console.log(`slept for ${ms/1000}s`)    // 3rd
    })(2000)
    console.log("end")                          // 2nd
    
    function* sleep(ms) {
        console.log("start sleeping")                                           // 1st
        yield new Promise(resolve => setTimeout(resolve, ms))
    }
    (function(ms) {
        var iterator = sleep(ms)
        iterator.next().value.then(() => console.log(`slept for ${ms/1000}s`))  // 3rd
        console.log("end")                                                      // 2nd
    })(2000)
    

References