七、异步操作

1,异步操作概述

(1)单线程模式

单线程模型指的是,jacascrapt只在一个线程上执行。javascript同时只能执行一个任务,其他任务都必须在后面排队等待。注意,javascript只在一个线程上运行,不代表javascript引擎只有一个线程。事实上,javascript引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合。

javascript之所以采用单线程,而不是多线程,跟历史有关系。jacascript从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,太复杂了。如果javascript同时有两个线程,一个线程在网页DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?是不是还要有锁机制?所以为了避免复杂性,javascript一开始就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。javascript语言本身并不慢,慢的是读写外部数据,比如等待Ajax请求返回结果。这个时候,如果对方服务器迟迟没有响应,或者网络不通畅,就会导致脚本的长时间停滞。

如果排队是因为计算量大,CPU忙不过来倒也正常,但是很多时候CPU是闲着的,因为IO操作(输入输出)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。javascript语言的设计者意识到,这时CPU完全可以不管IO操作,挂起处于等待中的任务,先运行排在后面的任务。等到IO操作返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是javascript内部采用“事件循环”机制(event loop)。

单线程模型虽然对javascript构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果用得好,javascript程序是不会出现堵塞的,这就是为什么node可以用很少的资源,应付大流量访问的原因。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许javascript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变javascript单线程的本质。

(2)同步任务和异步任务

程序里面所有的任务,可以分成两类:同步任务和异步任务。

同步任务是那些没有被引擎挂起、在主线程上排队执行的任务。只有前一个任务执行完毕,才能执行后一个任务。

异步任务是那些被引擎放在一遍边,不进入主线程、而进入任务队列的任务。只有引擎认为某个异步任务可以执行了(比如Ajax操作从服务器得到了结果),该任务(采用回调函数的形式)才会进入主线程执行。排在异步任务后面的代码,不用等待异步任务结束会马上运行,异步任务不具有“堵塞”效应。

举例来说,Ajax操作可以当做同步任务处理,也可以当做异步任务处理,由开发者决定。如果是同步任务,主线程就等着Ajax操作返回结果,再往下执行;如果是异步任务,主线程在发出Ajax请求以后,就直接往下执行,等到Ajax操作有了结果,主线程再执行对应的回调函数。

(3)任务队列和事件循环

javascript运行时,除了一个正在运动的主线程,引擎还提供一个任务队列(task queue),里面各种需要当前程序处理的异步任务。(实际上,根据异步任务的类型,存在多个任务队列。为了方便理解,这里假设只存在一个队列。)

首先,主线程会去执行所有的同步任务。等到同步任务全部执行完,就会去看任务队列里面的异步任务。如果满足条件,那么异步任务就重新进入主线程开始执行,这时它就变成同步任务了。等到执行完,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。

异步任务的写法通常是回调函数。一旦异步任务重新进入主线程,就会执行对应的回调函数。如果一个异步任务没有回调函数,就不会进入任务队列,不会重新进入主线程,因为没有用回调函数指定下一步的操作。

javascript引擎怎么知道异步任务有没有结果,能不能进入主线程呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。这种循环检查的机制,就叫做事件循环。“事件循环是一个程序结构,用于等待和发送消息和事件。”

(4)异步操作的模式

下面总结异步操作的几种模式。

a,回调函数

回调函数是异步操作最基本的方法。

下面是两个函数f1和f2,编程的意图是f2必须等到f1执行完成,才能执行。

1. function f1() {
2. // ...
3. }
4.
5. function f2() {
6. // ...
7. }
8.
9. f1();
10. f2();

上面代码的问题在于,如果f1是异步操作,f2会立即执行,不会等到f1结束再执行。这时,可以考虑改写f1,把f2写成f1的回调函数。

1. function f1(callback) {
2. // ...
3. callback();
4. }
5.
6. function f2() {
7. // ...
8. }
9.
10. f1(f2);

回调函数的优点是是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合) (coupling),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任 务只能指定一个回调函数。

b,事件监听

另一种思路是采用事件驱动模式。异步任务的执行不取决于代码的顺序,而取决于某个事件是否发生。 还是以 f1 和 f2 为例。首先,为 f1 绑定一个事件(这里采用的 jQuery 的写法)。

1. f1.on('done', f2);

上面这行代码的意思是,当 f1 发生 done 事件,就执行 f2 。然后,对 f1 进行改写:

1. function f1() {
2.     setTimeout(function () {
3.         // ...
4.         f1.trigger('done');
5.     }, 1000);
6. }

