js基础(4)--异步编程与事件循环

202 阅读28分钟

22. Set、Map

它类似于数组,但是成员的值都是唯一的,没有重复的值。

22.1 Set

属性:

  • Set.prototype.size:只读,返回 Set 实例的成员总数。

操作方法

  • Set.prototype.add(value):添加某个值,返回 Set 结构本身。
  • Set.prototype.delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
  • Set.prototype.has(value):返回一个布尔值,表示该值是否为Set的成员。
  • Set.prototype.clear():清除所有成员,没有返回值。

遍历方法

  • Set.prototype.keys():返回键名的遍历器
  • Set.prototype.values():返回键值的遍历器
  • Set.prototype.entries():返回键值对的遍历器
  • Set.prototype.forEach():使用回调函数遍历每个成员

由于 Set 结构没有键名,只有键值(或者说键名和键值是同一个值),所以 keys 方法和 values 方法的行为完全一致。

应用

  1. 数组去重
    [...new Set(array)]
    // 或
    Array.from(new Set(array))
    
  2. 字符串去重
    [...new Set(str)].join('')
    

22.2 WeakSet

WeakSet 结构与 Set 类似,也是不重复的值的集合。

WeakSet 与 Set 的区别:

  1. WeakSet 的成员只能是对象
  2. WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。

由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历

操作方法:

  • WeakSet.prototype.add(value):向 WeakSet 实例添加一个新成员。
  • WeakSet.prototype.delete(value):清除 WeakSet 实例的指定成员。
  • WeakSet.prototype.has(value):返回一个布尔值,表示某个值是否在 WeakSet 实例之中。

WeakSet 没有size属性

22.3 Map

Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应

Map 也可以接受一个(二维)数组作为参数。该数组的成员是一个个表示键值对的数组:

const map = new Map([
  ['name', '张三'], // 对应[key, value]
  ['title', 'Author']
]);

map.size // 2
map.has('name') // true
map.get('name') // "张三"
map.has('title') // true
map.get('title') // "Author"

不仅仅是数组,任何具有 Iterator 接口、且每个成员都是一个双元素的数组的数据结构都可以当作 Map 构造函数的参数:

const set = new Set([
  ['foo', 1],
  ['bar', 2]
]);
const m1 = new Map(set);
m1.get('foo') // 1

const m2 = new Map([['baz', 3]]);
const m3 = new Map(m2);
m3.get('baz') // 3

属性:

  • Map.prototype.size:只读,返回 Map 结构的成员总数。

操作方法:

  • Map.prototype.set(key, value):设置/更新键名 key 对应的键值为 value,然后返回整个 Map 结构;
  • Map.prototype.get(key):读取key对应的键值;
  • Map.prototype.delete(key):删除某个键;
  • Map.prototype.has(key):某个键是否在当前 Map 对象之中;
  • Map.prototype.clear():清除所有成员,没有返回值。

遍历方法

Map 结构原生提供三个遍历器生成函数和一个遍历方法。

  • Map.prototype.keys():返回键名的遍历器
  • Map.prototype.values():返回键值的遍历器
  • Map.prototype.entries():返回所有成员的遍历器
  • Map.prototype.forEach():遍历 Map 的所有成员。

Map 的遍历顺序就是插入顺序。

22.4 WeakMap

WeakMap 与 Map 的区别

  • WeakMap 只接受对象作为键名
  • WeakMap 的键名所引用的对象都是弱引用,即垃圾回收机制不将该引用考虑在内。

注意,WeakMap 弱引用的只是键名,而不是键值。键值依然是正常引用。

const wm = new WeakMap();
let key = {};
let obj = {foo: 1};

wm.set(key, obj);
obj = null;
wm.get(key)
// Object {foo: 1}

上面代码中,键值 obj 是正常引用。所以,即使在 WeakMap 外部消除了 obj 的引用,WeakMap 内部的引用依然存在。

操作方法:

  • WeakMap.prototype.set()
  • WeakMap.prototype.get()
  • WeakMap.prototype.has()
  • WeakMap.prototype.delete()

WeakMap 没有遍历操作(即没有keys()、values()和entries()方法),无法清空(即没有 clear 方法),也没有size属性。

23. Promise

23.1 含义

所谓 Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。

  • Promise 新建后就会立即执行;
  • 如果调用 resolve 函数和 reject 函数时带有参数,那么它们的参数会被传递给回调函数;

优点:

  • 可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。

缺点:

  • 首先,无法取消 Promise,一旦新建它就会立即执行,无法中途取消;
  • 其次,如果不设置回调函数,Promise 内部抛出的错误,不会反应到外部;
  • 第三,当处于 pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

23.2 用法

1. 返回一个 Promise,状态会传递

resolve 函数的参数除了正常的值以外,还可能是另一个 Promise 实例:

注意,这时p1的状态就会传递给p2,也就是说,p1的状态决定了p2的状态。如果p1的状态是pending,那么p2的回调函数就会等待p1的状态改变;如果p1的状态已经是resolved或者rejected,那么p2的回调函数将会立刻执行。

