2019年8月

本系列题目源自于:Github

参考其解析和相关知识点记录自己的思考学习过程

题目

以下代码输出什么:

function sayHi() {
  console.log(name)
  console.log(age)
  var name = 'Lydia'
  let age = 21
}

sayHi()
  • A: Lydiaundefined
  • B: LydiaReferenceError
  • C: ReferenceError21
  • D: undefinedReferenceError

答案

D

主要知识点是let声明变量存在的暂时性死区问题。var声明的变量的作用域是整个封闭函数,在同一个封闭函数内,重复使用var声明同名的变量,对应的是同一个。var声明的变量会被提升,即一开始就会占好内存,只是并没有定义类型和值,也就是undefined,直到程序执行到为其赋值的地方。let和const声明的变量则为了保证变量得到正确的使用,即使是在非严格模式下,也通过暂时性死区来保证变量初始化之前无法被使用。

解析

let、const和块级作用域

let 语句声明一个块级作用域的本地变量,并且可选的将其初始化为一个值。

在ES6之前,JavaScript的变量作用域只有全局作用域和函数作用域,所以也只需要var进行变量声明。然而块级作用域基本上是其他语言必备的特性之一,全局作用域和函数作用域显然是不够的。在没有块级作用域的情况下,任何除了函数体的大括号界定都没有新的作用域,和外层函数共享作用域,使得内部用var声明的变量会被提升到函数体的开头,尽管这个变量并不会在其他的地方使用。如果函数体结构复杂,很有可能会造成变量重复声明,导致逻辑上错误。

块语句是JavaScript的一个基础特性,也是大部分变成语言用于区分逻辑分区的有力特征,比如if(){ Statement }for(){ Statement }等,以大括号界定的零个或多个语句的组合。通过var声明的变量不存在块级作用域,即块语句中的var声明仍然不受约束的提升到外层去。

ES6提出了letconst关键字,声明具有块级作用域的变量,这里先只谈let(把const理解成必须赋值且不能更改的let就行)。let和var最直接的对比:

// example_1.js
var x = 1;
{
  var x = 2;
}
console.log(x); // 输出 2

// example_2.js
let x = 1;
{
  let x = 2;
}
console.log(x); // 输出 1

在相同作用域下使用let或者const,声明同名变量,是会报错的。除了明显的大括号产生的作用域之外,if的条件语句,for的循环设置,都被算在下属的作用域内。尤其是使用for时,使用var声明的迭代标记,很有可能干扰外部的变量,或者出现延迟求值的情况。比如官网的例子:

var a = [];
for (var i = 0; i < 10; i++) {
      a[i] = function () {console.log(i);};
}
a[0]();                // 10
a[1]();                // 10
a[6]();                // 10

/********************/

var a = [];
for (let i = 0; i < 10; i++) {
      a[i] = function () {console.log(i);};
}
a[0]();                // 0
a[1]();                // 1
a[6]();                // 6

由于var声明的变量被提升,循环体内创建的闭包并没有办法保存i的瞬间值。使用let声明的变量在块级作用域内能强制执行更新变量

暂时性死区

有了作用域规则后,大概就能理解var和let的提升情况。首先要知道的就是,var和let(以及const)声明的变量都会提升,主要区别就是提升的位置不同。另外一点就是新的概念暂时性死区(TDZ)。作用域内使用let或const声明的变量,在被初始化之前,都在暂时性死区中不可用。

使用let声明的变量受到块级作用域的约束,可以用于创建私有的成员变量。比如官网的例子:

var Thing;

{
  let privateScope = new WeakMap();
  let counter = 0;

  Thing = function() {
    this.someProperty = 'foo';
    
    privateScope.set(this, {
      hidden: ++counter,
    });
  };

  Thing.prototype.showPublic = function() {
    return this.someProperty;
  };

  Thing.prototype.showPrivate = function() {
    return privateScope.get(this).hidden;
  };
}

首先在外部privateScope和counter这两个变量时不可访问的。利用WeakMap的特性,对象可以作键,将需要隐藏的属性放在一个外部不可访问的容器中,提供一个对外的访问方法,在闭包中可以拿到这个容器,来避免外部直接访问想要隐藏的属性,同时隐藏了修改的途径。当然了,使用函数闭包的方式也可以用var来模拟私有变量,利用的是var逃不出函数作用域的特性。

var Thing;

(function () {
  var privateScope = new WeakMap();
  var counter = 0;

  Thing = function () {
    this.someProperty = "foo";

    privateScope.set(this, {
      hidden: ++counter
    });
  };

  Thing.prototype.showPublic = function () {
    return this.someProperty;
  };

  Thing.prototype.showPrivate = function () {
    return privateScope.get(this).hidden;
  }
})();
// 利用IIFE来初始化Thing,外部同样不可见私有属性

对于var来说,同一作用域内不存在重复声明的问题,但是let和const是不允许的,会抛出语法错误。并且要注意一下语法结构所隐含的作用域。

  • if条件结构:不同的分支有自己的作用域
  • switch条件结构:所有的case和default在不使用块语句时共用一个作用域
  • for循环结构:整个共用一个作用域,初始化条件声明的变量在循环体内可用

因为有了这些限制,是的let和const的提升行为和var有了明显的不同。var的提升可以认为是声明和赋值被分开了,但是声明确确实实一开始就做了,占用了该占用的内存。而let和const不一样,一定在定义被执行时才会初始化,初始化之前会导致引用错误。这样的变量在作用域顶端进入“暂时性死区”,处于不可访问的状态。这样听上去很奇怪,就跟没有被提升一样,但是用typeof你会发现并不是这样。又是官网的例子:

