神奇的Promise
背景
小游戏开启高性能模式后游戏可能出现偶现的时序问题,比如反复切换场景或者弹窗的时候,一些逻辑偶现出现异常,如报错或者渲染时序异常,这其实和 Promise 时序问题有关系。
引子
为了说清楚问题,我们从一段代码开始说起:
[代码]var arr = []
const foo = async () => {
arr.push(2)
}
setTimeout(() => {
arr.push(4)
}, 0)
;(async () => {
arr.push(1)
await foo()
arr.push(3)
})();
setTimeout(() => {
console.log(arr)
}, 0);
[代码]
这段代码,从 JavaScript 执行时序上来讲,结果最终控制台应输出如下结果:
[1, 2, 3, 4]
小游戏内表现
环境
运行结果
小游戏开发者工具未勾选 ES6 转 ES5
[1, 2, 3, 4]
小游戏开发者工具勾选 ES6 转 ES5
[1, 2, 3, 4]
小游戏 iOS 未勾选 ES6 转 ES5
[1, 2, 3, 4]
小游戏 iOS 勾选 ES6 转 ES5
[1, 2, 4, 3]
小游戏安卓 未勾选 ES6 转 ES5
[1, 2, 3, 4]
小游戏安卓 勾选 ES6 转 ES5
[1, 2, 3, 4]
这里就会引出非常奇怪的问题,勾选了ES6转ES5之后为什么小游戏 iOS 端的运行结果是 “错的”?
ES6转ES5
要回答这个问题,要先从 ES6 转 ES5 说起,上面的代码片段,有 async 和 await 这样的关键词,这是 ES6 才新增的语法,详见 MDN 。
这种语法的兼容性已经很高,但小游戏可能需要跑在很低版本的iOS系统上,比如 iOS 9 的系统来调用这种语法会直接报错。
所以为了兼容性,小游戏的业务代码一般都会执行 ES6 到 ES5(https://babeljs.io/) 的转换,通过转换,业务代码可以仍然使用ES6语法,但是最终构建后会变成ES5语法,比如上面的代码经过ES6 转 ES5,会变成下面的代码:
[代码]var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _regenerator = _interopRequireDefault(require("@babel/runtime/regenerator"));
var _asyncToGenerator2 = require("@babel/runtime/helpers/asyncToGenerator");
var foo = /*#__PURE__*/function () {
var _ref = _asyncToGenerator2( /*#__PURE__*/_regenerator.default.mark(function _callee() {
return _regenerator.default.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
arr.push(2);
case 1:
case "end":
return _context.stop();
}
}
}, _callee);
}));
return function foo() {
return _ref.apply(this, arguments);
};
}();
setTimeout(function () {
arr.push(4);
}, 0);
_asyncToGenerator2( /*#__PURE__*/_regenerator.default.mark(function _callee2() {
return _regenerator.default.wrap(function _callee2$(_context2) {
while (1) {
switch (_context2.prev = _context2.next) {
case 0:
arr.push(1);
_context2.next = 3;
return foo();
case 3:
arr.push(3);
case 4:
case "end":
return _context2.stop();
}
}
}, _callee2);
}))();
setTimeout(function () {
console.log(arr);
}, 0);
[代码]
可以看到,经过转换的代码,已经没有了 async 和 await 这样的关键词,而这里的 _asyncToGenerator2 其实是由 Babel 来定义的,如下所示:
[代码]function asyncGeneratorStep(n, e, r, t, o, a, c) {
try {
var i = n[a](c)
, u = i.value
} catch (n) {
return void r(n)
}
i.done ? e(u) : Promise.resolve(u).then(t, o)
}
function _asyncToGenerator(n) {
return function() {
var e = this
, r = arguments;
return new Promise((function(t, o) {
var a = n.apply(e, r);
function c(n) {
asyncGeneratorStep(a, t, o, c, i, "next", n)
}
function i(n) {
asyncGeneratorStep(a, t, o, c, i, "throw", n)
}
c(void 0)
}
))
}
}
[代码]
至于为什么要将async和await转换类似 Generator 的方式,网上有很多解读文章,这里不做赘述,目前的版本看起来不太容易理解,其实早期的实现版本更加易读:
[代码]function _asyncToGenerator(fn) {
return function () {
var gen = fn.apply(this, arguments);
return new Promise(function (resolve, reject) {
function step(key, arg) {
try {
var info = gen[key](arg);
var value = info.value;
} catch (error) {
// error handling
reject(error);
return;
} if (info.done) {
// all done, pass the return value out
resolve(value);
} else {
return Promise.resolve(value).then(function (value) {
step("next", value);
}, function (err) {
step("throw", err);
});
}
}
return step("next");
});
};
}
[代码]
可以看到,经过转换后的代码,像是个状态机,在不同状态之间跳转,而每一次状态扭转,则是通过 Promise.resolve(value).then() 的方式(划重点)。
到目前为止的结论是,经过ES6转ES5,async和await这种语法,已经变成通过ES5的语法来实现了,但为什么会影响运行结果呢?
Promise
要更好的回答开始的问题,还需要继续来看下Promise。Promise 是社区提的一个异步解决方案,在ES6被正式列为规范,因为这个特殊性,Promise的兼容性会更好,如下图所示,Safari 8 就支持了。但为了兼容性,仍然有不少业务会针对 Promise 做 polyfill,这种 polyfill 一般是识别当前环境没有原生的 Promise 实现则用 ES5 来实现 Promise,以保证在任何环境下,业务代码的 Promise 调用都是合法的。
这里我们有必要简单来讲下 Promise 的 polyfill,这种 ES6 规范新增的原生对象的 polyfill,Babel是直接引用 https://github.com/zloirock/core-js 的模块。
因为上面提到了Promise then,这里我们来看下 then 是怎么实现的:
[代码] Internal.prototype = defineBuiltIn(PromisePrototype, 'then', function then(onFulfilled, onRejected) {
var state = getInternalPromiseState(this);
var reaction = newPromiseCapability(speciesConstructor(this, PromiseConstructor));
state.parent = true;
reaction.ok = isCallable(onFulfilled) ? onFulfilled : true;
reaction.fail = isCallable(onRejected) && onRejected;
reaction.domain = IS_NODE ? process.domain : undefined;
if (state.state == PENDING) state.reactions.add(reaction);
else microtask(function () {
callReaction(reaction, state);
});
return reaction.promise;
});
[代码]
上面的代码有一句很关键的实现是通过创建一个微任务去执行回调函数:
[代码]microtask(function () {
callReaction(reaction, state);
});
[代码]
至此先来了解下宏任务和微任务。
宏任务和微任务
下面的内容直接摘自 MDN 文档:
一个任务就是指计划由标准机制来执行的任何 JavaScript,如程序的初始化、事件触发的回调等。 除了使用事件,你还可以使用 setTimeout 或者 setInterval 来添加任务。
任务队列和微任务队列的区别很简单,但却很重要:
当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行.
每次当一个任务退出且执行上下文为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务。
关于微任务,再根据MDN文档补充一些解释,来自在 JavaScript 中通过 queueMicrotask() 使用微任务:
JavaScript 中的 promises 和 Mutation Observer API 都使用微任务队列去运行它们的回调函数,但当能够推迟工作直到当前事件循环过程完结时,也是可以执行微任务的时机。为了允许第三方库、框架、polyfills 能使用微任务,Window 暴露了 queueMicrotask() 方法,而 Worker 接口则通过 WindowOrWorkerGlobalScope mixin 提供了同名的 queueMicrotask() 方法。
继续回到上面的 Promise的 polyfill,Promise 的 then 实现是通过创建一个微任务去执行回调函数。因此如果当前环境能够调用 queueMicrotask,通过 polyfill 实现的Promise和原生的Promise时序上并无差异。那么上面的 microtask 具体是怎么实现的呢?是否为调用queueMicrotask函数,继续来看 core-js 的microtask.js:
[代码]// modern engines have queueMicrotask method
if (!queueMicrotask) {
flush = function () {
var parent, fn;
if (IS_NODE && (parent = process.domain)) parent.exit();
while (head) {
fn = head.fn;
head = head.next;
try {
fn();
} catch (error) {
if (head) notify();
else last = undefined;
throw error;
}
} last = undefined;
if (parent) parent.enter();
};
// browsers with MutationObserver, except iOS - https://github.com/zloirock/core-js/issues/339
// also except WebOS Webkit https://github.com/zloirock/core-js/issues/898
if (!IS_IOS && !IS_NODE && !IS_WEBOS_WEBKIT && MutationObserver && document) {
toggle = true;
node = document.createTextNode('');
new MutationObserver(flush).observe(node, { characterData: true });
notify = function () {
node.data = toggle = !toggle;
};
// environments with maybe non-completely correct, but existent Promise
} else if (!IS_IOS_PEBBLE && Promise && Promise.resolve) {
// Promise.resolve without an argument throws an error in LG WebOS 2
promise = Promise.resolve(undefined);
// workaround of WebKit ~ iOS Safari 10.1 bug
promise.constructor = Promise;
then = bind(promise.then, promise);
notify = function () {
then(flush);
};
// Node.js without promises
} else if (IS_NODE) {
notify = function () {
process.nextTick(flush);
};
// for other environments - macrotask based on:
// - setImmediate
// - MessageChannel
// - window.postMessage
// - onreadystatechange
// - setTimeout
} else {
// strange IE + webpack dev server bug - use .bind(global)
macrotask = bind(macrotask, global);
notify = function () {
macrotask(flush);
};
}
}
module.exports = queueMicrotask || function (fn) {
var task = { fn: fn, next: undefined };
if (last) last.next = task;
if (!head) {
head = task;
notify();
} last = task;
};
[代码]
这里需要注意的是,小游戏的并不是运行在 WebView 上,在 iOS 端是运行在 javascriptcore之上,这种环境下是没有 queueMicrotask 函数的,因此如果在小游戏iOS端执行Promise polyfill,并不是通过 queueMicrotask 来执行回调函数,在小游戏 iOS 上则是落到了逻辑:
[代码]macrotask = bind(macrotask, global);
notify = function () {
macrotask(flush);
};
[代码]
这里则是问题的关键了,明明是为了通过微任务去执行回调,但polyfill 代码居然是 macrotask 的方式来实现去实现了microtask!
最后再来看下 core-js 是怎么实现 macrotask 的(详见task.js):
[代码]if (!set || !clear) {
set = function setImmediate(handler) {
validateArgumentsLength(arguments.length, 1);
var fn = isCallable(handler) ? handler : Function(handler);
var args = arraySlice(arguments, 1);
queue[++counter] = function () {
apply(fn, undefined, args);
};
defer(counter);
return counter;
};
clear = function clearImmediate(id) {
delete queue[id];
};
// Node.js 0.8-
if (IS_NODE) {
defer = function (id) {
process.nextTick(runner(id));
};
// Sphere (JS game engine) Dispatch API
} else if (Dispatch && Dispatch.now) {
defer = function (id) {
Dispatch.now(runner(id));
};
// Browsers with MessageChannel, includes WebWorkers
// except iOS - https://github.com/zloirock/core-js/issues/624
} else if (MessageChannel && !IS_IOS) {
channel = new MessageChannel();
port = channel.port2;
channel.port1.onmessage = listener;
defer = bind(port.postMessage, port);
// Browsers with postMessage, skip WebWorkers
// IE8 has postMessage, but it's sync & typeof its postMessage is 'object'
} else if (
global.addEventListener &&
isCallable(global.postMessage) &&
!global.importScripts &&
location && location.protocol !== 'file:' &&
!fails(post)
) {
defer = post;
global.addEventListener('message', listener, false);
// IE8-
} else if (ONREADYSTATECHANGE in createElement('script')) {
defer = function (id) {
html.appendChild(createElement('script'))[ONREADYSTATECHANGE] = function () {
html.removeChild(this);
run(id);
};
};
// Rest old browsers
} else {
defer = function (id) {
setTimeout(runner(id), 0);
};
}
}
[代码]
模拟任务的方式在不同场景下很多,而在iOS小游戏下则会落入如下逻辑:
[代码] // Rest old browsers
} else {
defer = function (id) {
setTimeout(runner(id), 0);
};
}
[代码]
也就是说:在 iOS 小游戏下,通过 polyfill 出来的 Promise,实际上是通过 setTimeout 为0 的定时器来实现的。听起来并没什么问题,反正都是尽可能快执行,但结合上面的提到的宏任务和微任务的差别就可以知道,这实际上将执行上下文的控制权交出去了,此刻会执行此次事件循环中的下一个任务,这就可能导致时序上的问题:
经过ES6转ES5,async和await并不是原生的语法,只是通过ES5 polyfill出来的;
ES5 版本的async和await代码,实际上是个状态机,通过 Promise.resolve().then() 的方式驱动每一次状态转换;
每一次状态转换预期是通过微任务的方式去执行的,而微任务是不会被打断的,只要状态没有到end,就一直会扭转;特别的,不会打断是指,是不会被之前注册的 setTimeout 回调打断的,因为 setTimeout 注册的是宏任务;
小程序的iOS端,Promise 必须要进行 Polyfill,原因也是webkit的https://bugs.webkit.org/show_bug.cgi?id=161942,这里不做赘述;
小程序 iOS 端,经过 polyfill 的Promise,Promise.resolve().then() 已经不是通过微任务的方式在执行回调了,而是宏任务的方式;
带来的影响是,await foo() 这种调用已经不在可信,因为可能被其他地方的定时器注册的宏任务打断;
[代码]var arr = []
const foo = async () => {
arr.push(2)
}
setTimeout(() => {
arr.push(4)
}, 0)
;(async () => {
arr.push(1)
await foo()
arr.push(3)
})();
[代码]
此时我们再对着引子里面提到的代码逐一解释:
定义foo函数,等待被调用;
注册延迟为0的定时器,即注册一个宏任务;
定义并立即执行的匿名函数,该函数为async类型,函数逐行执行逻辑为:
a. 数组arr推入1:arr.push(1);
b. 通过await的方式调用foo函数,跳转到foo函数执行上下文,通过 Promise.resolve().then() 的方式流转到 end 状态,在小游戏iOS下,是宏任务的方式,js执行控制权已经交出去;因为在准备流转到end状态的时候控制权已经交出去了,所以步骤2定义的任务可以执行,因此输入arr推入4:arr.push(4);
c. 下一次事件循环,步骤b注册的任务得以执行,foo 状态执行完毕,匿名函数继续往下执行,即arr.push(3);
因此小游戏 iOS 下,如果勾选了 ES6 转 ES5,执行时序像是错的。
小结
此刻我们再来梳理下一开始的结论:
环境
运行结果
解释
小游戏开发者工具未勾选 ES6 转 ES5
[1, 2, 3, 4]
对齐Web标准,async和await为V8实现
小游戏开发者工具勾选 ES6 转 ES5
[1, 2, 3, 4]
开发者工具并没有做 Promise 的polyfill,因此运行结果仍然符合预期
小游戏 iOS 未勾选 ES6 转 ES5
[1, 2, 3, 4]
async和await为 jscore 实现,运行结果符合预期
小游戏 iOS 勾选 ES6 转 ES5
[1, 2, 4, 3]
async和await为ES5 polyfill 实现,且Promise同样有Promise
小游戏安卓 未勾选 ES6 转 ES5
[1, 2, 3, 4]
async和await为V8实现
小游戏安卓 勾选 ES6 转 ES5
[1, 2, 3, 4]
安卓没有做 Promise 的polyfill,因此运行结果仍然符合预期
说了那么多,这里的指导意义是什么?
小游戏代码勾选ES6转ES5是合理的, 为了更好的兼容性;
经过ES6转ES5,小游戏 iOS 端,await并不是真正意义上的阻塞,如果另外有个地方注册了定会器并且和当前函数有变量依赖关系,则会存在偶现的时序问题;
此问题在小游戏高性能模式比较容易出现,但其实出现问题才是符合预期的,小游戏普通模式未出现可能是 jscore 在普通模式和高性能模式的差异;