const p1 = new Promise(function (resolve, reject) {
  setTimeout(() => reject(new Error('fail')), 3000)
})

const p2 = new Promise(function (resolve, reject) {
  setTimeout(() => resolve(p1), 1000)
})

p2
  .then(result => console.log("---res:",result))
  .catch(error => console.log("---err:",error))

// ---err: Error: fail

上面代码中,p1是一个 Promise,3 秒之后变为rejected。p2的状态在 1 秒之后改变,resolve方法返回的是p1。由于p2返回的是另一个 Promise,导致p2自己的状态无效了,由p1的状态决定p2的状态。所以,后面的then语句都变成针对后者(p1)。又过了 2 秒,p1变为rejected,导致触发catch方法指定的回调函数。

2. 调用 resolve 或 reject 并不会终结 Promise 的参数函数的执行

new Promise((resolve, reject) => {
  resolve(1);
  console.log(2);
}).then(r => {
  console.log(r);
});
// 2
// 1

最好加上 return:

new Promise((resolve, reject) => {
  return resolve(1);
  // 后面的语句不会执行
  console.log(2);
}).then(r => {
  console.log(r);
});
// 1

但是,如果 Promise 状态已经变成resolved,再抛出错误是无效的。因为 Promise 的状态一旦改变,就不会再变了。

const promise = new Promise(function(resolve, reject) {
  resolve('ok');
  throw new Error('test');
});
promise
  .then(function(res) { console.log("res:",res) })
  .catch(function(error) { console.log("err:",error) });

23.3 相关方法

  1. Promise.prototype.then(onFulfilled [, onRejected])返回一个新的 Promise 实例

  2. Promise.prototype.catch(onRejected):用于指定发生错误时的回调函数,是 .then(null, onRejected) 或 .then(undefined, onRejected) 的别名;

  3. Promise.prototype.finally(onFinally):ES2018 引入,总会执行;

  4. Promise.any():Stage 3 草案阶段,只要有一个 resolved,就 resolve,否则 reject;

    var resolved = Promise.resolve(42);
    var rejected = Promise.reject(-1);
    var alsoRejected = Promise.reject(Infinity);
    
    Promise.any([resolved, rejected, alsoRejected]).then(function (result) {
      console.log(result); // 42
    });
    
    Promise.any([rejected, alsoRejected]).catch(function (results) {
      console.log(results); // [-1, Infinity]
    });
    
  5. Promise.all(iterable):全部 resolved 才 resolve,任意一个失败时 reject;

    1. 当且仅当传入的可迭代对象为空时为同步:
      var p = Promise.all([]); // will be immediately resolved
      console.log(p);
      console.log("log");
      // Promise {<resolved>: Array(0)}
      // log
      
  6. Promise.race():只要有一个 promise 状态改变,就执行(不论是 resolved、rejected);状态与该 promise 一致;

  7. Promise.allSettled():ES2020 引入,所有 promise 状态都改变(不论是 resolved、rejected)后执行;一旦结束,状态总是fulfilled,执行 then 方法的 onFulfilled;

    const p1 = Promise.resolve(42);
    const p2 = Promise.reject(-1);
    
    Promise
        .allSettled([p1, p2])
        .then(function (results) {
            console.log(results);
        });
    // [
    //    { status: 'fulfilled', value: 42 },
    //    { status: 'rejected', reason: -1 }
    // ]
    
  8. Promise.resolve():将现有对象转为 Promise 对象;

    Promise.resolve('foo')
    // 等价于
    new Promise(resolve => resolve('foo'))
    

    参数不同的情况:

    1. Promise 实例:直接返回;

    2. thenable 对象:将这个对象转为 Promise 对象,然后立即执行该对象的 then 方法;

    3. 原始值或者非 thenable 对象:返回一个新的 Promise 对象,状态为resolved;同时参数会传给回调函数;

    4. 不带参数:直接返回一个 resolved 状态的 Promise 对象。

      立即 resolve() 的 Promise 对象,是在本轮“事件循环”(event loop)的结束时执行,而不是在下一轮“事件循环”的开始时。

      setTimeout(function () {
        console.log('three');
      }, 0);
      
      Promise.resolve().then(function () {
        console.log('two');
      });
      
      console.log('one');
      
      // one
      // two
      // three
      
      const thenable = {
        then: function(resolve, reject) {
          console.log('flag-0');
          resolve(1);
          console.log('flag-1');
        }
      };
      
      const p1 = Promise.resolve();
      p1.then(() => {
        console.log(2);
      });
      
      console.log('flag-2');
      
      const p2 = Promise.resolve(thenable);
      p2.then((value) => {
        console.log(value);
      });
      
      setTimeout(() => {
        console.log('timeout');
      }, 0);
      
      const p3 = Promise.resolve();
      p3.then(() => {
        console.log(3);
      });
      
      console.log('flag-3');
      
      /*
      flag-2
      flag-3
      2
      flag-0
      flag-1
      3
      1
      timeout
      */
      
      1. 先输出同步代码flag-2,flag-3;
      2. 然后执行微任务,依次注册了 p1.then,p2.then,p3.then,并 resolve 了,所以依次执行 then 方法(对于 thenable 对象,本轮事件循环是执行 thenable 对象的 then 方法),输出 2,flag-0,flag-1,3;
      3. 在 p2.then 中的 resolve,最后放入微任务队列,所以是在微任务中最后输出的;
      4. timeout 是宏任务,最后输出;
  9. Promise.reject():返回一个新的 Promise 实例,该实例的状态为rejected。

    const p = Promise.reject('出错了');
    // 等同于
    const p = new Promise((resolve, reject) => reject('出错了'))
    

    注意,Promise.reject() 方法的参数,会原封不动地作为 reject 的理由,变成后续方法的参数。这一点与 Promise.resolve 方法不一致。

    const thenable = {
      then(resolve, reject) {
        reject('出错了');
      }
    };
    
    Promise.reject(thenable)
    .catch(e => {
      console.log(e === thenable)
    })
    // true //注意:不是'出错了'
    

