05.22 JavaScript 常用特性解释——类型、迭代器、生成器、同步

某些 JavaScript(ECMAScript)特性比其他的容易理解。生成器(Generators)看起来很奇怪——像 C/C++ 中的指针。类型(Symbols)看起来同时既像原语又像对象。

这些特性都是相互关联,相互构建的。因此你不能脱离其他特性而只理解一个。

因此在本文,我会涉及到类型、全局类型、迭代器、可迭代对象、生成器、异步/等待,以及异步迭代器。首先我会解释“为什么”他们在这里,然后我会用一些有用的例子来展示他们是如何工作的。

这是一个相当高阶的问题,但是它并不复杂。本文会让你牢牢掌握这些所有概念。

好的,我们开始吧。

Symbols

在 ES2015,一个名为 symbol 的新的(第六类)数据类型产生了。

为什么?

这里列出三个主要原因:

原因 1 ——添加向后兼容的新的内核特性

JavaScript 开发者和 ECMAScript 委员会(TC39)需要一种可以添加新的对象属性的方式,而不打破已有的方法,比如循环中的 for 或者 Object.keys 。

例如,如果我有一个对象,var myObject = {firstName:'raja', lastName:'rao'} ,如果我运行 Object.keys(myObject) ,会返回 [firstName, lastName] 。

现在如果我添加了另一个属性,也就是在 myObject 添加 newProperty ,如果你运行 Object.keys(myObject) ,那么应该仍然返回之前的值,[firstName, lastName],而不要返回 [firstName, lastName, newProperty] 。如何做到这一点?

早前我们确实不能做到,因此一个名为 Symbols 的新的数据类型产生了。

如果你作为一个 symbol 来添加 newProperty ,然后 Object.keys(myObject) 会无视掉这个属性(由于它不识别它),并仍然返回 [firstName, lastName] !

原因 2 ——避免命名冲突

他们仍然想保留这些属性的唯一性。通过这种方式他们可以保留添加到全局的新属性(而且你可以添加对象属性)而不用担心命名冲突。

例如,你有一个对象,在对象中你正在添加一个自定义的 toUpperCase 到全局的 Array.prototype 。

现在,想想你加载了另一个库(或者说是 ES2019 发布的库),而且它的 Array.prototype.toUpperCase 版本与自定义的不同。然后你的函数可能会由于命名冲突而崩溃。

那么你要如何解决这种你可能不知道的命名冲突的问题?这就是 Symbols 要出现的地方。他们内部创建了唯一值,可以让你创建添加属性而不用担心命名冲突。

原因 3 ——通过“众所周知(Well-known)” 的 Symbols 允许钩子(hooks)调用到内核方法

想象你希望使用一些内核函数,比如说 String.prototype.search 来调用你的自定义函数。也就是说, ‘somestring’.search(myObject); 应该调用 myObject 的搜索函数,并将 ‘somestring’ 作为参数传入!怎样才能做到?

这就是 ES2015 提出的一系列全局 symbols ,即被称为“众所周知” 的 symbols 。而且只要你的对象包含这些 symbols 的其中一个作为属性,你就能将内核函数重新定位来调用你的函数!

关于这部分,在此我们不多说,我会在本文后面部分深入讨论。但是首先,我们先了解 Symbols 实际上是怎么工作的。

创建 Symbols

你可以通过调用名为 Symbol 全局的函数/对象创建一个 symbol 。这个函数返回了一个数据类型为 symbol 的值。

注:因为 Symbol 有方法,它们表面上可能与对象相似,但他们不是——他们是原语。你可以将它们看做一个“特殊”对象,他们与一般的对象有相似之处,但是他们表现的不像对象。

例如:Symbols 和对象一样有方法,但是不同于对象,它们是不可变的且唯一的。

Symbols 不能使用 “new” 关键字来创建

因为 symbols 不是对象,而 new 关键字返回了一个对象,我们不能使用 new 返回一个 symbols 数据类型。

