0%

深入探究JavaScript

JS方法/函数重载的姿势

JavaScript不支持重载的语法,它没有重载所需要的函数签名。

ECMAScript函数不能像传统意义上那样实现重载。而在其他语言(如 Java)中,可以为一个函数编写两个定义,只要这两个定义的签名(接受的参数的类型和数量)不同即可。如前所述,ECMAScirpt函数没有签名,因为其参数是由包含零或多个值的数组来表示的。而没有函数签名,真正的重载是不可能做到的。 — JavaScript高级程序设计(第3版)3.7.2小节

在JavaScript中,函数名本身就是变量,函数声明类似于变量赋值。当同个函数名被多次声明时,后声明的内容将覆盖前面的内容。尽管JavaScript无法做到真正的重载,但是可以通过检查传入函数中参数的类型和数量并作相应的处理,从而实现重载的效果,曲线救国。

借助流程控制语句

通过判断传入参数的数量(arguments.length),执行相应的代码块。

巧用闭包特性

1
2
3
4
var ninja = {};
addMethod(ninja, 'whatever', function(){/* code */});
addMethod(ninja, 'whatever', function(a){/* code */});
addMethod(ninja, 'whatever', function(a,b){/* code */});

addMethod函数接收3个参数:目标对象、目标方法名、函数体,当函数被调用时:

先将目标object[name]的值存入变量old中,因此起初old中的值可能不是一个函数;接着向object[name]赋值一个代理函数,并且由于变量old、fn在代理函数中被引用,所以old、fn将常驻内存不被回收。

1
2
3
4
5
6
7
8
9
10
11
12
function addMethod(object, name, fn) {
var old = object[name]; // 保存前一个值,以便后续调用
object[name] = function(){ // 向object[name]赋值一个代理函数
// 判断fn期望接收的参数与传入参数个数是否一致
if (fn.length == arguments.length)
// 若是,则调用fn
return fn.apply(this, arguments)
else if (typeof old == 'function') // 若否,则判断old的值是否为函数
// 若是,则调用old
return old.apply(this, arguments);
};
}

代理函数被调用时:

先判断传入参数与其父级作用域中fn期望接收参数的个数是否一致,若是则调用该fn;

若否,则判断其父级作用域中old值类型是否为函数,若是则调用该old;

当old中存有上一次生成的代理函数时,则会重复前面两个步骤,直至old值不为代理函数


上述两种方法都是通过检查参数个数来实现重载,不区分参数类型。此外,方法1在继承时重载的那些函数无法被重写,而方法2通过逐个执行代理函数,比对参数个数,直至找到目标函数,效率不高。

巧用引用类型特性

核心思想:由于ECMAScript函数是一种引用类型对象,可扩展属性与方法。借此通过创建一个容器用于存储要重载的函数,并将容器挂载到代理函数上以便后续访问,而代理函数利用闭包特性访问容器。

重载顺序:首先查找参数类型匹配的函数,其次查找参数个数匹配的函数。

存储格式:键值对,键名由逗号与参数个数或参数类型组成,键值为要重载的函数,如下:

1
2
3
4
5
{
',0': function(){/* code */},
',1': function(a){/* code */},
',string,number': function(a,b){/* code */}
}

工具函数被调用时

  1. 先判断是否已重载过,若有,直接将要重载的函数按格式存入容器;
  2. 若未重载过,则创建一个容器变量;
  3. 判断未重载前的值是否为一个函数,若是,则以逗号+参数个数的格式存入容器;
  4. 将要重载的函数存入容器;
  5. 代理原函数,并将容器挂载到代理函数上;
  6. 当代理函数被调用时,将依次查找容器中匹配的函数并调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 重载工具函数