24. Iterator

24.1 概念

遍历器对象本质上,就是一个指针对象。

Iterator 接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即 for...of 循环。当使用 for...of 循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。

24.2 默认 Iterator 接口

ES6 规定,默认的 Iterator 接口部署在数据结构的 Symbol.iterator 属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”(iterable)。Symbol.iterator 属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。

原生具备 Iterator 接口的数据结构:

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的 arguments 对象
  • NodeList 对象

类数组对象遍历

let iterable = {
  0: 'a',
  1: 'b',
  2: 'c',
  length: 3,
  [Symbol.iterator]: Array.prototype[Symbol.iterator]
};
for (let item of iterable) {
  console.log(item); // 'a', 'b', 'c'
}

并不是所有类似数组的对象都具有 Iterator 接口,一个简便的解决方法,就是使用 Array.from 方法将其转为数组。

let arrayLike = { length: 2, 0: 'a', 1: 'b' };

// 报错
for (let x of arrayLike) {
  console.log(x);
}

// 正确
for (let x of Array.from(arrayLike)) {
  console.log(x);
}

默认调用 Iterator 接口的情况

  • for...of
  • 解构赋值
  • 扩展运算符(...)
    • 只要某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符,将其转为数组:
      let arr = [...iterable];
      
  • yield* 后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口:
    let generator = function* () {
      yield 1;
      yield* [2,3];
      yield 4;
    };
    
    var iterator = generator();
    
    iterator.next() // { value: 1, done: false }
    iterator.next() // { value: 2, done: false }
    iterator.next() // { value: 3, done: false }
    iterator.next() // { value: 4, done: false }
    iterator.next() // { value: undefined, done: true }
    
  • Array.from()
  • Map(), Set(), WeakMap(), WeakSet()
  • Promise.all()
  • Promise.race()

24.3 遍历器对象的 return(),throw()

遍历器对象除了具有 next 方法,还可以具有 return 方法和 throw 方法。

return 方法的使用场合

如果 for..of 循环提前退出(通常是因为出错,或者有 break 语句),就会调用 return 方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署 return 方法。

function readLinesSync(file) {
  return {
    [Symbol.iterator]() {
      return {
        next() {
          return { done: false };
        },
        return() {
          file.close();
          return { done: true };
        }
      };
    },
  };
}

下面的两种情况,都会触发执行return方法:

// 情况一
for (let line of readLinesSync(fileName)) {
  console.log(line);
  break;
}

// 情况二
for (let line of readLinesSync(fileName)) {
  console.log(line);
  throw new Error();
}

情况一输出文件的第一行以后,就会执行return方法,关闭这个文件;情况二会在执行return方法关闭文件之后,再抛出错误。

注意,return 方法必须返回一个对象,这是 Generator 规格决定的。

throw 方法主要是配合 Generator 函数使用,一般的遍历器对象用不到这个方法。

24.4 Iterator 接口与 Generator 函数

Symbol.iterator 方法的最简单实现,还是使用下一章要介绍的 Generator 函数:

let myIterable = {
  [Symbol.iterator]: function* () {
    yield 1;
    yield 2;
    yield 3;
  }
}
[...myIterable] // [1, 2, 3]

// 或者采用下面的简洁写法

let obj = {
  * [Symbol.iterator]() {
    yield 'hello';
    yield 'world';
  }
};

for (let x of obj) {
  console.log(x);
}
// "hello"
// "world"

25. Generator

执行 Generator 函数会返回一个遍历器对象,所以可以看作是 Iterator 生成函数。

function* () {
    yield 1;
    yield 2;
    return 3;
}

注意,yield 表达式只能用在 Generator 函数里面,用在其他地方都会报错。

Generator 函数执行后,返回一个遍历器对象。该对象本身也具有 Symbol.iterator 属性,执行后返回自身。

function* gen(){
  // some code
}

var g = gen();
g[Symbol.iterator]() === g // true

25.1 方法

  • Generator.prototype.next()
  • Generator.prototype.return()
  • Generator.prototype.throw()

Generator.prototype.next()

next 方法的参数

