ES6 In Depth: Subclassing

两周之前,我们介绍了ES6中新增加的一个类的系统,它可以用来做面向对象式的创建对象。我们可以展示一下,你可以怎么使用它来编写代码,如下:

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
class Circle {
  constructor(radius) {
    this.radius = radius;
    Circle.circlesMade++;
  };

  static draw(circle, canvas){
    // Canvas drawing code
  };

  static get chiclesMade() {
    return !this._count ? 0 : this._count;
  };
  static set circlesMade(val) {
    this._count = val;
  };

  area() {
    return Math.pow(this.radius, 2) * Math.PI;
  };

  get radius() {
    return this._radius;
  };
  set radius(radius) {
    if (!Number.isInteger(radius))
      throw new Error("Circle radius must be an integer.");
    this._radius = radius;
  };
}

很不幸的是,就如一些人指出的,这并没有体现ES6中类的其它作用。就像传统的类系统(如,C++ 或者 Java),ES6允许继承,也就是一个类可以将另外一个作为基类,然后为自己增加更多的特性进而实现扩展。让我们更为接近这一新特性,查看其操作的可能性。

在我们开始讨论子类化之前,还需要花些时间来复习一下属性的继承和动态的原型链。

JS的继承

当我们创建一个对换昌,我们可以为其增加属性,同时它继承于其本身原型对象的属性。JS程序大大应该会很熟悉存在的Object.create这个API,它让我们可以简单地做到这点:

1
2
3
4
5
6
7
8
9
var proto = {
  value: 4,
  method() { return 14; }
}

var obj = Object.create(proto);

obj.value; //4
obj.method(); //14

接着,当我们为obj添加在proto拥有相同名称的属性时,这些obj属性就相当于proto的影子:

1
2
3
obj.value = 5;
obj.value; //5
proto.value; //4

子类化的基础

这时,我们应该知道我们应该怎么通过类来处理对象创建时的原型链。再次重复一下,当我们创建一个类时,我们在类定义的内部为constructor方法创建一个新的函数,它可以处理所有的静态方法。我们也可以为创建好的函数增加一个包含原型属性的对象,它可以处理实例对象的方法。为了创建一个能继承所有静态属性的新类,我们不得不创建一个函数对象来继承基类的函数对象。类似的,对于实例方法,我们不得不为创建一个新的原型链上的函数,并继承于基类的原型类对象。

这描述有点让人迷惑,让我们来看一个例子,它会显示我们是怎么不用新语法来实现继承,接着进行些细小的扩展来让其更美观些。

继续我们之前例子,假设我们有一个类Shape,我们希望其可以子类化:

1
2
3
4
5
6
7
8
9
class Shape {
  get color() {
    return this._color;
  }
  set color(c) {
    this._color = parseColorAsRGB(c);
    this.markChanged();  // repaint the canvas later
  }
}

当我们试图编写这样的代码时,对于static的属性我们依然会遇到之前文章同样的问题:没有语法来改变原型链的函数定义。当你可以有一方法类如Object.setPrototypeOf来解决这问题时,这方法比那些直接在原型链上创建函数要显得性能更为低劣。

1
2
3
4
5
6
7
8
9
class Circle {
  // As above
}

// Hook up the instance properties
Object.setPrototypeOf(Circle.prototype, Shape.prototype);

// Hook up the static properties
Ojbect.setPrototypeOf(Circle, Shape);

这代码是十分丑陋的。我们增加了类的语法,可以让我们将对象的所有逻辑都封装在一个地方中,而不是像刚才的包括其它的”hooking things up(使用勾子方法提升功能)”的逻辑。Java、Ruby和其它的面向对象的语言中,都有一个方式来声明一个子类化的类,并指出来源于哪个类,所以我们也应该这样。我们使用关键字extends,所以我们可以编写成:

1
2
3
class Circle extends Shape {
  // As above
}

你可以在extends的后面添加任意的表达式,只要它是一个合法的prototype的原型构建器,如:

  • 其它类
  • 由存在的继承框架产生的与类相似的函数
  • 一个普通的函数
  • 一个包含一个函数或者类的变量
  • 一个对象的访问属性(obj[name])
  • 一个函数调用

如何你不想实例继承于Object.prototype,你甚至可以使用 null

Super 属性

所以现在我们可以进行子类化,同时我们可以继承属性,可以通过我们的继承为方法创建影子方法。但是,如果你想避免这些影子方法呢?

想象一下我们希望为我们的Circle编写一个子类,它可以通过某些因素进行缩放。为了做到这点,我们可以编写下面显得不太自然的类:

1
2
3
4
5
6
7
8
9
10
11
class ScalableCircle extends Circle {
  get radius() {
    return this.scalingFactor * super.radius;
  }
  set raadius() {
    throw new Error("ScalableCircle radius is constants." +
                                   "Set scaling factor instead.");
  }

  // Code to handle scalingFactor
}

注意在radius的getter方法中使用了super.radius。这个super的关键字可以让我们忽略自身的属性,而开始从我们的原型链中查找属性,因此也会过滤所有之前我们提到的影子方法。

我们可以在任意方法的函数定义中访问super属性(随便提醒,super[expr]也能工作)。当这些函数从原始对象中脱离出来后,super的方法访问实际上关联的是方法第一次定义的对象。这就意味着将一个方法脱离原有操作并定义到一个本地变量中,并不会改变super的方法访问产生的行为。

1
2
3
4
5
6
7
8
9
var obj = {
  toString() {
    return "MyObject: " + super.toString();
  }
}

obj.toString(); // MyObject: [object Object]
var a = obj.toString;
a(); // MyObject: [object Object]

子类化后的内置操作(基类方法的继承)

另外,你们可能希望在JS语言内部编写些扩展功能。JS内部的数据结构赋予其巨大的能力,可以利用这些能力来创建新的数据类型,其也是子类化设计中的基础。JS支持你编写一个具有版本的数组。(我知道,相信我,我知道)。版本数组应该可以修改,可能提交修改,回退到之前的提交变化。有一种方式来快速的编写版本数组,就是创建Array的子类。

1
2
3
4
5
6
7
8
9
10
11
12
13
class VersionedArray extends Array {
  constructor() {
    super();
    this.history = [[]];
  }
  commit() {
    // Save changes to history
    this.history.push(this.slice());
  }
  revert() {
    this.splice(0, this.length, this.history[this.history.length - 1]);
  }
}

VersionedArray的实例保持有数组中的一些重要属性,它们可以说是Array的实例,方法包括有mapfiltersortArray.isArray()会将其实例认为是数组,它们还可以自动地更新数组的length属性。再者,返回数组的函数此时会返回一个VersionedArray(如Array.prototype.slice())!

类构建器的派生

你可能已经注意到了上面的例子中的constructor方法有super()

在传统的类模型中,构建器是用来初始化类实例的内部状态的。每个连续的子类负责初始化状态和相关的具体子类(什么叫连续的子类?晕)。我们希望这些能起作用,所以需要子类可以通过扩展来操作相同的初始化代码。

为了调用基类的构建器,我们可以再次使用super这关键字,它是操作就是个函数。这语法只有在使用extends的类的constructor的方法中是合法的,我们可能重新编写我们形状(Shape)类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Shape {
  constructor() {
    this._color = color;
  }
}

class Circle extends Shape {
  constructor(color, radius) {
    super(color);
    this.radius = radius;
  }

  // As from above
}

在JS中,我们通过编写构建器,来操作this对象、安装属性和初始化内部的状态。一般来说,当我们通过new调用构建器时this对象就创建成功了,就像是Object.create()会处理其构建器的prototype的属性。但是,有些内置操作会在不同的内部对象布局上。例如,数组在内存中的位置不同于常规的对象。因为,我们想看到子类的内置操作,我们需要让基类分配到this对象。如果它是内置操作,我们会得到我们想要的对象行为,如果它是普通的构建器,我们会得到期望中的默认的this对象。

可能会得到个奇怪的结论,认为this绑定的是子类的构建器。当我们运行到基类的构建器时,允许其指定this对象,但我们子类实际上并不会有this的值。因此,在没有调用基类构建器之前,所有访问子类构建器会导致一个ReferenceError错误。

就如之前的文章,你可以省略掉contructor方法,派生出来的构建器也是可能省略的,它就如你编写:

1
2
3
constructor(...args) {
  super(...args);
}

有时,构建器并不能作用于this对象。相反,它们会通过其它的方式来创建对象,并初始化,接着直接返回此对象。如果是这一情况,那就不用使用super了。所有的构建器都会直接返回一个对象,不依赖于其基类构建器是否调用。

new.target

基类指定this对象时会有其它怪异的效果,那就是有时基类并不知道指定哪类的对象。假设,你正在编写一个对象框架的库,你希望有个基类Collection,某些子类会是数组,某些子类会是映射(Map)。然后,这时你运行Collection构建器,你并不会被告知是创建了哪类对象。

因此我们在进行内置操作时,当我们运行内置的构建器,我们需要知道其原始类的prototype。没有原型,我们是不能创建一个对象,使其有相应的实例方法的。为了解决这Collection的怪异情况,我们需要增加语法来暴露其信息给JS代码。我们已经增加了一个新的元属性(Meta Property) new.target,它关联构建器,可能直接调用new。通过new调用一个函数时,new.target会设置到其调用的函数中。调用super的函数中的new.target会作用new.target的值 。

这很难理解,所以让我用代码来解释我说的意思吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class foo {
  constructor() {
    return new.target;
  }
}

class bar extends foo {
  // This is included explicitly for clarity. It is not necessary
  // to get these results.
  constructor() {
    super();
  }
}

//foo directly invoked, so new.target is foo
new foo(); // foo

// 1) bar directly invoked, so new.target is bar
// 2) bar invokes foo via super(), so new.target is still bar
new bar(); // bar

我们已经解决了上面Collection所描述的问题,因为Collection构建器只检查new.target,就可以知道其类的家族,查看其是否是内置操作。

new.target在任何的函数中都是合法的,如果一个方法没有由new调用时,它会被设置了undefined

最好的两个世界(多继承)

希望你已经从这新特性的头脑风暴中生还出来,十分感谢能一直走到现在。现在,让我们花点时间来讨论,这些新特性怎么解决我们的问题。很多人很直率地质疑在JS加入继承这一特性是否好的。你可能会认为继承在创建对象上并不如组合一般好,或者认为相比于旧式的原型链模型,这精简的新语法会减少其设计上的灵活性。不容质疑的是,混入(mixin)通过扩展的方式来分享代码从而创建对象已经成为惯用手法。同时,它还有个好的原因:它提供了简单方式来将不相关的代码放置到同一个对象中,而不需要理解这两部分不相关代码是否适合在同一继承结构中。

在这主题上有很多不同的激烈信仰,但是我认为这并不值得讨论。首先,对一个语言增加类的特性,并不一定要求开发人员使用。第二,也同等的重要,语言中有类这一特性并不意味着它总是解决继承问题的最好方法!实际上,部分问题使用原型链继承的方式更为合适。到今天为此,类只是为你提供了额外的工具;它并不是唯一的,也不是最为必要的。

如果你想继续使用混入的方式,你可以理解那些由不同事情整合继承出来的类,才能通过混入来实现继承,以保证每件事能正常进行。不幸的是,现在这方式可能会修改继承模型,这显得十分刺耳。所以,JS并不对继承多个类进行实现。不过,它还是有一混合的方式来允许在类框架中使用混入方式的。详细看一下下面的函数,它是基于众所周知的extend混入的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function mix(...mixins) {
  class Mix {}

  // Programatically add all the methods and accessors 
  // of the mixins to class Mix.
  for (let mixin of mixins) {
    copyProperties(Mix, mixin);
    copyProperties(Mix.prototype, mixin.prototype);
  }

  return Mix;
}

function copyProperties(target, source) {
  for (let key of Reflect.ownKeys(source)) {
    if ( key !== "constructor" && key!== "prototype" && key !== "name" ) {
      let desc = Object.getOwnPropertyDescriptor(source, key);
      Object.definProperty(target, key, desc);
    }
  }
}

现在我们可以使用mix函数还创建一个组合型的超级类,通过变量mixins,我们不需要创建详细的继承关系。细想一下,当你编写一个协作编辑工具时,需要记录编辑的动作,同时需要将其内容序列化。你可以使用mix函数来编写一个DistributedEdit的类。

1
2
3
class DistributedEdit extends mix(Loggable, Serializable) {
  //Event methods
}

这对两个世界都很好。通过这例子,可以简单地看到怎么处理使得模型可以混入多个基类:我们可以简单地将基类放到mix函数中,并用类进行扩展继承。

当前可用性

好了,我们已经谈到许多有关子类化的内置等所有新功能,但是你是否现在可能使用它呢?

好吧,部分吧。在主要的浏览器商家中,Chrome已经支持我们今天所讨论到大部分的内容。在严格模式中,你可以我们讨论过的所有所有事情,除了Array的子类化。其它内置操作也可以使用,但是Array会出现一些额外的问题,所以可以不奇怪地确定还没有完成。我在firefox实现此功能,并快要接近尾声了(所有除了Array)。可以检查一下 bug 1141863 看到更多的信息,但是它会在几周后的日更新版本中出现。

再者,Edge已经支持super,但并不支持子类化的内置操作。Safari并不支持任何函数功能。

转换编译器在部分会有些不利的地方。它们可能创建类,可能使用super,但它们并没有一种方式来子类化内部操作,因为你需要一个引擎来支持一个类的实例能回溯到内置的操作方法(参考Array.prototype.splice)。

好了,这真是长啊。下周,Jason Orendorff 会回来并一起讨论ES6的模块系统。

Comments

ES6 In Depth: let and const

今天我要讨论的特性刚第一眼可能显得很简单,但实际上是很强大的。

当BrenDan Eich在1995年开始设计JS的第一个版本时,它做了很多错误的事情,这些事情还包括之后的语言发展中。例如,当你不小心错误处理Date和其它对象时,会自动转为NaN。但是,有些重要的事情他是正确的,有先见之明:对象、原型、第一类函数有词法作用域(就是函数是有作用域的)、默认是可变的。这语言基础是十分良好的,它优于大家对它的第一印象。

同时,Brendan 创建了个特别的设计并影响到今天的文章 – – 我认为他这决定是可以称为一个错误的。它是个小事情,十分微小的事情,你可能使用JS多年还没注意到它。但是,它是重要的,因为从这语言来看,这个错误的设计部分在今天来看,我们认为它是好的部分。

它不得不与变量一起处理。

问题1:代码块没有作用域

这问题看起来很无辜:在JS函数中进行var声明时,变量作用域是可以在整个函数体中的。其中,有两个方式来看到此问题引起的后果。

一个是在代码块中声明的变量的作用域不仅仅是在此代码块中,它会作用于整个函数。

你之前也许并不会注意到这点,但我担心这一点可能会你并不想看到的。让我们通过一场景来看一下这个狡猾的Bug。

你在某些代码中使用一个名称为t的变量:

1
2
3
4
5
6
7
8
function runTowerExperiment(tower, startTime) {
  var t = startTime;

  tower.on('click', function() {
    ... code that uses t ...
  });
  ... more code ...
}