// prints out 'undefined'
console.log(typeof undeclaredVariable);
// results in a 'ReferenceError'
console.log(typeof i);
let i = 10;

变量undeclaredVariable确实没有被声明,使用typeof判断类型会得到undefined,而通过let声明的变量i,在初始化之前尝试探测其类型,一样会得到引用异常。

undefined是一个全局属性,即global.undefined或window.undefined。其值是原始值undefined原始值undefined会自动赋值给那些只是被声明的变量,或者是那些没有实际传参的形式参数(因为函数的形参相当于声明的临时变量)。typeof操作符用于返回操作数的类型,有三种类型的变量类型是undefined--全局属性undefined、声明但未赋值的变量和完全未声明的变量。全局属性undefined和声明但未赋值的变量,类型是undefined可以理解,因为他们相当于在内存中占了个坑,但是里面空空如也,完全不知道是什么。而对于没有声明过的变量,在内存中找了一遍都没有找到,就更不知道是什么了。

再来说ReferenceError,在严格模式下操作了一个未声明的变量,会抛出这个异常。根据上面的例子发现,非严格模式下尝试探测暂时性死区内变量的类型也会产生该异常。对于已声明和未声明的变量,主要差异在于:

  • 未声明变量总是在全局的,而已声明的变量总是约束在特定的上下文中
  • 已声明的变量在相关上下文开始之前就已经创建了(所以var和let声明的变量都是被提升的)
  • 已声明的变量时其执行上下文的不可配置属性

所以let和const声明的变量更加严格,非严格模式下,未声明的变量可以直接进行赋值等操作,间接产生了全局变量,这非常不安全。而使用var声明的变量直接被提升,检查其类型由于没有被初始化所以是undefined,初始化之前的赋值又会被覆盖,可能会产生迷惑。因此let和const在兼顾块级作用域的前提下,使用暂时性死区的特性,来将这些数据的提升保护起来。提升理论上应该只是程序执行的优化,而不应该在编程中影响应有的逻辑,所以暂时性死区更加严格。

暂时性死区真的超严格

当var和let相遇了会发生什么。来看官网的例子:

function test(){
   var foo = 33;
   if (true) {
      let foo = (foo + 55); // ReferenceError
   }
}
test();

test()函数作用域下用var声明并初始化了变量foo,在if的条件语句块儿中,用let声明了一个变量foo,并将其赋值为表达式(foo+55)的值。这时候要先考虑声明提升导致的暂时性死区,因为在上下文中寻找变量遵循就近原则。if条件语句块中有let声明,就应当考虑到会锁定就近foo变量,这时候在计算(foo+55)就会产生访问暂时性死区中变量的情况,会严格抛出异常,而不是用外部的foo进行计算并赋值。

另一种情况发生在for...of结构中,使用变量迭代时,也会出现暂时性死区问题。比如官网的例子:

function go(n) {
  // n here is defined!
  console.log(n); // Object {a: [1,2,3]}

  for (let n of n.a) { // ReferenceError
    console.log(n);
  }
}

go({a: [1, 2, 3]});

将for循环结构拆开,我们大概会得到以下的内容:

let values = n.a.values();
let iterator = values.next(); // 迭代器行为
while(!iterator.done) {
  let n = iterator.value;
  console.log(n);
  iterator = values.next();
}

实际上的上下文环境不会像上面代码一样,只是拆开方便讲解步骤,把上面的几行代码都看作一个上下文环境来分析。let n会将n提升,然后会访问n的a属性,这样就不难发现暂时性死区的存在了。

除此之外,还要注意的就是,var声明并赋值的变量不受块级作用域的约束,会影响到块级作用域以外的变量,以下这种情况会产生重复声明的异常:

let x = 1;

if (true) {
  var x = 2; // SyntaxError for re-declaration
}

条件语句块中的var声明和外部的let声明重复了,所以会抛出异常,如果外部也是使用var声明的,则不会产生异常,且条件语句块中的赋值会覆盖外面的赋值。

总结

本题目的分析思路,从变量是否可访问出发。不考虑变量提升(我始终认为变量提升不应当影响编程逻辑),var声明的变量要求比较宽松,你在初始化之前访问,认为就是一个不存在的变量,所以就是输出undefined。而let声明的变量对作用域有更高的要求,结合新的特性“暂时性死区”,相当于开启了严格模式,你不可以在变量被赋值前就访问。至于说要考虑提升,你可以认为程序在执行的时候,你声明的变量都要提前占坑,即使你不赋值,内存得先占住,直到你赋值的时候才会往内存中设置值。其实从“声明”两个字,就应该知道声明变量是一个提前准备的过程。

可迭代协议

这个协议约束对象定义自己的的迭代行为,for...of这种循环结构,就是在被循环对象上进行迭代访问,这就依赖其定制的迭代行为。类似Array和Map都有自己的迭代行为,普通的对象并没有提前定义迭代行为。可迭代对象要求实现@@iterator方法,即访问对象的[Symbol.iterator]属性必须是可行的,对象本身或者其原型链上必须有这个属性。

[Symbol.iterator]这个属性的值应当是一个函数,返回一个符合迭代器协议的对象。需要被迭代的时候,@@iterator方法被无参调用,返回一个“迭代器实例”,用这个实例来获取每次访问的值。

可迭代协议实际上是规定了可迭代与不可迭代对象之间的差异,由于JavaScript没有Interface(接口)的概念,必须要通过协议的方式,来约束具有特定行为的对象应当具备的特性。

迭代器协议