* @param {Object} ctx - 上下文
* @param {String} name - 函数名
* @param {Function} fn - 函数体
* @param {String} type - 参数类型
* @author 范围兄 <ambit_tsai@qq.com>
* @example 不指定参数类型
* overload(obj, 'do', function(){...});
* overload(obj, 'do', function(a){...});
* @example 指定参数类型
* overload(obj, 'do', function(a,b){...}, 'string,number');
*/
function overload(ctx, name, fn, type){
type = type? type.trim().toLowerCase(): fn.length;
// 已重载过
if(typeof ctx[name]==='function' && typeof ctx[name]._$fnMap==='object'){
ctx[name]._$fnMap[','+type] = fn; // 将fn存入_$fnMap
return;
}
// 未重载过
var fnMap = {}; // 容器
if(typeof ctx[name] === 'function'){
// 若ctx[name]是一个函数,则存入容器
fnMap[','+ctx[name].length] = ctx[name];
}
fnMap[','+type] = fn;
ctx[name] = function overloading(){ // 代理
var args = arguments,
len = args.length,
type, i;
for(i=0, type=''; i<len; ++i){ // 计算参数类型
type += ',' + typeof args[i];
}
// 依次匹配:参数类型->参数个数
if(fnMap[type]) return fnMap[type].apply(this, args);
if(fnMap[','+len]) return fnMap[','+len].apply(this, args);
throw 'Overload: no matched function';
};
ctx[name]._$fnMap = fnMap; // 将fnMap挂载到代理上
}

JS异步操作的方法

回调函数

回调函数是异步编程中最基本的方法。假设有三个函数f1、f2、f3f2需要等待f1的执行结果,而f3是独立的,不需要f1和f2的结果,如果我们写成同步,就是这样的:

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

如果f1执行的很快,可以; 但是如果f1执行的很慢,那么f2和f3就会被阻塞,无法执行。这样的效率是非常低的。但是我们可以改写,将f2写成是f1的回调函数,如下:

1
2
3
4
5
6
  function f1(callback){
    setTimeout(function () {
      // f1的任务代码
      callback();
    }, 1000);
  }

 那么这时候执行代码就是这样:

1
2
f1(f2);
f3()

这样,就是一个异步的执行了,即使f1很费时间,但是由于是异步的,那么f3()就会很快的得到执行,而不会受到f1和f2的影响。

注意: 如果我们把f1写成这样呢?

1
2
3
4
function f1(callback){
  // f1的任务代码
  callback();
}

然后,我们同样可以这么调用:

1
2
f1(f2);
f3()

这时候还是异步的吗? 答案:不是异步。 这里的回调函数并非真正的回调函数,如果没有利用setTimeout含函数,那么f3()的执行同样需要等到f1(f2)完全执行完毕,这里要注意。而我们就是利用setTImeout才能做出真正的回调函数。

事件监听

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

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

这里的意思是: 当f1发生了done事件,就执行f2, 然后,我们对f1进行改写:

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

f1.trigger(‘done’)表示, 执行完成后,立即触发done事件,从而开始执行f2。

这种方法的优点就是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以去耦合,有利于实现模块化,缺点就是整个程序都要变成事件驱动型,运行流程会变得很不清晰。

发布订阅

第二种方法的事件,实际上我们完全可以理解为“信号”,即f1完成之后,触发了一个 ‘done’,信号,然后再开始执行f2。

我们假定,存在一个“信号中心”,某个任务执行完成,就向信号中心“发布”(publish)一个信号,其他任务可以向信号中心“订阅”这个信号, 从而知道什么时候自己可以开始执行。 这个就叫做“发布/订阅模式”, 又称为“观察者”模式 。

这个模式有多种实现, 下面采用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 () {
      // f1的任务代码
      jQuery.publish("done");
    }, 1000);
  }

jquery.pushlish(“done”)的意思是: f1执行完成后,向“信号中心”jQuery发布“done”信号,从而引发f2的执行。

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

  

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

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

promise对象

promise是commonjs工作组提出来的一种规范,目的是为异步编程提供统一接口。

简答的说,它的思想是每一个异步任务返回一个promise对象,该对象有一个then方法,允许指定回调函数。 比如,f1的回调函数f2,可以写成:

1
f1().then(f2);

f1要进行下面的改写(这里使用jQuery的实现):

1
2
3
4
5
6
7
8
 function f1(){
    var dfd = $.Deferred();
    setTimeout(function () {
      // f1的任务代码
      dfd.resolve();
    }, 500);
    return dfd.promise;
  }

这样的优点在于,回调函数编程了链式写法,程序的流程可以看得很清楚,而且有一整套的配套方法,可以实现很多强大的功能 。

如:指定多个回调函数:

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

再比如,指定发生错误时的回调函数:

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

而且,他还有一个前面三种方法都没有的好处:如果一个任务已经完成,再添加回调函数,该回调函数会立即执行。 所以,你不用担心是否错过了某个事件或者信号,这种方法的确定就是编写和理解,都比较困难。