目前,每件事都工作得很好。现在,你想增加保龄球的测速功能,所以,你在内部的callback函数中增加些if表达式。

1
2
3
4
5
6
7
8
9
10
11
12
function runTowerExperiment(tower, startTime) {
  var t = startTime;

  tower.on('click', function() {
    ... code that uses t ...
    if (bowlingBall.altitude() <= 0 ) {
      var t = readTachymeter();
      ...
    }
  });
  ... more code ...
}

噢,你无意地增加了第二个名称为t的变量。现在,之前正常工作的t,与一个新的内部的变量t关联上了,替代了外部早已存在的那个变量。

JS中var的作用域类似于PS中的桶刷工具,它会从声明、向前、向后各方向得到扩展,直到它碰到函数的边界,否则一直会起作用。因此,此处目前的t的作用域是向后兼容的,它就是我们在开始进行函数时创建的,这称作为变量升域(hoisting)。我几乎认为,JS引擎通过一个微小的代码起重机,将每个varfunction提升到函数闭域的顶部。

现在,升域也是有它好处的。没有这一特性,很多在全局作用域中能很好工作的完美的好的技术将不能在即时调用函数表达式(IIFE) 使用。但与此同时,升域会导致一个恶心的Bug: 当你使用t去计算时可能会得到NaN,这时你很难去追踪到它,特别是当你的代码大于此时的小例子时。

添加一个新的代码块会导致之前的代码块出现意想不到的错误。它只影响到我?它是不是真的怪异?我们并不想影响到之前的结果。

但是,相对于var的第二个问题,这个问题还是小的。

问题2:变量在循环中会过度地分享

你可以先猜测一下,当你运行这代码时会出现什么情况。它很简单:

1
2
3
4
5
var messages = ["Hi!", "I'm a web page!", "alert() is fun!"];

for (var i = 0; i < messages.length; i++;){
  alert(messages[i]);;
}

如果你看了这一系列的文章,你会知道我很喜欢在例子代码中使用alert()。也许,你也知道alert()是个糟糕的API,它是同步操作的。所以,当一个警告提示窗出现时,输入事件是不会传递的。在你的JS代码、实际在你的整个UI界面基本会是暂停的,直到用户点击确定选项。

多数时候,你在网页中使用alert()是个错误的选择,我之所以使用它,是因为我只觉得alert()是个很好的教学工具。

同时,我还应该被劝说放弃所有的杂乱和坏的行为,它意味着我可以这样创造一个说话的猫:

1
2
3
4
5
6
7
var messages = ["Meow!", "I'm a taling cat!", "callbacks are fun!");

for (var i=0; i < messages.length; i++) {
  setTimeout(function () {
    cat.say(messages[i]);
  }, i * 1500);
}

但是这会出错。这并不会顺序地说出三个消息,这代码猫会说出三次的”undefined”。

你能指出这个问题吗?

这问题出现在唯一的变量i中,它会作用于循环内部和三个延时的返回函数。但当循环完成时,i的值会变成3,因为messages.length等于3,但任一返回函数并没有被调用。

所以,当第一个延时函数工作时,会调用cat.say(messages[i]),它实际会是messages[3],而这会导致出现undefined

有很多的方法来解决这一问题,但这个是var的作用域规则导致的第二个问题。首先,确保代码不会有这一问题是十分好的。

let 新于 var

在很多时候,JS在设计上的缺陷是不能弥补的(其它语言也会设计缺陷,但JS犹为突出)。向后兼容意味着不能改变已经存在的Web中JS代码行为。甚至,标准委员会也没致力于此,认为解决这些怪异的问题就是自动插入分号(意思是注意细节?)。浏览器制作人并不想对这种破坏性的改变进行实现,因为这些改变会成为其用户的惩罚(也可能会因此失去部分用户)。

所以,大概十年之前,当 Brendan Eich 决定解决这问题时,那时只有一种方式来实现。

他添加了一个新的关键字let,它可以用来声明变量,就像var一样,但其作用域规则更好。

它看起来这样子:

1
let t = readTachymeter();

或者这样:

1
2
3
for (let i = 0; i < messages.length; i++) {
  ...
}

letvar是不一样的。如果你只是需要在你代码中做个全局的搜索和替换,又可以在你的部分代码中进行中断(有可能是无意的),那么你可以使用怪异的var。但是在更多时候,在ES6中,你应该在任何地方都要停止使用var,而使用let。正因为如此,标题为“let 新于 var”。

letvar到底有哪些不一样呢?很高兴你会提问:

  • let变量是代码块作用域的 使用let来声明一个变量,其作用域只会在包含的作用域中,而不是整个函数的内部。

let也会有一定的升域情况,但是它并不是任意的。用let替换var,可以简单地解决runTowerExperiment例子中的问题。如果你在任何的地方都使用let,你将不会再遇到此类问题。

  • 全局的let变量并不会成为全局对象的属性 是的,你不能通过编写window.variableName来访问它们。相反,它们运行在网页中的一个不可见的包含所有JS代码的块中。

  • for (let x ...)的每次循环中会创建新的绑定的变量x。这会有些微小的不同的,意味着for (let ...)循环执行多次时,每次其内部包含一个闭包。就像之前我们的猫例子,每个闭包会得到循环的不同的一个复本,而不是每次闭包得到同一个循环变量。

所以,对于猫这例子说,将var修改为let就可以解决之前的问题。

这作用于所有的三类for循环表达式:for-of, for-in 和老式C语言的分号方式。

  • 在没有遇到let声明之前使用变量会出错 当控制流没有到达变量声明的代码行时,其是未初始化的。例如:
1
2
3
4
5
function update() {
  console.log("current time:", t); //ReferenceError
  ...
  let t = readTachymeter();
}

这一规则可以帮助你抓取到Bug。结果并不是NaN,你将会在出现问题的代码行中得到一个异常。

这个变量在作用域但并没有被初始化的这一区域,被称为 temporal deal zone(临时死区间)。我一直等待更为专业的术语来描述这事情,就像在写科幻小说一样,然而没有。

(性能详情:在多数情况下,(在变量查找方式中)你可以告知变量是否声明,或者不仅仅去查找相关代码。所以,JS引擎实际上并不是每次都会进行额外的检查,以确认变量是否初始化。但是,在闭包中有时是不明确的,在这些情况下,就意味着使用let会比var慢。)

(作用域详情:在某些编程语言中,变量的作用域开始于其声明的地方,而不是在整个闭合的代码中向后查找。标准委员会认为可以通过let来使用这作用域规则。如此,当tlet t的语句后面调用时,会简单得到一个引用错误,所以,它也不会关联到任一变量。它应该在闭合的作用域中关联到变量t上,但这方法对于闭包或者函数式的升域都是无效的,所以它实际上会被忽略掉。)

  • 重复声明变量会引起语法错误 这一规则也是利于检测细小的错误。同时,它的不同点还有:当你试图将全局的let转化为var,会导致些问题,因为它曾经是个全局let的变量。

如果你的某些脚本声明了相同的全局变量,你最好保持使用var。如果你试图转化为let时,那些第二个用到此变量的脚本会出错的。

或者,使用ES6的模块,但是那是之后某天的故事了。

(语法详情:let是在严格的代码模式下是保留关键它。在非严格代码模式下,为了向后兼容,你依然可以声明变量、函数和参数的名称为let。你可以编写var let = 'q';,但你最好不要这让做。同时,let let;也是不允许的。)

除了这些不一样的地方,letvar其实是一样的。它们可以通过逗号来分开声明多个变量,例如,他们也都支持解构(释构?destructuring)。

注意,类class的声明类似于let,而不var。如果你在加载的脚本中多个声明一个类class,在第二次时你会因为重复声明类而得到一个错误。

常量(const)

是的,新的东西。

ES6介绍了一个三方的关键字,它可以在let的旁边使用: const

使用const声明变量类似于let,但不能在声明之外对它进行赋值,这会引起一个语法错误。

1
2
3
4
const MAX_CAT_SIZE_KG = 3000; //

MAX_CAT_SIZE_KG = 5000; // SyntaxError
MAX_CAT_SIZE_KG++; // nice try, but still a SyntaxError

明显够了,你不能只声明一个const变量而不赋值:

1
const theFairest; // SyntaxError, you troublemaker

命名空间的秘密

“命名空间是一个非常好的主意 – 让我们更多地使用它” – Tim Peters,”Python 的秘决”

在私底下,内嵌式的作用域是始终围绕编程语言的一个核心概念。自从ALGOL 出来后一直就是这样,已经大概有57年了,但对今天来说,此结论更为正确。

在ES3之前,JS只有全局的任务域和函数作用域。(让我们忽略掉with表达式。)ES3介绍了try-catch的表达式,它会增加一个新类型的作用域,此作用域只用来操作异常的变量并作用于catch代码块。ES5通过调用eval()会产生一个新的作用域。ES6添加了代码块作用域,for循环作用域,新的全局let作用域,模块作用域,还有用来解释参数中默认参数的额外作用域。

所有在ES3之后添加的作用域都是必要的,它们使得JS在程序上和面向对象特性上更合理、精确、直观,就如闭包一样;同时,可以和闭包无缝协作。也许你之前并没有注意到这些作用域的规则,如果是这样,说明JS做的工作并没有困扰你。

现在我能使用letconst吗?

可以。为了在Web中使用它们,你不得不使用ES6的编译器,如Babel, Traceur, 或者 TypeScript。(Babel 和 Traceur 并不支持 temporal deal zone(临时死区间)。)

io.js 支持letconst,但只能在严格模式下使用,Node.js 在支持上是一样的,但参数--harmony也是必要的。

Brendan Eich 在九年前实现了Firefox中let的第一个版本,这一特性在其标准化进程中进行了彻底的重新设计。为了符合标准,Shu-yu Guo 更新了此实现方式,代码审核由Jeff Walden和其它人完成。

好了,我们展开双手来欢迎新特性。ES6史诗级的特性到现在结束了。在之前两周中,我们完成了多数人渴望在ES6看到的特性的文章。但是首先要说明,下周我们将对之前早些时间提到的新特性:super。所以,和Eric Faust 一块加入我们,深入了解ES6的子类化。

Comments

ES6 In Depth: Classes

今天,我们可以从这一系列的之前复杂的文章中得到些喘息的机会。现在没有新的、没见过的 生成器、代理,这些会影响JS内部运算逻辑的hook,没有新的数据结构可去除用户自己的需求解决方案。相反,我们来讨论些语法性的、惯用的、待解释清楚的旧问题:JS中的对象构建器。

问题

我们想要创建一个最为精典的面向对象原则的例子:圆形类。想象一下,我们为一个画布的库编写一个圆形类。在所有事情中,我们需要知道怎么做到以下项:

  • 在一画布中画一个圆形
  • 记录画布中圆形的所有数量
  • 记录每一圆形的半径,并确定如何读写这值
  • 计算圆形的面积

这用JS的语言可以这么说:我们首先应该创建一个函数构建器,然后为这个函数增加必要的属性,接着使用一个对象来替换构建器中的prototype的属性。当我们开始创建一个实例对象时,这个prototype对象会包含所有属性。即使一个简单的例子,当你输入所有数据时,多数这样的模板也就完成了:

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
function Circle(radius) {
  this.radius = radius;
  Circle.circlesMade++;
}

Circle.draw = function draw(circle, canvas) { /* Canvas drawing code */ }

Object.defineProperty(Circle, "circlesMade", {
  get: function() {
    return !this._count ? 0 : this._count;
  },

  set: function(val) {
    this._count = val;
  }
});

Circle.prototype = {
  area: function area() {
    return Math.pow(this.radius, 2) * Math.PI;
  }
};

Object.defineProperty(Circle.prototype, "radius", {
  get: function() {
    return this._radius;
  },

  set: function() {
    if (!Number.isInteger(radius))
      throw new Error("Circle radius must be an integer");
    this._radius = radius;
  }
});

这代码不仅仅很繁琐,它还离理想的有些远。它要求对函数的工作方式拥有很好的理解,这很重要,同时,了解各种将属性设置到创建的实例对象中。如果这方法看起来很复杂,不要担心。这整篇文章的目的就是展示一种很简单的方式来做到这些事情。

定义方法的语法

在第一次尝试新方法之前,ES6提供了一种新的语法来为一个对象增加特殊的属性。对于上面的例子,它可以很容易地为Circle.prototype添加area的方法,它会让 radius的setter/getter方法更为有效些。作为JS一个面向对象的新方法,人们很乐意去使用一个设计更为精简的对象访问方式。我们需要一个新的方式来为一个对象增加方法,比如添加时如obj.prop = method,而不需要Object.defineProperty这么笨重。人们希望可以让下列的事情更为简单:

  1. 为一个对象增加普通的函数属性。
  2. 为一个对象增加生成器函数属性。
  3. 为一个对象增加普通的访问函数属性。
  4. 为一个已经完成的对象,通过[]来进行以上三种的操作。我们将这称为 计算属性名称(Computed property names)。

有些事情在之前不能操作的。例如,没有一种方式来为obj.prop定义setter和getter方法。因此,新的语法是必定要添加的。你现在可以类似编写如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var obj = {
  // Methods are now added without a function keyword, using the name of the
  // property as the name of the function.
  method(args) { ... },

  // To make a method that's a generator instead, just add a '*', as normal.
  *genMethod(args) { ... },

  // Accessors can now go inline, with the help of |get| and |set|. You can
  // just define the functions inline. No generators, though.

  // Note that a getter installed this way must have no arguments
  get propName() { ... },

  // Note that a setter installed this way must have exactly one argument
  set propName(arg) { ... },

  // To handle case (4) above, [] syntax is now allowed anywhere a name would
  // have gone! This can use symbols, call functions, concatenate strings, or
  // any other expression that evaluates to a property id. Though I've shown
  // it here as a method, this syntax also works for accessors or generators.
  [functionThatReturnsPropertyName()] (args) { ... }
};

利用新的语法,我们可以编写如下

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 Circle(radius) {
  this.radius = radius;
  Circle.circlesMade++;
}

Circle.draw = function draw(circle, canvas) { /* Canvas drawing code */ }

Object.defineProperty(Circle, "circlesMade", {
  get: function() {
    return !this._count ? 0 : this._count;
  },

  set: function(val) {
    this._count = val;
  }
});

Circle.prototype = {
  area() {
    return Math.pow(this.radius, 2) * Math.PI;
  },

  get radius() {
    return this._radius;
  },
  set radius(radius) {
    if (!Number.isInteger(radius))
      throw new Error("Circle radius must be an integer.");
    this._radius = radius;
  }
};

显然,这代码与之前的并不完全一样。这里的对象方法定义是可以配置和可列举的,但是在开始的代码中并不没有配置和列举。在实践中,这是很少会引起注意,我为了简洁会减少之前的其中列举和配置。

接着,这代码是不是更好些了,对不对?不幸的是,即使有新的方法定义语法,我们还是没有很多方式来定义Circle,就如我们必须定义函数。没有方法当你在定义函数时,又可以访问其属性。