==yield 表达式本身没有返回值==,或者说总是返回undefined。==next 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。==

function* foo(x) {
  var y = 2 * (yield (x + 1));
  var z = yield (y / 3);
  return (x + y + z);
}

var a = foo(5);
a.next() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}

var b = foo(5);
b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }

Generator.prototype.throw()

  • Generator 函数返回的遍历器对象,都有一个 throw 方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获:
    var g = function* () {
      try {
        yield;
      } catch (e) {
        console.log('内部捕获', e);
      }
    };
    
    var i = g();
    i.next();
    
    try {
      i.throw('a');
      i.throw('b');
    } catch (e) {
      console.log('外部捕获', e);
    }
    // 内部捕获 a
    // 外部捕获 b
    
  • 如果 Generator 函数内部没有部署 try..catch 代码块,那么 throw 方法抛出的错误,将被外部 try..catch 代码块捕获:
    var g = function* () {
      while (true) {
        yield;
        console.log('内部捕获', e);
      }
    };
    
    var i = g();
    i.next();
    
    try {
      i.throw('a');
      i.throw('b');
    } catch (e) {
      console.log('外部捕获', e);
    }
    // 外部捕获 a
    
  • throw 方法抛出的错误要被内部捕获,前提是必须至少执行过一次 next 方法,否则 Generator 函数还没有开始执行,这时 throw 方法抛错只可能抛出在函数外部;
  • throw 方法被捕获以后,会附带执行下一条 yield 表达式。也就是说,会附带执行一次 next 方法:
    var gen = function* gen(){
      try {
        yield console.log('a');
      } catch (e) {
        // ...
      }
      yield console.log('b');
      yield console.log('c');
    }
    
    var g = gen();
    g.next() // a
    g.throw() // b
    g.next() // c
    

Generator.prototype.return()

Generator 函数返回的遍历器对象,还有一个return方法,可以返回给定的值,并且终结遍历 Generator 函数。

function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

var g = gen();

g.next()        // { value: 1, done: false }
g.return('foo') // { value: "foo", done: true }
g.next()        // { value: undefined, done: true }

如果 Generator 函数内部有 try...finally 代码块,且正在执行 try 代码块,那么 return 方法会导致立刻进入 finally 代码块,执行完以后,整个函数才会结束。

function* numbers () {
  yield 1;
  try {
    yield 2;
    yield 3;
  } finally {
    yield 4;
    yield 5;
  }
  yield 6;
}
var g = numbers();
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }
g.return(7) // { value: 4, done: false }
g.next() // { value: 5, done: false }
g.next() // { value: 7, done: true }
// 注意:2、6 不会执行

next()、throw()、return() 的共同点

next()、throw()、return() 这三个方法本质上是同一件事。它们的作用都是让 Generator 函数恢复执行,并且使用不同的语句替换 yield 表达式。

  • next() 是将 yield 表达式替换成一个值;
  • throw() 是将 yield 表达式替换成一个 throw 语句;
  • return() 是将 yield 表达式替换成一个 return 语句。

25.2 for..of 循环

return 语句的返回值,不包括在for...of循环之中:

function* foo() {
  yield 1;
  yield 2;
  return 3;
}

for (let v of foo()) {
  console.log(v);
}
// 1 2

不要重用生成器

生成器不应该重用,即使 for..of 循环的提前终止,例如通过 break 关键字。在退出循环后,生成器关闭,并尝试再次迭代,不会产生任何进一步的结果。

var gen = (function *(){
    yield 1;
    yield 2;
    yield 3;
})();
for (let o of gen) {
    console.log(o);
    break;//关闭生成器
} 

//生成器不应该重用,以下没有意义!
for (let o of gen) {
    console.log(o);
}

25.3 yield* 表达式

ES6 提供了yield*表达式,作为解决办法,用来在一个 Generator 函数里面执行另一个 Generator 函数。

function* foo() {
  yield 'a';
  yield 'b';
}

function* bar() {
  yield 'x';
  yield* foo();
  yield 'y';
}

// 等同于
function* bar() {
  yield 'x';
  yield 'a';
  yield 'b';
  yield 'y';
}

// 等同于
function* bar() {
  yield 'x';
  for (let v of foo()) {
    yield v;
  }
  yield 'y';
}

25.4 作为对象属性的 Generator 函数

let obj = {
  * myGeneratorMethod() {
    ···
  }
};
// 等同于
let obj = {
  myGeneratorMethod: function* () {
    // ···
  }
};

25.5 Generator 函数的this

Generator 函数总是返回一个遍历器,ES6 规定这个遍历器是 Generator 函数的实例,也继承了 Generator 函数的prototype对象上的方法。

function* g() {
    this.a = 1;
}

g.prototype.hello = function () {
  return 'hi!';
};

let obj = g();

obj instanceof g // true
obj.hello() // 'hi!'
obj.a // undefined

让 Generator 函数返回一个正常的对象实例,既可以用 next 方法,又可以获得正常的 this 的方法:

function* gen() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}

function F() {
  return gen.call(gen.prototype);
}

