前言
JavaScript 的物件系統是以原型 (prototype) 為基礎,相較起來,大部分主流的語言的物件是以類別 (class) 為基礎,兩者有一些差異。JavaScript 建立物件的方法較為靈活,會依需求而有不同的建立方式。較常見的有以下三種:
- 物件實字 (object literal)
- 以函式 (function) 為建構子 (constructor)
- (新) 使用
class
建立類別 (class)
前兩種在 ES5 時代就出現了,class
則是 ES6 後才出現的語法糖。如果不是要維護舊有程式碼,應優先使用 class
;但許多現存的程式仍未轉換到新的語法,所以也要能閱讀用原有的手法撰寫的程式碼。
在本文中,我們以二維坐標點為例,展示不同的做法,供讀者參考。
使用物件實字 (Object Literal) 建立物件
物件實字 {}
就像是一個雜湊表 (hash table),可以增加純量 (value) 或是函式物件 (function object) 或內嵌的物件實字等。透過物件實字來建立物件是最簡單的方式,可參考以下程式碼建立 Point
物件:
// Object with fields per object.
let Point = {
x: 0,
y: 0
};
// Shared function among objects.
Object.setPrototypeOf(Point, {
distanceTo: function(p) {
let dx = this.x - p.x;
let dy = this.y - p.y;
return Math.sqrt(dx * dx + dy * dy);
}
});
雖然我們會在心中把 Point
當成類別來用,嚴格上來說,Point
本身也是一個物件,該物件有兩個公開屬性 x
和 y
以及一個公開方法 distanceTo
。此處的 this
是一個特殊的變數,會指向物件實字本身。
使用 Point
物件的方式如下:
let p = copy(Point);
let q = copy(Point);
q.x = 3;
q.y = 4;
assert(p.distanceTo(q) === 5, "The distance should be 5");
// Deep copy.
function copy (src) {
let dest = {};
// Inherit prototype from `src`.
Object.setPrototypeOf(dest, Object.getPrototypeOf(src));
// Copy properties from `src`.
for (let prop in src) {
if (src.hasOwnProperty(prop)) {
dest[prop] = src[prop];
}
}
return dest;
}
// Home-made `assert`.
function assert(cond, msg) {
if (!(cond)) {
throw msg;
}
}
物件實字所有的屬性和方法都是公開的,而直接使用公開屬性在軟體工程上是不好的習慣,所以我們較少直接從物件實字來建立物件,而會用下一節的方法。
以函式 (Function) 為建構子 (Constructor)
在 ES5 的時代,使用函式做為物件的建構子是主流的手法。延續我們的例子,以函式建構 Point
的範例如下:
let Point = function (x, y) {
// The private fields
var _x;
var _y;
// Property `x` of the object.
Object.defineProperty(this, "x", {
get: function () {
return _x;
},
set: function (value) {
_x = value;
}
});
// Property `y` of the object.
Object.defineProperty(this, "y", {
get: function () {
return _y;
},
set: function (value) {
_y = value;
}
});
// Init the fields of the object.
this.x = x;
this.y = y;
return this;
};
// The public method `distanceTo`.
Point.prototype.distanceTo = function (p) {
return Math.sqrt(Math.pow(this.x - p.x, 2) + Math.pow(this.y - p.y, 2));
};
在 Point
物件中,_x
和 _y
是私有的,利用閉包 (closure) 包在 Point
物件中。我們另外定義 x
和 y
兩個公開屬性去存取相對應的私有屬性,最後回傳 this
本身。在 JavaScript 中,函式會用來控制可視域 (scope)。
在 Point
中,不論將 x
和 y
設為 field 或 property 都沒有差別,但在工程的觀察上,使用 property 會優於直接使用 field,因 property 可控制包覆的私有 field。
我們將 distanceTo
拉到 Point
的原型鍵而不直接寫在建構函式內,這是為了節約記憶體。許多教材會將 distanceTo
寫在建構函式內,這在語法上比較好懂,但每建立一個物件時就會重覆建立一個函式,記憶體耗費較多。
使用方式如下:
let p = new Point(0, 0);
console.log(`(${p.x}, ${p.y})`);
p.x = 3;
p.y = 4;
console.log(`(${p.x}, ${p.y})`);
console.log(p.distanceTo(new Point(0, 0)));
對於建構函式,我們會使用 new
來製作新的物件。
(新) 使用 class
保留字建立類別 (Class)
class
是 ES6 後引入的新語法,由於 JavaScript 在本質上仍採用以原型 (prototype) 為基礎的物件,所以 class
算是一種語法糖。
我們用 class
建立 Point
物件:
let Point = (function () {
let fields = new WeakMap();
return class Point {
constructor (x, y) {
fields.set(this, {});
this.x = x;
this.y = y;
}
// The getter of `x`
get x() {
return fields.get(this).x;
}
// The setter of `x`
set x(value) {
fields.get(this).x = value;
}
// The getter of `y`
get y() {
return fields.get(this).y;
}
// The setter of `y`
set y(value) {
fields.get(this).y = value;
}
// The method `distanceTo`
distanceTo(p) {
return Math.sqrt(Math.pow(this.x - p.x, 2) + Math.pow(this.y - p.y, 2));
}
};
})();
這裡的寫法和一般線上教材略有不同,這是為了處理私有屬性的議題。原本的 class
沒有私有屬性的概念,我們用 IIFE 的手法將 fields
包在閉包中,做為儲存私有屬性的物件。fields
本身用 WeakMap 而非 Map 是預防記憶體洩露 (memory leak)。
部分讀者可能會對 Point
物件可回傳感到疑惑,此處的 Point
物件是 class expression,類似於 function expression,所以可以當成回傳值。
該物件使用的方式和先前雷同:
let p = new Point(0, 0);
console.log(`(${p.x}, ${p.y})`);
p.x = 3;
p.y = 4;
console.log(`(${p.x}, ${p.y})`);
console.log(p.distanceTo(new Point(0, 0)));
結語
在本文中,我們見到三種建立物件的方式。除非要刻意使用 ES5 版本的 JavaScript,使用 class
保留字來建立物件應該是比較簡單的方式。但另外兩種方式也要能讀懂,因為有許多代碼仍用這些手法來建立物件。