• Callback Promise Generator Async-Await 和异常处理的演进


    根据笔者的项目经验,本文讲解了从函数回调,到 es7 规范的异常处理方式。异常处理的优雅性随着规范的进步越来越高,不要害怕使用 try catch,不能回避异常处理。

    我们需要一个健全的架构捕获所有同步、异步的异常。业务方不处理异常时,中断函数执行并启用默认处理,业务方也可以随时捕获异常自己处理。

    优雅的异常处理方式就像冒泡事件,任何元素可以自由拦截,也可以放任不管交给顶层处理。

    文字讲解仅是背景知识介绍,不包含对代码块的完整解读,不要忽略代码块的阅读。

    1. 回调

    如果在回调函数中直接处理了异常,是最不明智的选择,因为业务方完全失去了对异常的控制能力。

    下方的函数 请求处理 不但永远不会执行,还无法在异常时做额外的处理,也无法阻止异常产生时笨拙的 console.log('请求失败') 行为。

    function fetch(callback) {
        setTimeout(() => {
            console.log('请求失败')
        })
    }
    
    fetch(() => {
        console.log('请求处理') // 永远不会执行
    })
    

    2. 回调,无法捕获的异常

    回调函数有同步和异步之分,区别在于对方执行回调函数的时机,异常一般出现在请求、数据库连接等操作中,这些操作大多是异步的。

    异步回调中,回调函数的执行栈与原函数分离开,导致外部无法抓住异常。

    从下文开始,我们约定用 setTimeout 模拟异步操作

    function fetch(callback) {
        setTimeout(() => {
            throw Error('请求失败')
        })
    }
    
    try {
        fetch(() => {
            console.log('请求处理') // 永远不会执行
        })
    } catch (error) {
        console.log('触发异常', error) // 永远不会执行
    }
    
    // 程序崩溃
    // Uncaught Error: 请求失败
    

    3. 回调,不可控的异常

    我们变得谨慎,不敢再随意抛出异常,这已经违背了异常处理的基本原则。

    虽然使用了 error-first 约定,使异常看起来变得可处理,但业务方依然没有对异常的控制权,是否调用错误处理取决于回调函数是否执行,我们无法知道调用的函数是否可靠。

    更糟糕的问题是,业务方必须处理异常,否则程序挂掉就会什么都不做,这对大部分不用特殊处理异常的场景造成了很大的精神负担。

    function fetch(handleError, callback) {
        setTimeout(() => {
            handleError('请求失败')
        })
    }
    
    fetch(() => {
    	console.log('失败处理') // 失败处理
    }, error => {
    	console.log('请求处理') // 永远不会执行
    })
    

    番外 Promise 基础

    Promise 是一个承诺,只可能是成功、失败、无响应三种情况之一,一旦决策,无法修改结果。

    Promise 不属于流程控制,但流程控制可以用多个 Promise 组合实现,因此它的职责很单一,就是对一个决议的承诺。

    resolve 表明通过的决议,reject 表明拒绝的决议,如果决议通过,then 函数的第一个回调会立即插入 microtask 队列,异步立即执行

    简单补充下事件循环的知识,js 事件循环分为 macrotask 和 microtask。
    microtask 会被插入到每一个 macrotask 的尾部,所以 microtask 总会优先执行,哪怕 macrotask 因为 js 进程繁忙被 hung 住。
    比如 setTimeout setInterval 会插入到 macrotask 中。

    const promiseA = new Promise((resolve, reject) => {
        resolve('ok')
    })
    promiseA.then(result => {
        console.log(result) // ok
    })
    

    如果决议结果是决绝,那么 then 函数的第二个回调会立即插入 microtask 队列。

    const promiseB = new Promise((resolve, reject) => {
        reject('no')
    })
    promiseB.then(result => {
        console.log(result) // 永远不会执行
    }, error => {
        console.log(error) // no
    })
    

    如果一直不决议,此 promise 将处于 pending 状态。

    const promiseC = new Promise((resolve, reject) => {
    	// nothing
    })
    promiseC.then(result => {
        console.log(result) // 永远不会执行
    }, error => {
        console.log(error) // 永远不会执行
    })
    

    未捕获的 reject 会传到末尾,通过 catch 接住

    const promiseD = new Promise((resolve, reject) => {
        reject('no')
    })
    promiseD.then(result => {
        console.log(result) // 永远不会执行
    }).catch(error => {
        console.log(error) // no
    })
    

    resolve 决议会被自动展开(reject 不会)

    const promiseE = new Promise((resolve, reject) => {
        return new Promise((resolve, reject) => {
            resolve('ok')
        })
    })
    promiseE.then(result => {
        console.log(result) // ok
    })
    

    链式流,then 会返回一个新的 Promise,其状态取决于 then 的返回值。

    const promiseF = new Promise((resolve, reject) => {
        resolve('ok')
    })
    promiseF.then(result => {
        return Promise.reject('error1')
    }).then(result => {
        console.log(result) // 永远不会执行
        return Promise.resolve('ok1') // 永远不会执行
    }).then(result => {
        console.log(result) // 永远不会执行
    }).catch(error => {
        console.log(error) // error1
    })
    

    4 Promise 异常处理

    不仅是 reject,抛出的异常也会被作为拒绝状态被 Promise 捕获。

    function fetch(callback) {
        return new Promise((resolve, reject) => {
            throw Error('用户不存在')
        })
    }
    
    fetch().then(result => {
        console.log('请求处理', result) // 永远不会执行
    }).catch(error => {
        console.log('请求处理异常', error) // 请求处理异常 用户不存在
    })
    

    5 Promise 无法捕获的异常

    但是,永远不要在 macrotask 队列中抛出异常,因为 macrotask 队列脱离了运行上下文环境,异常无法被当前作用域捕获。

    function fetch(callback) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                 throw Error('用户不存在')
            })
        })
    }
    
    fetch().then(result => {
        console.log('请求处理', result) // 永远不会执行
    }).catch(error => {
        console.log('请求处理异常', error) // 永远不会执行
    })
    
    // 程序崩溃
    // Uncaught Error: 用户不存在
    

    不过 microtask 中抛出的异常可以被捕获,说明 microtask 队列并没有离开当前作用域,我们通过以下例子来证明:

    Promise.resolve(true).then((resolve, reject)=> {
    	throw Error('microtask 中的异常')
    }).catch(error => {
    	console.log('捕获异常', error) // 捕获异常 Error: microtask 中的异常
    })
    

    至此,Promise 的异常处理有了比较清晰的答案,只要注意在 macrotask 级别回调中使用 reject,就没有抓不住的异常。

    6 Promise 异常追问

    如果第三方函数在 macrotask 回调中以 throw Error 的方式抛出异常怎么办?

    function thirdFunction() {
        setTimeout(() => {
            throw Error('就是任性')
        })
    }
    
    Promise.resolve(true).then((resolve, reject) => {
        thirdFunction()
    }).catch(error => {
        console.log('捕获异常', error)
    })
    
    // 程序崩溃
    // Uncaught Error: 就是任性
    

    值得欣慰的是,由于不在同一个调用栈,虽然这个异常无法被捕获,但也不会影响当前调用栈的执行。

    我们必须正视这个问题,唯一的解决办法,是第三方函数不要做这种傻事,一定要在 macrotask 抛出异常的话,请改为 reject 的方式。

    function thirdFunction() {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                reject('收敛一些')
            })
        })
    }
    
    Promise.resolve(true).then((resolve, reject) => {
        return thirdFunction()
    }).catch(error => {
        console.log('捕获异常', error) // 捕获异常 收敛一些
    })
    

    请注意,如果 return thirdFunction() 这行缺少了 return 的话,依然无法抓住这个错误,这是因为没有将对方返回的 Promise 传递下去,错误也不会继续传递。

    我们发现,这样还不是完美的办法,不但容易忘记 return,而且当同时含有多个第三方函数时,处理方式不太优雅:

    function thirdFunction() {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                reject('收敛一些')
            })
        })
    }
    
    Promise.resolve(true).then((resolve, reject) => {
        return thirdFunction().then(() => {
            return thirdFunction()
        }).then(() => {
    		return thirdFunction()
        }).then(() => {
        })
    }).catch(error => {
        console.log('捕获异常', error)
    })
    

    是的,我们还有更好的处理方式。

    番外 Generator 基础

    generator 是更为优雅的流程控制方式,可以让函数可中断执行:

    function* generatorA() {
        console.log('a')
        yield
        console.log('b')
    }
    const genA = generatorA()
    genA.next() // a
    genA.next() // b
    

    yield 关键字后面可以包含表达式,表达式会传给 next().value

    next() 可以传递参数,参数作为 yield 的返回值。

    这些特性足以孕育出伟大的生成器,我们稍后介绍。下面是这个特性的例子:

    function* generatorB(count) {
        console.log(count)
        const result = yield 5
        console.log(result * count)
    }
    const genB = generatorB(2)
    genB.next() // 2
    const genBValue = genB.next(7).value // 14
    // genBValue undefined
    

    第一个 next 是没有参数的,因为在执行 generator 函数时,初始值已经传入,第一个 next 的参数没有任何意义,传入也会被丢弃。

    const result = yield 5
    

    这一句,返回值不是想当然的 5。其的作用是将 5 传递给 genB.next(),其值,由下一个 next genB.next(7) 传给了它,所以语句等于 const result = 7

    最后一个 genBValue,是最后一个 next 的返回值,这个值,就是函数的 return,显然为 undefined

    我们回到这个语句:

    const result = yield 5
    

    如果返回值是 5,是不是就清晰了许多?是的,这种语法就是 await。所以 Async Awaitgenerator 有着莫大的关联,桥梁就是 生成器,我们稍后介绍 生成器

    番外 Async Await

    如果认为 Generator 不太好理解,那 Async Await 绝对是救命稻草,我们看看它们的特征:

    const timeOut = (time = 0) => new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(time + 200)
        }, time)
    })
    
    async function main() {
        const result1 = await timeOut(200)
        console.log(result1) // 400
        const result2 = await timeOut(result1)
        console.log(result2) // 600
        const result3 = await timeOut(result2)
        console.log(result3) // 800
    }
    
    main()
    

    所见即所得,await 后面的表达式被执行,表达式的返回值被返回给了 await 执行处。

    但是程序是怎么暂停的呢?只有 generator 可以暂停程序。那么等等,回顾一下 generator 的特性,我们发现它也可以达到这种效果。

    番外 async await 是 generator 的语法糖

    终于可以介绍 生成器 了!它可以魔法般将下面的 generator 执行成为 await 的效果。

    function* main() {
        const result1 = yield timeOut(200)
        console.log(result1)
        const result2 = yield timeOut(result1)
        console.log(result2)
        const result3 = yield timeOut(result2)
        console.log(result3)
    }
    

    下面的代码就是生成器了,生成器并不神秘,它只有一个目的,就是:

    所见即所得,yield 后面的表达式被执行,表达式的返回值被返回给了 yield 执行处。

    达到这个目标不难,达到了就完成了 await 的功能,就是这么神奇。

    function step(generator) {
        const gen = generator()
        // 由于其传值,返回步骤交错的特性,记录上一次 yield 传过来的值,在下一个 next 返回过去
        let lastValue
        // 包裹为 Promise,并执行表达式
        return () => Promise.resolve(gen.next(lastValue).value).then(value => {
            lastValue = value
            return lastValue
        })
    }
    

    利用生成器,模拟出 await 的执行效果:

    const run = step(main)
    
    function recursive(promise) {
        promise().then(result => {
            if (result) {
                recursive(promise)
            }
        })
    }
    
    recursive(run)
    // 400
    // 600
    // 800
    

    可以看出,await 的执行次数由程序自动控制,而回退到 generator 模拟,需要根据条件判断是否已经将函数执行完毕。

    7 Async Await 异常

    不论是同步、异步的异常,await 都不会自动捕获,但好处是可以自动中断函数,我们大可放心编写业务逻辑,而不用担心异步异常后会被执行引发雪崩:

    function fetch(callback) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                reject()
            })
        })
    }
    
    async function main() {
        const result = await fetch()
        console.log('请求处理', result) // 永远不会执行
    }
    
    main()
    

    8 Async Await 捕获异常

    我们使用 try catch 捕获异常。

    认真阅读 Generator 番外篇的话,就会理解为什么此时异步的异常可以通过 try catch 来捕获。

    因为此时的异步其实在一个作用域中,通过 generator 控制执行顺序,所以可以将异步看做同步的代码去编写,包括使用 try catch 捕获异常。

    function fetch(callback) {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                reject('no')
            })
        })
    }
    
    async function main() {
        try {
            const result = await fetch()
            console.log('请求处理', result) // 永远不会执行
        } catch (error) {
            console.log('异常', error) // 异常 no
        }
    }
    
    main()
    

    9 Async Await 无法捕获的异常

    和第五章 Promise 无法捕获的异常 一样,这也是 await 的软肋,不过任然可以通过第六章的方案解决:

    function thirdFunction() {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                reject('收敛一些')
            })
        })
    }
    
    async function main() {
        try {
            const result = await thirdFunction()
            console.log('请求处理', result) // 永远不会执行
        } catch (error) {
            console.log('异常', error) // 异常 收敛一些
        }
    }
    
    main()
    

    现在解答第六章尾部的问题,为什么 await 是更加优雅的方案:

    async function main() {
        try {
            const result1 = await secondFunction() // 如果不抛出异常,后续继续执行
            const result2 = await thirdFunction() // 抛出异常
            const result3 = await thirdFunction() // 永远不会执行
            console.log('请求处理', result) // 永远不会执行
        } catch (error) {
            console.log('异常', error) // 异常 收敛一些
        }
    }
    
    main()
    

    10 业务场景

    在如今 action 概念成为标配的时代,我们大可以将所有异常处理收敛到 action 中。

    我们以如下业务代码为例,默认不捕获错误的话,错误会一直冒泡到顶层,最后抛出异常。

    const successRequest = () => Promise.resolve('a')
    const failRequest = () => Promise.reject('b')
    
    class Action {
        async successReuqest() {
            const result = await successRequest()
            console.log('successReuqest', '处理返回值', result) // successReuqest 处理返回值 a
        }
    
        async failReuqest() {
            const result = await failRequest()
            console.log('failReuqest', '处理返回值', result) // 永远不会执行
        }
    
        async allReuqest() {
            const result1 = await successRequest()
            console.log('allReuqest', '处理返回值 success', result1) // allReuqest 处理返回值 success a
            const result2 = await failRequest()
            console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
        }
    }
    
    const action = new Action()
    action.successReuqest()
    action.failReuqest()
    action.allReuqest()
    
    // 程序崩溃
    // Uncaught (in promise) b
    // Uncaught (in promise) b
    

    为了防止程序崩溃,需要业务线在所有 async 函数中包裹 try catch

    我们需要一种机制捕获 action 最顶层的错误进行统一处理。

    为了补充前置知识,我们再次进入番外话题。

    番外 Decorator

    Decorator 中文名是装饰器,核心功能是可以通过外部包装的方式,直接修改类的内部属性。

    装饰器按照装饰的位置,分为 class decorator method decorator 以及 property decorator(目前标准尚未支持,通过 get set 模拟实现)。

    Class Decorator

    类级别装饰器,修饰整个类,可以读取、修改类中任何属性和方法。

    const classDecorator = (target: any) => {
        const keys = Object.getOwnPropertyNames(target.prototype)
        console.log('classA keys,', keys) // classA keys ["constructor", "sayName"]
    }
    
    @classDecorator
    class A {
        sayName() {
            console.log('classA ascoders')
        }
    }
    const a = new A()
    a.sayName() // classA ascoders
    

    Method Decorator

    方法级别装饰器,修饰某个方法,和类装饰器功能相同,但是能额外获取当前修饰的方法名。

    为了发挥这一特点,我们篡改一下修饰的函数。

    const methodDecorator = (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
        return {
            get() {
                return () => {
                    console.log('classC method override')
                }
            }
        }
    }
    
    class C {
        @methodDecorator
        sayName() {
            console.log('classC ascoders')
        }
    }
    const c = new C()
    c.sayName() // classC method override
    

    Property Decorator

    属性级别装饰器,修饰某个属性,和类装饰器功能相同,但是能额外获取当前修饰的属性名。

    为了发挥这一特点,我们篡改一下修饰的属性值。

    const propertyDecorator = (target: any, propertyKey: string | symbol) => {
        Object.defineProperty(target, propertyKey, {
            get() {
                return 'github'
            },
            set(value: any) {
                return value
            }
        })
    }
    
    class B {
        @propertyDecorator
        private name = 'ascoders'
    
        sayName() {
            console.log(`classB ${this.name}`)
        }
    }
    const b = new B()
    b.sayName() // classB github
    

    11 业务场景 统一异常捕获

    我们来编写类级别装饰器,专门捕获 async 函数抛出的异常:

    const asyncClass = (errorHandler?: (error?: Error) => void) => (target: any) => {
        Object.getOwnPropertyNames(target.prototype).forEach(key => {
            const func = target.prototype[key]
            target.prototype[key] = async (...args: any[]) => {
                try {
                    await func.apply(this, args)
                } catch (error) {
                    errorHandler && errorHandler(error)
                }
            }
        })
        return target
    }
    

    将类所有方法都用 try catch 包裹住,将异常交给业务方统一的 errorHandler 处理:

    const successRequest = () => Promise.resolve('a')
    const failRequest = () => Promise.reject('b')
    
    const iAsyncClass = asyncClass(error => {
        console.log('统一异常处理', error) // 统一异常处理 b
    })
    
    @iAsyncClass
    class Action {
        async successReuqest() {
            const result = await successRequest()
            console.log('successReuqest', '处理返回值', result)
        }
    
        async failReuqest() {
            const result = await failRequest()
            console.log('failReuqest', '处理返回值', result) // 永远不会执行
        }
    
        async allReuqest() {
            const result1 = await successRequest()
            console.log('allReuqest', '处理返回值 success', result1)
            const result2 = await failRequest()
            console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
        }
    }
    
    const action = new Action()
    action.successReuqest()
    action.failReuqest()
    action.allReuqest()
    

    我们也可以编写方法级别的异常处理:

    const asyncMethod = (errorHandler?: (error?: Error) => void) => (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
        const func = descriptor.value
        return {
            get() {
                return (...args: any[]) => {
                    return Promise.resolve(func.apply(this, args)).catch(error => {
                        errorHandler && errorHandler(error)
                    })
                }
            },
            set(newValue: any) {
                return newValue
            }
        }
    }
    

    业务方用法类似,只是装饰器需要放在函数上:

    const successRequest = () => Promise.resolve('a')
    const failRequest = () => Promise.reject('b')
    
    const asyncAction = asyncMethod(error => {
        console.log('统一异常处理', error) // 统一异常处理 b
    })
    
    class Action {
        @asyncAction async successReuqest() {
            const result = await successRequest()
            console.log('successReuqest', '处理返回值', result)
        }
    
        @asyncAction async failReuqest() {
            const result = await failRequest()
            console.log('failReuqest', '处理返回值', result) // 永远不会执行
        }
    
        @asyncAction async allReuqest() {
            const result1 = await successRequest()
            console.log('allReuqest', '处理返回值 success', result1)
            const result2 = await failRequest()
            console.log('allReuqest', '处理返回值 success', result2) // 永远不会执行
        }
    }
    
    const action = new Action()
    action.successReuqest()
    action.failReuqest()
    action.allReuqest()
    

    12 业务场景 没有后顾之忧的主动权

    我想描述的意思是,在第 11 章这种场景下,业务方是不用担心异常导致的 crash,因为所有异常都会在顶层统一捕获,可能表现为弹出一个提示框,告诉用户请求发送失败。

    业务方也不需要判断程序中是否存在异常,而战战兢兢的到处 try catch,因为程序中任何异常都会立刻终止函数的后续执行,不会再引发更恶劣的结果。

    像 golang 中异常处理方式,就存在这个问题
    通过 err, result := func() 的方式,虽然固定了第一个参数是错误信息,但下一行代码免不了要以 if error {...} 开头,整个程序的业务代码充斥着巨量的不必要错误处理,而大部分时候,我们还要为如何处理这些错误想的焦头烂额。

    而 js 异常冒泡的方式,在前端可以用提示框兜底,nodejs端可以返回 500 错误兜底,并立刻中断后续请求代码,等于在所有危险代码身后加了一层隐藏的 return

    同时业务方也握有绝对的主动权,比如登录失败后,如果账户不存在,那么直接跳转到注册页,而不是傻瓜的提示用户帐号不存在,可以这样做:

    async login(nickname, password) {
    	try {
    		const user = await userService.login(nickname, password)
    		// 跳转到首页,登录失败后不会执行到这,所以不用担心用户看到奇怪的跳转
    	} catch (error) {
    		if (error.no === -1) {
    			// 跳转到登录页
    		} else {
    			throw Error(error) // 其他错误不想管,把球继续踢走
    		}
    	}
    }
    

    补充

    nodejs 端,记得监听全局错误,兜住落网之鱼:

    process.on('uncaughtException', (error: any) => {
        logger.error('uncaughtException', error)
    })
    
    process.on('unhandledRejection', (error: any) => {
        logger.error('unhandledRejection', error)
    })
    

    在浏览器端,记得监听 window 全局错误,兜住漏网之鱼:

    window.addEventListener('unhandledrejection', (event: any) => {
        logger.error('unhandledrejection', event)
    })
    window.addEventListener('onrejectionhandled', (event: any) => {
        logger.error('onrejectionhandled', event)
    })
    

    如有错误,欢迎斧正,本人 github 主页:https://github.com/ascoders 希望结交有识之士!

  • 相关阅读:
    Python-温度的转换
    这些Servlet知识你一定要知道,金九银十大厂面试官都爱问
    【建议收藏】一份阿里大牛花了三天整理出来的XML学习笔记,写的非常详细
    一年六个月十八天,从外包到字节跳动客户端提前批,没想到我也能够逆袭
    面试官:小伙子,你能给我说一下HashMap的实现原理吗?
    盘点一下面试官最爱问的泛型和包装类,建议反复观看,真的写的非常详细
    深度分析:面试阿里,字节跳动,美团90%被问到的List集合,看完还不懂算我输
    2020阿里Java面试题目大汇总,看看你离阿里还有多远,附答案!
    不会吧,你连Java 多线程线程安全都还没搞明白,难怪你面试总不过
    java开发两年,连Spring中bean的装配都不知道?你怎么涨薪啊
  • 原文地址:https://www.cnblogs.com/ascoders/p/6358838.html
Copyright © 2020-2023  润新知