JavaScript 具備物件導向程式設計(OOP)的能力。在 OOP 中,你會想從「模板」(類別)建立物件(實例),讓這些物件包含特定的資料與功能。在 OOP 的脈絡下,資料屬性稱為_欄位_,函式屬性則稱為_方法_。
在 2015 年類別被加入語言規範之前,JavaScript 根本沒有類別,但它允許使用以原型為基礎的繼承來進行物件導向程式設計。而即使現在已經有class關鍵字可用,JavaScript 仍然是一門_以原型為基礎_的語言。
為了理解「以原型為基礎的語言」是什麼意思,以及 JavaScript 實際上如何運作,我們要回到還沒有類別的那個年代。
在 JavaScript 中,模板(類別)是由一般的函式來實現。當一個函式被當作這樣的模板使用時,它稱為_建構函式_,而慣例是函式名稱要以大寫字母開頭。使用new關鍵字呼叫建構函式時,就會從這個模板衍生出實例(物件)。
function Car() {
// ...
}
const myCar = new Car();
const yourCar = new Car();
有一點很重要:在 JavaScript 中,即使實例已經建立,實例與建構函式之間仍保持著關係。每個實例物件都包含一個隱藏的內部屬性,在語言規範中稱為[[prototype]]。它會保存建構函式prototype鍵的值的參照。沒錯,你沒看錯,JavaScript 函式可以擁有鍵值對,因為它在背後其實也是個物件。
總結如下:
prototype屬性會成為實例的_原型_。通常,你會希望所有衍生出的物件(實例)都包含某些欄位,並在建立物件時為這些欄位傳入初始值。這可以透過 this 關鍵字來實現。在建構函式內部,this 代表將透過new建立的新物件。當建構函式以new呼叫時,this會自動從建構函式回傳。
這表示我們可以透過在建構函式中把欄位加到this上,來為新實例加入欄位。
function Car(color, weight) {
this.color = color;
this.weight = weight;
this.engineRunning = false;
}
const myCar = new Car('red', '2mt');
myCar.color;
// => 'red'
myCar.engineRunning;
// => false
方法是透過建構函式的prototype屬性加入的。在方法內部,你可以透過this存取實例的欄位。這之所以可行,是因為以下的一般規則。
當一個函式以物件的方法被呼叫時,它的
this會設為呼叫該方法的那個物件。1
function Car() {
this.engineRunning = false;
// ...
}
Car.prototype.startEngine = function () {
this.engineRunning = true;
};
Car.prototype.addGas = function (litre) {
// ...
};
const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true
上面範例中的myCar是一般的 JavaScript 物件,如果我們去檢視它(例如在瀏覽器主控台中),不會在myCar物件裡直接找到值為函式的startEngine屬性。那麼,上面的程式碼到底是怎麼運作的呢?
這裡的祕密就叫做_原型鏈_。當你嘗試存取物件的任何屬性(欄位或方法)時,JavaScript 會先檢查該鍵是否能直接在物件本身找到。如果找不到,它會繼續到原始物件的[[prototype]]屬性所參照的物件中尋找這個鍵。如前所述,在我們的範例中,[[prototype]]指向建構函式的prototype屬性。JavaScript 就是在那裡找到startEngine函式的,因為我們把它加在那裡。
function Car() {
// ...
}
Car.prototype.startEngine = function () {
// ...
};
而且這條鏈不只到這裡為止。Car.prototype的[[prototype]]屬性(myCar.[[prototype]].[[prototype]])會參照Object.prototype(Object建構函式的prototype屬性)。它包含所有 JavaScript 物件都能使用的通用方法,例如toString()。總而言之,你可以呼叫myCar.toString(),而這個方法之所以存在,是因為 JavaScript 會沿著整條原型鏈尋找該方法。
請注意,只有在取得值時才會沿著原型鏈走訪。直接設定屬性或刪除實例物件的屬性,只會影響那個特定的實例。如果你習慣了以類別為基礎的繼承的語言,這可能不是你所預期的。
如今,JavaScript 支援使用class關鍵字來定義類別。這在 2015 年被加入語言規範。一方面,這提供了語法糖,讓類別更容易閱讀與撰寫。新語法和 C++ 或 Java 等語言撰寫類別的方式更為相似,從那些語言轉換過來的開發者會更容易適應。另一方面,類別語法為原型語法中沒有的新語言功能開闢了道路。
使用新語法時,類別是以class關鍵字定義,後面接著類別名稱,以及以大括號包住的類別主體。主體包含建構函式的定義,也就是一個名為constructor的特殊方法。這個函式的運作方式和原型語法中的建構函式一模一樣。類別主體也包含所有方法的定義。方法的語法和我們看過的簡寫表示法類似,用來把函式以值的形式加進物件中,請參閱物件概念。
class Car {
constructor(color, weight) {
this.color = color;
this.weight = weight;
this.engineRunning = false;
}
startEngine() {
this.engineRunning = true;
}
addGas(litre) {
// ...
}
}
const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true
請記住,在背後,JavaScript 仍然是一門以原型為基礎的語言。我們在上面「原型語法」一節中學到的所有機制仍然適用。
在 JavaScript 中,預設所有實例欄位都是公開的,可以直接存取與指定值。
不過,有個行之有年的慣例:以底線開頭的欄位和方法應被視為私有,絕對不該從類別外部直接存取。
私有欄位有時會搭配 getter 與 setter。使用get和set關鍵字,你可以定義當與函式同名的屬性被存取或指定值時所執行的函式。
class Car {
constructor() {
this._mileage = 0;
}
get mileage() {
return this._mileage;
}
set mileage(value) {
throw new Error(`Mileage cannot be manipulated, ${value} is ignored.`);
// Just an example, usually you would not provide a setter in this case.
}
}
const myCar = new Car();
myCar.mileage;
// => 0
myCar.mileage = 100;
// => Error: Mileage cannot be manipulated, 100 is ignored.
this 範例:作為物件方法,MDN。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#as_an_object_method(2021 年 12 月 3 日引用) ↩
在這個練習中,你將模擬一套以視窗為基礎的電腦系統。 你會建立一些可以移動和調整大小的視窗。 下圖呈現了你在下面會用到的各種值。
<--------------------- screenSize.width --------------------->
^ ┌────────────────────────────────────────────────────────────┐
| │ │
| │ position.x, _ │
| │ position.y \ │
| │ \<----- size.width -----> │
| │ ^ *──────────────────────┐ │
| │ | │ title │ │
| │ | ├──────────────────────┤ │
screenSize.height │ | │ │ │
| │ size.height │ │ │
| │ | │ contents │ │
| │ | │ │ │
| │ | │ │ │
| │ v └──────────────────────┘ │
| │ │
| │ │
v └────────────────────────────────────────────────────────────┘
📣 為了練習你在 JavaScript 上的廣泛技能,試著用原型語法解出第 1 和第 2 個任務,其餘任務則用類別語法吧。
定義一個名為Size的類別(建構函式)。
它有兩個欄位width和height,用來儲存視窗目前的尺寸。
建構函式應該接受這些欄位的初始值。
寬度以第一個參數傳入,高度則以第二個參數傳入。
預設的寬度和高度分別是80和60。
此外,定義一個方法resize(newWidth, newHeight),它接受新的寬度和高度作為參數,並變更欄位以反映新的尺寸。
const size = new Size(1080, 764);
size.width;
// => 1080
size.height;
// => 764
size.resize(1920, 1080);
size.width;
// => 1920
size.height;
// => 1080
定義一個名為Position的類別(建構函式),它有兩個欄位x和y,分別儲存視窗左上角目前的水平和垂直位置。
建構函式應該接受這些欄位的初始值。
x的值以第一個參數傳入,y的值則以第二個參數傳入。
兩個欄位的預設值都是0。
位置(0, 0)是螢幕的左上角,往右移動時x值會變大,往下移動時y值會變大。
另外定義一個方法move(newX, newY),它接受新的 x 和 y 參數,並變更屬性以反映新的位置。
const point = new Position();
point.x;
// => 0
point.y;
// => 0
point.move(100, 200);
point.x;
// => 100
point.y;
// => 200
定義一個ProgramWindow類別,包含下列欄位:
screenSize:保存一個固定值,型別為Size,其width為 800、height為 600size:保存一個型別為Size的值,初始值為Size實例的預設值position:保存一個型別為Position的值,初始值為Position實例的預設值視窗開啟(建立)時,一開始一律具有預設的尺寸和位置。
const programWindow = new ProgramWindow();
programWindow.screenSize.width;
// => 800
// Similar for the other fields.
附註:使用ProgramWindow這個名稱而不是Window,是為了將這個類別與瀏覽器環境中內建的Window類別區分開來。
ProgramWindow類別應該包含一個方法resize。
它應該接受一個型別為Size的參數作為輸入,並嘗試將視窗調整為指定的尺寸。
不過,新的尺寸不能超出某些界限。
x = 400、y = 300,而要求調整為height = 400、width = 300,那麼視窗會被調整為height = 300、width = 300,因為螢幕在y方向上不夠大,無法完全容納這個要求。const programWindow = new ProgramWindow();
const newSize = new Size(600, 400);
programWindow.resize(newSize);
programWindow.size.width;
// => 600
programWindow.size.height;
// => 400
除了調整大小的功能之外,ProgramWindow類別也應該包含一個方法move。
它應該接受一個型別為Position的參數作為輸入。
move方法和resize類似,不過這個方法調整的是視窗的位置,而不是尺寸。
和resize一樣,新的位置不能超出某些限制。
x和y的最小位置都是 0。x = 250、y = 100,而要求移動到x = 600、y = 200,那麼視窗會被移動到x = 550、y = 200,因為螢幕在x方向上不夠大,無法完全容納這個要求。const programWindow = new ProgramWindow();
const newPosition = new Position(50, 100);
programWindow.move(newPosition);
programWindow.position.x;
// => 50
programWindow.position.y;
// => 100
實作一個changeWindow函式,它接受一個ProgramWindow實例作為輸入,並將視窗變更為指定的尺寸和位置。
這個函式在套用變更之後,應該回傳傳入的ProgramWindow實例。
視窗的寬度應該是 400、高度是 300,並定位在 x = 100、y = 150。
const programWindow = new ProgramWindow();
changeWindow(programWindow);
programWindow.size.width;
// => 400
// Similar for the other fields.