generator函数的异步应用

generator函数将JavaScript异步编程带入了一个全新的阶段!

比如,有一个任务是读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。

相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着

  

协程

 传统的编程语言中,早就有了异步编程的解决方案,其中一种叫做协程,意思是多个线程互相协作,完成异步任务

  协程优点像函数,又有点像线程,运行流程如下:

  • 第一步,协程A开始执行。
  • 第二步,协程A执行到一半,进入暂停执行权转移到协程B
  • 第三步,(一段时间后)协程B交还执行权
  • 第四步,协程A恢复执行

  上面的协程A,就是异步任务,因为它分为两段(或者多段)执行。

  举例来说,读取文件的协程写法如下:

1
2
3
4
5
function *asyncJob() {
// ...其他代码
var f = yield readFile(fileA);
// ...其他代码
}

  上面代码的函数asyncJob是一个协程,奥妙就在于yield命令, 它表示执行到此处,执行权交给其他协程,也就是说yield命令是异步两个阶段的分界线。

  协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续向后执行,它的最大优点就是代码的写法非常像同步操作,如果去除yield命令,简直是一模一样。

协程的Generator函数实现

  Generator函数是协程在ES6中的实现,最大特点就是可以交出函数的执行权(即暂停执行)。

  整个Generator函数就是一个封装的异步任务,或者说异步任务的容器。 异步任务需要暂停的地方,都用yield语句注明。 如下:

1
2
3
4
5
6
7
8
function* gen(x) {
var y = yield x + 2;
return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

  在调用gen函数时 gen(1), 会返回一个内部指针(即遍历器)g。 这是Generator函数不同于普通函数的另一个地方,即执行它(调用函数)不会返回结果, 返回的一个指针对象 。调用指针g的next方法,会移动内部指针(即执行异步任务的第一阶段),指向第一个遇到的yield语句,这里我们是x + 2,但是实际上这里只是举例,实际上 x + 2 这句应该是一个异步操作,比如ajax请求。 换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。 value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator函数是否执行完毕,即是否还有下一个阶段。

Generator函数的数据交换和错误处理

  Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。

  next返回值的value属性,是 Generator 函数向外输出数据;next方法还可以接受参数,向 Generator 函数体内输入数据。

1
2
3
4
5
6
7
8
function* gen(x){
var y = yield x + 2;
return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }

  上面代码中,第一next方法的value属性,返回表达式x + 2的值3。第二个next方法带有参数2,这个参数可以传入 Generator 函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收。因此,这一步的value属性,返回的就是2(变量y的值)。

Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}

var g = gen(1);
g.next();
g.throw('出错了');
// 出错了

上面代码的最后一行,Generator 函数体外,使用指针对象的throw方法抛出的错误,可以被函数体内的try...catch代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。

异步任务的封装**下面看看如何使用 Generator 函数,执行一个真实的异步任务。**

1
2
3
4
5
6
7
var fetch = require('node-fetch');

function* gen(){
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}

  上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了yield命令。

  执行这段代码的方法如下。

1
2
3
4
5
6
7
8
var g = gen();
var result = g.next();

result.value.then(function(data){
return data.json();
}).then(function(data){
g.next(data);
});

  上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用next方法(第二行),执行异步任务的第一阶段。由于Fetch模块返回的是一个 Promise 对象,因此要用then方法调用下一个next方法。

  可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。

1
2
3
4
5
6
7
8
9
10
11
function* gen(x) {
yield 1;
yield 2;
yield 3;
return 4;
}
var a = gen();
console.log(a.next());
console.log(a.next());
console.log(a.next());
console.log(a.next());

  最终,打印台输出

img

即开始调用gen(),并没有真正的调用,而是返回了一个生成器对象,a.next()的时候,执行第一个yield,并立刻暂停执行,交出了控制权; 接着,我们就可以去a.next() 开始恢复执行。。。 如此循环往复。  

每当调用生成器对象的next的方法时,就会运行到下一个yield表达式。 之所以称这里的gen()为生成器函数,是因为区别如下:

  • 普通函数使用function来声明,而生成器函数使用 function * 来声明
  • 普通函数使用return来返回值,而生成器函数使用yield来返回值。
  • 普通函数式run to completion模式 ,即一直运行到末尾; 而生成器函数式 run-pause-run 模式, 函数可以在执行过程中暂停一次或者多次。并且暂停期间允许其他代码执行。

