文章

JS异步

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(funccode, delay);
  • 第一个参数 funccode 是将要推迟执行的函数名或者一段代码
  • 第二个参数 delay 是推迟执行的毫秒数。
1
2
3
4
5
6
console.log(1);
setTimeout('console.log(2)',1000);
console.log(3);
// 1
// 3
// 2
  1. 如果推迟执行的是函数,就直接将函数名,作为 setTimeout 的参数。
1
2
3
4
5
function f() {
  console.log(2);
}

setTimeout(f, 1000);
  1. setTimeout 多个参数
1
2
3
4
setTimeout(function (a,b) {
  console.log(a + b);
}, 1000, 1, 1);
// setTimeout共有4个参数。最后那两个参数,将在1000毫秒之后回调函数执行时,作为回调函数的参数。
  1. 回调函数是对象

如果回调函数是对象的方法,那么 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

本文由作者按照 CC BY 4.0 进行授权