1
0
mirror of https://github.com/ruanyf/es6tutorial.git synced 2025-05-24 18:32:22 +00:00
es6tutorial/docs/class.md
2015-07-02 08:43:53 +08:00

29 KiB
Raw Blame History

Class和Module

Class基本语法

1概述

JavaScript语言的传统方法是通过构造函数定义并生成新对象。下面是一个例子。

function Point(x,y){
  this.x = x;
  this.y = y;
}

Point.prototype.toString = function () {
  return '(' + this.x + ', ' + this.y + ')';
}

上面这种写法跟传统的面向对象语言比如C++和Java差异很大很容易让新学习这门语言的程序员感到困惑。

ES6提供了更接近传统语言的写法引入了Class这个概念作为对象的模板。通过class关键字可以定义类。基本上ES6的class可以看作只是一个语法糖它的绝大部分功能ES5都可以做到新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。上面的代码用ES6的“类”改写就是下面这样。

//定义类
class Point {

  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '('+this.x+', '+this.y+')';
  }

}

上面代码定义了一个“类”可以看到里面有一个constructor方法这就是构造方法而this关键字则代表实例对象。也就是说ES5的构造函数Point对应ES6的Point类的构造方法。

Point类除了构造方法还定义了一个toString方法。注意定义“类”的方法的时候前面不需要加上function这个保留字直接把函数定义放进去了就可以了。

ES6的类完全可以看作构造函数的另一种写法。

Class Point{
  // ...
}

typeof Point // "function"

上面代码表明,类的数据类型就是函数。

构造函数的prototype属性在ES6的“类”上面继续存在。事实上除了constructor方法以外类的方法都定义在类的prototype属性上面。

Class Point {
  constructor(){
    // ...
  }

  toString(){
    // ...
  }

  toValue(){
    // ...
  }
}

// 等同于

Point.prototype = {
  toString(){},
  toValue(){}
}

由于类的方法除constructor以外都定义在prototype对象上面所以类的新方法可以添加在prototype对象上面。Object.assign方法可以很方便地一次向类添加多个方法。

Class Point {
  constructor(){
    // ...
  }
}

Object.assign(Point.prototype, {
  toString(){},
  toValue(){}
})

prototype对象的constructor属性直接指向“类”的本身这与ES5的行为是一致的。

Point.prototype.constructor === Point // true

2constructor方法

constructor方法是类的默认方法通过new命令生成对象实例时自动调用该方法。一个类必须有constructor方法如果没有显式定义一个空的constructor方法会被默认添加。

constructor() {}

constructor方法默认返回实例对象即this完全可以指定返回另外一个对象。

class Foo {
  constructor() {
    return Object.create(null);
  }
}

new Foo() instanceof Foo
// false

上面代码中constructor函数返回一个全新的对象结果导致实例对象不是Foo类的实例。

3实例对象

生成实例对象的写法与ES5完全一样也是使用new命令。如果忘记加上new像函数那样调用Class将会报错。

// 报错
var point = Point(2, 3);

// 正确
var point = new Point(2, 3);

与ES5一样实例的属性除非显式定义在其本身即定义在this对象上否则都是定义在原型上即定义在class上

//定义类
class Point {

  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '('+this.x+', '+this.y+')';
  }

}

var point = new Point(2, 3);

point.toString() // (2, 3)

point.hasOwnProperty('x') // true
point.hasOwnProperty('y') // true
point.hasOwnProperty('toString') // false
point.__proto__.hasOwnProperty('toString') // true

上面代码中x和y都是实例对象point自身的属性因为定义在this变量上所以hasOwnProperty方法返回true而toString是原型对象的属性因为定义在Point类上所以hasOwnProperty方法返回false。这些都与ES5的行为保持一致。

var p1 = new Point(2,3);
var p2 = new Point(3,2);

p1.__proto__ === p2.__proto__
//true

上面代码中p1和p2都是Point的实例它们的原型都是Point所以__proto__属性是相等的。

这也意味着可以通过实例的__proto__属性为Class添加方法。

var p1 = new Point(2,3);
var p2 = new Point(3,2);