async/await

async函数基于Generator又做了几点改进:

  • 内置执行器,将Generator函数和自动执行器进一步包装。
  • 语义更清楚,async表示函数中有异步操作,await表示等待着紧跟在后边的表达式的结果。
  • 适用性更广泛,await后面可以跟promise对象和原始类型的值(Generator中不支持)

  很多人都认为这是异步编程的终极解决方案,由此评价就可知道该方法有多优秀了。它基于Promise使用async/await来优化then链的调用,其实也是Generator函数的语法糖。 async 会将其后的函数(函数表达式或 Lambda)的返回值封装成一个 Promise 对象,而 await 会等待这个 Promise 完成,并将其 resolve 的结果返回出来。

  await得到的就是返回值,其内部已经执行promise中resolve方法,然后将结果返回。使用async/await的方式写回调任务:

1
2
3
4
5
6
7
8
9
10
11
async function dolt(){
console.time('dolt');
const time1=300;
const time2=await step1(time1);
const time3=await step2(time2);
const result=await step3(time3);
console.log(`result is ${result}`);
console.timeEnd('dolt');
}

dolt();

  可以看到,在使用await关键字所在的函数一定要是async关键字修饰的。

  功能还很新,属于ES7的语法,但使用Babel插件可以很好的转义。另外await只能用在async函数中,否则会报错

JS的事件执行机制

一、js的内存模型

img

img

二、js代码执行机制:

  • 所有同步任务都在主线程上的栈中执行。

  • 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。

  • 一旦”栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,选择出需要首先执行的任务(由浏览器决定,并不按序)。

三、宏任务与微任务:

  1. MacroTask(宏观Task) setTimeout, setInterval, , requestAnimationFrame(请求动画), I/O

  2. MicroTask(微观任务) process.nextTick, Promise, Object.observe, MutationObserver

  3. 先同步 再取出第一个宏任务执行 所有的相关微任务总会在下一个宏任务之前全部执行完毕 如果遇见 就 先微后宏

案例一:(在主线程上添加宏任务)

1
2
3
4
5
6
7
8
9
console.log(1)

setTimeout(function () {

console.log(2);

},0)

console.log(3) //1 3 2

先看代码:一个打印,一个定时器,一个打印

因为定时器是异步操作,又是宏任务,所以先执行第一个打印,接着将setTimeout放入宏任务队列,接着执行第二个打印,再执行宏任务队列中的setTimeout

案例二:(在主线程上添加微任务)

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

先看代码:一个打印,一个new promise,一个promise.then,一个打印

因为new promise会立即执行,promise.then是异步操作且是微任务

所以,先执行第一个打印,执行new Promise,将promise.then放入微任务队列,接着执行第二个打印,再执行微任务队列中的promise.then

案例三:(宏任务中创建微任务)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
console.log('1');

setTimeout(function () {
console.log('2');
new Promise(function (resolve) {
console.log('3');
resolve();
}).then(function () {
console.log('4')
})
},0)

new Promise(function (resolve) {
console.log('5');
resolve();
}).then(function () {
console.log('6')
})

setTimeout(function () {
console.log('7');
new Promise(function (resolve) {
console.log('8');
resolve();
}).then(function () {
console.log('9')
})
console.log('10')
},0)

console.log('11')  

// 1 5 11 6 2 3 4 7 8 10 9

先看代码:一个打印,第一个定时器,一个new promise,一个promise.then,第二个定时器,一个打印

定时器是异步操作,又是宏任务,\promise.then是异步操作且是微任务****

所以,先执行第一个打印(1),将第一个定时器放入宏任务队列,执行new Promise(5),将promise.then放入微任务队列,将第二个定时器放入宏任务队列,执行打印(11);

主线程上的代码执行完毕后,看是否有微任务?此时:微任务队列中有一个promise.then,执行它(6);微任务执行完毕看宏任务队列;

此时宏任务队列中两个定时器,延时都是0秒,所以按顺序执行就ok,先执行第一个定时器

第一个定时器中:一个打印,一个mew promise,一个promise.then(微任务);**(宏任务中包含微任务,一定要将宏任务中的微任务执行完,再去执行下一个宏任务)**

