|
本期作者钱程游戏技术中台资深开发工程师01 什么是 Promise?1.1 Promise 的背景介绍Promise 最早出现在 1988 年,由 Barbara Liskov、Liuba Shrira 首创(论文:Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems[1])。并且在语言 MultiLisp 和 Concurrent Prolog 中已经有了类似的实现。JavaScript 中,Promise 的流行是得益于 jQuery 的方法 jQuery.Deferred(),其他也有一些更精简独立的 Promise 库,例如:Q、When、Bluebird。// Q/2009-2017import Q from 'q'function wantOdd () { ? ?const defer = Q.defer() ? ?const num = Math.floor(Math.random() * 10) ? ?if (num % 2) { ? ? ? ?defer.resolve(num) ? ?} else { ? ? ? ?defer.reject(num) ? ?} ? ?return defer.promise}wantOdd() ? ?.then(num => { ? ? ? ?log(`Success: ${num} is odd.`) // Success: 7 is odd. ? ?}) ? ?.catch(num => { ? ? ? ?log(`Fail: ${num} is not odd.`) ? ?})由于 jQuery 并没有严格按照规范来制定接口,促使了官方对 Promise 的实现标准进行了一系列重要的澄清,该实现规范被命名为 Promise/A+。后来 ES6(也叫 ES2015,2015 年 6 月正式发布)也在 Promise/A+ 的标准上官方实现了一个 Promise 接口。new Promise( function(resolve, reject) {...} /* 执行器 */ ?)想要实现一个 Promise,必须要遵循如下规则:Promise 是一个提供符合标准的 then() 方法的对象。初始状态是 pending,能够转换成 fulfilled 或 rejected 状态。一旦 fulfilled 或 rejected 状态确定,再也不能转换成其他状态。一旦状态确定,必须要返回一个值,并且这个值是不可修改的。ECMAScript’s Promise global is just one of many Promises/A+ implementations.主流语言对于 Promise 的实现:Golang/promise、Python/promise、C#/Real-Serious-Games/c-sharp-promise、PHP/Guzzle Promises、Java/IOU、Objective-C/PromiseKit、Swift/FutureLib、Perl/stevan/promises-perl。// Golang Examplefunc main() { ?p1 := promise.New(func(resolve func(int), reject func(error)) { ? ?factorial := findFactorial(20) ? ?resolve(factorial) ?}) ?p2 := promise.New(func(resolve func(string), reject func(error)) { ? ?ip, err := fetchIP() ? ?if err != nil { ? ? ?reject(err) ? ? ?return ? ?} ? ?resolve(ip) ?}) ?factorial, _ := p1.Await() ?fmt.Println(factorial) ?IP, _ := p2.Await() ?fmt.Println(IP)}// Other Code...?1.1.1?旨在解决的问题由于 JavaScript 是单线程事件驱动的编程语言,通过回调函数管理多个任务。在快速迭代的开发中,因为回调函数的滥用,很容易产生被人所诟病的回调地狱问题。Promise 的异步编程解决方案比回调函数更加合理,可读性更强。传说中比较夸张的回调:现实业务中依赖关系比较强的回调:// 回调函数function renderPage() { ?const secret = genSecret() ?// 获取用户登录态 ?getUserToken({ ? ?secret, ? ?success: token => { ? ? ?// 获取游戏列表 ? ? ?getGameList({ ? ? ? ?token, ? ? ? ?success: data => { ? ? ? ? ?// 渲染游戏列表 ? ? ? ? ?render({ ? ? ? ? ? ?list: data.list, ? ? ? ? ? ?success: () => { ? ? ? ? ? ? ?// 埋点数据上报 ? ? ? ? ? ? ?report() ? ? ? ? ? ?}, ? ? ? ? ? ?fail: err => { ? ? ? ? ? ? ?console.error(err) ? ? ? ? ? ?} ? ? ? ? ?}) ? ? ? ?}, ? ? ? ?fail: err => { ? ? ? ? ?console.error(err) ? ? ? ?} ? ? ?}) ? ?}, ? ?fail: err => { ? ? ?console.error(err) ? ?} ?})}实际上更真实的情况,往往是一个回调函数在多个文件间透传,要搞清楚最终在哪里触发需要翻越整个项目。使用 Promise 梳理流程后:// Promisefunction renderPage() { ?const secret = genSecret() ?// 获取用户登录态 ?getUserToken(token) ? ?.then(token => { ? ? ?// 获取游戏列表 ? ? ?return getGameList(token) ? ?}) ? ?.then(data => { ? ? ?// 渲染游戏列表 ? ? ?return render(data.list) ? ?}) ? ?.then(() => { ? ? ?// 埋点数据上报 ? ? ?report() ? ?}) ? ?.catch(err => { ? ? ?console.error(err) ? ?})}若其中某个流程需要复用,单独把它抽离出来即可。// 获取游戏列表// 仅为示例,与实际业务无关function getGameXYZ() { ?const secret = genSecret() ?// 获取用户登录态 ?return getUserToken(token) ? ?.then(token => { ? ? ?// 获取游戏列表 ? ? ?return getGameList(token) ? ?})}// 渲染页面function renderPage() { ?getGameXYZ() ? ?.then(data => { ? ? ?// 渲染游戏列表 ? ? ?return render(data.list) ? ?}) ? ?.then(() => { ? ? ?// 埋点数据上报 ? ? ?report() ? ?}) ? ?.catch(err => { ? ? ?console.error(err) ? ?})}// 其他场景function doABC() { ?getGameXYZ() ? ?.then(data => { ? ? ?// ... ? ?}) ? ?.then(data => { ? ? ?// ... ? ?}) ? ?.catch(err => { ? ? ?console.error(err) ? ?})}?1.2 实现一个超简易版的 PromisePromise 的运转实际上是一个观察者模式,then() 中的匿名函数充当观察者,Promise 实例充当被观察者。const p = new Promise(resolve => setTimeout(resolve.bind(null, 'from promise'), 3000))p.then(console.log.bind(null, 1))p.then(console.log.bind(null, 2))p.then(console.log.bind(null, 3))p.then(console.log.bind(null, 4))p.then(console.log.bind(null, 5))// 3 秒后// 1 2 3 4 5 from promise// 实现const defer = () => { ?let pending = [] // 充当状态并收集观察者 ?let value = undefined ?return { ? ?resolve: (_value) => { // FulFilled! ? ? ?value = _value ? ? ?if (pending) { ? ? ? ?pending.forEach(callback => callback(value)) ? ? ? ?pending = undefined ? ? ?} ? ?}, ? ?then: (callback) => { ? ? ?if (pending) { ? ? ? ?pending.push(callback) ? ? ?} else { ? ? ? ?callback(value) ? ? ?} ? ?} ?}}// 模拟const mockPromise = () => { ?let p = defer() ?setTimeout(() => { ? ?p.resolve('success!') ?}, 3000) ?return p}mockPromise().then(res => { ?console.log(res)})console.log('script end')// script end// 3 秒后// success!02 Promise 怎么用?2.1 使用 Promise 异步编程在 Promise 出现之前往往使用回调函数管理一些异步程序的状态。// 常见的异步 Ajax 请求格式ajax(url, successCallback, errorCallback)Promise 出现后使用 then() 接收事件的状态,且只会接收一次。案例:插件初始化工作中使用封装好的插件时,往往需要等待插件初始化成功后进行下一步操作。使用回调函数:// 2. NPMimport PPlugin from 'PPlugin'// ...// 其他代码// ...PPlugin.init(data => { ?console.log('初始化完成', data)})插件代码:const PPlugin = {/* Pass */ }// 此处为理想情况,随着业务快速迭代,会变得不可控,往往需要多个状态判断let ppInitStatus = falselet ppInitCallback = nullPPlugin.init = callback => { ?if (ppInitStatus) { ? ?callback & callback(/* 数据 */) ?} else { ? ?ppInitCallback = callback ?}}// 客户端桥接...// 服务端接口...// 经历了一系列同步异步程序后初始化完成ppInitCallback & ppInitCallback(/* 数据 */)ppInitStatus = true使用 Promise:插件代码:const PPlugin = {/* Pass */ }let initOk = nullconst ppInitStatus = new Promise(resolve => initOk = resolve)PPlugin.init = callback => { ?ppInitStatus.then(callback).catch(console.error)}// 客户端桥接...// 服务端接口...// 经历了一系列同步异步程序后初始化完成initOk(/* 数据 */)相对于使用回调函数,逻辑更清晰,什么时候初始化完成和触发回调一目了然,不再需要重复判断状态和回调函数。当然更好的做法是只给使用方输出状态和数据,至于如何使用由使用方决定。插件代码:const PPlugin = {/* Pass */ }let initOk = nullPPlugin.init = new Promise(resolve => initOk = resolve)// 客户端桥接...// 服务端接口...// 经历了一系列同步异步程序后初始化完成initOk(/* 数据 */)使用插件:?2.2 链式调用then() 必然返回一个 Promise 对象,Promise 对象又拥有一个 then() 方法,这正是 Promise 能够链式调用的原因。const p = new Promise(r => r(1)) ?.then(res => { ? ?console.log(res) // 1 ? ?return Promise.resolve(2) ? ? ?.then(res => res + 10) // === new Promise(r => r(1)) ? ? ?.then(res => res + 10) // 由此可见,每次返回的是实例后面跟的最后一个 then ?}) ?.then(res => { ? ?console.log(res) // 22 ? ?return 3 // === Promise.resolve(3) ?}) ?.then(res => { ? ?console.log(res) // 3 ?}) ?.then(res => { ? ?console.log(res) // undefined ? ?return '最强王者' ?})p.then(console.log.bind(null, '是谁活到了最后:')) // 是谁活到了最后: 最强王者由于返回一个 Promise 结构体永远返回的是链式调用的最后一个 then(),所以在处理封装好的 Promise 接口时没必要在外面再包一层 Promise。// 包一层 Promisefunction api() { ?return new Promise((resolve, reject) => { ? ?axios.get(/* 链接 */).then(data => { ? ? ?// ... ? ? ?// 经历了一系列数据处理 ? ? ?resolve(data.xxx) ? ?}) ?})}// 更好的做法:利用链式调用function api() { ?return axios.get(/* 链接 */).then(data => { ? ?// ... ? ?// 经历了一系列数据处理 ? ?return data.xxx ?})}?2.3 管理多个 Promise 实例Promise.all() / Promise.race() 可以将多个 Promise 实例包装成一个 Promise 实例,在处理并行的、没有依赖关系的请求时,能够节约大量的时间。function wait(ms) { ?return new Promise(resolve => setTimeout(resolve.bind(null, ms), ms))}// Promise.allPromise.all([wait(2000), wait(4000), wait(3000)]) ?.then(console.log)// 4 秒后 [ 2000, 4000, 3000 ]// Promise.racePromise.race([wait(2000), wait(4000), wait(3000)]) ?.then(console.log)// 2 秒后 2000?2.4 Promise 和 async&awaitasync&await 实际上只是建立在 Promise 之上的语法糖,让异步代码看上去更像同步代码,所以 async&await 在 JavaScript 线程中是非阻塞的,但在当前函数作用域内具备阻塞性质。let ok = nullasync function foo() { ?console.log(1) ?console.log(await new Promise(resolve => ok = resolve)) ?console.log(3)}foo() // 1ok(2) // 2 3?2.4.1 使用 async&await 的优势简洁干净写更少的代码,不需要特地创建一个匿名函数,放入 then() 方法中等待一个响应。// Promisefunction getUserInfo() { ?return getData().then( ? ?data => { ? ? ?return data ? ?} ?)}// async / awaitasync function getUserInfo() { ?return await getData()}处理条件语句当一个异步返回值是另一段逻辑的判断条件,链式调用将随着层级的叠加变得更加复杂,很容易让人混淆。使用 async&await 将使代码可读性变得更好。// Promisefunction getGameInfo() { ?getUserAbValue().then( ? ?abValue => { ? ? ?if (abValue === 1) { ? ? ? ?return getAInfo().then( ? ? ? ? ?data => { ? ? ? ? ? ?// ... ? ? ? ? ?} ? ? ? ?) ? ? ?} else { ? ? ? ?return getBInfo().then( ? ? ? ? ?data => { ? ? ? ? ? ?// ... ? ? ? ? ?} ? ? ? ?) ? ? ?} ? ?} ?)}// async / awaitasync function getGameInfo() { ?const abValue = await getUserAbValue() ?if (abValue === 1) { ? ?const data = await getAInfo() ? ?// ... ?} else { ? ?// ... ?}}处理中间值异步函数常常存在一些异步返回值,作用仅限于成为下一段逻辑的入场券,如果经历层层链式调用,很容易成为另一种形式的“回调地狱”。// Promisefunction getGameInfo() { ?getToken().then( ? ?token => { ? ? ?getLevel(token).then( ? ? ? ?level => { ? ? ? ? ?getInfo(token, level).then( ? ? ? ? ? ?data => { ? ? ? ? ? ? ?// ... ? ? ? ? ? ?} ? ? ? ? ?) ? ? ? ?} ? ? ?) ? ?} ?)}// async / awaitasync function getGameInfo() { ?const token = await getToken() ?const level = await getLevel(token) ?const data = await getInfo(token, level) ?// ...}对于多个异步返回中间值,搭配 Promise.all 使用能够提升逻辑性和性能。// async / await & Promise.allasync function foo() { ?// ... ?const [a, b, c] = await Promise.all([promiseFnA(), promiseFnB(), promiseFnC()]) ?const d = await promiseFnD() ?// ...}靠谱的 awaitawait 'str' 等于 await Promise.resolve('str'),await 会把任何不是 Promise 的值包装成 Promise,看起来貌似没有什么用,但是在处理第三方接口的时候可以 “Hold” 住同步和异步返回值,否则对一个非 Promise 返回值使用 then() 链式调用则会报错。?2.4.2 避免滥用 async&awaitawait 阻塞 async 函数中的代码执行,在上下文关联性不强的代码中略显累赘。// async / awaitasync function initGame() { ?render(await getGame()) // 等待获取游戏执行完毕再去获取用户信息 ?report(await getUserInfo())}// Promisefunction initGame() { ?getGame() ? ?.then(render) ? ?.catch(console.error) ?getUserInfo() // 获取用户信息和获取游戏同步进行 ? ?.then(report) ? ?.catch(console.error)}?2.4.3 ES2021 新特性:Top-level awaitNode.js 14+ 版本后,可以在 JavaScript module 中使用 await 操作符。在这之前,只能通过在 async 声明的场景中使用 await 操作符。MDN 官方案例[2]:// fetch requestconst colors = fetch('../data/colors.json') ?.then((response) => response.json())export default await colors?2.5 错误处理1. 链式调用中尽量结尾跟 catch 捕获错误,而不是第二个匿名函数。因为规范里注明了若 then() 方法里面的参数不是函数则什么都不做,所以 catch(rejectionFn) 其实就是 then(null, rejectionFn) 的别名。anAsyncFn().then( ?resolveSuccess, // 无法捕获 ?rejectError // `rejectError` 捕获 `anAsyncFn`)↑在以上代码中,anAsyncFn() 抛出来的错误 rejectError 会正常接住,但是 resolveSuccess 抛出来的错误将无法捕获,所以更好的做法是永远使用 catch。anAsyncFn() ?.then(resolveSuccess) ?.catch(rejectError) // 尽量使用 `catch`若想错误管理精细一点,也可以通过 rejectError 来捕获 anAsyncFn() 的错误,catch 捕获 resolveSuccess 的错误。anAsyncFn() ?.then( ? ?resolveSuccess, ? ?rejectError // 捕获 `anAsyncFn()` ?) ?.catch(handleError) // 捕获 `resolveSuccess`2. 通过全局属性监听未被处理的 Promise 错误。浏览器环境(window)的拒绝状态监听事件:- unhandledrejection 当 Promise 被拒绝,并且没有提供拒绝处理程序时,触发该事件。- rejectionhandled 当 Promise 被拒绝时,若拒绝处理程序被调用,触发该事件。// 初始化列表const unhandledRejections = new Map()// 监听未处理拒绝状态window.addEventListener('unhandledrejection', e => { ?unhandledRejections.set(e.promise, e.reason)})// 监听已处理拒绝状态window.addEventListener('rejectionhandled', e => { ?unhandledRejections.delete(e.promise)})// 循环处理拒绝状态setInterval(() => { ?unhandledRejections.forEach((reason, promise) => { ? ?console.log('handle: ', reason.message) ? ?promise.catch(e => { ? ? ?console.log(`I catch u!`, e.message) ? ?}) ?}) ?unhandledRejections.clear()}, 5000)注意:Promise.reject() 和 new Promise((resolve, reject) => reject()) 这种方式不能直接触发 unhandledrejection 事件,必须是满足已经进行了 then() 链式调用的 Promise 对象才行。?2.6 取消一个 Promise当执行一个超级久的异步请求时,若超过了能够忍受的最大时长,往往需要取消此次请求,但是 Promise 并没有类似于 cancel() 的取消方法,想结束一个 Promise 只能通过 resolve 或 reject 来改变其状态,社区已经有了满足此需求的开源库 Speculation。或者利用 Promise.race() 的机制来同时注入一个会超时的异步函数,但是 Promise.race() 结束后主程序其实还在 pending 中,占用的资源并没有释放。Promise.race([anAsyncFn(), timeout(5000)])?2.7 迭代器的应用若想按顺序执行一堆异步程序,可使用 reduce。每次遍历返回一个 Promise 对象,在下一轮 await 住从而依次执行。相同的场景,也可以使用递归实现,但是在 JavaScript 中随着数量增加,超出调用栈最大次数,便会报错。function wasteTime(ms) { ?return new Promise(resolve => setTimeout(() => { ? ?resolve(ms) ? ?console.log('waste', ms) ?}, ms))}// 依次浪费 3 4 5 3 秒 === 15 秒const arr = [3000, 4000, 5000, 3000]arr.reduce(async (last, curr) => { ?await last ?return wasteTime(curr)}, undefined)03 游戏联运业务中的实践?3.1 登录流程优化游戏联运业务中的登录模块,因为使用场景的复杂性,会有一个回调函数在各个文件间(此处指 Login.vue 和 State.js)传递。若想知道这个回调函数在哪里触发、传递了什么数据,需要逐层查找逻辑,并且需要进行类型判断。Login.vuemethods: { ?comLogin() { ? ?// Other Code... ? ?State.quickLogin(callback) // 1 ?},},State.jsquickLogin(callback) { ?// Other Code... ?if (this.canQuickLogin) { ? ?this.ppQuickLogin(callback) // 2 ?} else { ? ?getScript('//example.com/ppquicklogin.min.js').then(() => { ? ? ?// Other Code... ? ? ?this.ppQuickLogin(callback) // 2 ? ?}) ?}}ppQuickLogin(callback) { ?// Other Code... ?this.getUserState(callback) // 3}getUserState(callback) { ?// Other Code... ?if (isFunction(callback)) { ? ?callback(this.userInfo) // 4 ?}}使用 Promise 改写后,简单调用时仅需要关注状态和值(userInfo),无需过度关注其在上游链路经历了什么。Login.vuemethods: { ?async comLogin() { ? ?const userInfo = await State.quickLogin() // 1 ? ?// Other Code... ?},},State.jsasync quickLogin() { ?// Other Code... ?if (this.canQuickLogin) { ? ?return this.ppQuickLogin() // 2 ?} else { ? ?return getScript('//example.com/ppquicklogin.min.js').then(() => { ? ? ?// Other Code... ? ? ?return this.ppQuickLogin() // 2 ? ?}) ?}}async ppQuickLogin() { ?// Other Code... ?return this.getUserState() // 3}async getUserState() { ?// Other Code... ?return this.userInfo}?3.2 游戏页推送下载优化当前业务背景下,PC 游戏详情页常常充当手机游戏的宣传页面,来引导用户在手机端 App 下载对应游戏。在展示/进行推送之前需要确认几个前置条件,使用回调函数往往会产生冗余代码。index.vuemethods: { ?getSwitchStatus(callback) { ? ?// Other Code... ? ?if (isFunction(callback)) { ? ? ?callback(isSwitchOn) ? ?} ?}, ?getLoginStatus(callback) { ? ?// Other Code... ? ?if (isFunction(callback)) { ? ? ?callback(isLogined) ? ?} ?}, ?pushGame() { ? ?// Other Code... ? ?this.getSwitchStatus( ? ? ?isSwitchOn => { ? ? ? ?if (isSwitchOn) { ? ? ? ? ?this.getLoginStatus( ? ? ? ? ? ?isLogined => { ? ? ? ? ? ? ?if (isLogined) { ? ? ? ? ? ? ? ?// Other Code... ? ? ? ? ? ? ?} else { ? ? ? ? ? ? ? ?this.showLoginModal() ? ? ? ? ? ? ?} ? ? ? ? ? ?} ? ? ? ? ?) ? ? ? ?} ? ? ?} ? ?) ?},},使用 Promise 改写后,业务逻辑上便会更加清晰一点。index.vuemethods: { ?async getSwitchStatus() { ? ?// Other Code... ? ?return isSwitchOn ?}, ?async getLoginStatus() { ? ?// Other Code... ? ?return isLogined ?}, ?async pushGame() { ? ?// Other Code... ? ?const [isSwitchOn, isLogined] = await Promise.all([this.getSwitchStatus(), this.getLoginStatus()]) ? ?if (isSwitchOn & isLogined) { ? ? ?// Other Code... ? ?} else { ? ? ?// Other Code... ? ?} ?},},04 总结每当要使用异步代码时,请考虑使用 Promise。Promise 中所有方法的返回类型都是 Promise。Promise 中的状态改变是一次性的,建议在 reject() 方法中传递 Error 对象。尽量为所有的 Promise 添加 then() 和 catch() 方法。使用 Promise.all() 去运行多个 Promise。倘若想在 then() 或 catch() 后都做点什么,可使用 finally()。可以将多个 then() 挂载在同一个 Promise 上。async (异步)函数返回一个 Promise,所有返回 Promise 的函数也可以被视作一个异步函数。await 用于调用异步函数,直到其状态改变(fulfilled or rejected)。使用 async / await 时要考虑上下文的依赖性,避免造成不必要的阻塞。参考链接:[1]https://dl.acm.org/doi/10.1145/960116.54016[2]https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#top_level_await以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,欢迎点个“在看”吧!
|
|