var f = new F();

f.next();  // Object {value: 2, done: false}
f.next();  // Object {value: 3, done: false}
f.next();  // Object {value: undefined, done: true}

f.a // 1
f.b // 2
f.c // 3
含义

25.6 Generator 与上下文

Generator 函数执行产生的上下文环境,一旦遇到yield命令,就会暂时退出堆栈,但是并不消失,里面的所有变量和对象会冻结在当前状态。等到对它执行next命令时,这个上下文环境又会重新加入调用栈,冻结的变量和对象恢复执行。

25.7 异步应用

传统方法

ES6 诞生以前,异步编程的方法,大概有下面四种。

  • 回调函数
  • 事件监听
  • 发布/订阅
  • Promise 对象

Thunk 函数

将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。

js 中的 Thunk 函数

在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。

// 正常版本的readFile(多参数版本)
fs.readFile(fileName, callback);

// Thunk版本的readFile(单参数版本)
var Thunk = function (fileName) {
  return function (callback) {
    return fs.readFile(fileName, callback);
  };
};

var readFileThunk = Thunk(fileName);
readFileThunk(callback);

经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。

Thunk 函数转换器

任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 Thunk 函数转换器。

// ES5版本
var Thunk = function(fn){
  return function (){
    var args = Array.prototype.slice.call(arguments);
    return function (callback){
      args.push(callback);
      return fn.apply(this, args);
    }
  };
};

// ES6版本
const Thunk = function(fn) {
  return function (...args) {
    return function (callback) {
      return fn.call(this, ...args, callback);
    }
  };

使用上面的转换器,生成fs.readFile的 Thunk 函数。

var readFileThunk = Thunk(fs.readFile);
readFileThunk(fileA)(callback);

Thunk 函数的自动流程管理

Thunk 函数真正的威力,在于可以自动执行 Generator 函数。下面就是一个基于 Thunk 函数的 Generator 执行器。

function run(fn) {
  var gen = fn();

  function next(err, data) {
    var result = gen.next(data);
    if (result.done) return;
    result.value(next);
  }

  next();
}

var g = function* (){
  var f1 = yield readFileThunk('fileA');
  var f2 = yield readFileThunk('fileB');
  // ...
  var fn = yield readFileThunk('fileN');
};

run(g);

前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。

co 模块

co 模块是著名程序员 TJ Holowaychuk 于 2013 年 6 月发布的一个小工具,用于 Generator 函数的自动执行。

co 模块的原理

为什么 co 可以自动执行 Generator 函数?

前面说过,Generator 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。

两种方法可以做到这一点:

  • 回调函数:将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
  • Promise 对象:将异步操作包装成 Promise 对象,用 then 方法交回执行权。

co 模块其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个模块。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或 Promise 对象

co 源码

// Todo

26. async 函数

ES2017 引入

它就是 Generator 函数的语法糖。 async 函数就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await。

async 会将其后的函数(函数表达式或 Lambda)的返回值封装成一个 Promise 对象,而 await 会等待这个 Promise 完成,并将其 resolve 的结果返回出来。

async 函数对 Generator 函数的改进,体现在以下四点:

  1. 内置执行器:Generator 函数的执行必须靠执行器,所以才有了 co 模块,而async函数自带执行器。
  2. 更好的语义
  3. 更广的适用性:co 模块约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)
  4. 返回值是 Promise:而 Generator 函数的返回值是 Iterator 对象

Promise 通过 then 链来解决多层回调的问题,现在又用 async/await 来进一步优化它

26.1 用法

async 函数会返回一个 Promise 对象

  1. 如果在函数中 return 一个直接量,async 会把这个直接量通过 Promise.resolve() 封装成 Promise 对象。
    async function testAsync() {
        return "hello async";
    }
    
    const result = testAsync();
    console.log(result); 
    //输出:
    //Promise {<resolved>: "hello async"}
    
    
    // 上面的 async 函数,语义上等于
    function testAsync() {
      return Promise.resolve("hello async");
    }
    
  2. "一旦遇到 await 就立刻让出线程,阻塞后面的代码":是指从 await 语句的下一条开始阻塞;而 await 所等待的语句,不会被阻塞。
    async function async1() {
        console.log('async1 start')
        await async2()
        console.log('async1 end')
    }
    async function async2() {
        console.log('async2')
    }
    async1()
    console.log('script start')
    
    // async1 start
    // async2
    // script start
    // async1 end
    
    所以先打印async2,后打印的script start,最后打印被阻塞的 async1 end。
  3. async 函数有多种使用形式:
    // 函数声明
    async function foo() {}
    
    // 函数表达式
    const foo = async function () {};
    
    // 对象的方法
    let obj = { async foo() {} };
    obj.foo().then(...)
    
    // Class 的方法
    class Storage {
      constructor() {
        this.cachePromise = caches.open('avatars');
      }
    
      async getAvatar(name) {
        const cache = await this.cachePromise;
        return cache.match(`/avatars/${name}.jpg`);
      }
    }
    
    const storage = new Storage();
    storage.getAvatar('jake').then(…);
    
    // 箭头函数
    const foo = async () => {};
    