var mySymbol = new Symbol(); //throws error

Symbols 有“描述”

Symbols 可以包含一个描述——就是为了记录日志而使用。

//mySymbol variable now holds a "symbol" unique value
//its description is "some text"
const mySymbol = Symbol('some text');

Symbols 具有唯一性

const mySymbol1 = Symbol('some text');
const mySymbol2 = Symbol('some text');
mySymbol1 == mySymbol2 // false

如果我们使用 “Symbol.for” 方法,Symbols 表现的像单例模式

如果不通过 Symbol() 创建 Symbol ,你可以调用 Symbol.for() 。它需要传一个 “key”(string)来创建一个 Symbol 。如果这个 key 对应的 symbol 已经存在了,就会简单返回之前的 symbol !因此如果我们调用 Symbol.for 方法,它就会表现的像一个单例模式。

var mySymbol1 = Symbol.for('some key'); //creates a new symbol
var mySymbol2 = Symbol.for('some key'); // **returns the same symbol
mySymbol1 == mySymbol2 //true

使用 .for 的实际运用就是在一个地方创建一个 Symbol ,然后在其他地方访问相同的 Symbol 。

警告:Symbol.for 会使 symbol 不具有唯一性,因此如果 key 相同,你最后会重写里面的值。如果可能的话,尽量避免这么做!

Symbol 的“描述” vs. “key”

若只是为了更清楚的说,如果你不使用 Symbol.for ,那么 Symbols 是具有唯一性的。然而,如果你使用了它,而且如果你的 key 不是唯一的,那么返回的 symbols 也不是唯一的。

Symbols 可以是一个对象属性键

这是 Symbols 的一个非常奇特的事情——而且也是最令人困惑的。尽管他们看起来像一个对象,他们确实是原语。我们可以将 symbol 像 String 一样作为一个属性键关联到一个对象。

事实上,这也是使用 Symbols 的主要方式——作为对象属性!

注:使用 symbols 的对象属性称为“键属性”。

括号操作符 vs. 点操作符

因为点操作符只能用于字符串属性,在这你不能使用点操作符,因此你应该使用括号操作符。

使用 Symbols 的三个主要原因——回顾

现在我们回顾一下(上面说到的)三个主要原因来了解 Symbols 是如何工作的。

原因 #1 ——对于循环和其他的方法来说, Symbols 是不可见的

下面例子中的 for-in 循环遍历了对象 obj ,但是不知道(或者忽略了)prop3 和 prop4 ,因为它们是 symbols 。

下面是另一个例子, Object.keys 和 Object.getOwnPropertyNames 方法忽略了 Symbols 的特性名称。

原因 #2 ——Symbol 是唯一的

假设你想要一个叫做 Array.prototype.includes 的全局 Array 对象。它将与 JavaScript(ES2018)开箱即用的 includes 方法冲突。你该如何添加它才能不冲突呢?

首先,用 Symbol 创建一个名为 includes 变量,给它分配一个 Symbol 。然后使用括号表示法添加此变量(现在是一个 Symbol )到全局 Array 中。分配任何一个你想要的功能。

最后使用括号表示法调用这个函数。但是请注意,你必须在括号里传递真实的 Symbol 而不是一个字符串,类似于:arr[includes]() 。

原因 #3 ——众所周知的 Symbols(“全局”Symbols)

默认情况下,JavaScript 自动创建一堆 Symbols 变量,并将他们分配给全局 Symbol 对象(是的,我们使用相同的 Symbol() 去创建 Symbols)。

在 ECMAScript 2015 中,这些 Symbols 随后被添加到诸如数组和字符串等核心对象的核心方法,如 String.prototype.search 和 String.prototype.replace 。

举一些 Symbols 的例子:Symbol.match, Symbol.replace,,Symbol.search,Symbol.iterator 和 Symbol.split。

由于这些全局 Symbols 是全局且公开的,我们可以用核心的方法调用我们自定义函数而不是内部函数。