先执行打印(2),再执行new promise(3),**再执行promise.then(**4);第一个宏任务执行完,执行第二个宏任务(第二个定时器)

第二个定时器中:一个打印,一个new promise,一个promise.then(微任务),一个打印

先执行第一个打印(7),再执行new promise(8),再执行第二个打印(10),在执行promise.then(9) 

案例四:(微任务中创建宏任务)

1
2
3
4
5
6
7
8
9
10
11
12
13
new Promise((resolve) => {
console.log("1")
resolve()
}).then(() => {
console.log("2")
setTimeout(() => {
console.log("3")
},0)
})
setTimeout(() => {
console.log("4")
},1000)
console.log("5") //1 5 2 3 4

先看代码:一个new promise,(一个then,一个定时器(0秒)),一个定时器(1秒),一个打印 微任务中有宏任务,则将宏任务放入宏任务队列任务中

先执行new promise(1),再将promise.then放入微任务队列,将定时器放入宏任务队列(0秒),将定时器放入宏任务队列(1秒),执行打印(5)

接着看微任务队列,执行promise.then(2);微任务队列中都执行完再看宏任务队列

宏任务队列中两个定时器,一个延时0秒,一个延时1秒,所以先执行延时0秒的那个

第一个定时器:执行(3);

第二个定时器:执行(4)

JS事件执行

本文是承接Promise来说的,大家都知道,JavaScript脚本是单线程的语言,虽然有H5的Web-Worker加持,但是创建出来的子线程完全受主线程控制,且不得操作DOM,所以还是无法改变JavaScript单线程的本质

JavaScript是单线程执行的,无法同时执行多段代码。当某一段代码正在执行的时候,所有后续的任务都必须等待,形成一个队列。一旦当前任务执行完毕,再从队列中取出下一个任务,这也常被称为 “阻塞式执行”。所以一次鼠标点击,或是计时器到达时间点,或是Ajax请求完成触发了回调函数,这些事件处理程序或回调函数都不会立即运行,而是立即排队,一旦线程有空闲就执行。假如当前JavaScript线程正在执行一段很耗时的代码,此时发生了一次鼠标点击,那么事件处理程序就被阻塞,用户也无法立即看到反馈,事件处理程序会被放入任务队列,直到前面的代码结束以后才会开始执行。如果代码中设定了一个setTimeout,那么浏览器便会在合适的时间,将代码插入任务队列,如果这个时间设为0,就代表立即插入队列,但不是立即执行,仍然要等待前面代码执行完毕。所以 setTimeout 并不能保证执行的时间,是否及时执行取决于JavaScript 线程是拥挤还是空闲。

这里就涉及到了执行栈(Stack)和队列任务(Queue Task)的概念,将同步任务都放入主线程的Stack当中,将异步和延时的任务都放入Event Queue里面等待执行,Event Queue即为事件队列,所包含的全是事件,等执行栈为空之后就代表主线程执行完毕,再去Event Queue中读取第一个事件放入主线程,执行完毕再读取第二个…因此形成一个JavaScript的Event Loop(事件循环),Event Loop就是JavaScript的实现异步的一种方式,也是JavaScript的执行机制。

至于定时器(timer)嘛,因为里面的参数有一个是回调函数,另一个是延时执行的毫秒数,所以他也要放进队列中,而上面的引用部分有个延时0毫秒,它的含义就是立即放入队列,而不是立即放进执行栈执行;JavaScript还有一种函数叫做回调函数,阮一峰大神是这么说的:

