JS异步
JS 异步基础
单线程模型
单线程模型指的是,JavaScript 只在一个线程上运行。也就是说,JavaScript 同时只能执行一个任务,其他任务都必须在后面排队等待。
注意,JavaScript 只在一个线程上运行,不代表 JavaScript 引擎只有一个线程。事实上,JavaScript 引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合。
同步任务 synchronous 和异步任务 asynchronous
同步任务: 是那些没有被引擎挂起、在主线程上排队执行的任务。只有前一个任务执行完毕,才能执行后一个任务。
异步任务: 那些被引擎放在一边,不进入主线程、而进入任务队列的任务。只有引擎认为某个异步任务可以执行了(比如 Ajax 操作从服务器得到了结果),该任务(采用回调函数的形式)才会进入主线程执行。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有 “ 堵塞 “ 效应。
任务队列和事件循环
JavaScript 运行时,除了一个正在运行的主线程,引擎还提供一个任务队列(task queue),里面是各种需要当前程序处理的异步任务(实际上多个任务队列)
首先,主线程会去执行所有的同步任务。等到同步任务全部执行完,就会去看任务队列里面的异步任务。如果满足条件,那么异步任务就重新进入主线程开始执行,这时它就变成同步任务了。等到执行完,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。
异步任务的写法通常是回调函数。一旦异步任务重新进入主线程,就会执行对应的回调函数。如果一个异步任务没有回调函数,就不会进入任务队列,也就是说,不会重新进入主线程,因为没有用回调函数指定下一步的操作。
JavaScript 引擎怎么知道异步任务有没有结果,能不能进入主线程呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。这种循环检查的机制,就叫做事件循环
异步操作的模式
回调函数
回调函数是异步操作最基本的方法。
1
2
3
4
5
6
7
8
9
10
function f1(callback) {
// ...
callback();
}
function f2() {
// ...
}
f1(f2);
f2 等待 f1 执行完毕执行
回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(coupling),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。
事件监听
1
2
3
4
5
6
7
function f1() {
setTimeout(function () {
// ...
f1.trigger('done');
}, 1000);
}
// f1.trigger('done')表示,执行完成后,立即触发done事件,从而开始执行f2。
这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以 “ 去耦合 “(decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。阅读代码的时候,很难看出主流程。
发布/订阅(观察者)
事件完全可以理解成 “ 信号 “,如果存在一个 “ 信号中心 “,某个任务执行完成,就向信号中心 “ 发布 “(publish)一个信号,其他任务可以向信号中心 “ 订阅 “(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做 “ 发布/订阅模式 “(publish-subscribe pattern),又称 “ 观察者模式 “(observer pattern)。
这个模式有多种实现,下面采用的是 Ben Alman 的 Tiny Pub/Sub,这是 jQuery 的一个插件。
- 首先,f2 向信号中心 jQuery 订阅 done 信号。
1
jQuery.subscribe('done', f2);
- 然后,f1 进行如下改写。
1
2
3
4
5
6
function f1() {
setTimeout(function () {
// ...
jQuery.publish('done');
}, 1000);
}
jQuery.publish(‘done’) 的意思是,f1 执行完成后,向信号中心 jQuery 发布 done 信号,从而引发 f2 的执行。
- f2 完成执行后,可以取消订阅(unsubscribe)。
1
jQuery.unsubscribe('done', f2);
定时器
JavaScript 提供定时执行代码的功能,叫做定时器(timer),主要由 setTimeout() 和 setInterval() 这两个函数来完成。它们向任务队列添加定时任务。
setTimeout()
setTimeout 函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器。
var timerId = setTimeout(func code, delay);
第一个参数 func code 是将要推迟执行的函数名或者一段代码 - 第二个参数 delay 是推迟执行的毫秒数。
1
2
3
4
5
6
console.log(1);
setTimeout('console.log(2)',1000);
console.log(3);
// 1
// 3
// 2
- 如果推迟执行的是函数,就直接将函数名,作为 setTimeout 的参数。
1
2
3
4
5
function f() {
console.log(2);
}
setTimeout(f, 1000);
- setTimeout 多个参数
1
2
3
4
setTimeout(function (a,b) {
console.log(a + b);
}, 1000, 1, 1);
// setTimeout共有4个参数。最后那两个参数,将在1000毫秒之后回调函数执行时,作为回调函数的参数。
- 回调函数是对象
如果回调函数是对象的方法,那么 setTimeout 使得方法内部的 this 关键字指向全局环境,而不是定义时所在的那个对象。
1
2
3
4
5
6
7
8
9
10
11
var x = 1;
var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};
setTimeout(obj.y, 1000) // 1
// 输出的是1,而不是2。因为当obj.y在1000毫秒后运行时,this所指向的已经不是obj了,而是全局环境
为了防止出现这个问题,一种解决方法是将 obj.y 放入一个函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var x = 1;
var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};
setTimeout(function () {
obj.y();
}, 1000);
// 2
// obj.y放在一个匿名函数之中,这使得obj.y在obj的作用域执行,而不是在全局作用域内执行,所以能够显示正确的值。
另一种解决方法是,使用 bind 方法,将 obj.y 这个方法绑定在 obj 上面。
1
2
3
4
5
6
7
8
9
10
var x = 1;
var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};
setTimeout(obj.y.bind(obj), 1000)
setTimeout(f, 0)
setTimeout 的作用是将代码推迟到指定时间执行,如果指定时间为 0,即 setTimeout(f, 0),那么会立刻执行吗?
答案是不会,必须要等到当前脚本的同步任务,全部处理完以后,才会执行 setTimeout 指定的回调函数 f。也就是说,setTimeout(f, 0) 会在下一轮事件循环一开始就执行。
1
2
3
4
5
6
setTimeout(function () {
console.log(1);
}, 0);
console.log(2);
// 2
// 1
总之,setTimeout(f, 0) 这种写法的目的是,尽可能早地执行 f,但是并不能保证立刻就执行 f。
实际上,setTimeout(f, 0) 不会真的在 0 毫秒之后运行,不同的浏览器有不同的实现。以 Edge 浏览器为例,会等到 4 毫秒之后运行。如果电脑正在使用电池供电,会等到 16 毫秒之后运行;如果网页不在当前 Tab 页,会推迟到 1000 毫秒(1 秒)之后运行。这样是为了节省系统资源。
setInterval()
setInterval 函数的用法与 setTimeout 完全一致,区别仅仅在于 setInterval 指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行。
1
2
3
4
var i = 1
var timer = setInterval(function() {
console.log(2);
}, 1000)
每隔 1000 毫秒就输出一个 2,会无限运行下去,直到关闭当前窗口
- setInterval 指定的是 “ 开始执行 “ 之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间。
- 为了确保两次执行之间有固定的间隔,可以不用 setInterval,而是每次执行结束后,使用 setTimeout 指定下一次执行的具体时间。
1
2
3
4
5
var i = 1;
var timer = setTimeout(function f() {
// ...
timer = setTimeout(f, 2000);
}, 2000);
clearTimeout(),clearInterval()
setTimeout 和 setInterval 函数,都返回一个整数值,表示计数器编号。将该整数传入 clearTimeout 和 clearInterval 函数,就可以取消对应的定时器。
1
2
3
4
5
var id1 = setTimeout(f, 1000);
var id2 = setInterval(f, 1000);
clearTimeout(id1);
clearInterval(id2);
- setTimeout 和 setInterval 返回的整数值是连续的,也就是说,第二个 setTimeout 方法返回的整数值,将比第一个的整数值大 1。
1
2
3
4
function f() {}
setTimeout(f, 1000) // 10
setTimeout(f, 1000) // 11
setTimeout(f, 1000) // 12
- 利用这一点,可以写一个函数,取消当前所有的 setTimeout 定时器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(function() {
// 每轮事件循环检查一次
var gid = setInterval(clearAllTimeouts, 0);
function clearAllTimeouts() {
var id = setTimeout(function() {}, 0);
while (id > 0) {
if (id !== gid) {
clearTimeout(id);
}
id--;
}
}
})();
Promise
见ES6的Promise
异步函数
异步函数(async function)是 ECMAScript 2017 (ECMA-262) 标准的规范,几乎被所有浏览器所支持,除了 Internet Explorer。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function print(delay, message) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
console.log(message);
resolve();
}, delay);
});
}
async function asyncFunc() {
await print(1000, "First");
await print(4000, "Second");
await print(3000, "Third");
}
asyncFunc();
异步函数 async function 中可以使用 await 指令,await 指令后必须跟着一个 Promise,异步函数会在这个 Promise 运行中暂停,直到其运行结束再继续运行。
异步函数实际上原理与 Promise 原生 API 的机制是一模一样的,只不过更便于程序员阅读。
处理异常的机制将用 try-catch
块实现:
1
2
3
4
5
6
7
8
9
10
11
async function asyncFunc() {
try {
await new Promise(function (resolve, reject) {
throw "Some error"; // 或者 reject("Some error")
});
} catch (err) {
console.log(err);
// 会输出 Some error
}
}
asyncFunc();
如果 Promise 有一个正常的返回值,await 语句也会返回它:
1
2
3
4
5
6
7
8
9
async function asyncFunc() {
let value = await new Promise(
function (resolve, reject) {
resolve("Return value");
}
);
console.log(value);
}
asyncFunc();
输出:
Return value