举个例子:Symbol.search

例如,String 对象的 String.prototype.search 公共方法搜索一个 regExp 或字符串,并在发现索引的时候返回索引。

在 ES2015 中,它首先检测是否在查询 regExp (RegExp对象) 时实现了 Symbol.search 方法。如果是的话,就调用这个函数并将工作交给它。而且像 RegExp 这样的核心对象实现了 Symbol.search 的 Symbol ,确实做了这个工作。

迭代器和可迭代对象

为什么?

在我们大多数的 app 中,我们在不断的处理数据列表,然后需要将这些数据展示到浏览器上或者手机 app 中。我们一般会写我们自己的方法去存储和取出数据。

但事实是,我们已经有了像 for-of 一样的循环和展开标识符(…),可以从像数组,字符串,和 map 这样的标准对象取出数据集合。为什么我们不能在我们的对象中也用这些标准方法?

在下面的例子,我们不能使用 for-of 循环或者展开标识符来从 Users 类中获取数据。我们必须用一个自定义的 get 方法。

但是,在我们自己的对象中可以使用这些现有方法不是更好吗?为了完成这个想法,我们需要有一些规则让所有的开发者都可以遵循并可以让他们的对象也使用这些现有的方法。

如果他们遵循这些规则来从他们的对象中取出数据,那么这些对象就称为“可迭代对象(iterables)”。

规则如下:

主对象/类应该存储一些数据。

主对象/类必须有全局的“众所周知的” symbol ,即 symbol.iterator 作为它的属性,然后按照从规则 #3 到 #6 的每条规则来实现一个特有的方法。

symbol.iterator 方法必须返回另一个对象 —— 一个“迭代器”对象。

“迭代器”对象必须有一个名为 next 的方法。

next 方法应该可以访问存储在规则 #1 的数据。

如果我们调用 iteratorObj.next() ,应该返回存储在规则 #1 中的数据,如果想返回更多的值使用格式 {value:<stored>, done: false} ,如果不想返回其他更多的值则使用格式 {done: true} 。/<stored>

如果循序了所有的这 6 条规则,规则 #1 中的主对象就被称为“可迭代对象”。它返回的对象称为“迭代器”。

我们来看一下我们如何让我们的 Users 对象作为可迭代对象:

重要提示:如果我们调用一个可迭代对象(allUsers)for-off 循环或者展开标识符,他们内部调用 <iterable>[Symbol.iterator]() 来获取迭代器(就像 allUsersIterator ),然后使用迭代器来取出数据。/<iterable>

因此在某种程度上,所有这些规则都有一种标准方法来返回一个 iterator 对象。

生成器函数

为什么?

两个主要原因如下:

提供可迭代对象的高级抽象

提供新的流程控制来改善“回调地狱”之类的情况

下面我们一一说明。

理由 1 ——可迭代对象的包装

为了使我们的类/对象编程一个可迭代对象 ,除了通过遵循所有这些规则,我们还可以通过简单地创建一些称为“生成器”的函数来简化这些操作。

关于生成器的一些要点如下:

生成器函数在类中有一个新的 *<mygenerator> 语法,而且生成器函数有语法 function * myGenerator(){} 。/<mygenerator>

调用生成器 myGenerator() 返回一个生成器对象,它也实现了迭代器协议(规则),因此我们可以使用它作为一个可以直接使用的迭代器返回值。

生成器使用一个特有的 yield 声明来返回数据。

yield 声明保持记录前一个调用,而且可以简单的从它停止的地方继续。

如果你在一个循环中使用 yield ,每次我们在迭代器调用 next() 方法的时候,它会只运行一次。

示例一:

下面的代码可以向你展示你如何使用给一个生成器函数 (*getIterator()) 来代替使用 Symbol 。按照所有的规则的 iterator 方法和实现 next 方法。

示例二:

你可以使其更加简化。创建一个函数为生成器(使用*语法),同时如下所示使用 yield 一次返回一个返回值。

重要提示:尽管上面的例子中,我使用了单词 “iterator” 来表示 allUsers ,但是它确实是一个生成器对象。

生成器对象出来有 next 方法之外还有 throw 和 return 之类的方法!但是实际上,我们可以使用返回的对象,就像“迭代器”一样。

理由 2 ——提供更好更新的流程控制

提供新的流程控制可以帮助我们使用新的方式编写程序,也可以解决像“回调地狱”之类的问题。

注意生成器函数不像一个普通的函数,它可以 yield (存储函数的状态和返回值),而且在它 yielded 的时候就会准备着去获取额外的输入值。

在下面的图片中,每当它看见 yield ,就会返回这个值。你可以使用 generator.next(“一些新值”),而且在 yielded 时将新值传递出去。

下面的例子更加具体地展示了流程控制如何工作:

生成器语法和用法

生成器函数可以通过下面的方法调用:

我们可以在 “yield” 之后写更多的代码(不像 “return” 语句)

就像 return 关键字,yield 关键字也会返回值——但是它允许在 yielding 之后还有代码!

你可以有多个 yields

通过 “next” 方法来回给生成器传值

迭代器的 next 方法也可以将值传递给生成器,就像下面写的。

事实上,这个特性可以让生成器消除“回调地狱”。在这方面你会了解的更多一点。

这个特性也在库中大量使用,例如redux-saga。

下面的例子中,我们使用一个空的 next() 调用迭代器来得到问题。然后,当我们第二次调用 next(23) 时我们传入 23 作为值。

生成器有助于消除“回调地狱”。’如果我们有多个异步调用的时候,你知道的我们会进入回调地狱。

下面的例子就展示了像“co”之类的库如何利用生成器特性,让我们通过 next 方法传值来帮助我们同步地写异步代码。

注意在第5步和第10步,co 函数如何通过 next(result) 将结果从 promise 传回给生成器。

好的,我们来讨论异步/等待。

异步/等待

为什么?

就像你之前看到的,生成器有助于消除“回调地狱”,但是你需要一些像 co 一样的第三方的库才能完成。但是“回调地狱”仍然是一个大问题,ECMAScript 委员会决定只为生成器的这个问题创建一个封装,同时提出了新的关键字 async/await 。

生成器和异步/等待的区别如下:

异步/等待使用 await 而不是 yield 。

await 只对 Promises 有用。

它使用 async 函数关键字而不是 function* 。

因此异步/等待是生成器的一个重要子集,它包含了一个新的语法糖(Syntactic sugar)。

async 关键字告诉 JavaScript 编译器对这种函数区别对待。在函数中无论何时编译器只要遇到 await 关键字,编译器都会暂停。假定 await 后面的表达式返回了一个 promise ,而且在程序继续向下走之前一直等 promise 被处理或者被拒绝。

在下面的例子中,getAmount 函数调用了两个异步函数 getUser 和 getBankBalance 。我们在一个 promise 中也可以这么做,但是使用 async await 更加优雅且简单。

异步迭代器

为什么?

我们在循环中需要调用异步函数的情况非常常见。因此在 ES2018(已完成提案),TC39 委员会提出了新的 Symbol 的 Symbol.asyncIterator ,以及一个新的 for-await-of 结构来帮助我们简化对异步函数循环。

普通迭代器对象和异步迭代器对象的主要区别如下:

迭代器对象

迭代器对象的 next() 方法返回了像 {value: ‘some val’, done: false} 这样的值

用法:iterator.next() //{value: ‘some val’, done: false}

异步迭代器对象

异步迭代器对象的 next() 方法返回了一个 Promise ,解析后为 {value: ‘some val’, done: false} 这样的内容

用法:iterator.next().then(({ value, done })=> {//{value: ‘some val’, done: false}}

下面的例子展示了 for-await-of 是如何工作的,以及你怎么才能使用它。