可迭代协议是要求对象在需要被迭代时,如何实现自己的迭代行为;迭代器协议则是约定了一种标准的方式,可以产生有限或无限序列的值,还要求在迭代的最终总会有一个默认的返回值。所以说迭代器协议,是定义了怎样才算是一个迭代器。可以参考下面的迭代器一节。

迭代器可以显示的调用next()来逐个获取值,并通过done属性来判断是否完成,这个动作就是一个迭代行为。所以一般来说可迭代协议和迭代器协议是同时实现的。用迭代器协议的实现,作为可迭代的默认行为。最简单的迭代器:

var myIterator = {
    next: function() {
        // 自己定义如何返回下一个值,相当于决定访问序列的顺序和限度
    },
    [Symbol.iterator]: function() { return this } // 自身就是符合迭代器协议的对象
}

由于[Symbol.iterator]这个属性通常是不会显示的访问,且next()方法也有可能不会暴露出来,比如Array的实例,可以for...of进行迭代,但是next()并不是直接访问的,所以提供了一些方法来将使用的迭代器暴露出来。

let values = [1,2,3,4,5].values(); // 这里values就是数组内涵的迭代器
// 这里还可以使用.entries()来返回一个迭代器,这个迭代器的值是[index, ele]这种格式的
let n = values.next();
while(!n.done) {
  console.log(n.value); // 依次打印序列中的每一个值,打印出最后一个元素后,n.done === true,结束遍历
  n = values.next();
}

另外,for...in也是一种循环结构,一般针对对象的属性进行遍历(只会遍历那些“可遍历”的属性,具体可以参见defineProperty),但实际上一个普通的对象并不是可迭代的。for...in可以用作数组的遍历,但是顺序可能无法保证。

一些例子

可迭代协议里要求了@@iterator的实现,所以可以用这一点来判断一个对象是否可迭代:

let ss = "hello";
typeof ss[Symbol.iterator]; // 这里应当返回"function"

let iterator = ss[Symbol.iterator](); // 获取到字符串的迭代器实例
// console.log(iterator.toString()) 这里会返回[object String Iterator],表示它确实是一个迭代器

iterator.next(); // {value: 'h', done: false} 
// 显示的调用next(),可以发现字符串实际上是字符的序列
// 可迭代的对象一般都可以使用展开语法
[...ss]; // 你会得到['h','e','l','l','o'] 

为了让用户自己实现可迭代协议,[Symbol.iterator]属性是可以修改的,因此也可以覆盖已经实现的迭代行为:

let ss = new String("hello"); // 这里必须使用显示构造String实例,避免自动装箱
ss[Symbol.iterator] = function() {
  // 返回一个实现迭代器协议的对象
  return {
    next() {
      // 必须返回{value: any, done: boolean}
      return {value: "+" + ss[this._index++], done: this._index >= ss.length}
    },
    _index: 0
  }
};
console.log(iterator.next().value); // "+h"
console.log([...ss]); // [ '+h', '+e', '+l', '+l' ] 

对于string,number和boolean这三种基础数据类型,js有对应的三种包装类型String,Number和Boolean,但你以字面量的形式声明了变量,如let ss = "hello",并执行了ss.substring(2),String.prototype.substring实际上是String封装类实例的方法,所以JavaScript会创建对应的包装类型,然后调用指定的方法,然后会销毁掉的。所以如果不显示的创建包装类型,你是无法覆盖[Symbol.iterator]的,因为都覆盖到临时的那个装箱变量上。

迭代器

原文

In JavaScript an iterator is an object which defines a sequence and potentially a return value upon its termination

在JavaScript中,迭代器是这样一个对象:定义了一个序列,在终止时可能会返回一个值。更确切的说,迭代器是实现了迭代协议,要求是拥有一个next()方法,这个方法返回一个对象,包含value属性--序列中下一个值,和done属性--true表示序列中最后一个值已经被访问过。如果valuedone都有,其被称为迭代器的返回值。

迭代器一旦被创建,可以通过重复的调用next属性来显式的迭代。迭代器的迭代过程可以被称为是“消耗迭代器”,因为通常这个过程只执行一次。(原文比较拗口,其实就是表达迭代一个集合,相当于单程的地铁,一次经过一个站)。当最终值已经产生,额外再调用next()应当只是返回{done:true}(只有done属性没有value属性,已经不能算作迭代器的返回值了)。

JavaScript中最常见的迭代器就是数组迭代器,单纯的按照顺序返回关联数组中的每一个值。很容易去想象,所有的迭代器都可以被描述成数组,但实时并非如此。数组必须按照体量完整分配空间,但是迭代器只在需要的时候才被消耗,因此可能描述成一个不限大小的序列,比如0到无穷之间所有的整数形成的集合(即特定情况下,迭代器的next()是可以无限调用下去,永远有下一个值)。

下面用一个例子来说明。这个例子可以创建一个简单的范围迭代器,根据提供的起始点(start和end,左闭右开区间)来创建一个整数步长序列。最终返回值是创建序列的大小(iterationCount)。

// start 包含左端点 end 不包含右端点 step 整数间隔步长
function makeRangeIterator(start = 0, end = Infinity, step = 1) {
    let nextIndex = start;
      // 闭包内的用于计数的变量
    let iterationCount = 0;
        
      // 具备迭代器的定义特性,有一个next方法,next返回的对象有value属性和done属性
    const rangeIterator = {
       next: function() {
           let result;
           if (nextIndex < end) {
               result = { value: nextIndex, done: false }
               nextIndex += step;
               iterationCount++;
               return result;
           }
           return { value: iterationCount, done: true }
       }
    };
    return rangeIterator;
}

// 使用方法
let it = makeRangeIterator(1, 10, 2);