所谓”回调函数”(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

下面这幅图是从别人那里偷来的(ps:主要这幅图太有说服力度了,不信你看):

![img](https:////upload-images.jianshu.io/upload_images/8560482-92ec4b6e10c45e30.png?imageMogr2/auto-orient/strip|imageView2/2/w/601/format/webp

nodeJs里面提出了和任务队列有关联的方法process.nextTick(callback),它的含义是本次循环完毕等到下一次循环开始再执行,也就是在当前执行栈的尾部。

在网上经常看到这样的关键字,从广义上来讲,我们弄明白了同步异步,但是狭义上来说还有两个新的概念,其实这个概念我还真不确定官方是否同意,我是看到闹闹不爱闹在掘金中阐明的:

  1. 宏任务macro task [ˈmækrəʊ]:当前调用栈中执行的代码成为宏任务。(主代码快,定时器等等)。exp:script(全局任务),setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering
  2. 微任务micro task [ˈmaɪkrəʊ]: 当前(此次事件循环中)宏任务执行完,在下一个宏任务开始之前需要执行的任务,可以理解为回调事件。(promise.then,proness.nextTick等等)。exp:process.nextTick,promise,Object.observer,MutationObserver
  3. 宏任务中的事件放在callback queue中,由事件触发线程维护;微任务的事件放在微任务队列中,由js引擎线程维护。

不管这个东西存不存在,既然国人都这么叫了,那我是这么理解的:
他们口中的宏观任务就是我们的回调函数,宏观任务和微观任务就是我们的Event Queue,执行栈执行完毕会执行微观任务再执行宏观任务,我不建议大家继续这么称呼,其实macro task和micro task都属于是浏览器执行js的执行机制,这个我不是为了较真,估计我也是被我们公司的老总教训的太多了,不去不去怕了怕了o((⊙﹏⊙))o….

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(function() {
console.log('setTimeout');
});
Promise.resolve(function () {
console.log('resolve');
});
new Promise(function(resolve) {
console.log('promise');
resolve();
}).then(function() {
console.log('then');
});
console.log('console');

其实这个栗字很简单,进入script主线程,遇到setTimeout push到macro task,遇到resolve push到macro task,new Promise立即执行,率先打印,then push到micro task,接着第二个打印console,然后执行micro task打印出then,接着执行macro task打印出setTimeout,因为Promise.resolve这个回到函数未调用,有的浏览器报undefined,有的不打印。

到了这里就差不多了,为了帮助大家彻底吃透它,再来一剂猛药:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
console.log('1');
setTimeout(() => {
console.log('9');
this.$nextTick(() => {
console.log('11');
});
new Promise(function(resolve) {
console.log('10');
resolve();
}).then(function() {
console.log('12')
});
},5000);
this.$nextTick(() => {
console.log('3');
});
new Promise(function(resolve) {
console.log('2');
resolve();
}).then(function() {
console.log('4');
});
setTimeout(() => {
console.log('5');
this.$nextTick(() => {
console.log('7');
});
new Promise(function(resolve) {
console.log('6');
resolve();
}).then(function() {
console.log('8');
});
});

看到诸多异步延时任务先不要慌,一步一步来解读,代码中的this.$nextTick(callback)千万不要解读成上面的process.nextTick(callback),否则你会被坑惨的,process是nodeJs里面的,nodeJs执行机制和JavaScript的执行机制是不同的,nodeJs不会看你代码的层级关系哦,只关心你的事件的类型,按照这个顺序来执行代码,而我们的js是按照父级的事件,有着层级关系的执行。
vueJs的主线程先执行,首先打印出1,第一个setTimeout push到macro task,nextTick放入micro task,Promise立即执行,then push进micro task,第二个setTimeout push到macro task,接着执行micro task,打印3 4,最后执行macro task,注意这里有个坑,macro task里面有两个timer,第一个5000ms之后执行,所以先执行第二个,所以最后的答案小学生都知道,打印顺序从1到12。

防抖函数

一、函数为什么要防抖

有如下代码

1
2
3
window.onresize = () => {
console.log('触发窗口监听回调函数')
}

当我们在PC上缩放浏览器窗口时,一秒可以轻松触发30次事件。手机端触发其他Dom时间监听回调时同理。

这里的回调函数只是打印字符串,如果回调函数更加复杂,可想而知浏览器的压力会非常大,用户体验会很糟糕。

resizescroll等Dom事件的监听回调会被频繁触发,因此我们要对其进行限制。

二、实现思路

函数去抖简单来说就是对于一定时间段的连续的函数调用,只让其执行一次,初步的实现思路如下:

第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除前一次的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的是只有在执行函数的请求停止了一段时间之后才执行。

三、Debounce 应用场景

  • 每次 resize/scroll 触发统计事件
  • 文本输入的验证(连续输入文字后发送 AJAX 请求进行验证,验证一次就好)

四、函数防抖最终版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function debounce(method, wait, immediate) {
let timeout
// debounced函数为返回值
// 使用Async/Await处理异步,如果函数异步执行,等待setTimeout执行完,拿到原函数返回值后将其返回
// args为返回函数调用时传入的参数,传给method
let debounced = function(...args) {
return new Promise (resolve => {
// 用于记录原函数执行结果
let result
// 将method执行时this的指向设为debounce返回的函数被调用时的this指向
let context = this
// 如果存在定时器则将其清除
if (timeout) {
clearTimeout(timeout)
}
// 立即执行需要两个条件,一是immediate为true,二是timeout未被赋值或被置为null
if (immediate) {
// 如果定时器不存在,则立即执行,并设置一个定时器,wait毫秒后将定时器置为null
// 这样确保立即执行后wait毫秒内不会被再次触发
let callNow = !timeout
timeout = setTimeout(() => {
timeout = null
}, wait)
// 如果满足上述两个条件,则立即执行并记录其执行结果
if (callNow) {
result = method.apply(context, args)
resolve(result)
}
} else {
// 如果immediate为false,则等待函数执行并记录其执行结果
// 并将Promise状态置为fullfilled,以使函数继续执行
timeout = setTimeout(() => {
// args是一个数组,所以使用fn.apply
// 也可写作method.call(context, ...args)
result = method.apply(context, args)
resolve(result)
}, wait)
}
})
}

// 在返回的debounced函数上添加取消方法
debounced.cancel = function() {
clearTimeout(timeout)
timeout = null
}

return debounced
}

需要注意的是,如果需要原函数返回值,调用防抖后的函数的外层函数需要使用Async/Await语法等待执行结果返回

使用方法见代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function square(num) {
return Math.pow(num, 2)
}

let debouncedFn = debounce(square, 1000, false)

window.addEventListener('resize', async () => {
let val
try {
val = await debouncedFn(4)
} catch (err) {
console.error(err)
}
// 停止缩放1S后输出:
// 原函数的返回值为:16
console.log(`原函数返回值为${val}`)
}, false)

具体的实现步骤请往下看

五、Debounce 的实现

1. 《JavaScript高级程序设计》(第三版)中的实现

1
2
3
4
5
6
7
8
9
10
11
12
function debounce(method, context) {
clearTimeout(method.tId)
method.tId = setTimeout(() => {
method.call(context)
}, 1000)
}

function print() {
console.log('Hello World')
}

window.onresize = debounce(print)

我们不停缩放窗口,当停止1S后,打印出Hello World。

有个可以优化的地方: 此实现方法有副作用(Side Effect),改变了输入值(method),给method新增了属性

2. 优化第一版:消除副作用,将定时器隔离

1
2
3
4
5
6
7
8
9
10
11
function debounce(method, wait, context) {
let timeout
return function() {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
method.call(context)
}, wait)
}
}