p1.__proto__.printName = function () { return 'Oops' };

p1.printName() // "Oops"
p2.printName() // "Oops"

var p3 = new Point(4,2);
p3.printName() // "Oops"

上面代码在p1的原型上添加了一个printName方法由于p1的原型就是p2的原型因此p2也可以调用这个方法。而且此后新建的实例p3也可以调用这个方法。这意味着使用实例的__proto__属性改写原型必须相当谨慎不推荐使用因为这会改变Class的原始定义影响到所有实例。

4name属性

由于本质上ES6的Class只是ES5的构造函数的一层包装所以函数的许多特性都被Class继承包括name属性。

class Point {}
Point.name // "Point"

name属性总是返回紧跟在class关键字后面的类名。

5Class表达式

与函数一样Class也可以使用表达式的形式定义。

const MyClass = class Me {
  getClassName() {
    return Me.name;
  }
};

上面代码使用表达式定义了一个类。需要注意的是这个类的名字是MyClass而不是MeMe只在Class的内部代码可用指代当前类。

let inst = new MyClass();
inst.getClassName() // Me
Me.name // ReferenceError: Me is not defined

上面代码表示Me只在Class内部有定义。

如果Class内部没用到的话可以省略Me也就是可以写成下面的形式。

const MyClass = class { /* ... */ };

6不存在变量提升

Class不存在变量提升hoist这一点与ES5完全不同。

new Foo(); // ReferenceError
class Foo {}

上面代码中Foo类使用在前定义在后这样会报错因为ES6不会把变量声明提升到代码头部。这种规定的原因与下文要提到的继承有关必须保证子类在父类之后定义。

{
  let Foo = class {};
  class Bar extends Foo {
  }
}

如果存在Class的提升上面代码将报错因为let命令也是不提升的。

7严格模式

类和模块的内部,默认就是严格模式,所以不需要使用use strict指定运行模式。考虑到未来所有的代码其实都是运行在模块之中所以ES6实际上把整个语言升级到了严格模式。

Class的继承

基本用法

Class之间可以通过extends关键字实现继承这比ES5的通过修改原型链实现继承要清晰和方便很多。

class ColorPoint extends Point {}

上面代码定义了一个ColorPoint类该类通过extends关键字继承了Point类的所有属性和方法。但是由于没有部署任何代码所以这两个类完全一样等于复制了一个Point类。下面我们在ColorPoint内部加上代码。

class ColorPoint extends Point {

  constructor(x, y, color) {
    super(x, y); // 调用父类的constructor(x, y)
    this.color = color;
  }

  toString() {
    return this.color + ' ' + super.toString(); // 调用父类的toString()
  }

}

上面代码中constructor方法和toString方法之中都出现了super关键字它指代父类的实例即父类的this对象

子类必须在constructor方法中调用super方法否则新建实例时会报错。这是因为子类没有自己的this对象而是继承父类的this对象然后对其进行加工。如果不调用super方法子类就得不到this对象。

class Point { /* ... */ }

class ColorPoint extends Point {
  constructor() {
  }
}

let cp = new ColorPoint(); // ReferenceError

上面代码中ColorPoint继承了父类Point但是它的构造函数没有调用super方法导致新建实例时报错。

如果子类没有定义constructor方法这个方法会被默认添加代码如下。也就是说不管有没有显式定义任何一个子类都有constructor方法。

constructor(...args) {
  super(...args);
}

另一个需要注意的地方是在子类的构造函数中只有调用super之后才可以使用this关键字否则会报错。这是因为子类实例的构建是基于对父类实例加工只有super方法才能返回父类实例。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

class ColorPoint extends Point {
  constructor(x, y, color) {
    this.color = color; // ReferenceError
    super(x, y);
    this.color = color; // 正确
  }
}

上面代码中子类的constructor方法没有调用super之前就使用this关键字结果报错而放在super方法之后就是正确的。

下面是生成子类实例的代码。

let cp = new ColorPoint(25, 8, 'green');

cp instanceof ColorPoint // true
cp instanceof Point // true

上面代码中实例对象cp同时是ColorPoint和Point两个类的实例这与ES5的行为完全一致。

