乐趣区

异步的JavaScript

JS 本身是一门单线程的语言,所以在执行一些需要等待的任务 (eg. 等待服务器响应,等待用户输入等) 时就会阻塞其他代码。如果在浏览器中 JS 线程阻塞了,浏览器可能会失去响应,从而造成不好的用户体验。幸运的是 JS 语言本身和其运行的环境 (浏览器,Node) 都提供了一些解决方案让 JS 可以“异步”起来,在此梳理一下相关的知识点,如果你读完之后有所收获,那更是极好的。
Event Loop
JS 中每个函数都伴有一个自身的作用域(execution context),这个作用域包含函数的一些信息(eg. 参数,局部变量等),在函数被调用时,函数的作用域对象被推入执行栈(execution context stack), 执行完毕后出栈。当执行一些异步任务时,JS 仅调用相应的 API 并不去等待任务结果而是继续执行后续代码,这些异步任务被浏览器或者 Node 交由其他线程执行(eg. 定时器线程、http 请求线程、DOM 事件线程等),完成之后这些异步任务的回调函数会被推入相应的队列中,直到执行栈为空时,这些回调函数才会被依次执行。
举个例子:
function main() {
console.log(‘A)

setTimeout(function display() {
console.log(‘B’)
}, 0)

console.log(‘C’)
}

main()
以上代码在 Event Loop 中的执行过程如下:

类似于 setTimeout 这样的任务还有:setInterval, setImmediate, 响应用户操作的事件(eg. click, input 等), 响应网络请求(eg. ajax 的 onload,image 的 onload 等),数据库操作等等。这些操作有一个统一的名字:task,所以上图中的 message queue 其实是 task queue,因为还存在一些像:Promise,process.nextTick, MutationObserver 之类的任务,这些任务叫做 microtask,__microtask 会在代码执行过程中被推入 microtask queue 而不是 task queue__,microtask queue 中的任务同样也需要等待执行栈为空时依次执行。

一个 task 中可能会产生 microtask 和新的 task,其中产生的 microtask 会在本次 task 结束后,即执行栈为空时执行,而新的 task 则会在 render 之后执行。microtask 中也有可能会产生新的 microtask,会进入 microtask queue 尾部,并在本次 render 前执行。
这样的流程是有它存在原因的,这里仅仅谈下我个人的理解,如有错误,还请指出:浏览器中除了 JS 引擎线程,还存在 GUI 渲染线程,用以解析 HTML, CSS, 构建 DOM 树等工作,然而这两个线程是互斥的,只有在 JS 引擎线程空闲时,GUI 渲染线程才有可能执行。在两个 task 之间,JS 引擎空闲,此时如果 GUI 渲染队列不为空,浏览器就会切换至 GUI 渲染线程进行 render 工作。而 microtask 会在 render 之前执行,旨在以类似同步的方式 (尽可能快地) 执行异步任务,所以 microtask 执行时间过长就会阻塞页面的渲染。
setTimeout、setInterval、requestAnimationFrame
上文提到 setTimeout,setInterval 都属于 task,所以即便设置间隔为 0:
setTimeout(function display() {
console.log(‘B’)
}, 0)
回调也会异步执行。
setTimeout,setInterval 常被用于编写 JS 动画,比如:
// setInterval
function draw() {
// …some draw code
}

var intervalTimer = setInterval(draw, 500)

// setTimeout
var timeoutTimer = null

function move() {
// …some move code

timeoutTimer = setTimeout(move, 500)
}

move()
这其实是存在一定的问题的:

从 event loop 的角度分析:setInterval 的两次回调之间的间隔是不确定的,取决于回调中的代码的执行时间;
从性能的角度分析:无论是 setInterval 还是 setTimeout 都“无法感知浏览器当前的工作状态”,比如当前页面为隐藏 tab,或者设置动画的元素不在当前 viewport,setInterval & setTimeout 仍会照常执行,实际是没有必要的,虽然某些浏览器像 Chrome 会优化这种情况,但不能保证所有的浏览器都会有优化措施。再比如多个元素同时执行不同的动画,可能会造成不必要的重绘,其实页面只需要重绘一次即可。