3. 优化第二版:自动调整this正确指向

之前的函数我们需要手动传入函数执行上下文context,现在优化将 this 指向正确的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
function debounce(method, wait) {
let timeout
return function() {
// 将method执行时this的指向设为debounce返回的函数被调用时的this指向
let context = this
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
method.call(context)
}, wait)
}
}

4. 优化第三版:函数可传入参数

即便我们的函数不需要传参,但是别忘了JavaScript 在事件处理函数中会提供事件对象 event,所以我们要实现传参功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function debounce(method, wait) {
let timeout
// args为返回函数调用时传入的参数,传给method
return function(...args) {
let context = this
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
// args是一个数组,所以使用fn.apply
// 也可写作method.call(context, ...args)
method.apply(context, args)
}, wait)
}
}

5. 优化第四版:提供立即执行选项

有些时候我不希望非要等到事件停止触发后才执行,我希望立刻执行函数,然后等到停止触发n毫秒后,才可以重新触发执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function debounce(method, wait, immediate) {
let timeout
return function(...args) {
let context = this
if (timeout) {
clearTimeout(timeout)
}
// 立即执行需要两个条件,一是immediate为true,二是timeout未被赋值或被置为null
if (immediate) {
// 如果定时器不存在,则立即执行,并设置一个定时器,wait毫秒后将定时器置为null
// 这样确保立即执行后wait毫秒内不会被再次触发
let callNow = !timeout
timeout = setTimeout(() => {
timeout = null
}, wait)
if (callNow) {
method.apply(context, args)
}
} else {
// 如果immediate为false,则函数wait毫秒后执行
timeout = setTimeout(() => {
// args是一个类数组对象,所以使用fn.apply
// 也可写作method.call(context, ...args)
method.apply(context, args)
}, wait)
}
}
}

