使用新 JavaScript 中的模块、集合、代理等特性
在之前的 3 篇文章中,介绍了 ECMAScript 6 规范给 JavaScript 带来的一些巨大变化。如果您一直在关注本系列文章,那么您应该已经尝试了一些 语法变化,发现了新的 箭头函数 的函数式特性,还试验过在 JavaScript 程序中使用传统的 类语法。如果您像我一样,当您发现 ECMAScript 技术委员会在努力对大家最喜欢的拥有 20 年历史的脚本语言进行了重大变更,同时并没有牺牲它的易用性或基于原型的对象系统,那么您很可能会感到宽慰。
本系列的最后一篇文章将介绍标准库中现已包含的一些对象和类型。您一定在以前使用过一些特性,或许是在 JavaScript 或其他语言中,而其他特性可能会稍微(或极大地)拓展您的思维。它们都是有价值的附加特性,我敢肯定,它们会不断融入到您的工具包中。
对于日常编程,模块可能是对 ECMAScript 6 最明显的库增强。目前为止,根据 Node.js 约定,我们要求文件使用一个名为 exports 的全局变量对象来描述返回的值。现在不需要这么做!ECMAScript 6 使用 import 和 export 语句来形式化模块的概念。您可能已推断出,export 用于声明来自 ECMAScript 文件的指定值(通常是类或函数,但有时也包括变量),而 import 用于从该文件将这些导出的名称拉入到一个不同的文件中。
基本地讲,如果您有一个想要作为模块对待的文件(我们称之为 output),您可以仅仅使用 export 这个符号表示你希望在其他地方使用该文件,就像这样:
清单 1. 导出 output 函数
// output.jsexport function output() { console.log("OUT: ", arguments);}
在函数前输入关键字 export,这会告诉 ECMAScript 需要将此文件作为模块对待。因此,该函数将可供其他任何导入它的文件使用:
清单 2. 导入 output.js
import { out } from 'output.js';out("I'm using output!");
您可能已经注意到,import 语法有一个重要缺陷:为了使用该模块,您需要知道希望导入的所有名称。(但一些人可能认为这是一个优势,因为它可以确保导入者不会在不知情的情况下导入符号。)如果想获取从一个模块导出的所有 名称,可以使用通配符 (*) 导入语法,但您需要定义一个模块名称来限定它们的范围:
清单 3. 使用通配符导出
import * as Output from 'output.js';Output.out("I'm using output!");
要求提供模块名称可以实现一种范围限定机制;如果两个模块分别在没有模块名称的情况下导出一个 out,新的 out 会静默地替换之前的版本。输入的每个名称包装在模块名称中,以便进一步减少歧义。
ECMAScript 6 中引入的一个细微特性是新的 Symbol 类型。从表面上看,它似乎很普通:基本地讲,一个 Symbol 实例是一个不能复制到其他任何地方的唯一名称。就这么简单。
回想一下,ECMAScript 对象是一个名称-值对的集合,其中的值可以是数据(字符串、数字、对象引用等)或行为(采用函数引用的形式)。通常,如果您知道对象的名称,就可以获得它的值,这没什么疑问。
但是,在某些情况下,对象的所有者需要确保所选名称没有与其他名称冲突。在这种情况下,无需使用传统的基于 String 的名称,可以使用 Symbol 实例。Symbol 确保名称不会冲突。
举例而言,我们首先看看典型的 Person 类型:
清单 4. Person 对象
class Person { constructor(firstName, lastName, age) { this.firstName = firstName; this.lastName = lastName; this.age = age; }};var p = new Person("Fred", "Flintstone", 45);console.log(p["firstName"]);for (let m in p) { console.log(m, "=", p[m]);}
3 个对象字段可供任何知道其名字的人轻松访问。名称可通过简单地迭代对象的内容来获得。尽管 ECMAScript 从未被认为是一种高度安全的语言,但这个示例无疑舍弃了最基本的封装。
假设您需要让一些字段保持隐藏。可以首先让它们可通过 Symbol 名称而不是标准字符串进行访问:
清单 5. Symbol 取代 String
var firstNameS = Symbol("firstName");var lastNameS = Symbol("lastName");var ageS = Symbol("age");class Person { constructor(firstName, lastName, age) { this[firstNameS] = firstName; this[lastNameS] = lastName; this[ageS] = age; }};var p = new Person("Fred", "Flintstone", 45);console.log(p["firstName"]); // "undefined"for (let m in p) { console.log(m, "=", p[m]);}p.firstName = "Barney";console.log(p["firstName"]);console.log(p[firstNameS]); // "Fred"
如上所示,可以使用 Symbol 函数创建 Symbol 的实例。然后,每个实例可在关注的对象上用作名称。如果有人尝试使用正常的基于 String 的名称(比如 firstName)访问该字段,将会获得不明确的结果,因为数据不再位于该名称下。根据新规范,JavaScript 在标准对象迭代期间甚至不会显示基于 Symbol 的名称。任何尝试使用跨该对象的传统反射的行为都将失败。
同样需要注意的是,如果有人想从外部向该对象添加新成员(元对象编程 的一个例子),字符串 firstName 的使用将与现有成员冲突,或者取代现有成员。对于必须向现有对象添加额外行为或成员的库和框架,这一点特别重要 — 几乎所有现代框架目前都在使用它。 但是,从清单 5 的最后一行可以看出,如果调用方拥有 Symbol 实例,不要犹豫,可像之前一样使用它访问数据。不同于其他语言中的 private 关键字,Symbol 无法轻松地执行访问控制。
JavaScript 支持许多众所周知的成员名称,它们对创建遵循特定于环境模式的对象很有用。一个示例就是 iterator,可使用它在支持迭代行为的对象上命名函数。如果想创建一个伪装成标准 ECMAScript 迭代器的斐波纳契数列生成对象,需要创建一个包含 iterator 函数的对象。但是,由于任何人都能使用该名称,所以 ECMAScript 6 坚持要求您使用 iteratorSymbol 代替:
清单 6. Symbol.iterator
let fibonacci = { [Symbol.iterator]: function*() { let pre = 0, cur = 1; for (;;) { let temp = pre; pre = cur; cur += temp; yield cur; } }}for (let n of fibonacci) { // truncate the sequence at 1000 if (n > 1000) break; console.log(n);}
同样地,Symbol 的主要功能是帮助程序员避免库之间的名称冲突。这最初有点难掌握,但您可以尝试将 Symbol 视为基于它提供的字符串名称的唯一哈希值。
如果您使用 ECMAScript 超过 10 分钟,您就会知道该语言支持数组 — 数组自 1.0 版开始就是该规范的核心部分。尽管数组存在限制(最主要的限制是大小固定),但它们能很好地为我们服务;可能在未来数年也是如此。
但我们是时候承认一些事实了,即使我们从不会向任何人提起:数组……不是万能的。 为了帮助收拾残局,ECMAScript 6 向标准 JavaScript 环境添加了两个集合类型: Map 和 Set。
Map 是一组名称/值对,与 ECMAScript 对象非常相似。不同之处在于,Map 包含的方法使它比原始 ECMAScript 对象更容易使用:
另外,像 Array 一样,Map 包含受函数语言启发的方法,比如 forEach() 在 Map 自身上运行。
清单 7. Map 的实际应用
let m = new Map();m.set("key1", "value1");m.set("key2", "value2");m.forEach((key, value, map) => { console.log(key,"=",value," from ", map);})console.log(m.keys());console.log(m.values());
Set 看起来更像传统的对象集合,因为对象可简单地添加到集合中。但 Set 会依次检查每个对象,以确保它们未与集合中已存在的值重复:
清单 8. Set 的实际应用
let s = new Set();s.add("Ted");s.add("Jenni");s.add("Athen");s.add("Ted"); // duplicate!console.log(s.size); // 3
像 Map 一样,Set 之上也拥有方法,使它可以执行函数式交互,比如 forEach。从根本上讲,Set 像一个数组,但没有尖角括号。它动态增长,而且缺少任何形式的排序机制。如果使用 Set,您不能像数组一样按索引来查找对象。
ECMAScript 6 还引入了 WeakMaps 和 WeakSets,它们分别是通过弱引用(而不是通过传统的强引用)持有自己的值的 Map 和 Set。ECMAScript 规范非常清楚地描述了 WeakMap 内持有的对象上发生的事;对它的解释同样适用于 WeakSet:
如果一个被用作 WeakMap 键/值对的对象仅能跟随从 WeakMap 内开始的引用链访问,那么这个键/值对就无法访问,会自动从 WeakMap 删除。
本文不打算介绍 WeakMaps 和 WeakSets 的效用。它们主要用于库代码(尤其是与缓存相关的代码),在应用程序代码中可能不会过多地出现。
异步操作是 Node.js 的核心部分(通常是事件驱动编程的宣传内容),但从来无法轻松实现它们。最初,Node.js 社区似乎决定使用事件订阅,但一段时间后,开发人员都迁移到一种更倾向于回调驱动的风格。这给我们带来了令人望而生畏的回调地狱,Node.js 代码似乎 “布满了” 整个屏幕:
清单 9. 这就是回调地狱
fs.readdir(source, function (err, files) { if (err) { console.log('Error finding files: ' + err) } else { files.forEach(function (filename, fileIndex) { console.log(filename) gm(source + filename).size(function (err, values) { if (err) { console.log('Error identifying file size: ' + err) } else { console.log(filename + ' : ' + values) aspect = (values.width / values.height) widths.forEach(function (width, widthIndex) { height = Math.round(width / aspect) console.log('resizing ' + filename + 'to ' + height + 'x' + height) this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) { if (err) console.log('Error writing file: ' + err) }) }.bind(this)) } }) }) }})
请注意,对于上面的示例,我使用了两个空格的缩进。想象坚持使用 4 或 8 空格缩进的开发人员对代码的滚动浏览。
经历了大量痛苦和愤怒之后,ECMAScript 社区在异步计算中发布了一种回调的替代方案:现在的标准 Promise 类型。
在 JavaScript 中使用 Promise 是一把双刃剑。首先,构建代码来 “实现某种用途”(如清单 9 所示)的人现在返回 Promise,而不是使用传统的同步执行或 Node.js 回调方言。这使调用方能使用 Promise 的 then() 方法将顺序调用链接起来,使用 catch() 定义发生失败时应执行的操作:
清单 10. 异步调用
doSomething.then(function(result) { console.log(result); // It did something}).catch(function(err) { console.log(err); // Error});在 doSomething() 端,现在可编写代码来生成一个 Promise 实例,实现方法通常是将一个执行异步操作的函数放在 Promise 内:清单 11. 异步执行let promise = new Promise(function(resolve, reject) { let result = // do a thing, usually async in nature if (success) { resolve(result); } else { reject(Error("It didn't work")); }});
不同人的学习进度显然各不相同。在我的经验中,开发人员将使用从库传回的 Promise,所以我预料大部分开发人员首先会使用它们,而不是构造它们。随着时间的推移,更多开发人员可能构建自己的 Promise 供其他模块使用。
关于 Promise,还有更多内容可以介绍,但这些内容足够另外写一篇文章了。幸运的是,对于在深入分析某个事物之前不想使用它的人,原始的回调和事件并未消失,所以您无需立即采用此特性。
使用动态代理进行 JavaScript 编程已经非常流行,但 ECMAScript 6 标准化了新的 Proxy 类型。拥有标准化的方法,可帮助我们避免库之间发生意外冲突和/或混淆。实质上,Proxy 实现了 “拦截” 行为,使一个对象能插到另一个对象前面。然后在针对原始目标的任何方法调用或属性引用上,首先使用拦截对象。
将对象的方法替换为另一种定义,这对于 ECMAScript 并不新奇,但 Proxy 类型这么做的频率更高。它甚至能拦截目标对象上不存在的请求 — 方法调用、属性引用等。 一个例子胜过千言万语,所以让我们来编写一些代码。传统上,会使用方法调用日志来演示代理的功能,所以我也会这么做。首先,这里有一个 Person,一个普通的 ECMAScript 对象 — 如果您愿意,也可以称之为 POESO:
清单 12. 您称它为 Person,我称它为 POESO
let ted = new Person("Ted", "Neward", 45);
为增添乐趣,在构造后,我会在 Person 上放入一些有趣的方法。将这些方法添加到这里,也会表明动态代理可用于任何 ECMAScript 对象,无论它是如何构造或定义的:
清单 13. sayHowdy
ted.sayHowdy = function() { console.log(this[firstNameS] + " says howdy!");};ted.waveGoodbye = function(msg) { return "" + msg + " Buh-bye!";};ted.sayHowdy();console.log(ted.waveGoodbye("See you next time!"));
我们现在有两个方法:一个方法不接受参数,另一个方法接受一个参数并返回一个结果。它们本身不是很有趣,但它们将代表我们想捕获的方法。每次调用其中一个方法时,我们希望在控制台中能够看到一条消息显示 “method invoked”,理想情况下,还伴随一些有关该方法调用的有趣信息。
在我们忙碌时,能看到何时访问了属性该有多好!我们可以使用此特性检索值或设置它。可以通过代理这么做,也可以通过拦截构造函数调用和其他不常用的调用来实现此目的。实质上,代理可以拦截您对对象执行的任何操作,使您有机会执行其他操作,比如插入某个额外的行为。
要设置代理,首先需要识别目标,该目标也被认为是我们想拦截其方法或属性的对象。在本例中,我们将拦截清单 12 中的 Person 对象。接下来,我们需要识别想在调用方与目标之间插入的行为。在 ECMAScript 的说法中,这称为处理函数。每个处理函数可定义一个或多个方法,如下所示。
清单 14. 处理函数方法
我们将首先捕获任何获取和设置 Person(目标)对象上的属性的请求。为此,我们创建一个处理函数对象来分别提供 get 和 set 方法:
清单 15. 处理函数对象
let handler = {};handler.get = function(target, propName) { console.log("Handler.get() invoked", target,"returning value for",propName); return target[propName];}handler.set = function(target, propName, newValue) { console.log("Hander.set() invoked", target,"changing",target[propName],"to",newValue); target[propName] = newValue;}
请注意,set 处理函数不仅将消息记录到控制台,还会执行属性分配工作。这么做是有必要的,因为处理函数拥有完全控制权:如果您没有将属性分配给目标,就不会设置该属性。get 处理函数也是如此,它必须返回目标的属性值。如果您没有分配属性,返回的属性将是空的(或 undefined)。
最后一步是在目标和处理函数周围连接一个 Proxy 对象。在清单 16 中,我们将 Proxy 对象捕获回原始变量中。
清单 16. Person 代理
ted = new Proxy(ted, handler);
在某些场景中,您想坚持使用原始变量,以便无需通过拦截器即可访问目标。但是,在大多数时候,将会使用 Proxy 作为静默处理器,这样,使用目标对象的客户端甚至不会人知道它们与目标之间存在任何对象。
如果在处理函数就位后,将 waveGoodbye 和 sayHowdy 添加到对象,将调用处理函数来执行属性设置操作。这是因为,从技术上讲,waveGoodbye 和 sayHowdy 是函数类型的属性。如果运行此代码,将看到以下输出:
清单 17. 代理输出
Hander.set() invoked Person {} changing undefined to function () { console.log(this[firstNameS] + " says howdy!");}Hander.set() invoked Person { sayHowdy: [Function] } changing undefined to function (msg) { console.log(msg, "Buh-bye!");}Handler.get() invoked Person { sayHowdy: [Function], waveGoodbye: [Function] } returning value for sayHowdyHandler.get() invoked Person { sayHowdy: [Function], waveGoodbye: [Function] } returning value for Symbol(firstName)Ted says howdy!Handler.get() invoked Person { sayHowdy: [Function], waveGoodbye: [Function] } returning value for waveGoodbyeSee you next time! Buh-bye!
请注意,我们调用了 get() 处理函数。访问该方法意味着获取该方法(以便调用它),然后(对于 sayHowdy)获取该方法中引用的所有属性的值。
讲得更清楚一点,无论该属性是如何定义的,始终会调用 get 处理函数。即使我们在上述 Person 类上定义了一个方法,也是如此,像以下这样:
清单 18. 一个新 Person 方法
class Person { // ... as before eat() { console.log("Chomp chomp"); }}
如果调用了 Person 类型对象的 eat() 方法,ECMAScript 的第一个任务就是将属性名称 “eat” 解析为生成一个函数的属性。首先,它将获取该函数,然后立即调用它。如果我们想了解被调用函数的更多细节,我们需要在找到和返回该函数后,将一个新处理函数插入到调用过程中。最简单的方法是返回一个包装了原始函数的函数:
清单 19. 引入新处理函数
handler.get = function(target, propName) { let original = target[propName]; if (typeof original === 'function') { return (...args) => { console.log("Executing", propName, "with", args); const result = original.apply(target, args); console.log(">>> Returns",result); return result; } } else { const result = target[propName]; console.log("Property",propName,"holds", result); return result; }}
该过程可能看起来很复杂,其实不然。如果被访问的属性不是函数,只需获取结果并返回它。如果该属性是函数,那么可以创建一个函数字面常量并返回该常量。返回的函数字面常量将调用原始函数。在函数对象上使用 ECMAScript 的 apply 方法,可确保我们将正确的 this 绑定到适当位置。(如果我们使用的是 this 而不是 target,this 将是处理函数,而不是目标。)
小菜一碟,对吧
实际上,如果您之前从未看到这种类型的代码,可能会对它留下深刻印象。使用 Proxy,您可以执行类型安全属性验证(编写一个处理函数来确保为给定属性设置的值具有正确类型);远程执行(返回一个知道如何通过 HTTP API 执行远程调用的代理,将参数序列化为 JSON 数组并去序列化结果);或者甚至引入授权边界(使用一个将在内存中检查给定用户凭证的代理,包装一个域对象)。从形式上讲,所有这些用法都属于面向方面编程的范畴。将它们相结合,就提供了思考如何捕获 JavaScript 中的关注点的全新可能性。
ECMAScript 6 是目前最大胆的 JavaScript 修订版,这不可避免地需要一个调整期。ECMAScript 解释器还没有完全达到规范要求。如果您的代码有时发生故障,不要奇怪;请检查您的解释器,看看不支持哪些功能并根据需要调整代码。
另请记住,如果您的代码无法编译,您并非将一无所获,您可以使用一个流行的 Node.js 转换编译器 (transpiler) 将代码转换为不那么先进的 ECMAScript。
值得赞扬的是,ECMAScript 技术委员会推动着该语言向前发展,尽管仍支持大量的向后兼容性。由于整个原因,您可以循序渐进地采用 ECMAScript 6:挑选一个您喜欢的特性并集成到代码中。习惯使用该特性后,可以挑选另一个想要尝试的特性。您从不需要涉入到您(或您的生产力)无法处理的深度,但您可以不断探索前进。渐渐地,您可以开始利用标准 JavaScript 中包含的许多强大的新特性和约定。
长话短说,我宣布本系列到此结束。
return "Enjoy!";
期待下次再见到您!