let result = it.next();
while (!result.done) {
 console.log(result.value); // 1 3 5 7 9
 result = it.next();
}

console.log("Iterated over sequence of size: ", result.value); // [5 numbers returned, that took interval in between: 0 to 10]

注:无法通过反射判断对象是否可迭代

生成器函数

迭代器next方法的实现决定了如何访问集合中的元素,对于集合的操作需要非常谨慎,不能因为一个迭代的行为而导致集合产生了不可预期的影响。除此之外,next方法调用的时机不确定,两次next的调用期间,要维护集合的稳定。所以迭代器对于内部状态的维护非常重要。生成器函数是迭代器的一个可选替代方式,通过定义一个不连续执行的函数,定义迭代算法。

生成器函数的语法

function* funcName(...args): Generator {}

生成器函数的行为

调用生成器函数并不会执行函数体的任何代码,而是返回一个特殊的迭代器Generator--生成器。生成器调用next方法想要消耗一个值时,生成器函数体才会执行,并且在遇到yield时停下,将yield的操作数作为value返回,如果将所有的yield都消耗完了,将返回{value: undefined, done: true}作为迭代的终止。

多次调用生成器函数会得到全新的生成器,相互之间不会相互影响,但是都只能迭代一遍。

之所以叫Generator生成器,就是因为它能在调用next()时,按照既定的规则生成一个值,而不一定需要既定集合去迭代。yield单词本身有“产生,产出”的含义,作为关键字其操作数会作为迭代的结果被“产生”。

来自官网的例子:

function* makeRangeIterator(start = 0, end = 100, step = 1) {
    for (let i = start; i < end; i += step) {
        yield i;
    }
}

用工厂方法的形式也可以实现类似的功能:

function rangeIteratorFactory(start = 0, end = 100, step = 1) {
  return {
    next() {
      let done = this._currentValue >= end,
        result = {
          value: done ? undefined : this._currentValue,
          done
        };

      this._currentValue += step;

      return result;
    },
    _currentValue: start
  }
}

但是返回的终究是个普通的对象,只是实现了迭代器协议,正确使用也可以得到预期的结果。

高级生成器

生成器返回一个迭代器,按函数体中yield来完成迭代。每次调用next都会回到函数体上次结束的位置继续执行,直到下个yield语句。对于外部来说,调用next来获取,或者说消费下一个值,特定次数的调用,理论上结果应该是一样的。比如一个斐波那契数列生成器,使用一个无限循环来求数列的下一项:

function* fibonacci() {
  var fn1 = 0;
  var fn2 = 1;
  while (true) {  
    var current = fn1;
    fn1 = fn2;
    fn2 = current + fn1;
    yield current;
  }
}

只要通过这个生成器获得的迭代器,第三次调用next()都会返回value为1的结果。而生成器的工作流程可以发现有一个回到上次执行结尾的特性,相当于我们调用next()时,会从生成器函数上一次yield的位置之后继续执行。所以有的时候用一个“执行权”的概念来描述生成器函数的执行。借官网的一段代码:

let sequence = fibonacci();
console.log(sequence.next().value);     // 0
console.log(sequence.next().value);     // 1
console.log(sequence.next().value);     // 1

首先获取了生成器对应的迭代器,第一次调用next(),“执行权”从生成器函数的首行开始,初始化fn1和fn2,进入循环,创建变量current,赋值为fn1的值,操作fn1和fn2(分别为1和1),遇到yield将current的值组装到迭代器返回的结果中,将“执行权”交回到第一次调用next()的调用处,访问value属性并打印。“执行权”交出去了,但是执行环境还是保留着的,所以到第二次调用next()时,“执行权”回到生成器函数中上次停下的地方,即fn1和fn2分别为1和1的时候继续执行,回到循环头,再声明一个current并赋值,操作fn1和fn2(分别为1和2),遇到yield将current返回,再次交出“执行权”,外部得到结果1。后面的就依次类推。

不难发现“执行权”交回的时候,外部并无法影响其内部逻辑,对于斐波那契数列来说,将无限迭代下去。除非我们重新获取一个迭代器,否则我们无法重新开始。所以迭代器的next()函数,可以传入一个参数,用于执行权交回的时候,给定一个信号。这个信号在执行权交回的时候,作为yield语句的值,可能参与到后续的逻辑,所以我们再从官网拿个例子:

function* fibonacci() {
  var fn1 = 0;
  var fn2 = 1;
  while (true) {  
    var current = fn1;
    fn1 = fn2;
    fn2 = current + fn1;
    var reset = yield current;
    if (reset) {
        fn1 = 0;
        fn2 = 1;
    }
  }
}

当我们调用next(true)时,变量reset将被赋值为true,后续就可以重启序列了。生成器函数中yield将其操作数求值作为迭代的value返回,而next()传入的参数则作为执行权交回生成器函数式,整个yield语句的值,参与到后续的逻辑中。

除此之外,生成器函数返回的迭代器,通过调用throw()来制造一个异常,在生成器函数内部被捕捉,产生异常和异常的处理并不在一起。

yield*

yield* [[expression]];

这个表达式可以认为是迭代器的嵌套,用另一个迭代器的迭代情况来补充自身的迭代。拿一个官网的例子:

function* g1() {
  yield 2;
  yield 3;
  yield 4;
}

function* g2() {
  yield 1;
  yield* g1();
  yield 5;
}

在遇到yield*时,将从其操作数中进行迭代。也可以认为是将执行权进行一次传递。可以将“产生”这一动作委托给其他的可迭代对象,你可以yield* [1,2],则从数组中依次产生值。

生成器函数与异步

