ECMAScript 6 學習筆記(十):class 語法
簡介
ES6 的 class 可以看作只是一個語法糖,是傳統建構函式的一個封裝,事實上,類的所有方法都定義在類的 prototype 屬性上面。一個類裡面至少包含一個 constructor 方法,注意,定義“類”的方法的時候,前面不需要加上 function 這個關鍵字,直接把函式定義放進去了就可以了。另外,方法之間不需要逗號分隔,加了會報錯。
class Point { constructor(x, y) { this.x = x; this.y = y; } toString() { return '(' + this.x + ', ' + this.y + ')'; } } typeof Point // "function" Point === Point.prototype.constructor // true,類指向建構函式
類的屬性名,可以採用表示式。類的內部所有定義的方法,都是不可列舉的(non-enumerable)。
class Point { constructor(x, y) {...} toString() {...} [method]() {...} } Object.keys(Point.prototype) // [] Object.getOwnPropertyNames(Point.prototype) // ["constructor","toString"]
而在 ES5 中,這些方法是可列舉的
var Point = function (x, y) {...}; Point.prototype.toString = function() {...}; Object.keys(Point.prototype) // ["toString"] Object.getOwnPropertyNames(Point.prototype) // ["constructor","toString"]
構造器方法
constructor
方法是類的預設方法,通過new
命令生成物件例項時,自動呼叫該方法。一個類必須有constructor
方法,如果沒有顯式定義,一個空的constructor
方法會被預設新增。constructor
方法預設返回例項物件(即this),但是可以指定返回另外一個物件。
class Foo { constructor() { return Object.create(null); } } new Foo() instanceof Foo // false
類的例項
類必須使用new
呼叫,否則會報錯。與 ES5 一樣,例項的屬性除非顯式定義在其本身(即定義在this物件上),否則都是定義在原型上(即定義在class
上),類的所有例項共享一個原型物件。
一般來說可以通過__proto__
屬性獲取類的原型,從而為class
新增方法,但是__proto__
並不是語言本身的特性,這是各大廠商具體實現時新增的私有屬性。為了避免出錯,可以使用Object.getPrototypeOf
方法來代替它。
類的取值函式(getter)和存值函式(setter)
與 ES5 一樣,在“類”的內部可以使用 get 和 set 關鍵字,對某個屬性設定存值函式和取值函式,攔截該屬性的存取行為。存值函式和取值函式是設定在屬性的Descriptor
物件上的。
class CustomHTMLElement { constructor(element) { this.element = element; } get html() { return this.element.innerHTML; } set html(value) { this.element.innerHTML = value; } } var descriptor = Object.getOwnPropertyDescriptor( CustomHTMLElement.prototype, "html" ); "get" in descriptor// true "set" in descriptor// true
類表示式
與函式一樣,類也可以使用表示式的形式定義。採用 Class 表示式,可以寫出立即執行的 Class。
let person = new class { constructor(name) { this.name = name; } sayName() { console.log(this.name); } }('nick'); person.sayName(); // "nick"
注意
- 嚴格模式
類和模組的內部,預設就是嚴格模式,所以不需要使用use strict
指定執行模式。
- 不存在變數提升
類不存在變數提升(hoist),這種規定的原因與下文要提到的繼承有關,必須保證子類在父類之後定義。
new Foo(); // ReferenceError class Foo {}
- name 屬性
由於類的本質是函式,因此可以使用name
屬性返回類的名稱。
- 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
- this 的指向
類的方法內部如果含有 this,它預設指向類的例項,但是,如果將這個方法提取出來單獨使用,this 會指向該方法執行時所在的環境。
class Logger { printName(name = 'there') { this.print(`Hello ${name}`); } print(text) { console.log(text); } } const logger = new Logger(); const { printName } = logger; printName(); // TypeError: Cannot read property 'print' of undefined
解決方法:
- 在構造方法中繫結 this。
class Logger { constructor() { this.printName = this.printName.bind(this); } // ... }
- 使用箭頭函式。
class Logger { constructor() { this.printName = (name = 'there') => { this.print(`Hello ${name}`); }; } // ... }
- 使用 Proxy,獲取方法的時候,自動繫結 this。
function selfish (target) { const cache = new WeakMap(); const handler = { get (target, key) { const value = Reflect.get(target, key); if (typeof value !== 'function') { return value; } if (!cache.has(value)) { cache.set(value, value.bind(target)); } return cache.get(value); } }; const proxy = new Proxy(target, handler); return proxy; } const logger = selfish(new Logger());
靜態方法
如果在一個方法前,加上static
關鍵字,就表示該方法不會被例項繼承,而是直接通過類來呼叫,這就稱為“靜態方法”。如果靜態方法包含this
關鍵字,這個this
指的是類,而不是例項。靜態方法可以與非靜態方法重名。
class Foo { static classMethod() { return 'hello'; } } Foo.classMethod() // 'hello' var foo = new Foo(); foo.classMethod() // TypeError: foo.classMethod is not a function
父類的靜態方法,可以被子類繼承。靜態方法也是可以從super
物件上呼叫的。
class Foo { static classMethod() { return 'hello'; } } class Bar extends Foo { static classMethod() { return super.classMethod() + ', too'; } } Bar.classMethod() // "hello, too"
例項屬性
例項屬性除了定義在constructor()
方法裡面的this
上面,也可以定義在類的最頂層。
class IncreasingCounter { _count = 0; get value() { console.log('Getting the current value!'); return this._count; } }
靜態屬性
靜態屬性指的是 Class 本身的屬性,即Class.propName
,而不是定義在例項物件(this
)上的屬性。為了配合類的靜態方法,現在已有靜態屬性新寫法的提案:
// 老寫法 class Foo { // ... } Foo.prop = 1; // 新寫法 class Foo { static prop = 1; }
私有方法和私有屬性
- 通過命名區分
class Widget { // 公有方法 foo (baz) { this._bar(baz); } // 私有方法 _bar(baz) { return this.snaf = baz; } }
- 將私有方法移出模組
class Widget { foo (baz) { bar.call(this, baz); } // ... } function bar(baz) { return this.snaf = baz; }
-
利用 Symbol 值的唯一性(
Reflect.ownKeys()
依然可以獲取)
const bar = Symbol('bar'); const snaf = Symbol('snaf'); export default class myClass{ // 公有方法 foo(baz) { this[bar](baz); } // 私有方法 [bar](baz) { return this[snaf] = baz; } };
- 使用 #(提案)
class Point { #x; constructor(x = 0) { #x = +x; // 寫成 this.#x 亦可 } get x() { return #x } set x(value) { #x = +value } }
類的繼承
Class 可以通過extends
關鍵字實現繼承,這比 ES5 的通過修改原型鏈實現繼承,要清晰和方便很多。需要注意的是,父類的靜態方法,也會被子類繼承。
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
方法中呼叫super
方法,否則新建例項時會報錯。這是因為子類自己的this
物件,必須先通過父類的建構函式完成塑造,得到與父類同樣的例項屬性和方法,然後再對其進行加工,加上子類自己的例項屬性和方法。如果不呼叫super
方法,子類就得不到this
物件。
ES5 和 ES6 繼承實現的對比:
-
ES5:先創造子類的例項物件
this
,然後把父類的方法新增到this
上; -
ES6:先將父類例項物件的屬性和方法加到
this
上,再用子類建構函式修改this
。
如果子類沒有定義constructor
方法,這個方法會被預設新增,程式碼如下。也就是說,不管有沒有顯式定義,任何一個子類都有constructor
方法。
class ColorPoint extends Point { } // 等同於 class ColorPoint extends Point { 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; // 正確 } }
可以通過Object.getPrototypeOf
方法來從子類上獲取父類:
Object.getPrototypeOf(ColorPoint) === Point // true
super 關鍵字
super
有兩種用法,一是作為函式使用,代表父類的建構函式,ES6 中子類的建構函式必須執行一次super
函式,此時的super()
只能用在子類的建構函式中,相當於Parent.prototype.constructor.call(this)
;第二種用法是作為物件,在普通方法中,指向父類的原型物件,而在靜態方法中,指向父類。
// 用法1 class A { constructor() { console.log(new.target.name); } } class B extends A { constructor() { super(); } } new A() // A new B() // B // 用法2 class A { p() { return 2; } } class B extends A { constructor() { super(); console.log(super.p()); // 2 } } let b = new B();
這裡需要注意,由於super
指向父類的原型物件,所以定義在父類例項上的方法或屬性,是無法通過super
呼叫的。
class A { constructor() { this.p = 2; } } class B extends A { get m() { return super.p; } } let b = new B(); b.m // undefined
ES6 規定,在子類普通方法中通過super
呼叫父類的方法時,方法內部的this
指向當前的子類例項。
class A { constructor() { this.x = 1; } print() { console.log(this.x); } } class B extends A { constructor() { super(); this.x = 2; } m() { super.print(); } } let b = new B(); b.m() // 2
由於this
指向子類例項,所以如果通過super
對某個屬性賦值,這時super
就是this
,賦值的屬性會變成子類例項的屬性。
class A { constructor() { this.x = 1; } } class B extends A { constructor() { super(); this.x = 2; super.x = 3; console.log(super.x); // undefined console.log(this.x); // 3 } } let b = new B();
另外,在子類的靜態方法中通過super
呼叫父類的方法時,方法內部的this
指向當前的子類,而不是子類的例項。
class A { constructor() { this.x = 1; } static print() { console.log(this.x); } } class B extends A { constructor() { super(); this.x = 2; } static m() { super.print(); } } B.x = 3; B.m() // 3
使用super
的時候,必須顯式指定是作為函式、還是作為物件使用,否則會報錯。
class A {} class B extends A { constructor() { super(); console.log(super); // 報錯 } }
prototype 和 __proto__
__proto__
屬性是大多數瀏覽器對prototype
的實現,而 Class 同時有prototype
和__proto__
屬性,因此同時存在兩條繼承鏈。
-
子類的
__proto__
屬性,表示建構函式的繼承,總是指向父類; -
子類
prototype
屬性的__proto__
屬性表示方法的繼承,總是指向父類的prototype
屬性。
class A { } class B extends A { } B.__proto__ === A // true B.prototype.__proto__ === A.prototype // true
這是因為,類的繼承是按照下面的模式實現的:
class A { } class B { } Object.setPrototypeOf = function (obj, proto) { obj.__proto__ = proto; return obj; } // B 的例項繼承 A 的例項 Object.setPrototypeOf(B.prototype, A.prototype); // B 繼承 A 的靜態屬性 Object.setPrototypeOf(B, A); const b = new B();
可以理解為:
__proto__ prototype
子類例項的__proto__
屬性的__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
原生建構函式的繼承
ES 的原生建構函式包括以下這些:
- Boolean()
- Number()
- String()
- Array()
- Date()
- Function()
- RegExp()
- Error()
- Object()
ES5 中,這些原生建構函式是無法繼承的,因為子類無法獲得原生建構函式的內部屬性。ES5 是先新建子類的例項物件this
,再將父類的屬性新增到子類上,由於父類的內部屬性無法獲取,導致無法繼承原生的建構函式。ES6 允許繼承原生建構函式定義子類,因為 ES6 是先新建父類的例項物件this
,然後再用子類的建構函式修飾this
,使得父類的所有行為都可以繼承。
示例,修改陣列類:
class VersionedArray extends Array { constructor() { super(); this.history = [[]]; } commit() { this.history.push(this.slice()); } revert() { this.splice(0, this.length, ...this.history[this.history.length - 1]); } } var x = new VersionedArray(); x.push(1); x.push(2); x // [1, 2] x.history // [[]] x.commit(); x.history // [[], [1, 2]] x.push(3); x // [1, 2, 3] x.history // [[], [1, 2]] x.revert(); x // [1, 2]
注意,繼承Object的子類時,有一個行為差異。
class NewObj extends Object{ constructor(){ super(...arguments); } } var o = new NewObj({attr: true}); o.attr === true// false
上面程式碼中,NewObj
繼承了Object
,但是無法通過super
方法向父類Object
傳參。這是因為 ES6 改變了Object
建構函式的行為,一旦發現Object
方法不是通過new Object()
這種形式呼叫,ES6 規定Object
建構函式會忽略引數。
new.target 屬性
new
是從建構函式生成例項物件的命令。ES6 為new
命令引入了一個new.target
屬性,該屬性一般用在建構函式之中,返回new
命令作用於的那個建構函式。如果建構函式不是通過new
或Reflect.construct()
命令呼叫的,new.target
會返回undefined
,因此這個屬性可以用來確定建構函式是怎麼呼叫的。
function Person(name) { if (new.target !== undefined) { this.name = name; } else { throw new Error('必須使用 new 命令生成例項'); } } // 另一種寫法 function Person(name) { if (new.target === Person) { this.name = name; } else { throw new Error('必須使用 new 命令生成例項'); } } var person = new Person('張三'); // 正確 var notAPerson = Person.call(person, '張三');// 報錯
Class 內部呼叫new.target,返回當前 Class:
class Rectangle { constructor(length, width) { console.log(new.target === Rectangle); this.length = length; this.width = width; } } var obj = new Rectangle(3, 4); // 輸出 true
子類繼承父類時,new.target
會返回子類。利用這個特點,可以寫出不能獨立使用、必須繼承後才能使用的類。
class Shape { constructor() { if (new.target === Shape) { throw new Error('本類不能例項化'); } } } class Rectangle extends Shape { constructor(length, width) { super(); // ... } } var x = new Shape();// 報錯 var y = new Rectangle(3, 4);// 正確