类定义语法

尽管上面代码比较开始的代码已有改进了,但是它依然未达到人们想要的结果,人们希望在JS中有一个简洁的面向对象的解决方案。其它编程语言中有一结构来处理面向对象设计,他们称这结构为类(class)。

这很合理。接着,让我们开始添加类。

我们希望有一个系统,它允许我们增加一个构造函数的方法,其类似于为.prototype增加方法一般,以至于方法能出现在这个类的所有实例中。后来,我们拥有了喜爱的定义新方法的语法,我们肯定会使用它。然后,我们只需要有一种方式来区分哪个是作用类中所有实例的,哪个是作用特定的实例。在C++或Java,其关键字是static,这看起来也很好,我们使用它。

现在,需要在众多的方法中选择一个来作为构造函数,这很有用。在C++或Java中,它是和类史一样的,并且不会返回类型。因此,JS的构造函数也不会返回类型,同时我们需要一个.constructor的属性,为了向后兼容,让我们把这方法叫做constructor

把所有的东西结合一起,我们可能重新编写我们的Circle类,结果会是如下:

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
class Circle {
  constructor(radius) {
    this.radius = radius;
    Circle.circlesMade++;
  };

  static draw(circle, canvas) {
    // Canvas drawing code
  };

  static get circlesMade() {
    return !this._count ? 0 : this._count;
  };

  static set circlesMade(val) {
    thsi._count = val;
  };

  area() {
    return Math.pow(this.radius, 2) * Math.PI;
  };

  get radius() {
    return this._radius;
  };
  set radius(radius) {
    if (!Number.isInteger(radius))
      throw new Error("Circle radius must be an integer.");
    this._radius = radius;
  };
}

哇!我们不仅可以将与Circle有关的所有方法整合一起,而且所有事情都看起来很好很简洁。这肯定要优于我们开始做的代码。

尽管如此,你可能还存在部分的边界上问题。我在这里预测并定位一些问题:

  • 分号怎么样? – 有个趋向是“使JS类更像传统中的类”,所以我们决定用更传统的分隔符(分号)。不喜欢它?它是可选的,分号不是必须的。

  • 如果我不想操作构造函数,但又想为已经创建好的对象增加方法,怎么办? – 这也可以。这constructor方法也是可选的。如果你没有定义构造函数,系统会默认如你编写constructor() {}

  • 构造函数可以是生成器吗? – 不!如果添加的构造函数不是普通函数时会出现TypeError的错误,这里生成器和访问器是一样的。

  • 我能不能定义constructor为一个计算属性名称? – 很不幸,不行。那样真的很难被解释到,所以我们不能这样子。如果你为一个计算属性定义一方法,并命名为constructor,你也将会得到一个名为constructor的方法,那么你也不会得到一个类的构造函数了(想要操作方法,又想成为构造函数,那是不可能的)。

  • 当我修改Circle的值时,会不会错误地生成一个新的Circle? – 不!就像函数的表达式一样,类会为给定的名称进行内部的绑定,这绑定不能为外部操作所改变,所以无论你怎么设置Circle的内部作用域中的值,Circle.circlesMade++的值都会是期望中的一样。

  • 好吧,我可以将一个对象直接作为函数的参数,但新类语法并不能这样子 – 很幸运,ES6有这类的表达式。它可以命名也可以不命名,表现的效果与上面描述的是一样的,除了ES6不会在声明的作用域创建变量。

  • 那它有上面提到可列举等恶作功能吗? – 人们都想做到这点,所以你可以给对象安装方法,但列举对象的属性时,你只可以获取给这对象增加的属性,这样也更为合理些。因此,类中的安装方法是配置型的,但并不可列举。

  • 等,哪有实例变量?那static常量? – 你打到我了,你提到的这些并没在刚才的ES6的类定义中。但是,有好消息。它些已经和其它一些特性提到规格进程中了。我是十分支持使用staticconst在类语法中定义值的。实际上,它已经提到会议上了。我认为,我们可以在将来看到更多有关于此的讨论。

  • 好的,很好!我能使用它吗? – 并不能真正使用,但有些可用的工具(特别是 Babel ),所以今天你可以通过他们来使用类语法。很不幸运,在是所有主流的浏览器中运行还需要些时间。我已经把今天讨论到的都在Nightly version of Firefox中实现了,同时它也在Edge和Chrome实例了,但默认是不打开的。很不幸,现在看来在Safari中并没有实现。

  • Java 和 C++ 有子类和super关键字,但这里并没有提到,那JS呢 – 它可以。但是,这值得另外一文章来进行讨论。注意查看我们之后的关于子类的更新文章,同时我们还将讨论更多的JS类的功能。

如果没有 Jason Orendorff 和 Jeff Walden 负责的代码检查和指导,我将不会可能实现类。

下周,Jason Orendorff 会在一周的休息后回来,将讨论letconst主题。

Comments

ES6 In Depth: Proxies

这是我们今天要做的事情:

1
2
3
4
5
6
7
8
9
10
var obj = new Proxy({}, {
  get: function (target, key, receiver) {
    console.log(`getting ${key}!`);
    return Reflect.get(target, key, receiver);
  },
  set: function (target, key, value, receiver) {
    console.log(`setting ${key}!`);
    return Reflect.set(target, key, value, receiver);
  }
});

对于这第一个例子可能有些复杂,我将会下面进行各部分的说明。现在,先来检查一下我们创建的对象:

1
2
3
4
5
6
> obj.count = 1;
    setting count!
> ++obj.count;
    getting count!
    setting count!
    2

这里发生了什么?我们拦截了这个对象的属性访问,重载了”.”操作符。

它是怎么工作的?

计算机领域中最好技巧为虚拟化,这是用非常普通的技术来做不可思议的事情。这里说明是怎么做的。

  1. 使用任意图片

img1

  1. 在图片中的某部分周围画个轮廓

img2

  1. 现在,使用意外的东西,来替换所有轮廓里面或者外面的部分。这里只有一个规则,那就是向后兼容的规则。你的替换操作需要做到的是,之前在其它地方使用的人不会感觉到它的改变。

img3

通过计算机科学的电影如楚门的世界(The Truman Show)和黑客帝国(Teh Matrix),你可能非常熟悉这类型的hack。电影中,一个人在轮廓的内部,但是剩余的其它世界都已经被近乎合理的替代了。

为了满足向后兼容的规则,你的替代方式需要巧妙的设计,但真正的关键点在于画好轮廓。

对象轮廓,我理解为一个API的边界,一个接口。接口定义了两部分代码是怎么交互的,每部分期望其它提供什么信息。所以,如果在系统中存在一个接口,也就是说这个轮廓已经画好给你了。你知道,你可以替换这一部分,而其它部分就不用关心了。

当没有存在已有接口时,那么你就不得不创建了。一些酷酷的软件hack所有时间都在没有API的系统中画API的边界,同时将接口编写入现有系统中是个巨大的工程。