前面有提到生成器函数的行为,可以用执行权的交换来理解,这已经有异步的意味在里面。异步与同步相对,不阻塞线程来完成任务,宏观上看,一个任务的执行,往往不是在一段连续的时间内执行并完成的。而生成器函数的函数体,在调用next()时,可以从上次停下的地方继续执行,也就是说,整个函数体就是一个异步任务,在特定的时候停下交出执行权,又在特定的时候得到执行权继续完成。

JavaScript引擎是在单线程上执行的,为了实现异步编程,通常采用回调函数、事件监听、Pub/Sub和Promise等方式实现。协程的概念在很多语言中,也是异步编程的方案之一。协程也被当做轻量的线程,简单来看就像是函数间的切换执行。Generator函数就类似协程,通过yield来交出执行权,暂停自身的执行,等到特定的时候继续,所以说生成器函数体就是一个异步任务。生成器函数返回的迭代器,就相当于这个异步任务执行的控制器,调用next()来让生成器函数逐步执行,并且返回阶段性的信息,value分段执行的结果,done任务是否完成。

生成器函数可以停止可以恢复,是封装异步任务的基础,除此之外还为他设计了数据交换的方式(next().value和向next()传入参数),异常的产生与捕捉,都为生成器函数模式的异步编程提供支持。

用生成器函数封装异步任务

生成器函数利用执行权交换的特性,可以改变异步任务的宏观流程,偷了一个例子过来:

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

var g = gen()
var result = g.next()

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

可以看到gen函数体,不看yield基本上就是一个同步的处理流程,g.next()启动,拿到fetch返回的promise实例,在then方法中对数据进行json化,再通过next(data)将执行权交回,所以让gen的函数体看上去符合一般的逻辑流程。但是这样的写法会有一定的混淆,因为真正异步任务完成和结果被处理并不在一起。

Thunk函数

Thunk在专业释义中表达为“形(式)实(在)转换程序”。JavaScript中Thunk函数是高阶函数的一种,通常是用来将已有的函数,结合新的参数,形成新的函数。总的来说Thunk的作用是“替换”,偷一个例子来看:

fs.readFile(fileName, callback);

var Thunk = function (fileName) {
  return function (callback) {
    return fs.readFile(fileName, callback);
  };
};

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

readFile本来是一个多参数函数,Thunk将fileName作为替换内容,生成一个只接受callback的函数。所以你在调用readFileThunk时,其实都是提前将fileName参数替换掉的过程,或者说将readFile的调用替换掉了。具体可以参见Thunk的详细说明,简单理解成“传名调用”也可以,是一种替换表达式的实现方式。

其实Thunk函数的最内层return一定是要被替换的最原始多参数函数,然后用嵌套return function()来创造上下文接受部分参数,作为上层的替换结果。由于每一层return function()将多参数打散,是的在调用的时候会有Thunk(fileName)(callback)这样的情况,直到最后一个调用才真正执行,而前面的调用,相当于将参数进行缓存,有一个延迟求值的作用。

生成器函数的执行控制

从生成器函数的行为特性可以看到,整个执行流程实际上非常依赖外部对于next的调用,如果外部不知道生成器内部需要多次完成,可能还需要外部通过永真循环来让生成器函数彻底执行完。并且生成器函数包含多个异步操作时,后一阶段要依赖上一阶段,外部可能就需要进行then的嵌套。所以这个嵌套可以用递归的方式使其自动完成,而不需要知道实际有几个阶段。偷了一个例子:

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

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

  next();
}

function* g() {
  // ...
}

run(g);

run()方法接受一个生成器函数,函数体内先得到生成器对应的迭代器gen,定义递归函数next(),这个函数的作用就是控制迭代器消耗。不难发现这个next()其实就是一个指令,告诉生成器函数生成异步操作完成后继续向后就是了。控制生成器函数的执行,就是控制好执行权的转移,上面的例子是通过回调函数递归实现自动的执行权转移,换成Promise也可以通过then进行递归实现。

生成器函数实际上就是异步操作的容器,遇到异步操作将执行权交出,完成后应当重新得到执行权进行下一个异步此操作。所以生成器函数的自动执行的关键,就是在异步操作完成时能够主动的交还执行权。上面的例子中,就是通过next()方法的递归来实现自动的交还。总结起来,1. 回调函数型异步操作,包装成Thunk函数,回调中交还执行权。2. Promise型异步操作,then方法注册的回调进行交还。

async/await 函数

AsyncFunction

async function(){} 就是一个AsyncFunction的实例。Object.getPrototypeOf(async function(){}).constructor可以获取构造方法(虽然这样做并没有意义)。

let AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;

// 使用语法,用不用new都可以,且不创建闭包,上下文始终是全局
AsyncFunction(...args:any[], functionBody:string): function;

这个并不重要,只是提一下。

异步函数是通过事件循环异步执行的函数(因为一般的函数都作为单独的调用栈同步执行),隐式返回Promise代表其执行结果。异步函数语法和结构上更像是同步执行流程,比如官网的例子:

function resolveAfter2Seconds() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('resolved');
    }, 2000);
  });
}

async function asyncCall() {
  console.log('calling');
  var result = await resolveAfter2Seconds();
  console.log(result);
  // expected output: 'resolved'
}

asyncCall();

asyncCall的逻辑理解顺序,是等到resolveAfter2Seconds()函数对应的Promise解决了之后得到结果result在继续执行。已经看到了生成器函数的影子,await实际将执行权交出去了,promise解决了以后将执行权交回并继续。所以从流程上看更像是同步的。

语法

async function name(...args) { statements }
async (...args) => { statements }
async function(...args) { statements }

let ob = {
  async func(...args) { statements }
}