26.2 注意

  1. 任何一个 await 语句后面的 Promise 对象变为 reject 状态,那么整个 async 函数都会中断执行。所以最好把await命令放在try...catch代码块中。

    async function f() {
      await Promise.reject('出错了');
      await Promise.resolve('hello world'); // 不会执行
    }
    
  2. 多个 await 命令后面的异步操作,如果不存在继发关系,最好让它们同时触发:

    let [foo, bar] = await Promise.all([getFoo(), getBar()]);
    
  3. await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。

26.3 async 函数的实现原理

async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里

async function fn(args) {
  // ...
}

// 等同于

function fn(args) {
  return spawn(function* () {
    // ...
  });
}

function spawn(genF) {
  return new Promise(function(resolve, reject) {
    const gen = genF();
    function step(nextF) {
      let next;
      try {
        next = nextF();
      } catch(e) {
        return reject(e);
      }
      if(next.done) {
        return resolve(next.value);
      }
      Promise.resolve(next.value).then(function(v) {
        step(function() { return gen.next(v); });
      }, function(e) {
        step(function() { return gen.throw(e); });
      });
    }
    step(function() { return gen.next(undefined); });
  });
}

26.3 应用

  1. 并发请求,顺序输出
async function logInOrder(urls) {
  // 并发读取远程URL
  const textPromises = urls.map(async url => {
    const response = await fetch(url);
    return response.text();
  });

  // 按次序输出
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}

上面代码中,虽然 map 方法的参数是 async 函数,但它是并发执行的,因为只有 async 函数内部是继发执行,外部不受影响。后面的 for..of 循环内部使用了 await,因此实现了按顺序输出。

27. 异步遍历器

ES2018 引入了“异步遍历器”(Async Iterator),为异步操作提供原生的遍历器接口,即value和done这两个属性都是异步产生。

27.1 同步遍历器的问题

Iterator 对象的 next() 方法必须是同步的,只要调用就必须立刻返回值。 p

function idMaker() {
  let index = 0;

  return {
    next: function() {
      return new Promise(function (resolve, reject) {
        setTimeout(() => {
          resolve({ value: index++, done: false });
        }, 1000);
      });
    }
  };
}

上面代码中,next()方法返回的是一个 Promise 对象,不符合 Iterator 协议。

解决方法

将异步操作包装成 Thunk 函数或者 Promise 对象,即 next() 方法返回值的 value 属性是一个 Thunk 函数或者 Promise 对象,等待以后返回真正的值,而done属性则还是同步产生的。

function idMaker() {
  let index = 0;

  return {
    next: function() {
      return {
        value: new Promise(resolve => setTimeout(() => resolve(index++), 1000)),
        done: false
      };
    }
  };
}

const it = idMaker();

it.next().value.then(o => console.log(o)) // 1
it.next().value.then(o => console.log(o)) // 2
it.next().value.then(o => console.log(o)) // 3

27.2 异步遍历的接口

异步遍历器的最大的语法特点,就是调用遍历器的 next 方法,返回的是一个 Promise 对象。

asyncIterator
  .next()
  .then(
    ({ value, done }) => /* ... */
  );

对象的异步遍历器接口,部署在 Symbol.asyncIterator 属性上面。

27.3 for await...of

for..of 循环用于遍历同步的 Iterator 接口。新引入的 for await..of 循环,则是用于遍历异步的 Iterator 接口。

async function f() {
  for await (const x of createAsyncIterable(['a', 'b'])) {
    console.log(x);
  }
}
// a
// b

createAsyncIterable() 返回一个拥有异步遍历器接口的对象,for..of 循环自动调用这个对象的异步遍历器的 next 方法,会得到一个 Promise 对象。await 用来处理这个 Promise 对象,一旦 resolve ,就把得到的值(x)传入 for..of 的循环体。

for await...of循环也可以用于同步遍历器。

27.4 异步 Generator 函数

就像 Generator 函数返回一个同步遍历器对象一样,异步 Generator 函数的作用,是返回一个异步遍历器对象。

async function* gen() {
  yield new Promise(resolve => setTimeout(() => resolve("hello"), 1000));
}
const genObj = gen();
genObj.next().then(x => console.log(x));

gen 是一个异步 Generator 函数,执行后返回一个异步 Iterator 对象。对该对象调用 next 方法,返回一个 Promise 对象。

async function* readLines(path) {
  let file = await fileOpen(path);

  try {
    while (!file.EOF) {
      yield await file.readLine();
    }
  } finally {
    await file.close();
  }
}

异步 Generator 函数内部,能够同时使用 await 和 yield 命令。

28. 事件循环机制

28.1 概念

单线程

  • js 是单线程的,所有的同步任务都会在主线程中执行;
  • The JavaScript runtime can do one thing at a time;
  • 事件循环是唯一的;
  • 有多个任务队列;
  • 事件循环会保证按提交的顺序执行 task;

新标准中的 web worker 涉及到多线程

事件循环

HTML 规范:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