虚拟内存(https://en.wikipedia.org/wiki/Virtual_memory )、硬件虚拟化(https://en.wikipedia.org/wiki/Hardware_virtualization )、Docker(https://en.wikipedia.org/wiki/Docker_%28software%29 )、Valgrind(http://valgrind.org/ )、rr(http://rr-project.org/ ) —- 这些所有的项目在不同层次上将新的和甚至非期望的接口编写对现有系统中。在某些时候,它会消耗多年,然后一个新的操作系统特性、甚至一个新的硬件会出现,从而使得新边界工作得更好。

最好的虚拟化hack需要理解清楚虚拟化什么东西。在为某个事物编写API时,你不得不理解它。只要你理解了,你才能做出令人惊奇的事情。

ES6 为JS推出了虚拟化的最为基础的概念:对象。

什么是对象

不,真的,需要等等。想一下,当你知道对象是什么之后再往下看。

img4

这问题对我来说太难了,我从来没有听到过一个真正满意的定义。

这是不是很奇怪?定义一个基础的概念总是很难的 —- 有时可以查看一下 Euclid 的元素。ECMAScript 指导说明书或许是个好伴侣,但是,它定义一个对象为“类型对象的成员”,完全没有帮助的定义!

后来,说明书添加到“对象是属性的集合”,这也不怎么坏。如果你想要一个定义,现在可以做,但我们将在以后回来讨论它。

我之前有说过,要为某个事物编写API,需要了解它。所以,按照这道理,我认为如果我们做到所有的这些事情,我们需要深入了解对象,这样我们才能可能做些惊奇的事情。

所以,让我们跟随着ECMAScript委员会的步骤,来看看它是怎么使用对象来定义一个API、一个接口的。我们需要哪些方法?对象可能做些什么?

这些依赖于具体的对象。DOM元素对象可以做些确定的事情,AudioNode对象则可以做其它事情。但是,所有的对象都有那么一些基础的能力:

  • 对象拥有属性。你可以获取和设置属性,删除它们,等等。
  • 对象拥有原型。这是JS继承的原因。
  • 某些对象是函数或者构建器,你可以调用它们。

几乎所有的JS代码,使用属性、原型和函数就可以了。甚至,某些特殊的Element或AudioNode对象行为可以通过调用方法来作用,但这只不过是继承其函数属性。

所以,ECMAScript标准委员会定义了一堆、也就是14个内置的方法,所有的对象都会拥有的接口。这些并不令人感到奇怪,他们最终还是关注于这三个基本点上。

完整的接口方法可以在版本5和6的标准库 找到。这里,我将会对部分进行说明。怪异的双中括号,[[]],用于强调其是内部的方法,对普通的JS代码是隐藏的。不同于普通方法,你不能调用、删除或者重写这些方法。

  • obj.[Get] – – 获取属性的值

当JS代码使用obj.prop或者obj[key]时,其会被调用。

obj 是当前查找的对象,receiver 是第一个查找对象属性的对象。有时,我们不得不查找多个对象,obj可能会是receiver对象原型链上的对象。

  • obj.[Set] – – 为一个对象的属性指派值

当JS代码使用obj.prop - value或者obj[key] = value时,其会被调用。

在如obj.prop += 2的指派值中,[[GET]]方法会先调用,接着才调用[[Set]]方法。同理于++--

当JS代码使用key in obj时,会调用。

  • obj.[Enumerate] – – 列出obj对象的可列举的属性。

当JS代码使用for (key in obj) ...时,会调用。

这方法会返回一个迭代器对象,这就是for-in循环获得一个对象属性名称的原因。

当JS代码使用obj.__proto__或者Object.getPrototypeOf(obj)时,会调用。

  • functionObj.[Call] – – 调用一个函数。

当JS代码使用functionObj()或者 x.method(),会调用。这是可选的,并不是每个对象都是函数。

*constructerObj.[Construst] – – 调用一个构造器。

例如,当JS代码使用new Date(2890, 6, 2)时,会调用。这是可选的,并不是每个对象都是个构造器。

其中,newTarget 会在子类中参数扮演着一个角色,我们将会再之后的文章中进行说明。

也许,你可以猜测一下其它的七个方法。

贯穿着ES6的标准库,无论如何,针对对象的任意小语法或者内置函数,都是根据这内置的十四个方法来具体化的。ES6 为一个对象的核心划定了一个边界,而通过任意的JS代码作为代理,来替代各种核心方法。

当我们开始讨论重写这些内置的方法时,记住,我们正在讨论是 重写核心代码的方法,如obj.prop,内置的函数,如Object.keys(),还有更多。

代理

ES6 定义了一个新的全局构造器,Proxy。它有两个参数:一个target对象,一个handler对象。所以,一个简单的例子看起来如:

1
2
var target = {}, handler = {};
var proxy = new Proxy(target, handler);

让我们先把handler对象放一会儿,关注proxy和target对象是怎么关联的。

我可以用一句话来说明proxy是怎么工作的:所有的proxy的内部方法都会面向target。例如,如果某时调用proxy.[[Enumerate]](),它将会返回target.[[Enumerate]]()

让我们试一下,我们做些事,它将会触发proxy.[[Set]]()

1
proxy.color = "pink";

好了,刚才发生了什么?proxy.[[Set]]()应该会调用target.[[Set]](),所以,target应该有一个新的属性,是不是呢?

1
2
> target.color
    "pink"

是的,它是这样子的。还有,其它的内部方法也是一样的。在大多时候,proxy代理对象的行为也和target一样。

这样高保真的操作是受到限制的,是错觉。你可以发现proxy !== target。同时,当target通过类型检查时,其proxy代理对象有时并不能通过。甚至,例如,如果target是一个DOM元素,代理对象proxy并不是真正的一个元素,所以,有时如document.body.appendChild(proxy)会出现TypeError的错误。

代理处理器(Proxy handlers)

现在,让我们返回讨论handler对象,它是让代理proxy可用的原因。

这个handler对象的方法可以重载做任意proxy中的内部方法。

例如,如果你想拦截所有设置对象属性的操作,你可以定义handler.set()来做到。

1
2
3
4
5
6
7
8
9
10
var target = {};
var handler = {
  set: function(target, key, value, receiver) {
    throw new Error("Please don't set properties on this object.");
  }
};
var proxy = new Proxy(target, handler);

> proxy.name = "angelina";
    Error: Please don't set properties on this object.

所有的handle方法可以在 MDN的Proxy文档 看到。这里有十四个方法,这十四个方法也被提到ES6定义为内置方法中。

所有的handler方法都是可选的,如果一个内置方法没有被拦截,那么它将会传递给target,就如之前我们看到的一样。

例子:“不可能” 自动流对象

现在,我们对proxy代理已经有足够的了解了,在试用时有时显得很怪异,但有些又不可能离开proxy。

这里,有我们第一个训练的例子。让函数Tree()可以做到:

1
2
3
4
5
6
7
8
9
> var tree = Tree();
> tree
    { }
> tree.branch1.branch2.twig = "green";
> tree
    { branch1: { branch2: { twig: "green" } } }
> tree.branch1.branch3.twig = "yellow";
    { branch1: { branch2: { twig: "green" },
                        branch3: { twig: "yellow" } } }

注意,这些所有的内部对象 branch1/branch2/branch3 当需要时会神奇地自动创建。很方便,是不是?它可能是怎么工作的?

直到现在,并没有方法可以做到。但是,通过proxy代理,只需要几行代码。我们只需要控制tree.[[Get]]()方法。如果你喜欢挑战,你也许可以试图在阅读下面内容之前自己实现它。

这里是我的解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
function Tree() {
  return new Proxy({}, handler);
}

var handler = {
  get: function (target, key, receiver) {
    if (!(key in target)) {
      target[key] = Tree(); // auto-create a sub-Tree
    }
    return Reflect.get(target, key, receiver);
  }
};

注意,最后有Reflect.get()的调用,这证明其非常普通的需求。在这里的 handler 方法中,它代表的意思是“现在只要授权给target进行默认操作”。所以,ES6定义了一个新的 Reflect 对象 并包含有14个方法,你可以真正地使用起来。

例子:一个只读视图

我想,我可能会给人一种错误的印象,proxy代理是容易使用的。让我们做更多的一个例子,看看这看板是否正确吧。

这次,我们的分配更加复杂些:我们需要实现这个函数readOnlyView(object),它可接收任意对象并返回一个proxy代理,代理行为看起来像接收对象一样,除了代理对象是不可变的。所以,例如,它应该看起来这样:

1
2
3
4
5
6
7
> var newMath = readOnlyView(Math);
> newMath.min(54, 40);
    40
> newMath.max = Math.min;
    Error: can't modify read-only view
> delete newMath.sin;
    Error: can't modify read-only view

我们应该怎么实现它呢?

第一步,我们需要修改穿过方法的目标对象,截获它所有的内部方法。这里需要修改五个内部方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function NOPE() {
  throw new Error("can't modify read-only view");
}

var handler = {
  // Override all five mutating methods
  set: NOPE,
  defineProperty: NOPE,
  deleteProperty: NOPE,
  preventExtensions: NOPE,
  sePrototypeOf: NOPE
};

function readOnlyView(target) {
  return new Proxy(target, handler);
}

这是可行的,通过这只读视图,它会阻止设值、属性定义等。

这结构有什么漏洞吗?

这里最大的问题是[[Get]]方法,还有其它,可能会返回可变的对象。所以,当某个对象 x 是个只读的视图,x.prop 是可变的。这是个大大的漏洞。

为了修补它,我们需要添加一个handler.get()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var handler = {
  ...

  // Wrap other results i read-only veiws.
  get: function (target, key, receiver) {
    // Start by just doing the default behavior.
    var result = Reflect.get(target, key, receiver);

    // Make sure not to return a mutable object!
    if (Object(result) === result) {
      // result is an object.
      return readOnlyView(result);
    }
    // result is a primitive, so already immutable.
    return result;
  },

  ...
};

这样依然没有达到效果,类似的代码需要在其它方法中实现,包含getPrototypeOfgetOwnPropertyDescriptor

然后,会有些更多的问题。当一个 getter 或者 方法在这类的代理中被调用时,这个this值会传递给proxy代理对象本身。但是,就像之前我们看到的,proxy代理对象在很多的接收器(accessors)和方法的类型检查中是不会通过的,这里最好是对目标对象来替代代理对象。你能指出怎么做吗?

这文章看到创建一个代理对象是十分方便的,但是想创建一个拥有直觉行为的代理对象是十分困难的。

零碎的东西

  • proxy代理善于做什么

当你想监视或者记录一个对象的访问时,它确实是有用的。这样,它可以用来调试。测试框架可以用它来创建模拟对象(Mock object)

当你希望的行为只是一个普通可以做的,proxy代理是有用的,如:懒惰的流行属性设置。

尽管我很不喜欢这方式,但是,当使用代理时,想要知道其代码是怎么运行的,最好的办法之一是将代理中的handler对象使用另外一个代理进行包裹起来,从而用来记录每次handler方法的访问。

代理可以限制一个对象的访问,就像我们刚才做的readOnlyView。这类型的使用场景在应用代码中比较少见,但是Firefox使用代理对不同域名中的安全边界 进行了内部实现。代理是我们(Firefox)安全模型的一个关键点。

  • 代理 结合 WeakMaps 。在我们的readOnlyView例子中,每次的对象访问中我们会创建一个新的代理。使用 WeakMap 来创建每个代理对象,其可以缓存并减少内存的消耗,所以,虽然readOnlyView会传递很多的对象,但是真正只有一个代理对象被创建。

这是一个刺激使用WeakMap的使用场景。

  • 可废除的代理。ES6 还定义另外的函数Proxy.revocable(target, handler),它会创建一个代理,就像new Proxy(target, handler),但这个代理对象在之后是可以废除的。(Proxy.revocable返回一个包含有.proxy属性和.revoke方法的对象。)只要一个代理对象被废除,它就不能再工作了;所有的内部方法都会抛出(错误提示?)。

  • 对象不变量。在某些场景中,ES6会要求proxy的处理方法得出的结果包含有target对象的状态,这是为了想得到强制性的对所有对象的不可变的规则,甚至包括代理对象。例如,一个代理不能声明为不可扩展的,除非它的target对象是真正不能扩展的。

准确的规则在这里来说是比较复杂的,但是,如果你看到一个错误的信息如"proxy can't report a non-existent property as non-configurable"时,这就是原因。这有点像补救而改变代理对象怎么输出它自己;另外的可能是改变target对象用来进行反射,而不理会代理对象怎么输出。

现在对象是什么?

我想,我们已经偏离了“对象是属性的集合”。

我并不乐意这个定义,即使当然地包括原型(prototypes)和callability。我认为 “集合”这一词太笼统了,它并不能表明代理对象是什么,代理对象的处理方法可以做任意事情,它可以返回任意的结果。

为了说明一个对象能做什么,对对象方法进行标准化、虚拟化第一类的特性以至于每一个人都能使用,ECMAScript标准委员会正扩展其可能性。

现在,对象可能是任何事物。

也许,更为诚实的问题是“什么是一个对象?”,现在是,其有十二个内部方法,一个对象在JS程序中有[[Get]]操作,有[[Set]]操作,等等。

现在,我们是否理解了对象真正是什么了吗?我并不确定!我们做了不可思异的事情?是的,我们做了之前JS不可能做的事情。

今天我可以使用代理吗?

不。无论如何,它并不能在Web开发中使用。只有Firefox和微软的Edge浏览器支持代理,也没有额外的转换工具。

自从V8现实了代理的旧版本后,要在 Node.js 和 io.js 使用代理,需要使用默认关闭的选项(-- harmony_proxies) 和 harmony-reflect 工具。

所以,可以尝试使用代理。创建一个大的镜子,里面包括有成千的对象复制品,所有都是类似的,都是很难调试的。现在是时候使用代理了,但对于生产环境你的代理代码可能还是会有些危险的。

代理的第一次实现是在2010年,由Andreas Gal实现,Blake Kaplan进行代码审查。接着,标准委员会又完全重新设计了这一特性。Eddy Bruel 在2012年根据新的说明重新实现了。

我实现了Reflect,代理审查由 Jeff Walden 进行,它将会在这周末的Firefox的Nightly版本中出现 – – 除了Reflect.enumerate()没有实现。

下一步,我们将讨论ES6中最富有争议的特性,一个在Firefox内部的提出人更优于实现人的特性。所以,在于下周加入我们,Mozilla工程师 Eric Faust 展示 深入ES6 的类

ES6 In Depth: Generators, continued

欢迎回到 深入ES6。我希望你们能在我们的暑假期间也很快乐,但是编程大大的生活不能总是烟花和柠檬水,现在是时候拾起我的丢下的 —- 重新开始我已经讨论完美的主题(深入ES6)。

在5月份,我写到生成器,这是ES6中的新功能。我在ES6中称它为最为魔法的特性,谈到它是怎么影响将来的异步编程的。然后,我写到:

对于生成器还有更多的东西要说的。… 但是,我认为这文章已经够长的了,现在足够引起疑惑了。就如生成器一般,我们应该暂停一下,在以后的时间再继续 。

现在,是时候了。

你可以在这里看第一部分的内容。在阅读现在这篇文章时,最好先读一下链接中文章。继续,这里很有意思,它有有点长、有点令人困惑。但,它是个会说话的猫(?)。

快速场景

上次,我们关注生成器的基本用法,它也许可能有点奇怪,但是并不难理解。一个生成器函数看起来像个普通的函数,主要的不同点在于生成器函数的内容并不仅仅运行一次。每次运行时,会暂停而去执行yield的表达式。

这具体的细节已经在之前的文章已经说明,但是,我们并没有做个彻底的把所有部分都整合在一起的例子。让我们现在开始做吧。

1
2
3
4
5
6
7
8
function* someWords(){
  yield "hello";
  yield "world";
}

for (var word of someWords()) {
  alert(word);
}

这脚本够直接的了,但是,如果你注意到代码之间发生的事情,它就像是所有不同代码组成的一台戏,这时就是个不同的脚本了,它可能看起来会是这样:

(略)

这并不是什么哈姆雷特,但你可以想象一下这场景。

就如你在这戏中看到的,当一个生成器对象第一次出现时,它会暂停。每次当方法.next()被调用时,它就会醒来。

这动作是同步的、单线程的。注意,这里只有一个角色是可以在任意时间做任何事情,这角色不会互相打断对方,也不会与他人进行交互。他们轮流说话,而且,只要他们想,他们就可以想说多久就多久。(就像是莎士比亚!)

在这戏剧的一些版本中,生成器每次相当于被喂食给for-of循环。在这过程中,会有.next()方法在按序地执行,但并不会出现在你的任何代码中。这里,我把它放到舞台中,但对你们和你们的程序来说,所有的这些是在后台发生的,因为生成器和for-of循环在设计时是一起工作的,他们通过这个迭代接口 实现的。

所以,总结这里的点:

  • 生成器对象是个听话的机器人,它可以产出值;
  • 每次这机器人的程序包括一个简单的代码块,生成器的主体函数会生成这代码块。

怎么关闭一个生成器

生成器有一些非常巧妙的特性,我并没有在第一部分进行说明:

  • generator.return()
  • generator.next()可选参数
  • generator.throw(error)
  • yield*

我跳过这些主要是因为没有理解为什么这些特性会存在。我们很难注意到这些特性,在你们的脑子里并没有直接的印象。但是,当我们细想我们的程序是怎么使用生成器时,我们就能看到原因。

这里有些你可能会碰到过:

1
2
3
4
5
6
7
8
9
10
function doThings() {
  setup();
  try {
    // ..do some things ...
  } finally {
    cleanup();
  }
}

doThings();

这个cleanup方法可能会关闭连接或者是文件,释放系统资源,或者只是更新DOM去关闭“处理中”的加载图标。我们希望无论我们的工作是否成功完成都能执行它,所以它会出现在finally代码块中。

那么在生成器中会是怎么样的呢?

1
2
3
4
5
6
7
8
9
10
11
12
function* produceValues() {
  setup();
  try {
    // ... yield some values ...
  } finally {
    cleanup();
  }
}

for ( var value of produceValues()) {
  work(value);
}

这看起来很好,但是这里有点小问题:work(value)的调用并不在try的代码块中,如果有异常抛出,我们 cleanup 的时候会发生什么呢?

或者,当 for-of 循环中包含有 break 或者 return 语句时,cleanup的时候又会发生什么呢?

无论它怎么执行,你都可以控制它。

当我们谈到迭代器和for-of循环 时,我们说到迭代器的接口里有一个可选的 .return()的方法,当每次迭代完成之前它都会被自动调用。生成器也支持些方法。调用myGenerator.return()方法,会最终运行任意的finally的代码块,然后退出,就像是当前yield当作为return的返回语句一样。

注意,.return()并不会被JS的所有内容所调用,只有当JS使用到了迭代器的协议时才会。所以,一个生成器在没有运行 finally 代码块时有可能会被垃圾回收掉。

为什么这特性没有在剧本中体现呢?当一个任务正在依赖其它步骤完成的时候,生成器会被冻结,就像建造一幢大楼一样。突然,某人抛出了一个错误!for循环会抓取到它,并把它放到一边,并告诉生成器调用 .return()方法。生成器会安静地拆除整个建筑架子并关闭它。然后,for循环会获取到错误,一个普通的异常会继续处理中。

生成器管理

目前,我们看到了生成器与其用户之间的会话的其中一方面,先打断一下之前的剧场类比:

img

用户是主管,生成器会按照要求工作。但是,这并不是生成器的唯一编程方式。

在第一部分的文章中,我说过,生成器可以用于异步编程,也就是你可以用生成器来替代 异步的返回函数或者 promise 链。你可能会对它如何支持工作感到迷惑,为什么yield的能力那么有效(毕竟这是生成器特殊的能力)?毕竟,异步代码不仅是yield而已,它会触发更多的事情。然后,它返回到循环的事件中,等待处理那些异步进程,并完成它们。生成器是怎么做到这些的?没有返回函数,当数据 从文件、数据库和服务器中 返回时,生成器是怎么接收的呢?

为了接近答案并开始工作,如果我们通过仅能调用的.next()方法传递参数来给生成器,通过这交易,我们可以看到整个新类型的会话:

img

生成器的.next()方法实际上可以接收一个可选的参数,同时,它会聪明地将这参数作为value值返回到生成器中的yield的表达式中。是的,yield 并不类似于 return,这包含于个value值,生成器会获取一次。

1
var results = yield getDataAndLatte(request.areaCode);

上面一行简单的代码做了很多的事情:

  • 它会调用 getDataAndLatter(),此函数会返回一个我们在屏幕中看到的字符串“get me the database records for area code … “。
  • 它会暂停生成器,yield 这字符串值
  • 这时,许多时间会过去
  • 最终,某人调用.next({data: ..., coffee: ...})。我们存储相应的对象到本地的变量results中,并继续下一行代码。

为了展示其内容,下面的代码显示了的整个会话:

1
2
3
4
5
6
function* handle(request){
  var results = yield getDataAndLatter(request.areaCode);
  results.coffee.drink();
  var target = mostUrgentRecord(results.data);
  yield updateStatus(target.id, "ready");
}

注意,yield 仍然是之前的意思:暂停生成器并传递个value值给调用者。但是,事情发生了变化。生成器希望从其调用者中得到具体的支持性的行为,这意味着其希望调用者扮演着一个管理助手。

普通的函数并不会这样。它们倾向于服务它们调用者的需求。但是,你可以为生成器指定会话的代码,从而加大生成器与其调用者之间的联系。

管理助手式的生成器运行会是什么样?它并不是个复杂的事情,它可能看起来如:

1
2
3
4
5
6
7
8
9
10
11
12
function runGeneratorOnce(g, result) {
  var status = g.next(result);
  if (status.done) {
    return; // phew!
  }

  // The generator has asked us to fetch something and
  // call it back when we're done.
  doAsynchronousWorkIncludingEspressMachineOperations(
    status.value,
    (error, nextResult) => runGeneratorOnece(g, nextResult));
}

为了保证运行,我们不得不创建一个生成器并运行一次,类似:

1
runGeneratorOnce(handle(request), undefined);

在五月份,我提到Q.async()就是个例子,这库中的生成器会被看作为异步进程,并在需要时自动运行。runGeneratorOnce就是做这种事。实际上,生成器不会并不会处理其yield的值,这应该由其调用者来做。生成器只会yield Promise对象。

如果你已经懂得promise,现在又理解了生成器,你可以会想试着修改runGeneratorOnce来支持promise。这会是个困难的实践,但只要你做成了,你将可以直接使用promise来编写复杂的异步逻辑的算法,而不是.then()或者一个返回函数了。

怎么扩展生成器

你是否看到runGeneratorOnce是怎么处理错误的?它忽略了他们。

好的,这并不是好主意。我们总得需要生成器报告错误出来,生成器其实是支持的:你可以调用generator.throw(error)而不用generator.next(result)。这会导致yield的表达式会被抛出,就如.return(),生成器会被杀死。但是,如果当前yield的地方在一个try代码块中,然后then和finally代码块会被尊重,生成器会继续恢复运行。

修改runGeneratorOnce()并适应地使用.throw()是另外一个好的实践,这样可以保证异常在生成器内部抛出,并传播给调用者。所以,generator.throw(error)会正常抛出 error 错误,除非你在生成器中获取了它。

当一生成器遇到yield表达式并停止时,可能会有的操作:

  • 某些人会调用generator.next(value),这时,生成器会从上次结束执行的地方再次恢复执行。
  • 某些人会调用generator.return(),传递的值是可选的。这情况,无论生成器在做什么,将不会恢复执行,而只会执行 finally代码块。
  • 某些人会调用generator.throw(error),这生成器就会表现如yield表达式调用一个函数并返回错误一样。
  • 或者,没有人做任何事情。生成器会永远处理冻结状态。(是的,这可能会导致一个生成器进入try代码块之后,而不会去执行finally代码块。当生成器处于此状态时,它甚至可以从垃圾回收器那里重新恢复使用。)

一起工作的生成器

让我多展示一个特性,我们可以编写一个简单的生成器函数,作用是连接两个迭代对象集:

1
2
3
4
5
6
7
8
function* concat(iter1, iter2) {
  for (var value of iter1) {
    yield value;
  }
  for (var value of iter2) {
    yield value;
  }
}

ES6还支持缩写的方法:

1
2
3
4
function* concat(iter1, iter2) {
  yield* iter1;
  yield* iter2;
}

一个普通的yield表达式会 yield 一个简单值,一个yield*表达式会消费整个迭代器并yield所有的值。

这个语法还解决另外一个有趣的问题:在一个生成器内部应该怎么调用生成器的问题。在普通的函数中,我们可以从一个函数中抽取一部分的代码,并重构成为一个单独的函数,而不会修改其行为。很明显,我们也将会重构生成器,但是我们需要找出一个方法来调用分解出去的子程序,同时需要确认在每次原有yield值之前,处理好子程序的yield操作,尽管那子程序也会生产出值。yield*就是处理这情况的方式。

1
2
3
4
5
6
7
function* factoredOutChunkOfCode() { ... }

function* refactoredFunction() {
  ...
  yield* factoredOutChunkOfCode();
  ...
}

可以类比想象一下,一个管道程序将部分子任务授予给其它进行操作。当编写大型的基于生成器的项目时,需要保持代码的清晰和易管理,你可以看到这方法是十分重要的,就类似函数在同步代码中的重要一般。

退场

好了,这就是生成器。我希望你也能够和我一样喜欢它,很高兴回过头再次提到它。

下周,我们将讨论另外让人脑洞大开的特性,也是ES6中新的特性。这是新类型,它是微小又狡猾的,在你还没有在这里了解它时可能你已经使用到它了。请在下周加入我们,一起开始深入ES6代理

ES6 in Depth: Collections

在本周早些时候,ES6的规格说明文档,官方名称为ECMA-262, 6th Edition, ECMAScript 2015 Language Specification,已经解决所有问题并得到了Ecma标准委员会的认可,感谢 TC39 及每一位为此做出贡献的人。庆祝ES6。

更为令人兴奋的消息:下一版本将不超过六年的时间。标准委员会现在的目标是在每十二月出一个粗略的版本,第七版本的计划已经在开发中。

为了庆祝这一事件,我将适宜地讨论些特性,这特性是我很长时间内、一直渴望在JS看到的。同时,我认为它仍然在将来存在着提升的空间。

JS进化的难点

JS并不像其它编程语言,它在有些方面影响着JS语言的发展。

ES6 模块是一个好的例子。其它语言都有模块的系统,Racket有个好的,Python也有。当标准委员会决定为ES6加入模块时,为什么不直接复制现有的系统呢?

JS是不一样的,因为它运行于网页浏览器中,输入输出(I/O)可能会需要很长的时间。因此,JS需要一个的模块系统是能够支持异步加载代码的,同时它也不同连续地在不同的目录中检索模块。复制现有的系统是好的,但ES6的模块需要做些新的东西。

这是怎么影响到最终的设计的呢?这是个有意思的故事。但是,我们并不在这文章中讨论模块。

这文章讨论的是ES6的Keyed collections: Set、Map、WeakSet、WeakMap。这特性在很多程度上说类似于其它语言的Hash Table。但是,标准委员会做了些折中的方法,因为JS是不同的。

为什么集合

每个人都很熟知JS中已经内置了类似于哈希表(hash table)的结构:对象(object)。

毕竟,一个普通的对象并没有什么奇特的,只是一个打开的key-value的键值对。你可以获取、设置,还有删除属性,还可迭代它们 – 所有哈希表能做的事情。但为什么还要加这个新的特性呢?

好了,许多程序使用普通的对象存储k/v的键值对,对程序来说这是可行的,所以没有特殊的原因去转换到Map或者Set中。同时,使用对象存在着此众所周知的问题:

  • 对象在查找表时并不会查找对象中的方法,这可以避免些冲突
  • 因此,程序必须使用Object.create(null)(而不是普通的{})或者编码时特别小心,从而避免无意的内置方法(如Object.prototype.toString)被当成数据。
  • 属性的关键字永远是字符串,在ES6中也可以是标志符(symbol)。对象不能当作是关键字。
  • 没有有效的方法查询一个对象拥有多少个属性。

ES6添加个新点:普通的对象并不作为迭代器,所以它不能使用for-of循环、...操作符,等等。

再次,大多数程序并不会有什么问题,普通的对象将是个继续可用的方案。MapSet可作为其它的选择。

因为它们被设计为 避免用户数据和内置方法的冲突。ES6的集合不会用属性暴露它们的数据,这意味着表达式如obj.key或者obj[key]并不能访问哈希表数据,你将不得不编写map.get(key)。哈希表的实体,并不像属性,它并不会继承原型链。

这样的优势,它不像Objects,MapSet可以拥有方法,它可以在标准库中或者你自己的子类中可以添加更多的方法,并不会冲突。

Set

Set 是值的集合,它是可变的,所以你的程序可以在任意时候添加或删除值。目前,它就像是数组。但是,就像相同点,set和数组中也有许多不同点。

首先,不像数组,Set不会包括两个相同的值。如果你试图添加一个已经存在的值,并不会影响到什么。

1
2
3
4
5
6
7
> var desserts = new Set("🍪🍦🍧🍩");
> desserts.size
    4
> desserts.add("🍪");
    Set [ "🍪", "🍦", "🍧", "🍩" ]
> desserts.size
    4

这例子使用的是字符串,但是Set可以包含任意JS的值。类似字符串,多次添加同一对象或者数值也是没有影响的。

其次,Set 有自己的数据管理,可以使得其一的操作更加快速:

1
2
3
4
5
6
> // Check whether "zythum" is a word.
> arrayOfWords.indexOf("zythum") !== -1 // slow
    true

> setOfWords.has("zythum") // fast
    true

你不能通过index索引访问:

1
2
3
4
> arrayOfWords[15000]
    "anapanapa"
> setOfWords[15000] // sets don't support indexing
    undefined

以下是Set的操作:

  • new Set创建一个新的、空的Set。
  • new Set(iterable)可以创建一个新的set,并将迭代器的值放到set中。
  • set.size 得到set的值的数量
  • set.has(value)如果set中包含有给定的值,它返回true
  • set.add(value) 添加一个值到set中。如果set中已经存在这个值,就不会有作用。
  • set.delete(value) 从set中移除一个值。如果这个值并不在set中,不会有作用。.add().delete()都会返回set的本身,所以你可以链式地使用它们。
  • set[Symbol.iterator]()会返回一个包含set的值的迭代器,你通常不用显示地调用,但是它是通过此方法来实现set的迭代器的。这意味着你可以使用for (v of set){ ... },等等。
  • set.forEach(f)是最简单解释的代码,它就像是缩写:
1
2
for (let value of set)
   f(value, value, set);

这个方法类似于数组中的.forEach()方法。

  • set.clear() 删除set中的所有值。
  • set.keys()set.values()set.entires()返回相应的迭代器。它们可以适配于Map,所以我们将在下面提到Map。

众多特性,new Set(iterable)这个构建器是十分出众的,因为它操作着整个数据结构。你可以使用它来将数组转为一个set,用一行代码来消除重复的值。或者,传递一个生成器:它会完整的运行这个生成器并收集所有处理过的值放到一个set中。这构建器还可以让你复制一个已经存在的set。

上周我们承诺会提到ES6集合中的一些让人抱怨的地方,我将在这里提到。正如Set有各种的好处,但它同时也缺失部分方法,这些方法将会在以后工作增加:

  • 功能性的帮助方法已经在现在有数组中存在了,如 .map().filter().some().every()

  • 不作变化的 set1.union(set2)set1.intersection(set2)

  • 可以操作多个值的方法:set.addAll(iterable)set.removeAll(iterable)set.hasAll(iterable)

好消息是可以通过ES6的特性可以有效地实现这些方法。

Map

Map 是一个 key/value的键值对的集合,Map可以做:

  • new Map返回一个新的、空的map
  • new Map(pairs)创建一个新的map,并把存在的[key, value]键值对集合填充到这里面。键值对可以是一个已经存在的map对象,一个两个元素的数组,一个处理两个元素的数组的生成器,等等。
  • map.size返回map的实体数量。
  • map.has(key)测试是否包含有key,类似 key in obj
  • map.get(key)获取关联key的值,或者,如果没有这个实体,会返回 undefined,类似obje[key] = value
  • map.set(key, value)为 map 添加一个实体,包含key和value。如果已经存在有相同的key,旧实体会被覆盖,类似 obj[key] = value
  • map.delete(key)删除一个实体,类似delete obj[key]
  • map.clear()删除map的所有实体。
  • map[Symbol.iterator]()返回map中所有实体的迭代器,这迭代器的每个实体是一个[key, value]数组。
  • map.forEach(f)工作如:
1
2
for (let [key, value] of map)
    f(value, key, map);

这奇怪的参数是有顺序的,同样的,其类似于Array.prototype.forEach()

  • map.keys() 返回map的所有key的一个迭代器。
  • map.values() 返回map的所有值的一个迭代器。
  • map.entries() 返回map的所有实体的一个迭代器,就像map[Symbol.iterator]()。实际 上,这只是另一个名称而已。

那么有哪些不满意的呢?下面有些ES6没有的特性,但我觉得有用的:

  • 默认值的工厂,如 Python 中的 collections.defaultdict

  • Map.fromObject(obj)一个帮助的方法,可以将一个对象方便地写到map中,这样可以使用object-literal的方法。

好了。回到文章开始谈到的我们所关心的点,在浏览器中运行是怎么影响到这些JS特性的设计?这是我们开始所提到的。我有三个例子,这里讨论前两个。

JS是特殊的,部分一:哈希表没有哈希码?

这是一个有用的特性,然而ES6集合类并没有支持,至少目前我可以这么说。

比如,我们这里有 URL 对象的Set。

1
2
3
4
var urls = new Set;
urls.add(new URL(location.href)); // two URL objects.
urls.add(new URL(location.href)); // are the the same?
alert(urls.size); //2

这两个URL对象应该被认为是相等的,它们拥有相同的字段。但是,在JS中,这里的两个对象是不同的,这里也没有方法重写JS语言的相等比较方法。

其它语言则支持重写,在Java、Python、Ruby中,独立的类可以重写相等比较方法。在许多的实现机制中,独立的哈希表可以创建并使用不同的相等比较方法,C++就支持。

但是,这些所有的处理机制都是要求用户自己去实现哈希函数,和所有的暴露在外面的默认的哈希函数。委员会的选择是不暴露JS的哈希码,至少现在还没有。因为现在考虑的是数据的操作和安全,关注的而不是优于其它语言。

JS是特殊的,部分二:惊奇又可预测

你可能会认为从计算机出现的确定性的特为没有什么可以令人惊奇的,但是当我告知他们,Map和 Set 在迭代时会按照它们的插入顺序来访问实体,他们都觉得很惊奇,而且这特性是确定的。

我们习惯于哈希表是不确定的,我们学习并接受了这点。但是,我们有理由来避免这一点,就如我在2012年写道的:

  • 首先,有很多证据证明一些编程人员对不确定的迭代顺序感到怪异或者困惑。1 2 3 4 5 6

  • ECMAScript并没有对枚举的属性进行详细的说明,同时为了网络上的兼容性,所有主流的实现是强制为插入的顺序。但是,某些比如TC39并没有明确迭代的顺序,“对我们来说,网络会逐渐具体的”7

  • 哈希表的迭代顺序会暴露些对象哈希码,这会导致哈希实现函数的部分怪异的安全问题。比如,当一个对象暴露其哈希码时,其寻址方式必须是不可恢复的。(当展示对象的地址是由非信任的ECMAScript代码操作的,而不是ECMAScript主动操作的,这会是一个坏坏的安全Bug。)

所有的这些在2012年二月已经被讨论过了,我当时是赞成迭代器可以任意顺序。然后,我根据经验展示了根据插入顺序进行排序的哈希表是有多慢,我用C++做了测试,结果令人惊讶–文章

所以,我们在JS中结束以插入顺序排序的哈希表。

使用弱集合是有决定性原因的

上周,我们讨论了例子涉及到JS的动画库,我们希望在每个DOM元素中存储一个布尔的标志,如:

1
2
3
4
if (element.isMoving) {
  smoothAnimations(element);
}
element.isMoving = true;

不幸的是,为DOM元素设置一个可读写的属性是个不好主意,在之前的文章已经讨论过了。

那文章展示了怎么使用标志符来解决这一问题,但是,我们难道不能使用Set来做同样的事情吗?它看起来会如:

1
2
3
4
if (movingSet.has(element)) {
  smoothAnimations(element);
}
movingSet.add(element);

这样只有一个缺点:Map 和 Set 对象会在它们中的每个key和value中建立强关联。这意味着,如果文档中的一个DOM元素被移除时,垃圾回收器并不能回收内存,而是需要movingset被移除才行。典型的类库包括复杂的功能,在最好的情况下,它会进行根据用户需求进行复杂的clean-up-after-yourself,但,这可能会导致内存的泄露。

ES6提供了一惊奇的方法来解决它。将movingSet作为WeakSet,而不是Set`,内存泄露的问题就公被解决。

对于这特殊的问题,可以使用一个弱的集合或者标志符来解决。但是,哪个会更好呢?所有的讨论都是个权衡的过程,很不幸,在这文章说的话会有些长。如果你可以在整个网页的生命周期中使用简单的标志符,那可能是好的。如果你只想结束些生命周期短的标志符,会有个危险的地方,这时,考虑使用 WeakMap 可能避免内存泄露。

WeakMap 和 WeakSet

WeakMap 和 WeakSet 具体操作实际上类似于 Map 和 Set,但有些限制:

  • WeakMap 仅支持 new.has()get().set().delete()
  • WeakSet 公支持 new.has()add().delete()
  • 存在在 WeakSet 中的值和 WeakMap 中的关键字必须是对象。

这些限制性的条件可以保证垃圾回收器正常地回收 弱集合 中的超期的死亡对象。这类似于你可以获取弱关联或者弱key的字典,但ES6的弱集合的内存管理并不是这样子,而是决定于GC的脚本。

JS是特殊的,部分三:隐藏GC操作是不确定性的

在上面的场景中,这些弱的集合的实现方式类似于蜉蝣表ephemeron tables

简单来说,一个WeakSet不会对其包含的对象保持着链接。当一个WeakSet中的对象被回收时,就会被简单从WeakSet中移除。WeakMap也类似这样子。它不会与其任意的关键字key建立强链接,只要key是活动的,这关联就是可用的。

为什么要接受这些限制?为什么不为JS增加一个弱链接?

再次,标准委员会还非常不情愿将不确定的行为暴露给外部脚本,较低的跨浏览器兼容性是网站开发的毒药。弱链接会暴露垃圾回收器底层的实现细节,也就是,特定平台的可确定的任意行为。应用不应该依赖于特定平台的细节,同时,弱链接也很难让你知道,你当前的在浏览器中的测试是怎么依赖于GC行为操作的。它们是很难推断的。

相反,ES6的弱集合有一堆受限的特性,并且这些特性是确定的。实际上,当一个Key或者value被回收是不会受到监视的,所以一个应用是否结束并不依赖于它,即使是出现事故。

这也是为了网站开发,从而致使的一个令人惊奇的设计决定,它可以让JS成为更好的语言。

什么时候可以使用集合

现在,所有的集合类都在 Firefox、Chrome、Microsoft Edge、Safari 得到支持,为了在旧的浏览器中使用,可以使用工具,如 es6-collections

WeakMap 在Firefox中刚开始是由Andreas Gal实现的,他后台做了Mozilla的CTO。Tom Schuster实现了WeakSet,我实现了 Map 和 Set。感谢 Tooru Fujisawa 对这些地方提供也补丁包。

下周,深入ES6系列文章会中断两周,可以戏称为暑假吧。这系列文章已经包含了ES6的很多特性,但是一些更为强大的特性即将要到来。所以,请在7月9号我们归来时,加入我们吧。

ES6 In Depth: Using ES6 today with Babel and Broccoli

ES6已经存在了,还有些人已经开始谈论 ES7了。新的标准中有哪些特性会被保留,又有哪些闪亮的特性提供?作为一位网络开发人员,我们会对怎么使用它产生疑问。不仅一次,在之前的 深入ES6系列 中,我们一直在鼓励你们开始使用 ES6 来编码,通过用些有意思的工具来实现,我们之前已经有提到这方法:

If you’d like to use this new syntax on the Web, you can use Babel or Google’s Traceur to translate your ES6 code to web-friendly ES5.

今天,我们将一步步地向你展示它是怎么做的。之前提到的工具都称为是 transpilers(transcompiler暂译为 转换编译器)。转换编译器是为人熟知的 资源到资源的编译器,这编译器在不同的编程语言之间进行抽象层面中的转换。转换编译器可以让我们编写ES6的代码,同时也保证代码能够在每个浏览器中运行。

转换编译器是我们的救赎

转换编译器十分易用,你可以按以下描述进行,只要两步:

  1. 我们用ES6语法编写代码
1
2
let q = 99;
let myVariable = `${q} bottles of beer on the wall, ${q} bottles of beer.`;
  1. 我们使用上面的代码作为转换编译器的输入,它会处理这代码,并产出下面的代码:
1
2
3
"use strict";
var q = 99;
var myVariable = "" + q + " bottles of beer on the wall, " + q + " bottles of beer."

我们知道这是旧JS的写法,它能运行于任意的浏览器。

转换编译器内部是怎么从输入到产生,这是是复杂的过程,也超出了这文章的范围。让我们就像开车,而不要去管其所有的内部机械引擎。今天,我们把转换编译器看作是一个黑盒子,来处理我们的代码。

Babel的作用

在项目中使用Bable有不几种不同的方法,首先是命令行工具,你可以通过下面的命令来实现:

1
babel script.js --out-file script-compiled.js

在浏览器中预置Babel的版本也是可用的,你可以将Babel作为常规的js库文件,然后你可以将ES6的代码中的script标签中的类型修改为”text/babel”。

1
2
3
4
\<script src="node_modules/babel-core/browser.js"></script>
\<script type="text/babel">
// Your ES6 code
</script>

但是,这方法不能扩展:你的代码增长时,你会将代码分离成多个文件和目录,这方法是不允许的。这时,你需要一个构建工具,通过管道式来整合Babel。

在下面的部分,我们将Babel整合到一个构建工具中,Broccoli.js,接着我们将通过几个例子来编写并执行我们的ES6代码。当你运行出现错误时,你可以在这里回头查看完整的资源代码:broccoli-babel-examples。在这资源库中,你将找到三个例子项目:

  1. es6-fruits
  2. es6-website
  3. es6-modules

每个会建立在之前的例子之上(没发现)。我们开始会代码最小化,并且会是个一般的解决方案,它可以胜任于为个伟大的项目打关阵。在这文章中,我们将详细地讨论前两个例子。在我们做完之后,你自己可以去阅读、去理解第三个例子中的代码。

如果你认为,你可以等待浏览器支持这些特性,你将会被甩到后面。完全的浏览器支持,如果这会实现,那它将是个漫长的过程。转换编译器已经在这里,新的ECMAScript标准也将计划在年内发布。所以 ,我们将继续、经常地看到新的标准发布出来,而这会先于统一的浏览器平台。跳起来吧,利用这些新的ES6特性吧。

我们的第一个Broccoli 和 Babel项目

Broccoli是个被设计为尽量快的速度来构建项目的工具。你可以压缩和最小化文件,通过这些插件 操作的使用,还有其它很多其它的功能。它帮助我们负担了一大堆的事情,包括处理文件、目录,还有当我们每次修改项目时需要执行的命令。可以把它当成为:

在一定范围上,它类似 Rails 的资源文件的管理,只是它运行于Node,也没有纯粹的后端。

开始项目

Node

正如你猜测一样,你将不得不安装 Node 0.11 或者更高版本。

如果你是在unix系统,避免使用包管理工具,如apt, yum。这样可以避免在安装过程中使用root权限。最好的方法是,从刚才提供的链接地址中,使用你当前的用户手动下载二进制文件。你可以在这里 看到为什么不建议使用 root 权限。你也可以在这里 选择其它的安装方式。

Broccoli

我们首先将建立我们的Broccoli的项目:

1
2
3
4
5
mkdir es6-fruits
cd es6-fruits
npm init
# Create an empty file called Brocfile.js
touch Brocfile.js

现在,我们安装 broccoli 和 broccoli-cli

1
2
3
4
# the broccoli library
npm install --save-dev broccoli
# command line tool
npm install -g broccoli-cli
编写些ES6代码

我们创建 src 目录,接着在里面编辑文件 fruits.js:

1
2
mkdir src
vim src/fruits.js

在我们的新文件中,我们使用 ES6 的语法编写些小的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
let fruites = [
  {id: 100, name: 'strawberry'},
  {id: 101, name: 'grapefruit'},
  {id;102, name: 'plum'}
];

for (let fruit fo fruits) {
  let message = `ID: ${fruit.id} Name: ${fruit.name}`;
  console.log(message);
}

console.lgo(`List total: ${fruits.length}`);

上面的代码使用到了 ES6 的三个特性:

  1. let,用于本作用域内的声明(在之前的文章中已经有讨论过)
  2. for-of 循环
  3. 模板字符串

保存文件,并试着执行它。

1
node src/fruits.js

它还不能运行,但我们希望它可以在Node和任意的浏览器中都能够执行。

1
2
3
let fruits = [
     ^^^^^
SyntaxError: Unexpected identifier
到转换代码的时间了

现在,我们将使用 Broccoli 来加载我们的代码,并通过Babel来处理。我们将编辑文件 Brocfile.js 并添加以下的代码:

1
2
3
4
5
6
7
// import the babel plugin
var babel = require('broccoli-babel-transpiler');

// grab the source and transpile it in 1 step
fruits = babel(src'); // src/*.js

module.export = fruits;

注意到,我们加载了 broccoli-babel-transpiler,这是个 Broccoli 的插件,它包括了 Babel 的库,所以我们必须安装它:

1
npm install --save-dev broccolli-babel-transpiler

现在我们可以构建我们的项目,并通过下面代码来执行脚本:

1
2
broccoli build dist # compile
node dist/fruits.js # execute ES5

这个输出会看到:

1
2
3
4
ID: 100 Name: strawberry
ID: 101 Name: grapefruit
ID: 102 Name: plum
List total: 3

这十分的简单!你可以打开 dist/fruits.js来看看,转换后的代码文件是什么样子。Babel转换编译器的其中一个好的特性就是可产生良好可读性的代码。

为网站编写ES6代码

在第二个例子中,我们将提升个等级。首先,退出 es6-fruits 目录,并参照之前的【项目开始】的步骤来创建一个新的目录 es6-website

在 src 的目录中,我们创建三个文件:

src/index.html

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
<!DOCTYPE html>
<html>
    <head>
      <title>ES6 Today</title>
    </head>
    <style>
      body {
        border: 2px solid #9a9a9a;
        border-radius: 10px;
        padding: 6px;
        font-family: monospace;
        text-align: center;
      }
      .color {
        padding: 1rem;
        color: #fff;
      }
    </style>
    <body>
      <h1>ES6 Today</h1>
      <div id="info"></div>
      <hr>
      <div id="content"></div>

      <script src="//code.jquery.com/jquery-2.1.4.min.js"></script>
      <script src="js/my-app.js"></script>
    </body>
</html>

src/print-info.js

1
2
3
4
5
6
7
function printInfo() {
  $('#info')
  .append('<p>minimal website example with' +
                'Broccoli and Babel</p>');
}

$(printInfo);

src/print-colors.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ES6 Generator
function* hexRange(start, stop, step) {
  for (var i = start; i < stop; i += step) {
    yield i;
  }
}

function printColors() {
  var content$ = $('#content');

  // contrived example
  for ( var hex of hexRange(900, 999, 10) ) {
    var newDiv = $('<div>')
          .attr('class', 'color')
          .css({ 'background-color': `#${hex}` })
          .append(`hex code: #${hex}`);
    content$.append(newDiv);
  }
}