可用于匿名函数和箭头函数。

隐式返回一个Promise实例,函数体执行完成则对应resolve,函数体异常则对应reject。

描述

异步函数才可以包含await指令,该指令实际上会暂停异步函数的执行,交出执行权,待await指定的操作完成(可以参见Promise的解决)继续执行。所以只关注异步函数的流程,看上去变成了同步的过程,实际上通过执行权的转移,仍是异步的完成。实际上async/await只是展开了Promise.then嵌套回调的格式,不改变具体的执行顺序。

await原意就是“等待,期待”,在异步执行函数中,表达“当等待的东西得到结果了,我在继续执行”。

异步执行函数的return会隐式的传递给Promise.resolve,所以return await promiseValue和return promiseValue都是可以的,但是逻辑上是完全不同的。

和生成器函数的关系

一说async/await是生成器函数的语法糖。偷一下阮一峰的例子:

const readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) return reject(error);
      resolve(data);
    });
  });
};

const gen = function* () {
  const f1 = yield readFile('/etc/fstab');
  const f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

const asyncReadFile = async function () {
  const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

function*和async function对应,await和yield对应。不过生成器函数返回的是迭代器,且自动执行需要显示操作,而异步执行函数隐式返回promise是自动完成的。异步执行函数像普通函数一样直接调用,就会自动执行,不需要生成器函数那样的自己实现。且async/await有更好的理解,流程上更加清晰。同时也可以将async function理解成将函数体包装成promise,而await则是将then绑定的回调进行展开。如果把await之后的语句当做当前promise.then绑定的回调,就可以发现其实多个异步操作包含在异步执行函数中,是嵌套的promise。

异常处理

普通promise异步操作用then处理结果,用catch处理异常,多个异步操作也可以用Promise.all进行包装处理。如果异步执行函数中使用await移交了执行权,解决异常就应当类似生成器函数中处理异常,使用try...catch结构来捕捉。

注意

虽然async/await并没有改变异步执行的特性,但是执行顺序是被确定的,所以连续的await如果不存在依赖关系(使用Promise形式不存在嵌套时),最好不要都使用await,因为异步操作的启动相当于是阻塞的。后面的操作启动的要更晚,这明显不利于异步处理效率。这种情况用await Promise.all()更有效率。

再循环中使用await也要注意,在借用阮一峰的几个例子:

function dbFuc(db) {
  let docs = [{}, {}, {}];

  docs.forEach(async function (doc) {
    await db.post(doc);
  });
}

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  for (let doc of docs) {
    await db.post(doc);
  }
}

第一个函数可能得到错误的结果,因为forEach是并发的,针对docs的每个元素执行异步函数。但第二个函数中,显示的等待post的结果,会阻塞for循环,从而确保结果的正确。

另外,由于async/await具有生成器函数的执行特性,遇到await会将执行权交出去,并保留自己的执行上下文,使得特定情况下函数的上下文得以保留,比如阮一峰的例子:

const a1 = () => {
  b().then(() => c());
};

const a2 = async () => {
  await b();
  c();
};

调用a1以后,b()执行一个异步操作,绑定回调()=>c(),此时就不再等待,继续完成a1的执行。绑定的回调也是等事件循环来调度。而a2调用后,将执行权交到b()的异步操作,a2也并没有执行完,上下文也保留着。当然a1中绑定的回调由于创建了闭包,也能保留a1中必要的变量等。

async的生成器函数实现

不难看出异步执行函数就是自动执行的生成器函数,所以可以用一下方式来实现:

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

// 等同于

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

function spawn(genF) {
  // 隐式返回一个promise
  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); });
  });
}

实现了可迭代协议的对象就是可迭代对象,在for...of这种结构中,使用其迭代行为来访问集合中的值。比如Array和Map的实例都是拥有内置迭代行为的对象。Array的实例,调用[Symbol.iterator]()和values()都会得到遍历元素的迭代器,entries()可以参见文档。根据可迭代协议,@@iterator必须实现,对象本身或者其原型链上必须带有符合协议的[Symbol.iterator]属性。生成器函数返回迭代器实例,所以也可以用在[Symbol.iterator]属性上。

原文

什么是Event Loop

尽管事实上JavaScript是单线程的,Event Loop通过尽可能的将操作卸载到内核,让Node.js实现非阻塞的I/O操作。(其实这段话表达了JavaScript的执行是在单一线程上的,但是Node.js可能需要借助其他线程(将操作卸载到内核)来帮助实现异步)

因为大多数主流内核都是支持多线程操作的,可以在后台处理多个操作的执行。当其中一个操作完成,内核会通知Node.js,以便将合适的回调添加到poll队列中,并最终被执行。

Event Loop解释说明

Node.js一启动,变回初始化事件循环,处理输入的脚本(放入REPL的不在本文中说明),脚本可能调用异步API,可能调度定时器,也可能调用process.nextTick(),然后便开始了处理时间循环

下面的图标简单的展示了事件循环操作的顺序:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

注:每个块都被成为事件循环的一个阶段。

每一个阶段都有一个回调的FIFO(first in first out先进先出)队列要执行。每个阶段都有其特殊的方式,通常情况下,当事件循环进入特定的阶段,将执行特定于该阶段的所有操作,然后在执行该阶段的回调,直到队列全部执行完,或者到达最大执行数量。当队列耗尽或达到最大执行数量,将开始下一个阶段,依次循环。

由于这些操作中的任意一个操作,都有可能调度更多其他的操作,在poll阶段被处理的新的事件被内核加入队列,当轮训事件被处理时可以列起来。所以一个长时间执行的回调会让poll阶段占用的时间超过定时器的阈值。(这一段简直就是绕口令,放到现实中,大概就是某个异步操作的回调中,开启了另一个异步操作,当这一个异步完成时,会有新的任务加入到队列中)