为了协调事件、用户交互、脚本、UI 渲染和网络处理等行为,防止主线程的不阻塞,Event Loop 的方案应用而生。Event Loop 包含两类:一类是基于 Browsing Context,一种是基于 Worker。二者的运行是独立的,也就是说,每一个 JavaScript 运行的"线程环境"都有一个独立的 Event Loop,每一个 Web Worker 也有一个独立的 Event Loop。

任务分类

  • 宏任务(task):
    • 浏览器环境:script(整体代码),setTimeout,setInterval,I/O 操作,UI 交互事件,postMessage,MessageChannel,UI rendering 等;
    • node 环境:script(整体代码),setTimeout,setInterval,I/O 操作,setImmediate,postMessage,MessageChannel 等;
  • 微任务(micro task 或 jobs):
    • 浏览器环境:Promise.then catch finally,MutationObserver 回调(html5 新特性)等;
    • node 环境:Promise.then catch finally,process.nextTick 等;

优先级

  • 微任务 > 宏任务
  • UI rendering(包括SLP)> setTimeout/setInterval
  • setTimeout/setInterval > setImmediate(因为前者在 timer 阶段执行,后者在 check 阶段执行?)
  • process.nextTick > Promise

任务源

来自不同任务源的任务会进入到不同的任务队列。

  • setTimeout/setInterval 是同源的,共用一个编号池,技术上,clearTimeout()和 clearInterval() 可以互换。但是,为了避免混淆,不要混用取消定时函数。
  • Promise

任务队列

所有任务的执行都是基于函数调用栈完成的。

  • 宏任务队列:一次只处理一个,如果有另一个事件进来,就放到队尾;
  • 动画回调队列(RAF):会一次性执行完队列中的所有回调,如果在执行过程中又提交了新的动画,会延迟到下一帧执行;
  • 微任务队列:每次函数执行栈清空的时候,就会执行所有微任务。会把队列中的所有任务都执行,包括新添加进来的,直到执行完所有。所以如果提交和处理的速度一样快,会一直在处理微任务;

执行顺序

  1. 首先在 task queue 中取出第一个任务;
  2. 函数执行栈清空后,取出 microtask 队列中的所有任务顺序执行;
  3. 之后再取 task queue 中取出第一个任务(如果有 UI rendering 任务,会优先执行),周而复始,直至两个队列的任务都取完。

相关知识

  1. 渲染发生在每一帧的开始,包括样式计算(S:style calculation)、布局(L:layout--生成 render tree)和绘制(P:painting);
  2. RAF(requestAnimationFrame)回调作为渲染的一部分被执行(规范约定在 SLP 之前执行), ==RAF 既不属于宏任务, 也不属于微任务==
  3. ==UI render 比 callback 优先级高==,渲染相关的代码,不适合放在 task 里。(因此,不要使用 setTimeout 和 setInterval 做动画,应该使用 requestAnimationFrame。一方面因为 requestAnimationFrame 和浏览器渲染频率同步,不会造成不必要 cpu 浪费,另一方面是使用 setTimeout 做的动画,有可能会被推至下一个事件循环里。)
  4. 在浏览器中,setTimeout()/setInterval() 的每调用一次定时器的最小间隔是4ms (4 ms 是在 HTML5 spec 中精确的,并且在2010年及以后的跨浏览器中保持了一致);为了优化后台 tab 的加载损耗(以及降低耗电量),在未被激活的 tab 中定时器的最小延时限制为 1S(1000ms)。
  5. 屏幕刷新频率是 60 赫兹,所以每 16.6ms repaint 一次,但浏览器只有在 Dom 的 layout 或 style 改变的时候,才会执行 SLP;
  6. MessageChannel
  7. MutationObserver 不属于同步事件触发,而是一个异步回调。

28.2 一些例子

1. setTimeout

var a = {
    name: "Cherry",
    func1: function () {
        console.log(this.name)
    },
    func2: function () {
        setTimeout(function () {
            console.log('step 0')
            this.func1()
        }.bind(a)(), 3000); //a
       
        console.log('step 1');
       
        setTimeout(function () {
            console.log('step 4');
            this.func1()    //b
        }.bind(a), 5000);
        
        setTimeout(function(){
            console.log('step 3');
            this.func1()    //c
        }, 4000)
    }
};
a.func2()
console.log('step 2');
  • a: 注意这里是立即执行函数,所以不会等待3s,会直接输出;
  • b: 这里的 this 绑定在了对象 a 上
  • c: 这里的 this 没有绑定,默认是全局对象 window,所以 this.func1() 报错

2. Promise

例1:

new Promise(( resolve ) => {
    resolve();
})
.then(() => console.log(1))
.then(() => console.log(2))
.then(() => console.log(3))

new Promise(( resolve ) => {
    resolve();
})
.then(() => console.log(4))
.then(() => console.log(5))
.then(() => console.log(6))

/* 输出:
1
4
2
5
3
6
*/

