本系列其他译文请看JS工作机制 - 小白1991的专栏 - 掘金 (juejin.cn)
原文请查阅这里,本文采用知识共享署名 4.0 国际许可协议共享,BY Troland。
这是 JavaScript 工作原理的第四章。
为什么需要单线程限制?
在第一章,我们讨论了当调用栈中有长时间运行的函数调用时发生了什么。
假如我们在浏览器里运行一个复杂的图像变换算法。
当调用栈执行有函数需要执行时,浏览器是不能做其他任何事情的--它会被阻塞。此时浏览器不会渲染,也不会运行任何代码,你的UI完全的卡住了。
一旦你的浏览器在调用栈开始处理过多的任务,它将会停止响应很长时间。在那时,许多浏览器将会提出一个异常警告,询问是否需要关闭该也页面。 这个提醒非常丑陋:
JS程序的构建块
你可能在一个单独的js文件里写程序,但是你的程序一般有很多块组成,而且某个时间上只有一个块在执行,其余的将会稍后执行。最常见的块单元就是函数。
大多数开发者刚接触JS似乎有一个这样的理解,‘稍后’不是立刻发生在now之后的。
换句话讲,当前不能完成的任务将会异步完成,这样你就不会不经意间遇到以上提及的 UI 阻塞行为。
var response = ajax('https://example.com/api');
console.log(response);
// 'response' 将不会有数据返回
标准的AJAX请求不会同步完成,这意味着ajax方法中的代码还没有任何返回值给response变量。 一个简单的方式去等待异步方法的返回,就是使用回调函数
ajax('https://example.com/api', function(response) {
console.log(response);
// `response` 现在可用了
});
你可以完全发起一个同步的ajax请求。但是永远不要这样做。如果你发起了一个同步的请求,你的JS程序的UI将会阻塞,此时用户不能点击,输入数据,导航或者滚动。这将会冻结任何用户的交互体验,非常的糟糕。
// 假设你用的 jQuery
jQuery.ajax({
url: 'https://api.example.com/endpoint',
success: function(response) {
// 这里写回调
},
async: false //同步的方式
});
这种代码长这样,但是绝对不要这样做,不要毁了你的web。
上面是一个例子,我们可以把任何代码都放到异步执行里。
This can be done with the setTimeout(callback, milliseconds) function. What the setTimeout function does is to set up an event (a timeout) to happen later.
setTimeout可以达到这个效果,setTimeout做的事情就是设置一个稍后执行的event。
function first() {
console.log('first');
}
function second() {
console.log('second');
}
function third() {
console.log('third');
}
first();
setTimeout(second, 1000); // Invoke `second` after 1000ms
third();
我们将会得到这样的输出:
first
third
second
什么是Event Loop?
我们以一个稍微有点奇怪的声明开始,虽然它可以使JS代码异步(比如之前讨论的 setTimetout),直到ES6,实际上 JavaScript 本身并没有集成任何直接的异步编程概念。JavaScript 引擎只允许在任意时刻执行单个的程序片段。
更多JS引擎的细节,请看前一章。
所以,谁告诉JS引擎执行代码片段?实际上,JS引擎不是孤立执行的,它运行在一个寄宿环境中(Chrome或者Node)。实际上在今天,JS嵌入了几乎所有类型的硬件,从机器人到电灯泡。每一个设备为JS引擎提供了一个不同的寄宿环境。
所有宿主环境都含有一个被称为事件循环的内置机制,随着时间的推移,事件循环会执行程序中多个代码片段,每次都会调用 JS 引擎。
这意味着JS引擎只是一种按需执行的环境。这是一个封闭的环境,在其中进行事件的调度(运行JS 代码)。
所以,举个例子。当你的程序发起了一个请求去获取服务器的数据,并设置了一个回调函数。JS引擎告诉寄宿环境‘hi,我将会暂时挂起运行了,你什么时候完成了网络请求,拿到了数据,请调用回调函数’
浏览器之后设定了网络回应的监听,等有数据返回,它把回调函数加入到EventLoop中。 看一下示意图
什么是网页 API ?本质上,你没有权限访问这些线程,你只能够调用它们。它们是浏览器自带的,且可以在浏览器中进行并发操作。如果你是个 Node.js 开发者,这些是 C++ APIs。
说了半天,到底EventLoop是啥?
EventLoop只有一项简单的工作---监测调用栈和回调队列。如果调用栈是空的,它会从回调队列中取得第一个事件然后入栈,然后执行。
这样一次遍历被称为一个 tick。每个事件就是一个回调函数。
console.log('Hi');
setTimeout(function cb1() {
console.log('cb1');
}, 5000);
console.log('Bye');
让我们执行这段代码,然后看看会发生什么:
1.空状态。浏览器控制台是空的,调用栈也是空的。
2.console.log('Hi') 入栈。
3.执行 console.log('Hi')。
4.console.log('Hi') 出栈
setTimeout(function cb1() { ... })入栈。
6.执行 setTimeout(function cb1() { ... }),浏览器创建定时器作为网页 API 的一部分并将会为你处理倒计时。
7.setTimeout(function cb1() { ... }) 执行完毕并出栈。
8.console.log('Bye') 入栈。
9.执行 console.log('Bye')。
10.console.log('Bye') 出栈。
11.至少 5 秒之后,定时器结束运行并把 cb1 回调添加到回调队列。
12.事件循环从回调队列中获得 cb1 函数并且将其入栈。
13.运行 cb1 函数并将 console.log('cb1') 入栈。
14.执行 console.log('cb1')。
15.console.log('cb1') 出栈。
16.cb1 出栈
ES6 指明了EventLoop如何工作的,这意味着从技术上讲EventLoop在 JS 引擎负责的范围之内,而 JS 引擎将不再只是扮演着宿主环境的角色。
ES6 中 Promise 的出现是导致改变的主要原因之一,因为 ES6 要求更多权限,可以更直接,更细粒度地控制事件循环队列中的调度操作(之后会深入探讨)。
setTimeout(…) 是如何工作的?
setTimeout没有自动把你的回调放入Loop队列。它设置了一个计时器。当计时器到了,宿主环境将回调放置进了EventLoop,这样可以在未来拾取并执行它。
setTimeout(myCallback, 1000);
这不意味着1000毫秒之后它会被执行,只是会被添加到事件队列中。当队列中此时有其他已经添加进去的事件时,你的回调就只能等待。 现在,你知道Event Loop 如何运作的,也知道setTimeout是如何工作的:调用time传入参数0只是延缓执行直到调用栈被清空。
console.log('Hi');
setTimeout(function() {
console.log('callback');
}, 0);
console.log('Bye');
所以尽管设置了定时器是0,但是输出的是这样的
Hi
Bye
callback
ES6的Job是什么 ?
一个新的概念,叫做Job队列(这里的Job也就是微任务),在ES6中被引入了。它始终在Event Loop队列的顶部。你极有可能在处理 Promises(之后会介绍) 的异步行为的时候无意间接触到这一概念。 现在我们仅仅接触了概念,稍后你就会理解这些行为是如何被调度和处理的
想象这样的场景:Job队列附加在Event Loop队列中每个 tick 末尾的队列。Event Loop的一个 tick 所产生的某些异步操作不会导致添加全新的事件到事件循环队列中,但是反而会在当前 tick 的作业队列末尾添加一个作业项。
这意味着你可以添加一个其他的函数稍后执行,而且你可以确信它将会被在其他功能之前被执行。
一个Job可以导致其他的更多的Job被添加在同一个队列的末尾。
理论上,它可能是一个Job Loop,它无限循环的添加事件,这会让程序无法获得必要的资源继续执行,直到下一个tick.概念上讲,这有点像在代码中执行了一个无限循环。
Jobs are kind of like the setTimeout(callback, 0) “hack” but implemented in such a way that they introduce a much more well-defined and guaranteed ordering: later, but as soon as possible. Job有点像setTimeout(callback, 0),但是实现方式不同。它们拥有明确定义和有保证的执行顺序:稍后执行,但还是要尽可能的快
回调
就像你已经知道的那样,回调是目前非常普遍的一种方式去表达和管理异步编程。确实,回调是最重要的异步模式。数不尽的JS程序,即使是非常复杂和巧妙的的应用,也是基于回调的。
尽管回调也有缺点,而且许多开发者在尝试去找更好的异步模式。但是你如果不理解实现原理,那基本上是不可能有效使用任何抽象化的语法。 在接下来的章节,,我们将会深入探究这些抽象语法并理解更复杂的异步模式的必要性。
嵌套回调
看代码:
listen('click', function (e){
setTimeout(function(){
ajax('https://api.example.com/endpoint', function (text){
if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}
});
}, 500);
});
代码里有3个函数嵌套,每一个都表示一个异步的步骤。 这个风格的代码,经常被叫做‘回调地狱’。回调地狱不是一个嵌套/缩进的格式问题,它有更深刻的问题。
首先,我们在等待'click'事件,然后等待一段定时器去触发,然后我们需要再次等待Ajax回应,此时会再次全部重复一遍。 乍一眼看上去,可以上把以上具有异步特性的代码拆分为按步骤执行的代码,如下所示:
listen('click', function (e) {
// ..
});
然后
setTimeout(function(){
// ..
}, 500);
再然后
ajax('https://api.example.com/endpoint', function (text){
// ..
});
最后
if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}
以这样顺序执行的方式来表示异步代码看起来一气呵成,应该有这样的方法吧?
Promise
查看如下代码:
var x = 1;
var y = 2;
console.log(x + y);
这个很直观:计算出 x 和 y 的值,然后在控制台打印出来。
但如果 x 或者 y 的初始值是不存在的或不确定的呢?假设,在表达式中使用 x 和 y 之前,我们需要从服务器得到 x 和 y 的值。假设函数 loadX 和 loadY 分别从服务器获取 x 和 y 的值。然后,一旦得 x 和 y 的值,就可以使用 sum 函数计算出和值。
function sum(getX, getY, callback) {
var x, y;
getX(function(result) {
x = result;
if (y !== undefined) {
callback(x + y);
}
});
getY(function(result) {
y = result;
if (x !== undefined) {
callback(x + y);
}
});
}
// A sync or async function that retrieves the value of `x`
function fetchX() {
// ..
}
// A sync or async function that retrieves the value of `y`
function fetchY() {
// ..
}
sum(fetchX, fetchY, function(result) {
console.log(result);
});
这里需要记住的一点是-在代码片段中,x 和 y 是未来值,我们用 sum(..)(从外部)来计算和值,但是并没有判断x 和 y 是否马上同时有值。
这个粗糙的基于回调的技术还有很多需要改进的地方。这只是一小步,来帮助你理解使用未来值而不用担心值何时可用的好处。
Promise 值
让我们简略地看一下如何用 Promises 来表示 x+y :
function sum(xPromise, yPromise) {
// `Promise.all([ .. ])` 包含一组 Promise,
// 并返回一个新的 Promise 来等待所有 Promise 执行完毕
return Promise.all([xPromise, yPromise])
// 当新 Promise 解析完毕,就可以同时获得 `x` 和 `y` 的值并相加。
.then(function(values){
// `values` 是之前解析 promises 返回的消息数组
return values[0] + values[1];
} );
}
// `fetchX()` and `fetchY()` 返回 promise 来取得各自的返回值,这些值返回是无时序的。
sum(fetchX(), fetchY())
// 获得一个计算两个数和值的 promise,现在,就可以链式调用 `then(...)` 来处理返回的 promise。
.then(function(sum){
console.log(sum);
});
以上代码片段含有两层Promise。
fetchX() 和 fetchY() 都是直接调用,它们的返回值(promises!)都被传入 sum(…) 作为参数。虽然这些 promises 的 返回值也许会在现在或之后返回,但是无论如何每个 promise 都具有相同的异步行为。我们可以的推算 x 和 y 是与时间无关的值。暂时称他们为未来值。
第二层次的 promise 是由 sum(…) (通过 Promise.all([ ... ]))所创建和返回的,然后通过调用 then(…) 来等待 promise 的返回值。当 sum(…) 运行结束,返回 sum 未来值然后就可以打印出来。我们在 sum(…) 内部隐藏了等待未来值 x 和 y 的逻辑。
注意: 在 sum(…) 内部,Promise.all([ … ])创建了一个 promise(在等待 promiseX 和 promiseY 解析之后)的链式调用 ,.then(…) 创建了另一个 promise,该 promise 会由代码 values[0] + values[1] 立刻进行解析(返回相加结果)。因此,在代码片段的末尾即 sum(…) 的末尾链式调用 then(…)-实际上是在操作第二个返回的 promise 而不是第一个由 Promise.all([ ... ]) 创建返回的 promise。同样地,虽然我们没有在第二个then(…) 之后进行链式调用,但是它也创建了另一个 promise,我们可以选择观察/使用该 promise。我们将会在本章的随后内容中进行详细地探讨 promise 的链式调用相关。
在 Promises 中,实际上 then(…) 函数可以传入两个函数作为参数,第一个函数是成功函数,第二个是失败函数。
sum(fetchX(), fetchY())
.then(
// 成功句柄
function(sum) {
console.log( sum );
},
// 拒绝句柄
function(err) {
console.error( err ); // bummer!
}
);
当获取 x 或者 y 出现错误或者计算和值的时候出现错误,sum(…) 返回的 promise 将会失败,传入 then(…) 作为第二个参数的回调错误处理程序将会接收 promise 的返回值。
因为 Promise 封装了时间相关的状态-等待外部的成功或者失败的返回值,Promise 本身是与时间无关的,这样就能够以可预测的方式组成(合并) Promise 而不用关心时序或者返回结果。
除此之外,一旦 Promise 解析完成,它就会一直保持不可变的状态且可以被随意观察。
链式调用 promise 有时候真的很管用:
function delay(time) {
return new Promise(function(resolve, reject){
setTimeout(resolve, time);
});
}
delay(1000)
.then(function(){
console.log("after 1000ms");
return delay(2000);
})
.then(function(){
console.log("after another 2000ms");
})
.then(function(){
console.log("step 4 (next Job)");
return delay(5000);
})
// ...
调用 delay(2000) 创建一个将在 2 秒后返回成功的 promise,然后,从第一个 then(…) 的成功回调函数中返回该 promise,这会导致第二个 then(…) 返回的 promise 等待 2 秒后返回成功的 promise。
Note: 因为一个 promise 一旦结束,它的内部状态就不可以从外部改变,所以可以安全地把状态值随意分发给任意第三方。当涉及多方观察 Promise 的返回结果时候更是如此。不可变性听起来像是个晦涩的科学课题,但是,实际上这是 Promise 最根本和重要的方面,你得好好研究研究。
Promise 使用时机
Promise 的一个重要细节即确定某些值是否是真正的 Promise。换句话说,这个值是否具有 Promise 的行为。
我们可以利用 new Promise(…) 语法来创建 Promise,然后,你会认为使用 p instanceof Promise 来检测某个对象是否是 Promise 类的实例。然而,并不全然如此。
主要的原因在于你可以从另一个浏览器窗口(比如 iframe)获得 Promise 实例,iframe 中的 Promise 不同于当前浏览器窗口或框架中的 Promise,因此,会导致检测 Promise 实例失败。
除此之外,库或框架或许会选择使用自身自带的 Promise 而不是原生的 ES6 实现的 Promise。实际工作中,你可以使用库自带的 Promise 来兼容不支持 Promise 的老版本浏览器。
异常捕获
如果在创建 Promise 或者是在观察解析 Promise 返回结果的任意时刻,遇到了诸如 TypeError 或者 ReferenceError 的 JavaScript 错误异常,这个异常会被捕获进而强制 Promise 为失败状态。
比如:
var p = new Promise(function(resolve, reject){
foo.bar(); // `foo` 未定义,产生错误!
resolve(374); // 永不执行 :(
});
p.then(
function fulfilled(){
// 永不执行 :(
},
function rejected(err){
// `err` 会是一个 `TypeError` 异常对象
// 由于 `foo.bar()` 代码行.
}
);
但是,如果 Promise 成功解析了而在成功解析的监听函数(then(…) 注册回调)中抛出 JS 运行错误会怎么样?仍然可以捕捉到该异常,但你会发现处理这些异常的方式有些让人奇怪:
var p = new Promise( function(resolve,reject){
resolve(374);
});
p.then(function fulfilled(message){
foo.bar();
console.log(message); // 永不执行
},
function rejected(err){
// 永不执行
}
);
看起来 foo.bar() 抛出的错误异常真的被捕获到了。但其实事实上并没有,我们没有监测到其中一些更深入的错误。p.then(…) 调用本身返回另一个 promise,该promise将处理rejiected句柄。
拓展一下以上的说明,这是原文没有的。
var p = new Promise( function(resolve,reject){
resolve(374);
});
p.then(function fulfilled(message){
foo.bar();
console.log(message); // 永不执行
},
function rejected(err){
// 永不执行
}
).then(
function() {},
function(err) { console.log('err', err);}
);
如上代码所示就可以真正捕获到 promise 成功解析回调函数里面的代码错误。
处理未捕获的异常
据说一些技巧能更好的处理异常。
普遍的做法是为 Promises 添加 done(..) 回调,这会标记 promise 链的状态为 "done."。done(…) 并不会创建和返回 promise,因此,当不存在链式 promise 的时候,传入 done(..) 的回调就不会抛出错误。
和未捕获的错误状况一样:任何在 done(..) 失败处理函数中的异常都将会被抛出为全局错误(基本上是在开发者控制台)。
var p = Promise.resolve(374);
p.then(function fulfilled(msg){
// 数字没有字符类的函数,所以会报错
console.log(msg.toLowerCase());
})
.done(null, function() {
// 若发生错误,将会抛出全局错误
});
ES8 中的 Async/await
JavaScript ES8 中介绍了 async/await,这使得处理 Promises 更加容易了。我们将会简要介绍 async/await 的所有可能姿势并利用其来书写异步代码。
先瞧瞧 async/await 工作原理。
使用 async 函数定义一个异步函数。该函数会返回异步函数对象。AsyncFunction 对象表示在异步函数中运行代码。
当调用异步函数的时候,它会返回一个 Promise。异步函数返回时,返回值并不是 Promise,但会自动创建一个 Promise ,并使用返回值来解析该 Promise。当 async 函数抛出异常,Promise 失败回调会获取抛出的异常值。
async 函数可以包含一个 await 表达式,这样就可以暂停函数的执行来等待传入的 Promise 的返回结果,之后重启异步函数的执行并返回解析值。
你可以把 JavaScript 中的 Promise 看作 Java 中的 Future 或 C# 中的 Task。
async/await本意是用来简化 promises 的使用。
看下如下代码:
// 标准 JavaScript 函数
function getNumber1() {
return Promise.resolve('374');
}
// 和 getNumber1 一样
async function getNumber2() {
return 374;
}
类似地,抛出异常的函数等价于返回失败的 promises。
function f1() {
return Promise.reject('Some error');
}
async function f2() {
throw 'Some error';
}
await 关键字只能在 async 函数中使用并且允许你同步等待 Promise。如果在 async 函数外使用 promises,我们仍然必须使用 then 回调。
async function loadData() {
// `rp` 是个发起 promise 的函数。
var promise1 = rp('https://api.example.com/endpoint1');
var promise2 = rp('https://api.example.com/endpoint2');
// 现在,并发请求两个 promise,现在我们必须等待它们结束运行。
var response1 = await promise1;
var response2 = await promise2;
return response1 + ' ' + response2;
}
// 因为不再使用 `async function`,所以必须使用 `then`。
loadData().then(() => console.log('Done'));
你也可以使用异步函数表达式来定义异步函数。
异步函数表达式拥有和异步函数语句相近的语法。
异步函数表达式和异步函数语句的主要区别在于函数名,异步函数表达式可以忽略函数名来创建匿名函数。
异步函数表达式可以被用作 IIFE(立即执行函数表达式),可以在定义的时候立即运行。
像这样:
var loadData = async function() {
// `rp` 是个发起 promise 的函数。
var promise1 = rp('https://api.example.com/endpoint1');
var promise2 = rp('https://api.example.com/endpoint2');
// 现在,并发请求两个 promise,现在我们必须等待它们结束运行。
var response1 = await promise1;
var response2 = await promise2;
return response1 + ' ' + response2;
}
更为重要的是,所有的主流浏览器都支持 async/await
如果该兼容性不符合你的需求,你可以使用诸如 Babel 和 TypeScript 的 JS 转译器来转换为自己需要的兼容程度。
最后要说的是,不要盲目地使用最新的技术来写异步代码。理解 JavaScript 中 async 的内部原理是非常重要的,但正如编程中的其它东西一样,每种技术都有其优缺点。
书写高可用,强壮的异步代码的 5 条小技巧
1.简洁:使用 async/await 可以让你写更少的代码。每次书写 async/await 代码,你都可以跳过书写一些不必要的步骤: 比如不用写 .then 回调,创建匿名函数来处理返回值,命名回调返回值。
// `rp` 是个发起 promise 的工具函数。
rp(‘https://api.example.com/endpoint1').then(function(data) {
// …
});
对比:
// `rp` 是个发起 promise 的工具函数
var response = await rp(‘https://api.example.com/endpoint1');
2.错误处理:Async/await 允许使用日常的 try/catch 代码结构体来处理同步和异步错误。看下和 Promise 是如何写的:
function loadData() {
try { // 捕获同步错误.
getJSON().then(function(response) {
var parsed = JSON.parse(response);
console.log(parsed);
}).catch(function(e) { // 捕获异步错误.
console.log(e);
});
} catch(e) {
console.log(e);
}
}
对比:
async function loadData() {
try {
var data = JSON.parse(await getJSON());
console.log(data);
} catch(e) {
console.log(e);
}
}
3.条件语句:使用 async/await 来书写条件语句会更加直观。
function loadData() {
return getJSON()
.then(function(response) {
if (response.needsAnotherRequest) {
return makeAnotherRequest(response)
.then(function(anotherResponse) {
console.log(anotherResponse)
return anotherResponse
})
} else {
console.log(response)
return response
}
})
}
对比:
async function loadData() {
var response = await getJSON();
if (response.needsAnotherRequest) {
var anotherResponse = await makeAnotherRequest(response);
console.log(anotherResponse)
return anotherResponse
} else {
console.log(response);
return response;
}
}
4.堆栈桢:和 async/await 不同的是,从链式 promise 返回的错误堆栈中无法得知发生错误的地方。看如下代码:
function loadData() {
return callAPromise()
.then(callback1)
.then(callback2)
.then(callback3)
.then(() => {
throw new Error("boom");
})
}
loadData()
.catch(function(e) {
console.log(err);
// Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});
对比:
async function loadData() {
await callAPromise1()
await callAPromise2()
await callAPromise3()
await callAPromise4()
await callAPromise5()
throw new Error("boom");
}
loadData()
.catch(function(e) {
console.log(err);
// output
// Error: boom at loadData (index.js:7:9)
});
5.调试:如果使用 promise,你就会明白调试它们是一场噩梦。例如,如果你在 .then 代码块中设置一个断点并且使用诸如 "stop-over" 的调试快捷键,调试器不会移动到下一个 .then 代码块,因为调试器只会步进同步代码。
使用 async/await 你可以就像同步代码那样步进到下一个 await 调用。
不仅是程序本身还有库,书写异步 JavaScript 代码都是相当重要的。