上面代码中, f1.trigger('done') 表示,执行完成后,立即触发 done 事件,从而开始执 行 f2 。 这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以“去 耦合”(decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得 很不清晰。阅读代码的时候,很难看出主流程。

c,发布/订阅

事件完全可以理解成“信号”,如果存在一个“信号中心”,某个任务执行完成,就向信号中心“发 布”(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么 时候自己可以开始执行。这就叫做”发布/订阅模式”(publish-subscribe pattern),又称“观察 者模式”(observer pattern)。

这个模式有多种实现,下面采用的是 Ben Alman 的 Tiny Pub/Sub,这是 jQuery 的一个插件。 首先, f2 向信号中心 jQuery 订阅 done 信号。

1. jQuery.subscribe('done', f2);

然后, f1 进行如下改写。

1. function f1() {
2.     setTimeout(function () {
3.         // ...
4.         jQuery.publish('done');
5.     }, 1000);
6. }

上面代码中, jQuery.publish('done') 的意思是, f1 执行完成后,向信号中心 jQuery 发 布 done 信号,从而引发 f2 的执行。

f2 完成执行后,可以取消订阅(unsubscribe)。

1. jQuery.unsubscribe('done', f2);

这种方法的性质与“事件监听”类似,但是明显优于后者。因为可以通过查看“消息中心”,了解存在多少 信号、每个信号有多少订阅者,从而监控程序的运行。

(5)异步操作的流程控制

如果有多个异步操作,就存在一个流程控制的问题:如何确定异步操作执行的顺序,以及如何保证遵守 这种顺序。

1. function async(arg, callback) {
2.     console.log('参数为 ' + arg +' , 1秒后返回结果');
3.     setTimeout(function () { callback(arg * 2); }, 1000);
4. }

上面代码的 async 函数是一个异步任务,非常耗时,每次执行需要1秒才能完成,然后再调用回调函 数。 如果有六个这样的异步任务,需要全部完成后,才能执行最后的 final 函数。请问应该如何安排操作 流程?

1. function final(value) {
2.     console.log('完成: ', value);
3. }
4.
5. async(1, function (value) {
6.     async(2, function (value) {
7.         async(3, function (value) {
8.             async(4, function (value) {
9.                 async(5, function (value) {
10.                     async(6, final);
11.                 });
12.             });
13.         });
14.     });
15. });
16. // 参数为 1 , 1秒后返回结果
17. // 参数为 2 , 1秒后返回结果
18. // 参数为 3 , 1秒后返回结果
19. // 参数为 4 , 1秒后返回结果
20. // 参数为 5 , 1秒后返回结果
21. // 参数为 6 , 1秒后返回结果
22. // 完成: 12

上面代码中,六个回调函数的嵌套,不仅写起来麻烦,容易出错,而且难以维护。

a,串行执行

我们可以编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个。这就叫串 行执行。

1. var items = [ 1, 2, 3, 4, 5, 6 ];
2. var results = [];
3.
4. function async(arg, callback) {
5.     console.log('参数为 ' + arg +' , 1秒后返回结果');
6.     setTimeout(function () { callback(arg * 2); }, 1000);
7. }
8.
9. function final(value) {
10.     console.log('完成: ', value);
11. }
12.
13. function series(item) {
14.     if(item) {
15.         async( item, function(result) {
16.             results.push(result);
17.             return series(items.shift());
18.         });
19.     } else {
20.         return final(results[results.length - 1]);
21.     }
22. }
23.
24. series(items.shift());

上面代码中,函数 series 就是串行函数,它会依次执行异步任务,所有任务都完成后,才会执 行 final 函数。 items 数组保存每一个异步任务的参数, results 数组保存每一个异步任务的 运行结果。 注意,上面的写法需要六秒,才能完成整个脚本。

b,并行执行

流程控制函数也可以是并行执行,即所有异步任务同时执行,等到全部完成以后,才执行 final 函数。

1. var items = [ 1, 2, 3, 4, 5, 6 ];
2. var results = [];
3.
4. function async(arg, callback) {
5.     console.log('参数为 ' + arg +' , 1秒后返回结果');
6.     setTimeout(function () { callback(arg * 2); }, 1000);
7. }
8.
9. function final(value) {
10.     console.log('完成: ', value);
11. }
12.
13. items.forEach(function(item) {
14.     async(item, function(result){
15.         results.push(result);
16.         if(results.length === items.length) {
17.             final(results[results.length - 1]);
18.         }
19.     })
20. });

上面代码中, forEach 方法会同时发起六个异步任务,等到它们全部完成以后,才会执 行 final 函数。 相比而言,上面的写法只要一秒,就能完成整个脚本。这就是说,并行执行的效率较高,比起串行执行 一次只能执行一个任务,较为节约时间。但是问题在于如果并行的任务较多,很容易耗尽系统资源,拖 慢运行速度。因此有了第三种流程控制方式。

c,并行和串行的结合

所谓并行与串行的结合,就是设置一个门槛,每次最多只能并行执行 n 个异步任务,这样就避免了过 分占用系统资源。

1. var items = [ 1, 2, 3, 4, 5, 6 ];
2. var results = [];
3. var running = 0;
4. var limit = 2;
5.
6. function async(arg, callback) {
7.     console.log('参数为 ' + arg +' , 1秒后返回结果');
8.     setTimeout(function () { callback(arg * 2); }, 1000);
9. }
10.
11. function final(value) {
12.     console.log('完成: ', value);
13. }
14.
15. function launcher() {
16.     while(running < limit && items.length > 0) {
17.         var item = items.shift();
18.         async(item, function(result) {
19.             results.push(result);
20.             running--;
21.             if(items.length > 0) {
22.                 launcher();
23.             } else if(running == 0) {
24.                 final(results);
25.             }
26.         });
27.         running++;
28.     }
29. }
30.
31. launcher();

上面代码中,最多只能同时运行两个异步任务。变量 running 记录当前正在运行的任务数,只要低于 门槛值,就再启动一个新的任务,如果等于 0 ,就表示所有任务都执行完了,这时就执 行 final 函数。 这段代码需要三秒完成整个脚本,处在串行执行和并行执行之间。通过调节 limit 变量,达到效率和 资源的最佳平衡。

2,定时器

JavaScript 提供定时执行代码的功能,叫做定时器(timer),主要 由 setTimeout() 和 setInterval() 这两个函数来完成。它们向任务队列添加定时任务。

setTimeout()

setTimeout 函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时 器的编号,以后可以用来取消这个定时器。

1. var timerId = setTimeout(func|code, delay);

上面代码中, setTimeout 函数接受两个参数,第一个参数 func|code 是将要推迟执行的函数名 或者一段代码,第二个参数 delay 是推迟执行的毫秒数。

1. console.log(1);
2. setTimeout('console.log(2)',1000);
3. console.log(3);
4. // 1
5. // 3
6. // 2

上面代码会先输出1和3,然后等待1000毫秒再输出2。注意, console.log(2) 必须以字符串的形 式,作为 setTimeout 的参数。 如果推迟执行的是函数,就直接将函数名,作为 setTimeout 的参数。

1. function f() {
2.     console.log(2);
3. }
4.
5. setTimeout(f, 1000);

setTimeout 的第二个参数如果省略,则默认为0。

1. setTimeout(f)
2. // 等同于
3. setTimeout(f, 0)

除了前两个参数, setTimeout 还允许更多的参数。它们将依次传入推迟执行的函数(回调函数)。

1. setTimeout(function (a,b) {
2. console.log(a + b);
3. }, 1000, 1, 1);

上面代码中, setTimeout 共有4个参数。最后那两个参数,将在1000毫秒之后回调函数执行时,作 为回调函数的参数。 还有一个需要注意的地方,如果回调函数是对象的方法,那么 setTimeout 使得方法内部 的 this 关键字指向全局环境,而不是定义时所在的那个对象。

1. var x = 1;
2.
3. var obj = {
4.     x: 2,
5.     y: function () {
6.         console.log(this.x);
7.     }
8. };
9.
10. setTimeout(obj.y, 1000) // 1

上面代码输出的是1,而不是2。因为当 obj.y 在1000毫秒后运行时, this 所指向的已经不 是 obj 了,而是全局环境。 为了防止出现这个问题,一种解决方法是将 obj.y 放入一个函数。

1. var x = 1;
2.
3. var obj = {
4.     x: 2,
5.     y: function () {
6.         console.log(this.x);
7.     }
8. };
9.
10. setTimeout(function () {
11.     obj.y();
12. }, 1000);
13. // 2

上面代码中, obj.y 放在一个匿名函数之中,这使得 obj.y 在 obj 的作用域执行,而不是在全 局作用域内执行,所以能够显示正确的值。 另一种解决方法是,使用 bind 方法,将 obj.y 这个方法绑定在 obj 上面。

1. var x = 1;
2.
3. var obj = {
4.     x: 2,
5.     y: function () {
6.         console.log(this.x);
7.     }
8. };
9.
10. setTimeout(obj.y.bind(obj), 1000)
11. // 2

setInterval()

setInterval 函数的用法与 setTimeout 完全一致,区别仅仅在于 setInterval 指定某个任务 每隔一段时间就执行一次,也就是无限次的定时执行。

1. var i = 1
2. var timer = setInterval(function() {
3.     console.log(2);
4. }, 1000)

上面代码中,每隔1000毫秒就输出一个2,会无限运行下去,直到关闭当前窗口。 与 setTimeout 一样,除了前两个参数, setInterval 方法还可以接受更多的参数,它们会传入 回调函数。 下面是一个通过 setInterval 方法实现网页动画的例子。

1. var div = document.getElementById('someDiv');
2. var opacity = 1;
3. var fader = setInterval(function() {
4.     opacity -= 0.1;
5.     if (opacity >= 0) {
6.         div.style.opacity = opacity;
7.     } else {
8.         clearInterval(fader);
9.     }
10. }, 100);

上面代码每隔100毫秒,设置一次 div 元素的透明度,直至其完全透明为止。 setInterval 的一个常见用途是实现轮询。下面是一个轮询 URL 的 Hash 值是否发生变化的例 子。

1. var hash = window.location.hash;
2. var hashWatcher = setInterval(function() {
3.     if (window.location.hash != hash) {
4.         updatePage();
5.     }
6. }, 1000);

setInterval 指定的是“开始执行”之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实 际上,两次执行之间的间隔会小于指定的时间。比如, setInterval 指定每 100ms 执行一次,每 次执行需要 5ms,那么第一次执行结束后95毫秒,第二次执行就会开始。如果某次执行耗时特别长, 比如需要105毫秒,那么它结束后,下一次执行就会立即开始。 为了确保两次执行之间有固定的间隔,可以不用 setInterval ,而是每次执行结束后,使 用 setTimeout 指定下一次执行的具体时间。

1. var i = 1;
2. var timer = setTimeout(function f() {
3. // ...
4.     timer = setTimeout(f, 2000);
5. }, 2000);

上面代码可以确保,下一次执行总是在本次执行结束之后的2000毫秒开始。

clearTimeout(),clearInterval()

setTimeout 和 setInterval 函数,都返回一个整数值,表示计数器编号。将该整数传 入 clearTimeout 和 clearInterval 函数,就可以取消对应的定时器。

1. var id1 = setTimeout(f, 1000);
2. var id2 = setInterval(f, 1000);
3.
4. clearTimeout(id1);
5. clearInterval(id2);

上面代码中,回调函数 f 不会再执行了,因为两个定时器都被取消了。

setTimeout 和 setInterval 返回的整数值是连续的,也就是说,第二个 setTimeout 方法返 回的整数值,将比第一个的整数值大1。

1. function f() {}
2. setTimeout(f, 1000) // 10
3. setTimeout(f, 1000) // 11
4. setTimeout(f, 1000) // 12

上面代码中,连续调用三次 setTimeout ,返回值都比上一次大了1。 利用这一点,可以写一个函数,取消当前所有的 setTimeout 定时器。

1. (function() {
2. // 每轮事件循环检查一次
3. var gid = setInterval(clearAllTimeouts, 0);
4.
5. function clearAllTimeouts() {
6.     var id = setTimeout(function() {}, 0);
7.         while (id > 0) {
8.             if (id !== gid) {
9.                 clearTimeout(id);
10.             }
11.             id--;
12.         }
13.     }
14. })();

上面代码中,先调用 setTimeout ,得到一个计算器编号,然后把编号比它小的计数器全部取消。

实例:debounce 函数

有时,我们不希望回调函数被频繁调用。比如,用户填入网页输入框的内容,希望通过 Ajax 方法传 回服务器,jQuery 的写法如下。

1. $('textarea').on('keydown', ajaxAction);

这样写有一个很大的缺点,就是如果用户连续击键,就会连续触发 keydown 事件,造成大量的 Ajax通信。这是不必要的,而且很可能产生性能问题。正确的做法应该是,设置一个门槛值,表示两次 Ajax 通信的最小间隔时间。如果在间隔时间内,发生新的 keydown 事件,则不触发 Ajax 通信, 并且重新开始计时。如果过了指定时间,没有发生新的 keydown 事件,再将数据发送出去。 这种做法叫做 debounce(防抖动)。假定两次 Ajax 通信的间隔不得小于2500毫秒,上面的代码可 以改写成下面这样。

1. $('textarea').on('keydown', debounce(ajaxAction, 2500));
2.
3. function debounce(fn, delay){
4.     var timer = null; // 声明计时器
5.     return function() {
6.         var context = this;
7.         var args = arguments;
8.         clearTimeout(timer);
9.         timer = setTimeout(function () {
10.             fn.apply(context, args);
11.         }, delay);
12.     };
13. }

上面代码中,只要在2500毫秒之内,用户再次击键,就会取消上一次的定时器,然后再新建一个定时 器。这样就保证了回调函数之间的调用间隔,至少是2500毫秒。

运行机制

setTimeout 和 setInterval 的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件 循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。 这意味着, setTimeout 和 setInterval 指定的回调函数,必须等到本轮事件循环的所有同步任 务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保 证, setTimeout 和 setInterval 指定的任务,一定会按照预定时间执行。

1. setTimeout(someTask, 100);
2. veryLongTask();

上面代码的 setTimeout ,指定100毫秒以后运行一个任务。但是,如果后面的 veryLongTask 函 数(同步任务)运行时间非常长,过了100毫秒还无法结束,那么被推迟运行的 someTask 就只有等着,等到 veryLongTask 运行结束,才轮到它执行。 再看一个 setInterval 的例子。

1. setInterval(function () {
2.     console.log(2);
3. }, 1000);
4.
5. sleep(3000);
6.
7. function sleep(ms) {
8.     var start = Date.now();
9.     while ((Date.now() - start) < ms) {
10.     }
11. }

上面代码中, setInterval 要求每隔1000毫秒,就输出一个2。但是,紧接着的 sleep 语句需要 3000毫秒才能完成,那么 setInterval 就必须推迟到3000毫秒之后才开始生效。注意,生效 后 setInterval 不会产生累积效应,即不会一下子输出三个2,而是只会输出一个2。

setTimeout(f, 0)

含义:setTimeout 的作用是将代码推迟到指定时间执行,如果指定时间为 0 ,即 setTimeout(f, 0) ,那么会立刻执行吗? 答案是不会。因为上一节说过,必须要等到当前脚本的同步任务,全部处理完以后,才会执 行 setTimeout 指定的回调函数 f 。也就是说, setTimeout(f, 0) 会在下一轮事件循环一开 始就执行。

1. setTimeout(function () {
2.     console.log(1);
3. }, 0);
4. console.log(2);
5. // 2
6. // 1

上面代码先输出 2 ,再输出 1 。因为 2 是同步任务,在本轮事件循环执行,而 1 是下一轮事 件循环执行。 总之, setTimeout(f, 0) 这种写法的目的是,尽可能早地执行 f ,但是并不能保证立刻就执 行 f 。 实际上, setTimeout(f, 0) 不会真的在0毫秒之后运行,不同的浏览器有不同的实现。以 Edge 浏览器为例,会等到4毫秒之后运行。如果电脑正在使用电池供电,会等到16毫秒之后运行;如果网页不 在当前 Tab 页,会推迟到1000毫秒(1秒)之后运行。这样是为了节省系统资源。

应用:setTimeout(f, 0) 有几个非常重要的用途。它的一大应用是,可以调整事件的发生顺序。比如,网 页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的 事件回调函数触发。如果,想让父元素的事件回调函数先发生,就要用到 setTimeout(f, 0) 。

1. // HTML 代码如下
2. // <input type="button" id="myButton" value="click">
3.
4. var input = document.getElementById('myButton');
5.
6. input.onclick = function A() {
7.     setTimeout(function B() {
8.         input.value +=' input';
9.     }, 0)
10. };
11.
12. document.body.onclick = function C() {
13.     input.value += ' body'
14. };

上面代码在点击按钮后,先触发回调函数 A ,然后触发函数 C 。函数 A 中, setTimeout 将 函数 B 推迟到下一轮事件循环执行,这样就起到了,先触发父元素的回调函数 C 的目的了。 另一个应用是,用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入 文本, keypress 事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的的。

1. // HTML 代码如下
2. // <input type="text" id="input-box">
3.
4. document.getElementById('input-box').onkeypress = function (event) {
5.     this.value = this.value.toUpperCase();
6. }

上面代码想在用户每次输入文本后,立即将字符转为大写。但是实际上,它只能将本次输入前的字符转 为大写,因为浏览器此时还没接收到新的文本,所以 this.value 取不到最新输入的那个字符。只有 用 setTimeout 改写,上面的代码才能发挥作用。

1. document.getElementById('input-box').onkeypress = function() {
2.     var self = this;
3.     setTimeout(function() {
4.         self.value = self.value.toUpperCase();
5.     }, 0);
6. }

上面代码将代码放入 setTimeout 之中,就能使得它在浏览器接收到文本之后触发。 由于 setTimeout(f, 0) 实际上意味着,将任务放到浏览器最早可得的空闲时段执行,所以那些计算 量大、耗时长的任务,常常会被放到几个小部分,分别放到 setTimeout(f, 0) 里面执行。

1. var div = document.getElementsByTagName('div')[0];
2.
3. // 写法一
4. for (var i = 0xA00000; i < 0xFFFFFF; i++) {
5.     div.style.backgroundColor = '#' + i.toString(16);
6. }
7.
8. // 写法二
9. var timer;
10. var i=0x100000;
11.
12. function func() {
13.     timer = setTimeout(func, 0);
14.     div.style.backgroundColor = '#' + i.toString(16);
15.     if (i++ == 0xFFFFFF) clearTimeout(timer);
16. }
17.
18. timer = setTimeout(func, 0);

上面代码有两种写法,都是改变一个网页元素的背景色。写法一会造成浏览器“堵塞”,因为 JavaScript 执行速度远高于 DOM,会造成大量 DOM 操作“堆积”,而写法二就不会,这就 是 setTimeout(f, 0) 的好处。 另一个使用这种技巧的例子是代码高亮的处理。如果代码块很大,一次性处理,可能会对性能造成很大 的压力,那么将其分成一个个小块,一次处理一块,比如写成 setTimeout(highlightNext, 50) 的 样子,性能压力就会减轻。

3,Promise 对象

Promise 对象是 JavaScript 的异步操作解决方案,为异步操作提供统一接口。它起到代理作用 (proxy),充当异步操作与回调函数之间的中介,使得异步操作具备同步操作的接口。Promise 可 以让异步操作写起来,就像在写同步操作的流程,而不必一层层地嵌套回调函数。

首先,Promise 是一个对象,也是一个构造函数。

1. function f1(resolve, reject) {
2.     // 异步代码...
3. }
4.
5. var p1 = new Promise(f1);

上面代码中, Promise 构造函数接受一个回调函数 f1 作为参数, f1 里面是异步操作的代码。 然后,返回的 p1 就是一个 Promise 实例。 Promise 的设计思想是,所有异步任务都返回一个 Promise 实例。Promise 实例有一 个 then 方法,用来指定下一步的回调函数。

1. var p1 = new Promise(f1);
2. p1.then(f2);

上面代码中, f1 的异步操作执行完成,就会执行 f2 。 传统的写法可能需要把 f2 作为回调函数传入 f1 ,比如写成 f1(f2) ,异步操作完成后, 在 f1 内部调用 f2 。Promise 使得 f1 和 f2 变成了链式写法。不仅改善了可读性,而且对 于多层嵌套的回调函数尤其方便。

1. // 传统写法
2. step1(function (value1) {
3.     step2(value1, function(value2) {
4.         step3(value2, function(value3) {
5.             step4(value3, function(value4) {
6.             // ...
7.             });
8.         });
9.     });
10. });
11.
12. // Promise 的写法
13. (new Promise(step1))
14. .then(step2)
15. .then(step3)
16. .then(step4);

从上面代码可以看到,采用 Promises 以后,程序流程变得非常清楚,十分易读。注意,为了便于理 解,上面代码的 Promise 实例的生成格式,做了简化,真正的语法请参照下文。 总的来说,传统的回调函数写法使得代码混成一团,变得横向发展而不是向下发展。Promise 就是解 决这个问题,使得异步流程可以写成同步流程。 Promise 原本只是社区提出的一个构想,一些函数库率先实现了这个功能。ECMAScript 6 将其写 入语言标准,目前 JavaScript 原生支持 Promise 对象。

Promise 对象的状态

Promise 对象通过自身的状态,来控制异步操作。Promise 实例具有三种状态。

异步操作未完成(pending)

异步操作成功(fulfilled)

异步操作失败(rejected)

上面三种状态里面, fulfilled 和 rejected 合在一起称为 resolved (已定型)。 这三种的状态的变化途径只有两种。

从“未完成”到“成功”

从“未完成”到“失败”

一旦状态发生变化,就凝固了,不会再有新的状态变化。这也是 Promise 这个名字的由来,它的英语 意思是“承诺”,一旦承诺成效,就不得再改变了。这也意味着,Promise 实例的状态变化只可能发生 一次。 因此,Promise 的最终结果只有两种。

异步操作成功,Promise 实例传回一个值(value),状态变为 fulfilled 。

异步操作失败,Promise 实例抛出一个错误(error),状态变为 rejected 。

Promise 构造函数

JavaScript 提供原生的 Promise 构造函数,用来生成 Promise 实例。

1. var promise = new Promise(function (resolve, reject) {
2. // ...
3.
4.     if (/* 异步操作成功 */){
5.         resolve(value);
6.     } else { /* 异步操作失败 */
7.         reject(new Error());
8.     }
9. });

上面代码中, Promise 构造函数接受一个函数作为参数,该函数的两个参数分别 是 resolve 和 reject 。它们是两个函数,由 JavaScript 引擎提供,不用自己实现。 resolve 函数的作用是,将 Promise 实例的状态从“未完成”变为“成功”(即从 pending 变 为 fulfilled ),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去。 reject 函 数的作用是,将 Promise 实例的状态从“未完成”变为“失败”(即从 pending 变 为 rejected ),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。 下面是一个例子。

1. function timeout(ms) {
2.     return new Promise((resolve, reject) => {
3.         setTimeout(resolve, ms, 'done');
4.     });
5. }
6.
7. timeout(100)

上面代码中,timeout(100) 返回一个Promise实例。100毫秒以后,该实例的状态会变为fulfilled 。

Promise.prototype.then()

Promise 实例的 then 方法,用来添加回调函数。 then 方法可以接受两个回调函数,第一个是异步操作成功时(变为 fulfilled 状态)的回调函 数,第二个是异步操作失败(变为 rejected )时的回调函数(该参数可以省略)。一旦状态改变,就调用相应的回调函数。

1. var p1 = new Promise(function (resolve, reject) {
2.     resolve('成功');
3. });
4. p1.then(console.log, console.error);
5. // "成功"
6.
7. var p2 = new Promise(function (resolve, reject) {
8.     reject(new Error('失败'));
9. });
10. p2.then(console.log, console.error);
11. // Error: 失败

上面代码中, p1 和 p2 都是Promise 实例,它们的 then 方法绑定两个回调函数:成功时的回 调函数 console.log ,失败时的回调函数 console.error (可以省略)。 p1 的状态变为成 功, p2 的状态变为失败,对应的回调函数会收到异步操作传回的值,然后在控制台输出。then 方法可以链式使用。

1. p1
2.     .then(step1)
3.     .then(step2)
4.     .then(step3)
5.     .then(
6.         console.log,
7.         console.error
8. );

上面代码中, p1 后面有四个 then ,意味依次有四个回调函数。只要前一步的状态变 为 fulfilled ,就会依次执行紧跟在后面的回调函数。 最后一个 then 方法,回调函数是 console.log 和 console.error ,用法上有一点重要的区 别。 console.log 只显示 step3 的返回值,而 console.error 可以显 示 p1 、 step1 、 step2 、 step3 之中任意一个发生的错误。举例来说,如果 step1 的状 态变为 rejected ,那么 step2 和 step3 都不会执行了(因为它们是 resolved 的回调函 数)。Promise 开始寻找,接下来第一个为 rejected 的回调函数,在上面代码中 是 console.error 。这就是说,Promise 对象的报错具有传递性。

then() 用法辨析

Promise 的用法,简单说就是一句话:使用 then 方法添加回调函数。但是,不同的写法有一些细 微的差别,请看下面四种写法,它们的差别在哪里?

1. // 写法一
2. f1().then(function () {
3.     return f2();
4. });
5.
6. // 写法二
7. f1().then(function () {
8.     f2();
9. });
10.
11. // 写法三
12. f1().then(f2());
13.
14. // 写法四
15. f1().then(f2);

为了便于讲解,下面这四种写法都再用 then 方法接一个回调函数 f3 。写法一的 f3 回调函数 的参数,是 f2 函数的运行结果。

1. f1().then(function () {
2.     return f2();
3. }).then(f3);

写法二的 f3 回调函数的参数是 undefined 。

1. f1().then(function () {
2.     f2();
3.     return;
4. }).then(f3);

写法三的 f3 回调函数的参数,是 f2 函数返回的函数的运行结果。

1. f1().then(f2())
2.     .then(f3);

写法四与写法一只有一个差别,那就是 f2 会接收到 f1() 返回的结果。

1. f1().then(f2)
2.     .then(f3);

实例:图片加载

下面是使用 Promise 完成图片的加载。

1. var preloadImage = function (path) {
2.     return new Promise(function (resolve, reject) {
3.         var image = new Image();
4.         image.onload = resolve;
5.         image.onerror = reject;
6.         image.src = path;
7.     });
8. };

上面代码中, image 是一个图片对象的实例。它有两个事件监听属性, onload 属性在图片加载成 功后调用, onerror 属性在加载失败调用。 上面的 preloadImage() 函数用法如下。

1. preloadImage('https://example.com/my.jpg')
2. .then(function (e) { document.body.append(e.target) })
3. .then(function () { console.log('加载成功') })

上面代码中,图片加载成功以后, onload 属性会返回一个事件对象,因此第一个 then() 方法的 回调函数,会接收到这个事件对象。该对象的 target 属性就是图片加载后生成的 DOM 节点。

小结

Promise 的优点在于,让回调函数变成了规范的链式写法,程序流程可以看得很清楚。它有一整套接 口,可以实现许多强大的功能,比如同时执行多个异步操作,等到它们的状态都改变以后,再执行一个 回调函数;再比如,为多个回调函数中抛出的错误,统一指定处理方法等等。 而且,Promise 还有一个传统写法没有的好处:它的状态一旦改变,无论何时查询,都能得到这个状 态。这意味着,无论何时为 Promise 实例添加回调函数,该函数都能正确执行。所以,你不用担心是 否错过了某个事件或信号。如果是传统写法,通过监听事件来执行回调函数,一旦错过了事件,再添加 回调函数是不会执行的。 Promise 的缺点是,编写的难度比传统写法高,而且阅读代码也不是一眼可以看懂。你只会看到一 堆 then ,必须自己在 then 的回调函数里面理清逻辑。

微任务

Promise 的回调函数属于异步任务,会在同步任务之后执行。

1. new Promise(function (resolve, reject) {
2.     resolve(1);
3. }).then(console.log);
4.
5. console.log(2);
6. // 2
7. // 1

上面代码会先输出2,再输出1。因为 console.log(2) 是同步任务,而 then 的回调函数属于异步 任务,一定晚于同步任务执行。 但是,Promise 的回调函数不是正常的异步任务,而是微任务(microtask)。它们的区别在于,正 常任务追加到下一轮事件循环,微任务追加到本轮事件循环。这意味着,微任务的执行时间一定早于正 常任务。

1. setTimeout(function() {
2.     console.log(1);
3. }, 0);
4.
5. new Promise(function (resolve, reject) {
6.     resolve(2);
7. }).then(console.log);
8.
9. console.log(3);
10. // 3
11. // 2
12. // 1

上面代码的输出结果是 321 。这说明 then 的回调函数的执行时间,早于 setTimeout(fn, 0) 。因为 then 是本轮事件循环执行, setTimeout(fn, 0) 在下一轮事件循环开始时执行。

JavaScript-修炼之路第七层相关推荐

  1. 程序员修炼之路(十)程序员必读经典书籍和成长路线

    本篇文章是程序员修炼之路的第十篇. 原文地址:http://book.douban.com/doulist/1259081/?start=25&filter= 这篇文章主要是关于程序员学习路线 ...

  2. 程序员修炼之路(十四)IT外企那点儿事--也说跳槽

    最近一个月一直在忙项目,几乎没什么时间写博客,今天中午才有时间看看csdn,在论坛上看到一篇很好的文章,分享给大家.也给自己留作备用. 原文地址:http://www.cnblogs.com/forf ...

  3. 腾讯业务监控的修炼之路

    作者丨李光:现任职于腾讯社交网络运营部/织云产品团队,负责织云监控告警平台规划与运维新产品开发工作,具有多年业务运维.运营规划经验. 概述 本文作为监控告警产品的专题系列的第二篇文章,主要讨论的是IA ...

  4. 四层和七层交换技术-loadbalance

    1 四层交换技术简介 我们知道,二层交换机是根据第二层数据链路层的MAC地址和通过站表选择路由来完成端到端的数据交换的.三层交换机是直接根据第三层网络层IP地址来完成端到端的数据交换的. 四 层交换机 ...

  5. 七层负载均衡--Haproxy

    七层负载均衡--Haproxy 1 Haproxy的定义 2 七层负载均衡的概念 3 四层和七层负载均衡的对比 4 Haproxy的安装及部署 4.1 Haproxy实现负载均衡 4.2 建立监控 4 ...

  6. OSI 七层参考模型

    值得注意的是, OSI 参考模型本身并不是一个完整的网络体系结构,因为它并未确切地描述用于各层的协议和服务,它仅仅告诉我们每层应该做什么.不过, ISO 已经为各层制定了标准,但它们并不是参考模型的一 ...

  7. OSI七层与TCP/IP五层

    OSI七层与TCP/IP五层网络架构详解 OSI和TCP/IP是很基础但又非常重要的网络基础知识,理解得透彻对运维工程师来说非常有帮助.今天偶又复习了一下: (1)OSI七层模型 OSI中的层 功能 ...

  8. 面试必会系列 - 5.2 详解OSI模型与七层协议,网络TCP/IP基础,三次握手、四次挥手等

    本文已收录至 Github(MD-Notes),若博客中图片模糊或打不开,可以来我的 Github 仓库,包含了完整图文:https://github.com/HanquanHq/MD-Notes,涵 ...

  9. OSI七层与TCP/IP四/五层网络架构

    一.模型 (1)OSI七层模型 开放系统互连参考模型 (Open System Interconnect 简称OSI)是国际标准化组织(ISO)和国际电报电话咨询委员会(CCITT)联合制定的开放系统 ...

最新文章

  1. Python自动化运维之函数进阶
  2. 二、Netty服务端/客户端启动整体流程
  3. 简述抽象和封装,对你学习Java有一些作用
  4. 【整数反转】算法优化笔记
  5. CRM WebClient UI页面的跳转处理
  6. 准备树莓派下的模块开发环境
  7. SpringCloud基础组件总结,与Dubbo框架、SpringBoot框架对比分析
  8. zabbix 5.0所有依赖包_Zabbix“专家坐诊”第82期问答汇总
  9. 2011华为上机试题-Java
  10. QPCore Service与NetAssist冲突解决
  11. harmonyos鸿蒙,HarmonyOS鸿蒙入门篇
  12. 智能车改舵机中值步骤_智能车制作全过程(飞思卡尔)
  13. 如何自动加载scratch3.0的页面上实现自动加载原有的作品
  14. 千千音乐付费音乐爬取--json数据的处理
  15. 无法连接imssage信息服务器,苹果iPhone X用iMessage发短信信息总是失败解决方法
  16. 幽默感七个技巧_16个聊天幽默技巧 几招让你变的风趣幽默
  17. NodeMCU开发板详解
  18. Linux系列之soft lockup机制 浅析
  19. stm32学习笔记-中断系统
  20. 个人站长的疑问:怎么样才能做一个能赚钱的网站?

热门文章

  1. 知多点|小微等企业信贷风控流程中的六大步骤
  2. 批量收集照片并按规则命名
  3. docker部署nginx 并实现反向代理 配置多个域名多个端口
  4. loss函数之NLLLoss,CrossEntropyLoss
  5. Jetson NX emmc版本系统转移到SSD
  6. java实现潜艇大战(期末实训)
  7. UltraISO制作操作系统U盘启动盘来重装系统
  8. 教你一招:全面认识浏览器工具条
  9. 怎么用电脑把mkv格式转换成mp4
  10. 七天学会ASP.NET MVC