注:在Windows和Unix/Linux实现下有轻微的差异,但是并不重要。也许有七八个步骤,单实际重要的就是上面的流程。

阶段概述

timers:这个阶段执行setTimeout和setInterval调度的回调。即setTimeout和setInterval到点儿了要执行的函数

pending callbacks:被延迟到下一个迭代周期的那些回调

idel,prepare:仅在内部使用,并没有任何业务逻辑在这个阶段被执行

poll:回收新的I/O事件;执行I/O相关的回调(除了关闭回调,定时器回调和setImmediate回调)。node会在特定情况下在这一阶段被阻塞。(原文中almost all with the exception of表达的应该是整体中剔除,并不是异常)

check:执行setImmediate()回调

close callbacks:例如socket.on('close', …)的关闭类回调

每一轮事件循环之间,Node.js检查是否有正在等待中的异步I/O和定时器,没有的话就可以很干净的退出。(大概是这个意思)

阶段详述

timers

计时器会指定一个值,在这个值之后执行回调,而不是在用户指定的确切时间之后。计时器回调会在指定时间过去后尽可能早的执行。但是系统调度或者其他正在运行中的回调可能会延迟这一过程。

注:技术上来说,poll阶段控制计时器何时被执行

举例来说,如果你设定了一个100ms的定时器,然后你的脚本需要异步读取文件95ms:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

当事件循环进入poll阶段,队里暂时是空的,此时文件并没有读取完,所以会等待一段时间,这个时间差等于最近定时器到期的剩余时间。等了95ms后,fs.readFile()结束,他的回调被加入到poll队列且被执行,这是一个需要耗时10ms的回调。这个回调执行完后,暂时poll队列空了,事件循环会查看最近的已经到时间的定时器设定的阈值,然后到timer阶段执行定时器的回调。在这个例子中,你会发现从设置定时器开始,到定时器回调被执行,一共花费了95ms+10ms,这超过了设定的100ms。

注:为了防止poll阶段使整个事件循环卡住,libuv有一个硬性最大值限制(根据不同的操作系统),要求poll阶段在处理一定事件后停下。

setTimeout设置一个100ms后执行的回调,然后执行fs.readFile(),读取完成后执行一个耗时10ms的回调。这两个过程之间的时间我们忽略不计,setTimeout和fs.readFile在时间轴上几乎同时开始。到达95ms时,I/O率先完成,到poll阶段了就把I/O回调执行掉。这个回调在跑过5ms时,也就是时间轴到达100ms,定时器到点儿,timer阶段的队列被加入一个回调等待执行,然后poll阶段没有完成。等到105ms是poll阶段的回调执行干净了,来到timer阶段,将队列中的回调执行,这时候从定时器设置的回调中观察,延迟了105ms而不是设定的100ms。poll阶段占用的时间,直接作用在timer的延迟上。当然Node.js也不会让poll阶段卡死整个流程,所以poll阶段也会将一些本次做不完的任务推到下一次。

pending callbacks

这个阶段执行某些系统操作的回调,比如一些类型的TCP错误。比如TCP socket尝试连接时接到ECONNREFUSED,一些Unix系统会等到报告错误。这些会被放到pending阶段的队列。

poll

poll阶段主要有两个主要的作用:

  1. 计算需要阻塞多长时间并轮训I/O,然后
  2. 处理poll队列中的事件

当时间循环进入poll阶段并且没有设置定时器,以下两种情况会出现:

  • 如果poll队列不空,事件循环会迭代poll队列,同步执行回调直到队列清空,或者达到调用上限
  • 如果poll队列是空的,以下情况会出现:

    • 如果脚本有使用setImmediate()调度,事件循环会结束poll阶段进入check阶段来执行被调度的内容
    • 如果没有,事件循环会等待回调进入队列,且会立马执行加入的回调

一旦poll队列清空,事件循环会检查那些已经到达定时时间的定时器,如果有这样的已经到点儿的定时器,事件循环就会绕回到timers阶段来执行那些到点儿的定时器回调。

check

这个阶段可以在poll阶段一完成就立即执行回调,如果poll阶段已经空闲,且有脚本被setImmediate()压如队列,事件循环可能会直接进入check阶段而不是等待。

setImmediate()实际上是一个特殊的定时器,运行在事件循环以外的一个单独阶段。在poll阶段完成后,它使用一个libuv的API去调度执行回调。

一般来说,代码被执行了,事件循环最终会在poll阶段等待即将到来的连接或请求等。但是,如果有setImmediate()调度的回调,且poll阶段已经进入空闲,那么事件循环会继续运行到check阶段而不是等待。

close callbacks

如果socket或者句柄突然关闭,比如调用了socket.destroy(),‘close’事件在这个阶段被‘弹出’。否则将通过process.nextTick()使其‘弹出’。(我的理解应该是代码中on('close',function(){})这样的逻辑,监听socket的close事件的回调函数在这个阶段执行。)

setImmediate() VS setTimeout()

这两个函数功能类似,但是他们的行为存在差异,取决与他们何时被调用。

  • setImmediate()是用来在当前poll阶段一完成就立即执行回调
  • setTimeout()是调度一个脚本,在一个以毫秒为单位的阈值时间后执行。

定时器的执行顺序,会依赖于他们被调用的上下文。如果两者都是在主模块内调用,那么执行时机将由进程性能所影响(可能还会受到机器上其他应用程序的影响)。毕竟timers阶段和check阶段都在poll阶段之后,调用都在同一个poll阶段,执行就被当前的poll阶段所在的主线程影响。

