視窗系統

視窗系統

學習練習

簡介

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 函式可以擁有鍵值對,因為它在背後其實也是個物件。

Note

總結如下:

  • 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 會沿著整條原型鏈尋找該方法。

Caution

請注意,只有在取得值時才會沿著原型鏈走訪。直接設定屬性或刪除實例物件的屬性,只會影響那個特定的實例。如果你習慣了以類別為基礎的繼承的語言,這可能不是你所預期的。

類別語法

如今,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 仍然是一門以原型為基礎的語言。我們在上面「原型語法」一節中學到的所有機制仍然適用。

私有欄位、getter 與 setter

在 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.

  1. 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 個任務,其餘任務則用類別語法吧。

1. 定義 Size 來儲存視窗的尺寸

定義一個名為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

2. 定義 Position 來儲存視窗位置

定義一個名為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

3. 定義 ProgramWindow 類別

定義一個ProgramWindow類別,包含下列欄位:

  • screenSize:保存一個固定值,型別為Size,其width為 800、height為 600
  • size:保存一個型別為Size的值,初始值為Size實例的預設值
  • position:保存一個型別為Position的值,初始值為Position實例的預設值

視窗開啟(建立)時,一開始一律具有預設的尺寸和位置。

const programWindow = new ProgramWindow();
programWindow.screenSize.width;
// => 800

// Similar for the other fields.

附註:使用ProgramWindow這個名稱而不是Window,是為了將這個類別與瀏覽器環境中內建的Window類別區分開來。

4. 新增一個方法來調整視窗大小

ProgramWindow類別應該包含一個方法resize。 它應該接受一個型別為Size的參數作為輸入,並嘗試將視窗調整為指定的尺寸。

不過,新的尺寸不能超出某些界限。

  • 允許的最小高度或寬度是 1。 小於 1 的要求高度或寬度都會被截為 1。
  • 最大高度和寬度取決於視窗目前的位置,視窗的邊緣不能超出螢幕的邊緣。 大於這些界限的值都會被截為它們能取的最大尺寸。 例如,如果視窗的位置在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

5. 新增一個方法來移動視窗

除了調整大小的功能之外,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

6. 變更程式視窗

實作一個changeWindow函式,它接受一個ProgramWindow實例作為輸入,並將視窗變更為指定的尺寸和位置。 這個函式在套用變更之後,應該回傳傳入的ProgramWindow實例。

視窗的寬度應該是 400、高度是 300,並定位在 x = 100、y = 150。

const programWindow = new ProgramWindow();
changeWindow(programWindow);
programWindow.size.width;
// => 400

// Similar for the other fields.
透過 GitHub 編輯 連結會在新視窗或分頁中開啟
JavaScript Exercism

準備好開始 視窗系統 了嗎?

註冊 Exercism,透過 37 個概念159 個練習 和真人引導來學習並精通 JavaScript,全部免費。