类的prototype属性和__proto__属性

在ES5中每一个对象都有__proto__属性指向对应的构造函数的prototype属性。Class作为构造函数的语法糖同时有prototype属性和__proto__属性,因此同时存在两条继承链。

1子类的__proto__属性,表示构造函数的继承,总是指向父类。

2子类prototype属性的__proto__属性表示方法的继承总是指向父类的prototype属性。

class A {
}

class B extends A {
}

B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true

上面代码中子类A的__proto__属性指向父类B子类A的prototype属性的__proto__属性指向父类B的prototype属性。

这两条继承链可以这样理解作为一个对象子类B的原型__proto__属性是父类A作为一个构造函数子类B的原型prototype属性是父类的实例。

B.prototype = new A();
// 等同于
B.prototype.__proto__ = A.prototype;

此外考虑三种特殊情况。第一种特殊情况子类继承Object类。

class A extends Object {
}

A.__proto__ === Object // true
A.prototype.__proto__ === Object.prototype // true

这种情况下A其实就是构造函数Object的复制A的实例就是Object的实例。

第二种特性情况,不存在任何继承。

class A {
}

A.__proto__ === Function.prototype // true
A.prototype.__proto__ === Object.prototype // true

这种情况下A作为一个基类即不存在任何继承就是一个普通函数所以直接继承Funciton.prototype。但是A调用后返回一个空对象即Object实例所以A.prototype.__proto__指向构造函数Object的prototype属性。

第三种特殊情况子类继承null。

class A extends null {
}

A.__proto__ === Function.prototype // true
A.prototype.__proto__ === null // true

这种情况与第二种情况非常像。A也是一个普通函数所以直接继承Funciton.prototype。但是A调用后返回的对象不继承任何方法所以它的__proto__指向Function.prototype,即实质上执行了下面的代码。

class C extends null {
  constructor() { return Object.create(null); }
}

Object.getPrototypeOf()

Object.getPrototypeOf方法可以用来从子类上获取父类。

Object.getPrototypeOf(ColorPoint) === Point
// true

实例的__proto__属性

父类实例和子类实例的__proto__属性指向是不一样的。

var p1 = new Point(2, 3);
var p2 = new ColorPoint(2, 3, 'red');

p2.__proto__ === p1.__proto // false
p2.__proto__.__proto__ === p1.__proto__ // true

通过子类实例的__proto__属性可以修改父类实例的行为。

p2.__proto__.__proto__.printName = function () {
  console.log('Ha');
};

p1.printName() // "Ha"

上面代码在ColorPoint的实例p2上向Point类添加方法结果影响到了Point的实例p1。

原生构造函数的继承

下面是一个继承原生的Array构造函数的例子。

class MyArray extends Array {
  constructor(...args) {
    super(...args);
  }
}

var arr = new MyArray();
arr[1] = 12;

上面代码定义了一个MyArray类继承了Array构造函数因此就可以从MyArray生成数组的实例。这意味着ES6可以自定义原生数据结构比如Array、String等的子类这是ES5无法做到的。

上面这个例子也说明extends关键字不仅可以用来继承类还可以用来继承原生的构造函数。下面是一个自定义Error子类的例子。

class MyError extends Error {
}

throw new MyError('Something happened!');

class的取值函数getter和存值函数setter

与ES5一样在Class内部可以使用get和set关键字对某个属性设置存值函数和取值函数。

class MyClass {
  get prop() {
    return 'getter';
  }
  set prop(value) {
    console.log('setter: '+value);
  }
}

let inst = new MyClass();

inst.prop = 123;
// setter: 123

inst.prop
// 'getter'

上面代码中prop属性有对应的存值函数和取值函数因此赋值和读取行为都被自定义了。

Class的Generator方法