举个例子,如果我们运行如下不包含在I/O周期内的脚本(也就是不包含在任何回调中,纯纯的就是在main.js),定时器的执行顺序其实是不确定的,因为受到主线程的性能影响:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

根据不同的文档说明,setTimeout第二个参数如果传入0或者不传,表达尽快执行,这个尽快执行和setImmediate()的尽快执行仍然在阶段上存在差异(浏览器环境下,没有其他阻塞影响,两个setTImeout之间的最小间隔是4ms,嵌套设定定时器会明显感觉出这个问题)。

但是如果你在I/O循环内调用,immediate回调总是先执行:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

setImmediate()比setTimeout的优势在于,在同一个I/O周期内,setImmediate()调度总是在任何定时器之前,check阶段比timers阶段优先,不受定时器多少的影响。

I/O操作的回调总是在poll阶段执行,而poll阶段总是更积极的向check阶段进行而不是timers阶段。

setTimeout的函数签名:setTimeout(callback, delay[, ...args]),其中delay大于2147483647 或小于 1 时, delay 将设置为 1。非整数被截断为整数。所以第一个例子中,setTImeout调度一个回调在1ms后执行。如果timers阶段1ms已经到期了,那肯定是先输出‘timeout’,如果没有,到poll阶段,更积极的向check阶段进行,则‘immediate’先输出。因为线程调度有开销,进程切换也有开销,并不能保证JavaScript主线程执行时过去了多久,所以才有了不确定执行顺序的结论。

process.nextTick()

理解process.nextTick()

你也许注意到图表中并没有展示process.nextTick(),尽管这是异步API的一部分。这是因为process.nextTick()技术上并不是事件循环的一部分。相反,nextTickQueue会在当前操作完成后被处理,无视当前处于事件循环的哪一个阶段。

回看图表,在某个阶段的任意时间调用process.nextTick(),给定的回调会在事件循环继续下一阶段之前被解决。这可能会引起一些不好的情况,因为这样可能会造成主线程空等,比如递归调用process.nextTick(),这会组织事件循环进入poll阶段。

为什么允许process.nextTick()这种特殊的行为

function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(callback,
                            new TypeError('argument should be string'));
}

这里先介绍一下process.nextTick()函数,签名如下:

process.nextTick(callback[, ...args])

process.nextTick()将callback添加到“next tick queue”,这个队列会在当前JavaScript执行栈完成后,在事件循环将要进入下一个阶段之前,被完全处理掉。所以递归调用将产生死循环(不会到达v8引擎的最大调用栈限制)。

调用apiCall时,传入的arg不是string类型,会将一个错误传给指定的callback,但是在其他用户代码执行完毕后。

这种设定可能导致一些潜在的问题:

let bar;

// 函数名表达了这应当是一个异步操作,但是实际调用callback是同步调用的
function someAsyncApiCall(callback) { callback(); }

// 所以根据调用栈执行顺序,callback完成了之后,someAsyncApiCall才会调用完成
someAsyncApiCall(() => {
  // 此时bar并没有被赋值
  console.log('bar', bar); // undefined
});

bar = 1;

someAsyncApiCall是一个包含异步含义的函数前面,实际内容却是同步操作。这个方法被调用时,callback被提供给在同一个事件循环阶段被调用的someAsyncApiCall(),正是因为这个方法没有执行任何异步操作。结果解释,callback尝试引用bar变量,即使作用域内可能还没有值,也是因为同步的原因,执行栈并没有执行到为bar赋值的地方。

通过使用process.nextTick()调用callback,这段脚本就可以执行完,所有的变量和函数等,都可以在callback被调用前被初始化完成。而且还可以阻止事件循环进入下一阶段。在事件循环进入下一阶段之前,对用户提示错误也许是有用的。

let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 这样就可以正常的输出bar被赋予的值了
});

bar = 1;

下面是另外一个例子:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

当传入了端口,服务就会立即绑定到指定的端口,所以‘listening’回调会被立即调用。如果你调用"listen(8080)"是同步的,那么事件触发时,绑定监听的操作还没有执行,会导致监听不到。为了解决这个问题,“listening”事件被nextTick放到响应的队列中,让脚本有时间去绑定监听,而不是强行要求先绑定在监听。这更符合事件触发与监听的异步关系。

process.nextTick() VS setImmediate()

对用户来说这两个函数执行时机可能会产生困扰:

  • process.nextTick()会在同一阶段立即出发
  • setImmediate()会在紧跟着的下一个迭代周期执行,或者说事件循环的“下一跳”执行。

实际上名称应该交换(nextTick具有“立即”的含义,setImmediate却是“下一跳”的动作)。很明显,process.nextTick()要比setImmediate()更快的出发,名字都是遗留问题,就不改了吧。--一段废话

建议开发者任何情况下都使用setImmediate(),因为他的行为更容易被分析,并且更容易被环境兼容,比如浏览器环境下有setImmediate而没有nextTick

为什么使用process.nextTick()

两个主要的原因:

  1. 允许用户处理错误,清理不需要的资源,或者在事件循环继续之前尝试再次请求
  2. 又是需要让回调在调用栈清空之后,事件循环之前执行

一个例子:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  this.emit('event');
  // 如果使用nextTick将触发事件放在绑定结束,就能得到预期的结果
  // process.nextTick(()=>this.emit('event'));
}
util.inherits(MyEmitter, EventEmitter);

// 在构造方法中发射事件,脚本还没有处理到事件监听的操作
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
// 相当于在这里才emit,上面的on就能正常运作了