6. 优化第五版:提供取消功能

有些时候我们需要在不可触发的这段时间内能够手动取消防抖,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function debounce(method, wait, immediate) {
let timeout
// 将返回的匿名函数赋值给debounced,以便在其上添加取消方法
let debounced = function(...args) {
let context = this
if (timeout) {
clearTimeout(timeout)
}
if (immediate) {
let callNow = !timeout
timeout = setTimeout(() => {
timeout = null
}, wait)
if (callNow) {
method.apply(context, args)
}
} else {
timeout = setTimeout(() => {
method.apply(context, args)
}, wait)
}
}

// 加入取消功能,使用方法如下
// let myFn = debounce(otherFn)
// myFn.cancel()
debounced.cancel = function() {
clearTimeout(timeout)
timeout = null
}
}

至此,我们已经比较完整地实现了一个underscore中的debounce函数。

六、遗留问题

需要防抖的函数可能是存在返回值的,我们要对这种情况进行处理,underscore的处理方法是将函数返回值在返回的debounced函数内再次返回,但是这样其实是有问题的。如果参数immediate传入值不为true的话,当防抖后的函数第一次被触发时,如果原始函数有返回值,其实是拿不到返回值的,因为原函数是在setTimeout内,是异步延迟执行的,而return是同步执行的,所以返回值是undefined

第二次触发时拿到的返回值其实是第一次执行的返回值,第三次触发时拿到的返回值其实是第二次执行的返回值,以此类推。

1. 使用回调函数处理函数返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function debounce(method, wait, immediate, callback) {
let timeout, result
let debounced = function(...args) {
let context = this
if (timeout) {
clearTimeout(timeout)
}
if (immediate) {
let callNow = !timeout
timeout = setTimeout(() => {
timeout = null
}, wait)
if (callNow) {
result = method.apply(context, args)
// 使用回调函数处理函数返回值
callback && callback(result)
}
} else {
timeout = setTimeout(() => {
result = method.apply(context, args)
// 使用回调函数处理函数返回值
callback && callback(result)
}, wait)
}
}

debounced.cancel = function() {
clearTimeout(timeout)
timeout = null
}

return debounced
}

这样我们就可以在函数防抖时传入一个回调函数来处理函数的返回值,使用代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function square(num) {
return Math.pow(num, 2)
}

let debouncedFn = debounce(square, 1000, false, val => {
console.log(`原函数的返回值为:${val}`)
})

window.addEventListener('resize', () => {
debouncedFn(4)
}, false)

// 停止缩放1S后输出:
// 原函数的返回值为:16

2. 使用Promise处理返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function debounce(method, wait, immediate) {
let timeout, result
let debounced = function(...args) {
// 返回一个Promise,以便可以使用then或者Async/Await语法拿到原函数返回值
return new Promise(resolve => {
let context = this
if (timeout) {
clearTimeout(timeout)
}
if (immediate) {
let callNow = !timeout
timeout = setTimeout(() => {
timeout = null
}, wait)
if (callNow) {
result = method.apply(context, args)
// 将原函数的返回值传给resolve
resolve(result)
}
} else {
timeout = setTimeout(() => {
result = method.apply(context, args)
// 将原函数的返回值传给resolve
resolve(result)
}, wait)
}
})
}

debounced.cancel = function() {
clearTimeout(timeout)
timeout = null
}

return debounced
}

使用方法一:在调用防抖后的函数时,使用then拿到原函数的返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function square(num) {
return Math.pow(num, 2)
}

let debouncedFn = debounce(square, 1000, false)

window.addEventListener('resize', () => {
debouncedFn(4).then(val => {
console.log(`原函数的返回值为:${val}`)
})
}, false)

// 停止缩放1S后输出:
// 原函数的返回值为:16

使用方法二:调用防抖后的函数的外层函数使用Async/Await语法等待执行结果返回

使用方法见代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function square(num) {
return Math.pow(num, 2)
}

let debouncedFn = debounce(square, 1000, false)

window.addEventListener('resize', async () => {
let val
try {
val = await debouncedFn(4)
} catch (err) {
console.error(err)
}
console.log(`原函数返回值为${val}`)
}, false)

// 停止缩放1S后输出:
// 原函数的返回值为:16
-------------本文结束感谢您的阅读-------------
坚持原创技术分享,您的支持将鼓励我继续创作!