如果某个方法之前加上星号(*就表示该方法是一个Generator函数。

class Foo {
  constructor(...args) {
    this.args = args;
  }
  * [Symbol.iterator]() {
    for (let arg of this.args) {
      yield arg;
    }
  }
}

for (let x of new Foo('hello', 'world')) {
  console.log(x);
}
// hello
// world

上面代码中Foo类的Symbol.iterator方法前有一个星号表示该方法是一个Generator函数。Symbol.iterator方法返回一个Foo类的默认遍历器for...of循环会自动调用这个遍历器。

Class的静态方法

类相当于实例的原型所有在类中定义的方法都会被实例继承。如果在一个方法前加上static关键字就表示该方法不会被实例继承而是直接通过类来调用这就称为“静态方法”。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: undefined is not a function

上面代码中Foo类的classMethod方法前有static关键字表明该方法是一个静态方法可以直接在Foo类上调用Foo.classMethod()而不是在Foo类的实例上调用。如果在实例上调用静态方法会抛出一个错误表示不存在该方法。

父类的静态方法,可以被子类继承。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

class Bar extends Foo {
}

Bar.classMethod(); // 'hello'

上面代码中父类Foo有一个静态方法子类Bar可以调用这个方法。

静态方法也是可以从super对象上调用的。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

class Bar extends Foo {
  static classMethod() {
    return super.classMethod() + ', too';
  }
}

Bar.classMethod();

修饰器

修饰器Decorator用于修改类的行为。这是ES7的一个提案目前Babel转码器已经支持。

function testable(target) {
  target.isTestable = true;
}

@testable
class MyTestableClass () {}

console.log(MyTestableClass.isTestable) // true

上面代码中,@testable就是一个修饰器。它修改了MyTestableClass这个类的行为为它加上了静态属性isTestable。

修饰器函数的参数就是所要修饰的目标对象。比如上面代码中testable函数的参数target就是所要修饰的对象。如果希望修饰器的行为能够根据目标对象的不同而不同就要在外面再封装一层函数。

function testable(isTestable) {
  return function(target) {
    target.isTestable = isTestable;
  }
}

@testable(true) class MyTestableClass () {}
console.log(MyTestableClass.isTestable) // true

@testable(false) class MyClass () {}
console.log(MyClass.isTestable) // false

上面代码中修饰器testable可以接受参数这就等于可以修改修饰器的行为。

如果想要为类的实例添加方法可以在修饰器函数中为目标类的prototype属性添加方法。

function testable(target) {
  target.prototype.isTestable = true;
}

@testable
class MyTestableClass () {}

let obj = new MyClass();

console.log(obj.isTestable) // true

上面代码中修饰器函数testable是在目标类的prototype属性添加属性因此就可以在类的实例上调用添加的属性。

下面是另外一个例子。

// mixins.js
export function mixins(...list) {
  return function (target) {
    Object.assign(target.prototype, ...list)
  }
}

// main.js
import { mixins } from './mixins'

const Foo = {
    foo() { console.log('foo') }
}

@mixins(Foo)
class MyClass {}

let obj = new MyClass()

obj.foo() // 'foo'

上面代码通过修饰器mixins可以为类添加指定的方法。

修饰器可以用Object.assign()模拟。

const Foo = {
  foo() { console.log('foo') }
}

class MyClass {}

Object.assign(MyClass.prototype, Foo);

let obj = new MyClass();
obj.foo() // 'foo'

修饰器不仅可以修饰类,还可以修饰类的属性。

class Person {
  @readonly
  name() { return `${this.first} ${this.last}` }
}

上面代码中修饰器readonly用来修饰”类“的name方法。

此时,修饰器函数一共可以接受三个参数,第一个参数是所要修饰的目标对象,第二个参数是所要修饰的属性名,第三个参数是该属性的描述对象。

readonly(Person.prototype, 'name', descriptor);

function readonly(target, name, descriptor){
  // descriptor对象原来的值如下
  // {
  //   value: specifiedFunction,
  //   enumerable: false,
  //   configurable: true,
  //   writable: true
  // };
  descriptor.writable = false;
  return descriptor;
}

Object.defineProperty(Person.prototype, 'name', descriptor);

上面代码说明修饰器readonly会修改属性的描述对象descriptor然后被修改的描述对象再用来定义属性。下面是另一个例子。

class Person {
  @nonenumerable
  get kidCount() { return this.children.length; }
}

function nonenumerable(target, name, descriptor) {
  descriptor.enumerable = false;
  return descriptor;
}

Module

ES6的Class只是面向对象编程的语法糖升级了ES5的对象定义的写法并没有解决模块化问题。Module功能就是为了解决这个问题而提出的。

历史上JavaScript一直没有模块module体系无法将一个大程序拆分成互相依赖的小文件再用简单的方法拼装起来。其他语言都有这项功能比如Ruby的require、Python的import甚至就连CSS都有@import但是JavaScript任何这方面的支持都没有这对开发大型的、复杂的项目形成了巨大障碍。

在ES6之前社区制定了一些模块加载方案最主要的有CommonJS和AMD两种。前者用于服务器后者用于浏览器。ES6在语言规格的层面上实现了模块功能而且实现得相当简单完全可以取代现有的CommonJS和AMD规范成为浏览器和服务器通用的模块解决方案。

ES6模块的设计思想是尽量的静态化使得编译时就能确定模块的依赖关系以及输入和输出的变量。CommonJS和AMD模块都只能在运行时确定这些东西。比如CommonJS模块就是对象输入时必须查找对象属性。

var { stat, exists, readFile } = require('fs');

ES6模块不是对象而是通过export命令显式指定输出的代码输入时也采用静态命令的形式。

import { stat, exists, readFile } from 'fs';

所以ES6可以在编译时就完成模块编译效率要比CommonJS模块高。

1export命令import命令

模块功能主要由两个命令构成export和import。export命令用于用户自定义模块规定对外接口import命令用于输入其他模块提供的功能同时创造命名空间namespace防止函数名冲突。

ES6允许将独立的JS文件作为模块也就是说允许一个JavaScript脚本文件调用另一个脚本文件。该文件内部的所有变量外部无法获取必须使用export关键字输出变量。下面是一个JS文件里面使用export关键字输出变量。


// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;

上面代码是profile.js文件保存了用户信息。ES6将其视为一个模块里面用export命令对外部输出了三个变量。

export的写法除了像上面这样还有另外一种。


// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;

export {firstName, lastName, year};

上面代码在export命令后面使用大括号指定所要输出的一组变量。它与前一种写法直接放置在var语句前是等价的但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部一眼看清楚输出了哪些变量。

使用export命令定义了模块的对外接口以后其他JS文件就可以通过import命令加载这个模块文件


// main.js

import {firstName, lastName, year} from './profile';

function sfirsetHeader(element) {
  element.textContent = firstName + ' ' + lastName;
}

上面代码属于另一个文件main.jsimport命令就用于加载profile.js文件并从中输入变量。import命令接受一个对象用大括号表示里面指定要从其他模块导入的变量名。大括号里面的变量名必须与被导入模块profile.js对外接口的名称相同。

如果想为输入的变量重新取一个名字import语句中要使用as关键字将输入的变量重命名。


import { lastName as surname } from './profile';

ES6支持多重加载即所加载的模块中又加载其他模块。


import { Vehicle } from './Vehicle';

class Car extends Vehicle {
  move () {
    console.log(this.name + ' is spinning wheels...')
  }
}

export { Car }

上面的模块先加载Vehicle模块然后在其基础上添加了move方法再作为一个新模块输出。

2模块的整体输入module命令

export命令除了输出变量还可以输出方法或类class。下面是一个circle.js文件它输出两个方法area和circumference。


// circle.js

export function area(radius) {
  return Math.PI * radius * radius;
}

export function circumference(radius) {
  return 2 * Math.PI * radius;
}

然后main.js输入circlek.js模块。


// main.js

import { area, circumference } from 'circle';

console.log("圆面积:" + area(4));
console.log("圆周长:" + circumference(14));

上面写法是逐一指定要输入的方法。另一种写法是整体输入。


import * as circle from 'circle';

console.log("圆面积:" + circle.area(4));
console.log("圆周长:" + circle.circumference(14));

module命令可以取代import语句达到整体输入模块的作用。


// main.js

module circle from 'circle';

console.log("圆面积:" + circle.area(4));
console.log("圆周长:" + circle.circumference(14));

module命令后面跟一个变量表示输入的模块定义在该变量上。

3export default命令

为了给用户提供方便,有时我们希望,用户不用知道输入哪个方法,就能加载模块。这时就要用到export default命令,为所要加载的模块指定默认输出。

// export-default.js
export default function () {
  console.log('foo');
}

上面代码是一个模块文件export-default.js,它的默认输出是一个函数。

其他模块加载该模块时import命令可以为该匿名函数指定任意名字。

// import-default.js
import customName from './export-default';
customName(); // 'foo'

上面代码的import命令可以用任意名称指向export-default.js输出的方法。需要注意的是这时import命令后面不使用大括号。

import crc32 from 'crc32';
// 对应的输出
export default function crc32(){}

import { crc32 } from 'crc32';
// 对应的输出
export function crc32(){};

上面代码的两组写法,第一组是使用export default对应的import语句不需要使用大括号第二组是不使用export default对应的import语句需要使用大括号。

export default命令用在非匿名函数前也是可以的。

// export-default.js
export default function foo() {
  console.log('foo');
}

// 或者写成

function foo() {
  console.log('foo');
}

export default foo;

上面代码中foo函数的函数名foo在模块外部是无效的。加载的时候视同匿名函数加载。

export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export deault命令只能使用一次。所以import命令后面才不用加大括号因为只可能对应一个方法。

有了export default命令输入模块时就非常直观了以输入jQuery模块为例。

import $ from 'jquery';

如果想在一条import语句中同时输入默认方法和其他变量可以写成下面这样。

import customName, { otherMethod } from './export-default';

如果要输出默认的值,只需将值跟在export default之后即可。

export default 42;

export default也可以用来输出类。

// MyClass.js
export default class { ... }

// main.js
import MyClass from 'MyClass'
let o = new MyClass();

模块的继承

模块之间也可以继承。

假设有一个circleplus模块继承了circle模块。


// circleplus.js

export * from 'circle';
export var e = 2.71828182846;
export default function(x) {
    return Math.exp(x);
}

上面代码中的“export *”表示输出circle模块的所有属性和方法export default命令定义模块的默认方法。

这时也可以将circle的属性或方法改名后再输出。


// circleplus.js

export { area as circleArea } from 'circle';  

上面代码表示只输出circle模块的area方法且将其改名为circleArea。

加载上面模块的写法如下。


// main.js

module math from "circleplus";
import exp from "circleplus";
console.log(exp(math.pi));

上面代码中的"import exp"表示将circleplus模块的默认方法加载为exp方法。

ES6模块的转码

浏览器目前还不支持ES6模块为了现在就能使用可以将转为ES5的写法。

1ES6 module transpiler

ES6 module transpiler是square公司开源的一个转码器可以将ES6模块转为CommonJS模块或AMD模块的写法从而在浏览器中使用。

首先,安装这个转玛器。


$ npm install -g es6-module-transpiler

然后,使用compile-modules convert命令将ES6模块文件转码。


$ compile-modules convert file1.js file2.js

o参数可以指定转码后的文件名。


$ compile-modules convert -o out.js file1.js

2SystemJS

另一种解决方法是使用SystemJS。它是一个垫片库polyfill可以在浏览器内加载ES6模块、AMD模块和CommonJS模块将其转为ES5格式。它在后台调用的是Google的Traceur转码器。

使用时先在网页内载入system.js文件。


<script src="system.js"></script>

然后,使用System.import方法加载模块文件。


<script>
  System.import('./app');
</script>

上面代码中的./app指的是当前目录下的app.js文件。它可以是ES6模块文件System.import会自动将其转码。

需要注意的是,System.import使用异步加载返回一个Promise对象可以针对这个对象编程。下面是一个模块文件。


// app/es6-file.js:

export class q {
  constructor() {
    this.es6 = 'hello';
  }
}


然后,在网页内加载这个模块文件。


<script>

System.import('app/es6-file').then(function(m) {
  console.log(new m.q().es6); // hello
});

</script>

上面代码中,System.import方法返回的是一个Promise对象所以可以用then方法指定回调函数。