$(printColors);

你应该注意到这一点:function* hexRange。是的,这是 ES6 的生成器。这一个特性并没有在所有的浏览器得到支持。为了能够使用它(生成器),我们需要个工具。Babel 可以提供,我们也很快地使用到它。

下一步是合并所有的JS文件,并在网站中使用它。这最难的部分是编写我们的Brocfile 文件。这时,我们安装四个插件:

1
2
3
4
npm install --save-dev broccoli-babel-transpiler
npm install --save-dev broccoli-funnel
npm install --save-dev broccoli-concat
npm install --save-dev broccoli-merge-trees

然后,使用它们 :

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
// Babel transpiler
var babel = require('broccoli-babel-transpiler');
// filter trees (subsets of files)
var funnel = require('broccoli-funnel');
// concatenate trees
var concat = require('broccoli-concat');
// merge trees
var mergeTrees = require('broccoli-merge-trees');


// Transpile the source files
var appJs = babel('src');


// Grab the polyfill file provided by the Babel library
var babelPath = require.resolve('broccoli-babel-transpiler');
babelPath = babelPath.replace(/\/index.js$/, '');
babelPath += '/node_modules/babel-core';
var browserPolyfill = funnel(babelPath, {
  files: ['browser-polyfill.js']
});

// Add the Babel polyfill to the tree of transpiled files
appJs = mergeTrees([browserPolyfill, appJs]);
// Concatenate all the JS files into a single file
appJs = concat(appJs, {
  // we specify a concatenation order
  inputFiles: ['browser-polyfill.js', '**/*.js'],
  outputFile: '/js/my-app.js'
});