Promise 多个 then() 链式调用,并不是连续的创建了多个微任务并推入微任务队列,因为 then() 的返回值必然是一个 Promise,而后续的 then() 是上一步 then() 返回的 Promise 的回调

  • 传入 Promise 构造器的执行器函数内部的同步代码执行到 resolve(),将 Promise 的状态改变为 : undefined, 然后 then 中传入的回调函数 console.log('1') 作为一个微任务被推入微任务队列
  • 第二个 then() 中传入的回调函数 console.log('2') 此时还没有被推入微任务队列,只有上一个 then() 中的 console.log('1') 执行完毕后,console.log('2') 才会被推入微任务队列

例2:

new Promise(resolve => {
    resolve(1);
    
    Promise.resolve().then(() => {
      console.log(2)
    });
    console.log(4)
}).then(t => {
  console.log(t)
});
console.log(3);

/*
4
3
2
1
*/

new Promise().then() 先执行构造函数,执行过程中先 resolve 了,但是构造函数还没执行完,.then() 中的回调方法还没注册,所以不会输出 3;继续执行,Promise.resolve() 直接返回一个 resolved 状态的 Promise 对象,并注册了 .then() 回调方法,所以直接输出 2,最后输出 1。

3. setTimeout 与 Promise

console.log('golb1');

setTimeout(function() {
    console.log('timeout1');
    new Promise(function(resolve) {
        console.log('timeout1_promise');
        resolve();
    }).then(function() {
        console.log('timeout1_then')
    })
})

new Promise(function(resolve) {
    console.log('glob1_promise');
    resolve();
}).then(function() {
    console.log('glob1_then')
})

setTimeout(function() {
    console.log('timeout2');
    new Promise(function(resolve) {
        console.log('timeout2_promise');
        resolve();
    }).then(function() {
        console.log('timeout2_then')
    })
})

new Promise(function(resolve) {
    console.log('glob2_promise');
    resolve();
}).then(function() {
    console.log('glob2_then')
})

/* 输出:
golb1
glob1_promise
glob2_promise
glob1_then
glob2_then
Promise {<resolved>: undefined}
timeout1
timeout1_promise
timeout1_then
timeout2
timeout2_promise
timeout2_then
*/

4. promise 与 async 函数

例1:

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
    
async function async2() {
    console.log('async2')
}
    
console.log('script start')

setTimeout(function () {
    console.log('setTimeout')
}, 0)
    
async1();
    
new Promise(function (resolve) {
    console.log('promise1')
    resolve()
}).then(function () {
    console.log('promise2')
})
    
console.log('script end')

/* 输出:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/

例2:

function getUserInfo (id) {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve({
                id: id,
                name: 'xxx',
                age: 'xxx'
            })
        }, 200)
    })
}

const users = [{id: 1}, {id: 2}, {id: 3}]
let userInfos = []

const forEachLoop = () => {
    console.log('start')
    users.forEach(async user => {
        let info = await getUserInfo(user.id)
        userInfos.push(info)
    })
	console.log(userInfos)
    console.log('end')
}

// 等价于:
const forEachLoop2 = () => {
    console.log('start')
    users.forEach(user => {
        getUserInfo(user.id).then(info => userInfos.push(info))
    })
	console.log(userInfos)
    console.log('end')
}

/* 输出:
start
Promise {<pending>}
[]
end
*/

const forOfLoop = async () => {
    console.log('start')
    for(user of users) {
        let info = await getUserInfo(user.id)
        userInfos.push(info)
    }
	console.log(userInfos)
    console.log('end')
}

/* 输出:
start
Promise {<pending>}
[{…}, {…}, {…}]
end
*/
  • forEachLoop 等价于 forEachLoop2,async 函数在 forEach 中,是一个内部函数,不能阻塞外部的同步代码 userInfos 的打印,所以会输出空数组;
  • 而 forOfLoop 整体是一个 async 函数,await 会阻塞 userInfos 的打印。

5. UI rendering 与 死循环

document.body.style.backgroundColor = 'green'
while(true) {}
// body颜色会变绿色吗? 不会,同步代码死循环,阻塞了 UI rendering 任务

document.body.style.backgroundColor = 'yellow'
Promise.resolve().then(() => {
		while(true) {}
})
// body颜色会变黄吗? 不会,微任务比宏任务先执行,死循环阻塞了 UI rendering 任务

document.body.style.backgroundColor = 'red'
setTimeout(() => {
	while(true) {}
})
// body颜色会变红色吗? 会,setTimeout、UI rendering 同样属于宏任务,但是 UI rendering 优先执行

setTimeout(() => {
    while(true) {}
})
document.body.style.backgroundColor = 'red'
// body颜色会变红吗? 会,同上

6. 循环调用

执行以下两段代码分别会有什么反应?是堆栈溢出还是页面卡死?

function foo() {
  setTimeout(foo, 0)
}
// 宏任务循环调度,不影响页面响应,所以正常执行

function foo() {
  return Promise.resolve().then(foo)
}
// 由于一直在执行微任务,所以页面卡死

引用链接

  1. ECMAScript 6 入门
  2. 理解 JavaScript 的 async/await
  3. 令人费解的 async/await 执行顺序