在这种背景下,Mozilla 提出了 requestAnimationFrame,后被 Webkit 优化并采用,requestAnimationFrame 为编写 JS 动画提供了原生 API。
function draw() {
// …some draw code

requestAnimationFrame(draw)
}

draw()
requestAnimationFrame 为 JS 动画做了一些优化:

大多数屏幕的最高帧率是 60fps,requestAnimationFrame 默认会尽可能地达到这一帧率
元素不在当前 viewport 时,requestAnimationFrame 会极大地限制动画的帧率以节约系统资源
使用 requestAnimationFrame 定义多个同时段的动画,页面只会产生一次重绘。

当然 requestAnimationFrame 存在一定的兼容性问题,具体可参考 can i use。
Promise
fs.readdir(source, function (err, files) {
if (err) {
console.log(‘Error finding files: ‘ + err)
} else {
files.forEach(function (filename, fileIndex) {
console.log(filename)
gm(source + filename).size(function (err, values) {
if (err) {
console.log(‘Error identifying file size: ‘ + err)
} else {
console.log(filename + ‘ : ‘ + values)
aspect = (values.width / values.height)
widths.forEach(function (width, widthIndex) {
height = Math.round(width / aspect)
console.log(‘resizing ‘ + filename + ‘to ‘ + height + ‘x’ + height)
this.resize(width, height).write(dest + ‘w’ + width + ‘_’ + filename, function(err) {
if (err) console.log(‘Error writing file: ‘ + err)
})
}.bind(this))
}
})
})
}
})
假设最初学 JS 时我看到的是上面的代码,我一定不会想写前端。这就是所谓的“callback hell”,而 Promise 把回调函数的嵌套逻辑替换成了符合正常人思维习惯的线性逻辑。
function fetchSomething() {
return new Promise(function(resolved) {
if (success) {
resolved(res);
}
});
}
fetchSomething().then(function(res) {
console.log(res);
return fetchSomething();
}).then(function(res) {
console.log(‘duplicate res’);
return ‘done’;
}).then(function(tip) {
console.log(tip);
})
async await
async await 是 ES2017 引入的两个关键字,旨在让开发者更方便地编写异步代码,可是往往能看到类似这样的代码:
async function orderFood() {
const pizzaData = await getPizzaData() // async call
const drinkData = await getDrinkData() // async call
const chosenPizza = choosePizza() // sync call
const chosenDrink = chooseDrink() // sync call

await addPizzaToCart(chosenPizza) // async call
await addDrinkToCart(chosenDrink) // async call

orderItems() // async call
}
Promise 的引入让我们脱离了“callback hell”,可是对 async 函数的错误用法又让我们陷入了“async hell”。
这里其实 getPizzaData 和 getDrinkData 是没有关联的,而 await 关键字使得必须在 getPizzaData resolve 之后才能执行 getDrinkData 的动作,这显然是冗余的,包括 addPizzaToCart 和 addDrinkToCart 也是一样,影响了系统的性能。所以在写 async 函数时,应该清楚哪些代码是相互依赖的,把这些代码单独抽成 async 函数,另外 Promise 在声明时就已经执行,提前执行这些抽出来的 async 函数,再 await 其结果就能避免“async hell”,或者也可以用 Promise.all():

async function selectPizza() {
const pizzaData = await getPizzaData() // async call
const chosenPizza = choosePizza() // sync call

await addPizzaToCart(chosenPizza) // async call
}

async function selectDrink() {
const drinkData = await getDrinkData() // async call
const chosenDrink = chooseDrink() // sync call

await addDrinkToCart(chosenDrink) // async call
}

// return promise early
async function orderFood() {
const pizzaPromise = selectPizza()
const drinkPromise = selectDrink()

await pizzaPromise
await drinkPromise

orderItems() // async call
}

// or promise.all()
Promise.all([selectPizza(), selectDrink()]).then(orderItems) // async call
参考文章 && 拓展阅读

JavaScript Event Loop Explained
How to escape async/await hell
Tasks, microtasks, queues and schedules
requestAnimationFrame

退出移动版