// Grab the index file
var index = funnel('src', {files: ['index.html']});


// Grab all our trees and
// export them as a single and final tree
module.exports = mergeTrees([index, appJs]);

是时候构建并执行我们的代码了:

1
broccoli build dist

这里,你应该在dist目录中看到如下的结构:

1
2
3
4
5
$> tree dist/
dist/
|--index.html
|--js
|--|-- my-app.js

这是个静态的网站,你可以部署到任意的服务器中,检查这代码是可行的。例如,

1
2
3
cd dist/
python -m SimpleHTTPServer
# visit http://localhost:8000/

你可以看到:

es6 today

更多乐趣在 Babel 和 Broccoli 中

上面的第二个例子提供我们一方法来怎么使用Babel,从而完成项目。它应该可以让你理解一些时间了,如果你通过Babel、Broccoli更多地使用ES6,你可以查看一下这个项目:broccoli-babel-boilerplate。它也是用 Broccoli + Babel 开始的,它至少提高了两个点。这模板例子包括了 模块、引入、和单元测试。

你可以试试这里的配置例子:es6-modules,Brocfile 有很多魔法的地方,其类似与我们上面做的事件。

Comments

ES6 in Depth: Symbols

ES6 in Depth 是介绍 ES6(ECMAScript 标准的第6版本缩写) 的 JS 新特性的一系列文章。

备注:这文章是由 Coupofy team 的 JuliaDuong 编写,有越南语版本

什么是 ES6 的 symbols?

Symbols 并不是 Logos。

在你的代码中,你或许想到你可以使用些不小的图片:

1
let 😻 = 😺 × 😍; // SyntaxError

此处省略…

那么,什么是 Symbol呢?

第七种类型

自1997年的JS第一次标准化以来,它就包含有六种类型。直到 ES6,JS编程中的每个值都会在这些类别中。

  • Undefined 未定义
  • Null 空
  • Boolean 布尔
  • Number 数值
  • String 字符串
  • Object 对象

每个类型是其值的一个集合,前5个集合是有限的。其中,只有两个布尔值, true 和 false ,他们并没有增加新的布尔值。值 更多的有 数值 和 字符串。准确来说,有 18,437,736,874,454,810,627 不同的数值(包括 NaN,这数值的名称来自”Not a Number”的缩写)。相比字符串可能存在的数量, 我认为有 (2144,115,188,075,855,872 − 1) ÷ 65,535 – 尽管我有可能算错。

但是,对象值的集合是打开的。每个对象是唯一的,如珍贵的雪花。当你每次打开网页时,一堆的对象就会被创建。

ES6 symbols 也是值,但他们不是字符串,他们不是对象,他们是新的:第七种类型值。

让我们对随后来到的 ES6 symbol 的布置些场景。

一个简单、小的布尔

有时,它可以在JS对象中十分方便地存储一些临时的但实际属于其它的数据。

例如,你可以写一个JS库,用CSS的翻转(transitions)来让DOM元素在屏幕中收缩。你也注意到,当试图对一个div元素添加多个翻转时那是没有效果的。这显得很难受,时不时的”跳动“。你认为你可以修复这问题,但首先你需要找到一方法来判断这给定的元素是否已经移动了。

你怎么才能解决这问题?

一个方法是使用CSS的APIs 通过浏览器去查询这元素是否正在移动,但是这听起来很大刀。你的库应该已经知道这元素是正在移动的,那就是在你设置其移动的代码的第一个位置!

你可能希望的方法是实时跟踪移动的元素,你可以将所有移动的元素保存到一个数组中。当你库每次被调用来对一元素进行动画时,你可以查询一遍这个元素是否已经在数组中。

Hmm。如果这数组很大的话,这线性的检索是十分缓慢的。

你实际只是想做的是在元素的上面设置一个标识:

1
2
3
4
if (element.isMoving) {
  smoothAnimations(element);
}
element.isMoving = true;

这也有些潜在的问题,它们实际上有: 不仅仅是你的代码控制这些DOM。

  • 其它代码使用 for-in 或者 Object.keys()会使得你创建的属性出现困扰;
  • 某些其它明智库的作者可能已经考虑到了这一技术,然后这些已经存在的库会和你的库相互地作用,这是糟糕的。
  • 某些其它明智库的作者将来可能会想到这技术,然后你的库将来会出现冲突;
  • 标准委员会可能决定对所有的元素增加一个 .isMoving()方法,然后你真的湿身了;

好吧,你可以通过选择一个冗长而又笨拙的别人不用此来命名的字符串来解决后三个问题:

1
2
3
4
if (element.__$jorendorff_animation_library$PLEASE_DO_NOT_USE_THIS_PROPERTY$isMoving__) {
  smoothAnimations(element);
}
element.__$jorendorff_animation_library$PLEASE_DO_NOT_USE_THIS_PROPERTY$isMoving__ = true;

但这样子很费眼。

你实际上可以通过密码学的方法生成一个唯一的字符串来命名这属性:

1
2
3
4
5
6
7
// get 1024 Unicode characters of gibberish
var isMoving = SecureRandom.generateName();
...
if (element[isMoving]) {
  smoothAnimations(element);
}
element[isMoving] = true;

object[name]语法可以让你使用任意字符串来标识这个属性的名称,所以这代码是可用的:冲突几乎不可能出现,你的代码看起来很好。

但这将导致一个调试不好的体验,当你每次用console.log()来输出一个元素的这属性时,你将会看到大量的字符串堆在垃圾桶里。然后,如果你需要不仅仅是一个类似的属性时,那怎么办?你怎么保证它们是正常的?他们会在你每次重载时拥有不同的名称。

为什么会如此的艰难?我们只需要一个小小的布尔值而已!

Symbol(标志符) 是答案

程序可以创建标志符,来作为属性的关键字,而不会引起命名冲突:

1
var mySymbol = Symbol();

调用Symbol()可创建一个新的标志符,其值不会与其它任何值相等。

就像一个字符串或者数值,你可以使用标志符来作为属性的key,因为它不会与其它的值相等,所以使用标志符作为属性的key不会与其它属性有冲突。

1
2
obj[mySymbol] = "ok!"; // guaranteed not to collide
console.log(obj[mySymbol]); // ok!

这里,你可以用标志符来解决之前的问题:

1
2
3
4
5
6
7
8
// create a unique symbol
var isMoving = Symbol("isMoving");
...

if (element[isMoving]) {
  smoothAnimations(element);
}
element[isMoving] = true;

这代码有些要注意的点:

  • Symbol("isMoving")中的字符串”isMoving”表示为一个描述信息,这可用于调试。

  • element[isMoving]称为一个 symbol-keyed 属性,它是一个用标志符而不是字符串来命名的属性。除此之外,它是一个正常的属性(没什么区别)。

  • 类似数组,symbol-keyed 属性不能通过 点的语法obj.name来访问,必须使用中括号来访问。

  • 如果你熟悉了标志符,你可以直接地使用 symbol-keyed 属性。之前的例子中已经展示怎么获取和设置element[isMoving],如果我们需要,我们还可以使用 if (isMoving in element)或者甚至是delete element[isMoving]

  • 另外,只要isMoving在作用域中以上的操作都是可行的,这使得标志符拥有个弱封装的机制:一个模块内创建一些标志符,则可用于其所有的对象中,不用担心其他代码的属性创建而引起冲突。

因为标志符在设计时就是要避免冲突的,JS多数对象注入的特性忽略标志符的key的。例如,一个for-in的循环,只有循环会对对象的非字符串key起作用。标志符的key是被跳过的,Object.key(obj)ObjectgetOwnPropertyNames(obj)也是一样的。但是,标志符实际上并不是私有的:它可以使用新的API Object.getOwnPropertySymbos(obj)来列出一个对象的所以标志符的key。另外新的API,Reflect.ownKeys(obj),返回包括字符串和标志符的keys。(我们将会在即将到来的文章中全方位地讨论Reflect API。)

库和框架将会挖掘出许多的标志符的用法,然后,我们将会看到JS把标志符应用于更大的范围和目标中。

但是,标志符实际上是什么

1
2
> typeof Symbol()
"symbol"

标志符并不同其它东西。

它们在创建后就是不可变的。你不能为它们设置属性(如果你在严格模式中,你将会得到一个TypeError)。它们可以当作属性的名称,效果类似于字符串的key。

另外,每个标志符是唯一的,区别于其它标志符(即使其它的标志符拥有相同的描述信息),你可以轻松地创建新的标志符,这类似于对象。

ES6的标志符类似于其它语言的更为传统标志符 ,如Lisp和Ruby,但是并非完全接近这些语言。在Lisp中,所有的身份标识都是标志符。在JS,身份标识和多数属性的key经过考虑之后,依然定为是字符串。标志符只是额外的选项。

标志符的有个注意的地方:并不像其它语言一般,JS中的标志符不能自动转为字符串。试图将一个标志符和字符串拼接时,将得到得到 TypeError。

1
2
3
4
5
> var sym = Symbol("<3");
> "your symbol is " + sym
// TypeError: can't convert symbol to string
> `your symbol is ${sym}`
// TypeError: can't convert symbol to string

你可以通过精确地将标志符转为字符串来避免这一问题,编写方式为String(sym)或者sym.toString()

三个标志符的点

这里有三种方式来获取一个标志标志符。

  • 调用 Symbol()。就如我们已经谈到的,它在调用会每次返回唯一的标志符。
  • 调用 Symbol.for(string) 。这会访问已经存在于标志符登记处(symbol registry)的标志符。不像Symbol()会得到唯一的标志符,在标志符登记处中的标志符是可分享的。如果你调用Symbol.for("cat")三十次时,你在每次得到的是同一个标志符。当多个页面、或者多个模块位于同一页面时,这登记处就十分有用的,因为它们需要分享标志符。

  • Symbol.iterator来使用标志符定义标准。标准库中只定义了部分标志符,但每个都是有其目的的。

如果你依然没有确定标志符是怎么有作用的,最后一项就有意义了,因为它展示了 标志符在应用中得到证明是有用的。

ES6是怎么使用著名的标志符

我们已经看到在 ES6 中使用标志符可以避免与现有的代码产生冲突。几周前,在 迭代器的文章 中,我们看到循环for (var item of myArray)在开始时调用myArray[Symbol.iterator]()。我提到,这方法会在myArray.iterator()时调用到,因为标志符可以更好地向后兼容。

现在,我们知道了标志符的所有信息,很容易理解它是为什么和是什么。

这里还指出其它地方是怎么使用ES6著名的标志符的。(这些特性未在Firefox实现。)

  • 扩展 instanceof。在ES6中,表达式objectinstanceofconstructor在构造器(constructor)里面有一指定方法:constructor[Symbol.hasInstance](object)。这就是扩展。

  • 消除新特性与旧代码之间的冲突。这意思非常模糊,但是我们已经确定的是,ES6的Array方法在现有的网站就是不能运行的。其它网络标准也有类似的问题:简单地添加一个方法都会导致现有的网站崩溃。但是,这些问题主要是由于动态作用域导致的,所以,ES6推介使用特殊的标志符。

  • 支持新类型的string-matching。在ES5中,str.match(myObject)会试图将 myObject 转化为一个 RegExp。在ES6中,它会首先检查 myObject 是否有一个方法 myObjectSymbol.match。现在的库可以提供字符串转化的类,来支持RegExp对象能工作的地方也能正常运行。

每个用法都有些限制,很难看到这些特性在我每天的代码中起作用,但是从长远来看是可行的。著名的标志符对PHP和Python中的__doubleUnderscores进行了升级。标准委员会在将来使用它们,来为JS添加新特性,而不会危险到你已经存在代码。

什么时候使用 ES6 的标志符

标志符已经在 Firefox36 和 Chrome38 实现了。我自己已经在 Firefox 实现了,所以当你如果将symbol当作是cymbal时,你应该知道应该问谁了。

为了在未原生支持ES6 标志符的浏览器中使用标志符,你可以使用工具,如 core.js 。因为标志符实际上并不像以前的语言中的一样,这工具并不十分完美。请查看忠告

下周,我们将有两篇新的文章。首先,我们将会谈到一些长期的特性,也是在ES6中出现的–但受到抱怨的。我们将会涉及到两个特性,它们甚至是可以追溯到编程的源头。我们将会继续两个类似的特性,但只是点到为止。所以,请在下周加入我们,让我们深入ES6的集合中。

另外,让人迷恋的由 Gaston Silva 编写的文章谈到的并不是ES6的特性,但是,它也许在你项目的使用ES6上有一定的推动作用。下次见。

ES6 in Depth: Arrow funtions

箭头是JS每个开始的一部分,JS的第一个指导说可以在 HTML 注释中内置脚本代码,这会导致浏览器错误地认为JS代码是文本,从而不能运行JS。你可能会编写代码如下:

1
2
3
4
5
\<script language="javascript"> // \ for md safe
  <!--
     document.bgColor = "brown"; // red
  // -->
</script>

旧的浏览器只会看到两个不支持的标签和一个注释,只有新的浏览器会看到 JS 代码。

为了支持这奇怪的代码,你浏览器中的 JS 引擎会将 <!-- 认为是单行注释的开始。没有开玩笑,这至始至终是JS语言的一部分,而且至今仍然是可用的。这不仅仅可以在内置的 <script>的顶部,还可以在 JS 的任何地方,它甚至在 Node 也是能工作的。

由此,ES6 的注释风格第一次的标准化,但这并不是我们这里所谈到的箭头。

序列箭头-->也表示一个单行的注释,奇怪的是,HTML认为在-->之前的部分是注释,而JS认为-->之后剩余部分是注释。

这确实很怪异。这箭头只有当其出现在行的开头时才会认为是注释。这是因为在其它的内容中,-->是一个JS的操作符,“去到”操作符。

1
2
3
4
5
function countdown(n) {
  while (n --> 0) // "n goes to zero"
     alert(n);
  blastoff();
}

这代码确实可以运行链接, 这个循环运行会从 n 到 0 。这也不是ES6的一个新特性,它其实是类似箭头的操作组合,这确实会有些误导。你能猜出指出是什么吗?和平常一样,这答案的迷云可以在 Stack Overflow 中得到。

好了,<=表示小于等于操作符,你可以找出JS代码中更多的这些箭头。细想一下有哪些箭头,但是现在先暂停一下,观察一下一个不认识的箭头:

1
2
3
4
<!-- single-line comment
--> goes to operator
<=  less than or equal to
=>  ???

=>会发现什么呢?今天,让我们研究一下。

首先,让我们稍微讨论一下 函数。

无处不在的函数表达式

JS中一个有趣的特性是可以在任何时候添加你所需要的函数,你只需要在运行代码的中部编写合适的函数。

例如,你可以试图告诉浏览器当用户点击具体的按钮时做什么,你会开始编写:

1
$("#confetti-btn").click(

jQuery 的 .click()方法需要一个参数:一个函数。没问题,你仅需要编写合适的函数,如下:

1
2
3
4
$("#confetti-btn").click(function (event) {
  playTrumpet();
  fireConfettiCannon();
});

现在的我们已经很自然地编写出这类似的代码了,细想一下,这写法在JS流行之前是显得奇怪的,很多的编程语言并不会有这一特性。在1958年,Lisp 有其函数表达式,它叫 lambda 函数。但是,C++、Python、C# 和 Java 没有函数也生存了很多年。

这情况不会再有了,这四种语言现在也有 lambda 函数。更新的语言普遍都会内置有 lambda 函数。我们 JS 也很感谢 —- 早期 JS 编程人员大胆地构建大量的 lambda 库,从而使得这特性广泛地被使用。

稍微有些伤心的是,在我提到过的所有编程语言中,JS的lambda语法证明是最为冗长的。

1
2
3
4
5
6
7
// A very simple function in six languages.
function (a){ return a > 0; } //JS
[](int a) { return a > 0; } // C++
(lambda (a) (> a 0))  ;; Lisp
lambda a: a > 0  # Python
a => a > 0 // C#
a -> a > 0 // Java

你箭袋中的新箭头

ES6 介绍编写函数的新的语法:

1
2
3
4
5
6
7
// ES5
var selected = allJobs.filter(function (job) {
  return job.isSelected();
});

// ES6
var selected = allJobs.filter(job => job.isSelected());

当你仅是需要个简单的一个参数的函数,这新的箭头函数语法只需要简单地 Identifier => Expression。你可以不用编写 functionreturn这些关键字,同时包括 括号、大括号、分号。

(我个人是非常喜欢这个特性的,没有限定键入function对我来说十分的重要,因为我时不时会键入functoin,从而不得不回头去更正它。)

为了编写多参数的函数(或者没有参数、剩余和默认参数、或解构参数),你将需要添加括号和参数列表。

1
2
3
4
5
6
7
// ES5
var total = values.reduce(function (a, b) {
  return a + b;
}, 0);

// ES6
var total = values.reduce((a, b) => a + b, 0);

我认为它看起来是十分好的。

箭头函数可以像某些库中的功能性工具(functional tool)一样漂亮地工作,如 Underscore.js 和 Immutable。实际上,Immutable的文档 中的例子都是用ES6来编写的,所以其中的一些已经使用上了 箭头函数。

那么,对于非功能性的设置该怎么办呢?箭头函数 可以包含着一个代码块,而不仅是一个表达式。回顾一下我们之前的例子:

1
2
3
4
5
// ES5
$("#confetti-btn").click(function (event) {
  playTrumpet();
  fireConfettiCannon();
});

在ES6中如下:

1
2
3
4
5
// ES6
$("#confetti-btn").click(event => {
  playTrumpet();
  fireConfettiCannon();
});

一个小提升,在使用 Promises 时,这作用会更加有魔法,它可以将类如 }).then( function (result) {的行堆起来。

注意,代码块的箭头函数并不会自动返回一个值,所以需要 return 进行返回值操作。

当使用箭头函数来创建普通对象时,需要用括号将对象括起来:

1
2
3
// create a new empty object for each puppy to play with
var chewToys = puppies.map(puppy => {});  // BUG!
var chewToys = puppies.map(puppy => ({})); // ok

不幸的是,一个空对象和一个空的代码块实际上是一样的。在 ES6 中,箭头后面的 { 会总是被认为是 代码块的开始标识,而不是对象的开始。因此,代码 puppy => {}会被编译为一个箭头函数,并不会做什么,返回值为 undefined

更多的困扰,一个对象从字面中如 {key: value} 至少看起来像一包含了 标签语句的代码块,但这要看你的JS引擎怎么识别它。幸运的是,{ 是唯一的歧义的字符,所以使用括号来包含对象 是 唯一 你需要谨记的怪点。

this 是什么

普通的 function函数和箭头函数有些微小的差别。箭头函数没有它们自有的 this 的值。箭头函数中的 this 总是来源于包含此箭头函数的作用域。

在我们试图在实践中指出上面意味着什么之前,让我们稍微后退一下。

JS 中的 this 是怎么工作的?它的值来源于哪里?这并没有简短的答案。如果你头脑认为它很简单,是因为你已经处理它很长时间了。

引起 this 这提问的原因是 function 函数会自动接收一个 this的值,无论他们是否想要它。你是否编写过这黑代码:

1
2
3
4
5
6
7
8
9
10
{
  ...
  addAll: function addAll(pieces) {
    var self = this;
    _.each(pieces, function (piece) {
      self.add(piece);
    });
  },
  ...
}

这里,你可能会喜欢在内部的函数中编写 this.add(piece)。但是不幸,内部函数并不会继承外部函数的this值。在内部函数中,this将可能会是window或者undefined。临时变量self作用是将外部函数中的this传递到内部函数中。(另外的方法是在内部使用 .bind(this),但是两种方法都不是很漂亮。)

在 ES6 中,如果你按照以下规则使用,这种this的黑代码将会消失:

  • 当调用非箭头函数的方法时,使用 object.method()语法进行调用,如此这函数会从调用者中接收到一个有意义的this的值。
  • 在任何地方都使用箭头函数
1
2
3
4
5
6
7
8
// ES6 with method syntax
{
  ...
  addAll(pieces) {
    _.each(pieces, piece => this.add(piece));
  },
  ...
}

通过 方法箭头,我不再会编写 functoin了,这真是个很好的想法。

箭头和非箭头函数还有一个细小的差别:箭头函数并不会获取他们自己的 arguments对象。同时,在ES6中,你最好在任何的地方都使用剩余和默认参数。

使用箭头可穿透黑科技的核心

我们已经谈到很多箭头函数的实践应用,我还想提到一个可能会用到的点:把 ES6 中的箭头函数作为一个学习的工具,去挖掘计算领域深层次的东西。无论其是否实用,这都取决于你自己。

在1936年,Alonzo Church 和 Alan Turing 各自独立开发了强大的的数学计算模型。Turing 称之为 a-machines,但是大家从开始都称为 图灵机器(Turing machines)。Church 编写的是想取代函数,他的模型称为 λ-calculus。 (λ 是希腊语中的lambda的小写。) 这就是Lisp使用关键字 LAMBDA来声明函数的原因,这也是为什么我们今天称函数为 “lambda”的原因。

但是,什么是 λ-calculus ? 计算模型(model of computation) 表示什么呢?

简短的文字是很难解释的,但是这里是我的理解:λ-calculus 是第一种编程语言之一。虽然它并不是被设计为一个编程语言,随后的一到二十年存储程序的计算机也随之出现。但是,它作为一个非常简单、纯粹的数学语言,可以展示出你想要的各种计算。Church 希望这个模型能广泛地用来证明计算。

然后,他发现仅需要在他的系统中有:函数(functions)。

想象一下这种声明是怎么特别的:没有对象、数组,没有数值,没有if语句、while循环,没有分号、分配符号、逻辑操作符,或者任意的循环,白手起家,只使用函数能够做到 JS 做到的任何类型的计算操作。

下面是个数学算法的程序,使用了 Church λ 的符号:

1
fix = λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))

它等价于JS中的

1
2
var fix = f =>  (x => f(v => x(x)(v)))
                       (x => f(v => x(x)(v)));

如此,JS就包含了 λ-calculus (lambda积分)的实现,并且是能够运行。The λ-calculus is in JavaScript

关于 Alonzo Church 和之后的研究人员对 λ-calculus 做什么的故事,还有其是怎么潜移默化地影响着每个主流编程语言,这已经超出了这篇文章讨论的范围了。但是,如果你对基础计算科感兴趣,或者你仅仅是想看到一编程语言没有其它、只有函数是怎么做到循环和递归的,你可能要努力并花费些下雨的下午去查看 Church numeralsfixed-point combinators, 然后将代码运行于 Firefox 的控制台或者 Scratchpad 。由于 ES6 的箭头 拥有其它力量,JS可以光明地声称它是探索 λ-calculus 的最好编程语言。

什么时候使用箭头(箭头函数)

ES6 中的箭头函数在Firefox是由我还实现的,于2013年。Jan de Mooij 的工作是其更为快速,同时感谢 Tooru Fujisawa 和 ziyunfei 的补丁。

箭头函数也在微软的Edge浏览器的预览版本中支持了,它们也可以在 Babel , Traceur 和 TypeScript 中使用了,如此你现在可以使用它们(从而使用箭头函数)。

我们的下个主题是ES6中比较特殊的一个特性。我们可以看到typeof x会返回一个完成新的值。那么我们会问:什么时候返回的不是string?我们将会努力寻找其相等的含义,确实有些怪异。那么,请于下周加入我们,继续深入 ES6 吧。

ES6 in Depth: Destructuring

编辑者说明:今天文章的早期版本是由Firefox工具开发工程师 Nick Fitzgerald 完成的,其起初来自 Nick 的博客 (Destructuring Assignment in ES6)[http://fitzgeraldnick.com/weblog/50/]

什么是解构的赋值

解构的赋值允许你给数组或者对象的变量赋值时,其语法类似于数组或者对象。这种语法非常简洁,其表达的意思也比传统的属性赋值要易懂。

没有解构的赋值,你可能访问一个三元素的数组时会如下:

1
2
3
var first = someArray[0];
var second = someArray[1];
var third = someArray[2];

通过解构的赋值,与其等价的代码,而且更为精确和可读:

1
var [first, second, third] = someArray;

SpiderMonky作为Firefox的JS的解释器,已经支持大部分的解构,但是并不是所有。(Track SpiderMonkey’s destructuring (and general ES6) support in bug 694100)[https://bugzilla.mozilla.org/show_bug.cgi?id=694100]。

数组和迭代的解构

我们已经在上面看到解构的赋值,其通用的语法如下:

1
[ variable1, variable2, ..., variableN ] = array;

这会将 array 中的元素对应地分配到 variable1 到 variableN。如果你想同时声明变量,你可以在分配的前面增加varletconst

1
2
3
var [ variable1, variable2, ..., variableN ] = array;
let [ variable1, variable2, ..., variableN ] = array;
const [ variable1, variable2, ..., variableN ] = array;

实际上,用variable并不合适,因为你可以用你想的多深的内嵌模式(来分配值),如:

1
2
3
4
5
6
7
var [foo, [[bar], baz]] = [1, [[2], 3]];
console.log(foo);
// 1
console.log(bar);
// 2
console.log(baz);
// 3

再者,你可以用解构来跳过数组的部分元素:

1
2
3
var [,,third] = ["foo", "bar", "baz"];
console.log(third);
// "baz"

你还可以通过 “剩余”的模式来抓取数组中的尾部的元素:

1
2
3
var [head, ...tail] = [1, 2, 3, 4];
console.log(tail);
// [2, 3, 4]

当你访问数组中超边界或者不存在的元素时,你会得到通过索引返回的值:undefined.

1
2
3
4
5
6
console.log([][0]);
// undefined

var [missing] = [];
console.log(missing);
// undefined

注意,利用数组的分配模式的解构分配方式也适用于迭代中:

1
2
3
4
5
6
7
8
9
10
11
12
function* fibs() {
  var a = 0;
  var b = 1;
  while (true) {
     yield a;
     [a, b] = [b, a + b];
  }
}

var [first, second, third, fourth, fifth, sixth] = fibs();
console.log(sixth);
// 5

解构对象

对对象解构可以让你将对象的不同属性绑定到变量中。当你指定要绑定的属性时,相关你绑定的变量的值就等于其属性值。

1
2
3
4
5
6
7
8
9
10
var robotA = { name: "Benber" };
var robotB = { name: "Flexo" };

var { name: nameA } = robotA;
var { name: nameB } = robotB;

console.log(nameA);
// "Benber"
console.log(nameB);
// "Flexo"

还有个有益的简单语法,当属性和变量的名称一致时:

1
2
3
4
5
var { foo, bar } = { foo: "lorem", bar: "ipsum" };
console.log(foo);
// "lorem"
console.log(bar);
// "ipsum"

类似数组的解构,你可以解构更多的内嵌和组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
var complicatedObj = {
  arrayProp: [
     "Zapp",
     { second: "Brannigan" }
  ]
};

var { arrayProp: [first, { second }] } = complicatedObj;

console.log(first);
// "Zapp"
console.log(second);
// "Brannigan"

当你解构的属性并没有定义时,你会得到 undefined

1
2
3
var { missing } = {};
console.log(missing);
// undefined

一个潜在的问题你应该了解,当你解构一个对象并分配到变量中时,但是你并没有声明它们(就是没有 let, const, 或者 var ):

1
2
{ blowUp } = { blowUp: 10};
// Syntax error

之所以会发生这事,因为JS语法告诉解释引擎将{开头的表达式认为是一个块表达式(例如,{ console }就是个合法的块表达式)。解决方案还可以是将整个表达式用括号括起来:

1
2
({ safe } = {});
// No errors

解构的值不是对象、数组或者迭代

当你对nullundefined的解构时,你会得到错误:

1
2
var {blowUp} = null;
// TypeError: null has no properties

但是,你可以解构其它原始的类型,如 布尔、数值、字符串,然后得到undefined

1
2
3
var {wtf} = NaN;
console.log(wtf);
// undefined

这结果有点意想不到,但是通过更多的测试可以其实其原因很简单。当我们使用对象的分配模式,其值要求可被强制解释为对象。多数类型可以转化为对象,但是null 和 undefined 不行。当使用数组分配模式时,值必须允许迭代。

默认值

当你解构的属性没有定义时,你可以提供默认值:

1
2
3
4
5
6
7
8
9
var [missing = true] = [];
console.log(missing);
// true

var { message: msg = "Something went wrong" } = {};
console.log(msg);
// "Something went wrong"
var { x = 3 } = {}; console.log(x);
// 3

(笔者注:前第一二个特性已经在Firefox中实现了,但第三个没有,详见 bug 932080

解构的应用实践

定义函数参数

作为开发人员,我们希望使用更加合理的API,使用一个多属性的对象作为参数,而不是强制我们的用户来记住多个独立的参数(来作为函数的参数)。当我们想要引用其属性值时,我们可以使用解构来避免重复传入对象的属性。

1
2
3
function removeBreakpoint({ url, line, column }) {
  // ...
}

这代码块来源于真实的代码,它来自Firefox的JS调试工具(也是JS的实现方式)。我们发现这模式令人很是愉快。

注册对象参数

扩展之前的例子,我们可以在解构时给予对象属性默认值。当我们存在的一个对象,这个对象是用来存储注册信息的,其部分属性已有合理的默认值时,解构就显得十分有好处了。例如,jQuery 的 ajax函数传递一个注册对象作为第二个参数,可以重写成这样:

1
2
3
4
5
6
7
8
9
10
11
jQuery.ajax = function (url, {
  async = true,
  beforeSend = noop,
  cache = true,
  complete = noop,
  crossDomain = false,
  global = true,
  // ... more config
}) {
  // ... do stuff
};

这避免对注册对象的每个属性重复地进行类如 var foo = config.foo || theDefaultFoo; 的情况。

(笔者注:不幸,在Firefox中,对象默认值的缩略语法并没有实现。我知道,我们已经有些段落已经进行了说明。详见 bug 932080 )

在ES6迭代协议中

ES6 定义了迭代的协议,我们已经在这系列文章的早期文章中有谈到。当你对 Map进行迭代时,你会得到一系列的 [key, value] 键值对。我们可能解构这一对结构,可很容易访问基 key 和 value:

1
2
3
4
5
6
7
8
9
var map = new Map();
map.set(window, "the global");
map.set(document, "the document");

for (var [key, value] of map) {
  console.log(key + " is " + value);
}
// "[object Window] is the global"
// "[object HTMLDocument] is the document"

迭代时仅要 key :

1
2
3
for ( var [key] of map) {
  // ...
}

或者迭代只要 value,

1
2
3
for (var [,value] of map) {
  // ...
}
多个返回值

尽管JS语言并没有完全地支持返回多个值,但是这并不必要,因为你可以返回一个数组并对结果进行解构:

1
2
3
4
function returnMultipleValues() {
  return [1, 2];
}
var [foo, bar] = returnMultipleValues();

另外,你可以使用一个对象来作为容器,并命名返回的值:

1
2
3
4
5
6
7
function returnMultipleValues() {
  return {
     foo: 1,
     bar: 2
  };
}
var { foo, bar } = returnMultipleValues();

其实,这两种模式更好的方式是将结果保存到临时容器中:

1
2
3
4
5
6
7
8
9
function returnMultipleValues() {
  return {
     foo: 1,
     bar: 2
  };
}
var temp = returnMultipleValues();
var foo = temp.foo;
var bar = temp.bar;

或者,可能使用延续传递的样式:

1
2
3
4
function returnMultipleValues(k) {
  k(1, 2);
}
returnMultipleValues((foo, bar) => ...);
导入CommonJS的模块名称

还没有使用 ES6 的模块?还依然使用 CommonJS模块?没问题!当导入某些 CommonJS 模块 X 时,而模块 X 导出比你想要的还要更多的函数。利用解构,你可以精确地使用你想要使用的模块,避免命名的混乱:

1
const { SourceMapConsumer, SourceNode } = require("source-map");

总结

所以,你可以看到解构在很多细节上的应用都很有用,在 Mozilla 中我们已经对其有很多经验。Lars Hansen 在十年之前介绍过 Opera 中的解构,接着 Brendan Eich 为 Firefox 也增加了支持。我们知道,解构如果能应用于此语言的每天工作中,会使得所有的你的代码看起来更加的简洁。

五周之前,我们说过,ES6会改变你编写JS的方式。我们在大脑中有一系列的具体特性,每段时间学习到的东西都能使自己有些提升。合并在一起,它们将会最终影响到你工作中的每个项目。方式的改革引导变革。

顺应地应用ES6的解构需要团队的努力。特别感谢 Tooru Fujisawa (arai) 和 Arpad Borsos (Swatinem) 作出的贡献。

对于解构的支持,Chrome的开发工作正在进行中,其它的浏览器也毫无疑问地在以后的某时会支持。现在,如果你想在Web中使用解构,那么你需要使用 BabelTraceur


再次感谢 Nick Fitzgerald 的文章。

下周,我们将谈到一个特性,它或多或少地使用更简单的方式来改变你已经编写的JS代码,这些代码通常自始自终是语言的底层代码。你会关心吗?更为简洁的语法你是否会兴奋呢?我确信答案是肯定的,但是先不要回答我。在下周加入我们并进行了解,我们将会深入 箭头函数(arrow function)。