﻿# 前端工程研究：關於 JavaScript 的物件藍圖建立方法

我們都知道 JavaScript 物件建立的過程，大多都不需要事先設計「藍圖」就可以建立「物件」，不像 `C#` 或 `Java` 等強型別語言，需要先設計「藍圖」（也就是類別），才能產生物件。在 ES2015 出來之前，並沒有 `class` 語法，而是使採用**以原型為基礎**的物件導向設計模式 (Prototype-based OO)。本篇文章將介紹幾種在 JavaScript 裡面建立物件藍圖的方式。

### 不用藍圖的物件建立方法

很多人都說寫 JavaScript 的爽度很高，因為怎麼寫都可以，就以一個簡單的物件為例，根本連類別都不用宣告，直接用以下語法就可以建立物件：

```javascript
var a = {};
```

建立物件後，可以隨意新增或刪除屬性進去：

```javascript
var a = {};
a.name = 'Will'; // 擴增 name 屬性
delete a.name;   // 刪除 name 屬性
```

如果要建立一個含有 `name` 屬性的物件，可以這樣寫：

```javascript
var a = {
  name: 'Will'
};
```

也可以這樣寫，因為**屬性名稱**預設就是**字串型別**：

```javascript
var a = {
  'name': 'Will'
};
```

這樣寫也可以，讓**變數值**當成新物件的**屬性名稱**：

```javascript
var propName = 'name';
var a = {
  [propName]: 'Will'
};
```

如果要放 Symbol 物件進去，也可以這樣寫：

```javascript
var a = {
  name: 'Will',
  [Symbol.iterator]: function*() {
    for (let i in this) {
        yield this[i];
    }
  }
};
```

總之，你可以完全不用對物件進行規劃，就開始隨心所欲的建立物件，因此寫起來的爽度很高！相對的，在複雜的應用程式架構下，也比較容易失控！

### 定義物件的藍圖 (1) - 使用建構式函數

在 JavaScript 裡面，**函數**(function)其實就是**建構式**(constructor)，因此我們會透過定一個**函數**，來當成一個物件的**建構式**，也就是**物件的藍圖**。

如下範例就是一個**建構式**，建構式名稱為 `Lesson`，但看起來就像是一個**函數**：

```javascript
function Lesson(name) {
  this.name = name;
  this.sayHello = () => {
    return `Hello ${this.name}`;
  }
}
```

> 請注意 `this.sayHello` 必須使用 **箭頭函數** (Arrow Function) 才不會出錯！

這個**函數**有兩點不太相同：

1.  不需要 `return` 任何物件
2.  會使用 `this` 來代表未來即將建立的物件實體 (object instance)

當你想建立一個自訂型別為 `Lesson` 的物件時，就可以使用以下語法來建立：

```javascript
var a = new Lesson('JavaScript 開發實戰：核心概念篇');
a.sayHello();
```

![定義物件的藍圖 (1) - 使用建構式函數](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/c68c66192d84b9f43b22-85110564-ae00d880-b245-11ea-9ab3-d897ffda9ad8.png)

透過這種方式建立起的物件，由於事先透過建構式函數規劃與設計過，因此建立的物件，更能確保物件的一致性，也更加簡化物件建立的過程，不但可以讓程式碼更抽象，獲得更好的封裝，也能增加程式的可維護性！👍

### 定義物件的藍圖 (2) - 使用 `class` 類別

從 ES2015 開始，你開始可以透過 `class` 來定義類別，用來建立物件藍圖，如下範例：

```javascript
class Lesson {
  name; // 宣告屬性(Property)
  constructor(name) { // 建構式函數
    this.name = name;
  }
  sayHello() { // 宣告方法 (Method)
    return `Hello ${this.name}`;
  }
}
```

當要建立物件時，就跟以建構式函數建立物件的方式相同：

```javascript
var a = new Lesson('JavaScript 開發實戰：核心概念篇');
a.sayHello();
```

![定義物件的藍圖 (2) - 使用 class 類別](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/473c5408ee1c7c69e320-85110689-dbe61d00-b245-11ea-8e9a-f288bc1f30c4.png)

> 這樣的寫法，相較於 `C#` 或 `Java` 這種以類別為基礎的程式語言來說，其實上手會較快，因為語法相近。

透過這種方式建立的物件，你會發現跟**建構式函數**建立的物件，是相當接近的。你可以說 ES2015 推出的 `class` 語法，其實只是早期寫法的**語法糖**而已，實際上物件的特性並沒有什麼差異，只有些微的變化！⭐

### 如何實現物件的繼承

在[物件導向程式設計](https://en.wikipedia.org/wiki/Object-oriented_programming)領域中有許多基本原則，例如封裝、繼承、多形等等。在 JavaScript 裡，**繼承**是一大重點！

由於從 ES2015 開始，出現了兩種定義物件藍圖的方式，所以我用兩種不同的語法，來表達相同的物件繼承關係，藉此設計出一份更好的**物件藍圖**，各位可以比較一下差異，並選擇你喜歡的語法來寫即可：

1.  使用 `prototype` 實現物件繼承
    
    如下範例是一個簡單的物件繼承關係，建構式函數名稱為 `Lesson`，但我們透過**建構式函數**提供的 `prototype` 屬性來建立**物件實體**(object instance)的**上層物件**(parent object)：
    
    ```javascript
    function Lesson(name) {
      this.name = name;
    }
    
    Lesson.prototype.sayHello = function() {
      return `Hello ${this.name}`;
    }
    
    var a = new Lesson('JavaScript 開發實戰：核心概念篇');
    a.sayHello();
    ```
    
    ![使用 prototype 實現物件繼承](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/83329cfc886717652f2c-85204943-952e1b00-b34a-11ea-8c5d-0b3d61724145.png)
    
    上面這段程式碼比較特別的地方有以下幾點：
    
    1.  這個**建構式函數**只需要撰寫**初始化物件**的程式碼
    2.  物件需要**共用的部分**全部移到 `Lesson.prototype` 原型物件中，而不是保存在**物件實體**中
2.  使用 `class` 實現物件繼承
    
    我直接拿上面這個 `prorotype` 的例子，直接改寫成 `class` 的版本：
    
    ```javascript
    class Lesson {
      name;
      constructor(name) {
        this.name = name;
      }
      sayHello() {
        return `Hello ${this.name}`;
      }
    }
    
    var a = new Lesson('JavaScript 開發實戰：核心概念篇');
    a.sayHello();
    ```
    
    ![使用 class 實現物件繼承](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/0627f6bccd223f72bbd9-85204977-cdcdf480-b34a-11ea-9672-9074e74b2414.png)
    
    你可以從上述兩個範例的執行結果來看，物件的結構幾乎是完全相同的！而且使用 `class` 語法糖來定義物件繼承關係，會相對簡單許多！ 👍
    
    > 這裡比較特別的地方在於，透過 `class` 所建立的物件，只有**類別**中的 Methods (方法) 才會放進物件的**上層物件**中(`a.__proto__`)，也就是 `Lesson.prototype` 物件中。
    
3.  建立一個**沒有物件繼承**的『純物件』
    
    一般來說，你建立的任何一個物件，無論有幾層物件繼承，其頂層物件皆為 `Object.prototype`，那如果我真的想建立一個**完全沒有上層物件**的**純物件**，那該怎麼做呢？你可以參考以下程式寫法：
    
    ```javascript
    var a = Object.create(null);
    ```
    
    ![建立一個沒有物件繼承的『純物件』](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/1fc91f24835c7b86f13f-85116474-f7562580-b24f-11ea-9e6e-1a74c9b6e4f3.png)
    
    > 我覺得這樣的物件感覺真的蠻酷的，非常的乾淨！ 😃
    

### 如何實現物件的多層繼承

如果要在 JavaScript 實現多層的繼承，那麼程式碼就會再複雜一些！

1.  使用 `prototype` 實現多層物件繼承
    
    ```javascript
    // 定義上層建構式
    function Person(age,weight) {
      this.age = age;
      this.weight = weight;
    }
    // 定義下層建構式
    function Employee(age, weight, salary) {
      this.age = age;
      this.weight = weight;
      this.salary = salary;
    }
    // 將 Employee 的上層物件改為 Person 的物件實體
    Employee.prototype = new Person(0, 0);
    // 建立 Employee 物件實體
    var e = new Employee(23, 70, 40000);
    ```
    
    ![使用 prototype 實現多層物件繼承](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/dc4c6169231adb6186fc-85205143-e8549d80-b34b-11ea-96e2-da8703a5aee4.png)
    
    此時，變數 `e` 所指向的**物件**，其**上層物件**就是 `Employee.prototype`，而 `Employee.prototype` 的**上層物件**就是 `Person.prototype`，而 `Person.prototype` 的**上層物件**是誰呢？當你沒有特別定義的時候，那就是 `Object.prototype` 物件，這個物件幾乎是所有物件的最頂層物件！
    
    > `Object.prototype` 的**上層物件**為 `null`
    
2.  使用 `class` 實現多層物件繼承
    
    透過 JavaScript 的 `class` 所建立出來的物件，跟用傳統 `prototype` 建立出來的物件，其繼承關係可能跟你想像的有點不太一樣，尤其是拿 `C#` 與 `Java` 的類別特性來相比，在觀念上的差異其實是不太相同的！
    
    我直接拿上面這個例子，直接改寫成 `class` 的版本：
    
    ```javascript
    // 定義上層類別
    class Person {
      age;
      weight;
      constructor(age, weight) {
        this.age = age;
        this.weight = weight;
      }
    }
    // 定義下層類別
    class Employee extends Person {
      salary;
      constructor(age, weight, salary) {
        super(age, weight);
        this.age = age;
        this.weight = weight;
        this.salary = salary;
      }
    }
    // 建立 Employee 物件實體
    var e = new Employee(23, 70, 40000);
    ```
    
    ![使用 class 實現多層物件繼承](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/ed79ab9eb0f147ca81a6-85205335-649bb080-b34d-11ea-8548-47a202bbab03.png)
    
    從上圖示來看，你應該不難發現，如果你用 `C#` 與 `Java` 的類別特性來想，程式碼肯定不會如你預期的來執行！
    
    1.  上層類別的「屬性」其實並非「上層物件」的屬性，而是在建構式執行的時候，都寫入到「物件實體」中！
    2.  雖然 `Employee` 與 `Person` 是繼承關係，但並非「物件」的繼承，而僅是「類別」的繼承而已！
    
    如果我在 `Person` 類別加入一個 `getAge` 方法，你會發現 `getAge` 確實會繼承下來，但是最終的物件結構不太相同：
    
    ```javascript
    // 定義上層類別
    class Person {
      age;
      weight;
      constructor(age, weight) {
        this.age = age;
        this.weight = weight;
      }
      getAge() {
        return this.age;
      }
    }
    // 定義下層類別
    class Employee extends Person {
      salary;
      constructor(age, weight, salary) {
        super(age, weight);
        this.age = age;
        this.weight = weight;
        this.salary = salary;
      }
    }
    // 建立 Employee 物件實體
    var e = new Employee(23, 70, 40000);
    e.getAge();
    ```
    
    ![使用 class 實現多層物件繼承 - 上層類別加入方法](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/f7bcb905750a7bc6685e-85205491-34084680-b34e-11ea-96ac-b8eae1778b7d.png)
    
    從上圖可以看出，所有建構式寫入的屬性，都會出現在第一層物件實體中。但是上層類別的方法，會放在 `e.__proto__.__proto__` 物件中。
    
    ![類別繼承所產生的物件階層關係](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/bf3001d4b99d45cf3083-85205575-c577b880-b34e-11ea-91ff-03e6fcfd3ded.png)
    
3.  設定**一般物件**為**類別**的**上層物件**
    
    由於 `class` 其實只是 `prototype` 的**語法糖**，因此如果你想用 `class` 去繼承另一個**建構式**，也完全是可行的！範例如下：
    
    ```javascript
    function Animal (name) {
      this.name = name;
    }
    
    Animal.prototype.speak = function () {
      console.log(this.name + ' makes a noise.');
    }
    
    class Dog extends Animal {
      speak() {
        console.log(this.name + ' barks.');
      }
    }
    
    var d = new Dog('Mitzie');
    d.speak(); // Mitzie barks.
    ```
    
    如果你想設定 `class` 類別的**上層物件**，可以透過 [Object.setPrototypeOf()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf) 來達成，例如：
    
    ```javascript
    var Animal = {
      speak() {
        console.log(this.name + ' makes a noise.');
      }
    };
    
    class Dog {
      constructor(name) {
        this.name = name;
      }
    }
    
    // 直接將 Dog.prototype 設定為 Animal 物件
    Object.setPrototypeOf(Dog.prototype, Animal);
    
    var d = new Dog('Mitzie');
    d.speak(); // Mitzie makes a noise.
    ```
    

### 定義物件屬性的特性 - 使用 [Object.defineProperty](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty)

在 JavaScript 裡，還有一個進階的物件定義方法，就是去定義每個物件中**屬性的特性**(Attributes in property)，你可以使用 [Object.defineProperty()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) 去定義特定物件下某個屬性的**特性**，或透過 [Object.defineProperties()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperties) 一次定義多個屬性的特性。

我們通常會先準備好一個物件，然後使用 [Object.defineProperty](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) 去設定特定幾個屬性的特性，如下程式範例：

```javascript
// 先建立一個空物件
var obj = {};

// 然後定義該物件下的 name 屬性(Property)，並設定 descriptors (敘述內容)
Object.defineProperty(obj, 'name', {
  enumerable: false,
  configurable: false,
  writable: false,
  value: undefined
});
```

> 這裡的 `descriptors` 其實就是一個**物件**！

當我們在定義屬性的時候，其**描述器**(descriptors)有兩種類型：

1.  **資料描述器** (**data descriptor**)：用來描述特定屬性有哪些特性 (共 4 種)
    
    `configurable`
    
    -   用來設定該屬性『**是否可刪除**』以及『**是否允許再次變更資料描述器**』
    
    `enumerable`
    
    -   用來設定該屬性『**是否可枚舉**』( 透過 `for-in` 或 `Object.keys()` 取得物件中所有屬性)
    
    `value`
    
    -   用來設定該屬性的**預設值**
    -   當你設定 `value` 的時候，就不能設定**訪問描述器** (accessor descriptor)，這兩者是衝突的！
    
    `writable`
    
    -   用來設定該屬性的**值**『是否可被修改』
    -   當你設定 `writable` 的時候，就不能設定**訪問描述器** (accessor descriptor)，這兩者是衝突的！
2.  **存取描述器** (**accessor descriptor**)：用來描述存取屬性的 get/set 自訂邏輯
    
    以下是 ES2015 的範例：
    
    ```javascript
    class Lesson {
      constructor(name) {
        this.name = name;
      }
    }
    
    Object.defineProperty(Lesson.prototype, 'message', {
      get() { return `Hello ${this.name}`; },
      enumerable: false,  // 無法枚舉的屬性
      configurable: false // 無法被刪除的屬性
    });
    ```
    
    ![訪問描述器 (accessor descriptor)：用來描述存取屬性的 get/set 自訂邏輯](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/e3f6a9acb168432c1d8f-85120781-4d2dcc00-b256-11ea-928d-27b1e74f342e.png)
    
    以下是 ES5 的範例：
    
    ```javascript
    function Lesson(name) {
      this.name = name;
    }
    
    Object.defineProperty(Lesson.prototype, 'message', {
      get() { return 'Hello ' + this.name; },
      enumerable: false,  // 無法枚舉的屬性
      configurable: false // 無法被刪除的屬性
    });
    ```
    

### 凍結與密封物件結構

預設所有的 JavaScript 物件，所有屬性都是「**可變的**」(**Mutable**)。但你可以用 [Object.freeze()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) 將一個物件凍結，讓該物件從此之後不能再**新增**或**移除**任何屬性，甚至不能修改**屬性值**！換句話說，該物件會被轉變成一種「**不可變的**」狀態 (**Immutable**)！

```javascript
var a = {
  name: 'Will'
};

Object.freeze(a); // 凍結物件

a.name = 'John'; // 無法變更屬性值 (不會報錯)

delete a.name;   // 也無法刪除屬性 (不會報錯)

a.type = 'pp';   // 也無法新增屬性 (不會報錯)
```

被凍結的物件，基本上就再也無法變更，但你可以透過 [Object.assign()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) 快速重建一個新的物件。

```javascript
var a = {
  name: 'Will'
};

Object.freeze(a); // 凍結物件

var b = Object.assign({}, a); // 建立一個 {} 新物件，並將 a 物件的內容全部複製過去

var c = Object.assign({}, a, {type: 'pp'}); // 重建物件並加入新屬性
```

JavaScript 還有一個可以密封物件的 API 叫做 [Object.seal()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/seal)，它跟 [Object.freeze()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) 不一樣的地方，就是「密封物件」是可以變更屬性值的！

```javascript
var a = {
  name: 'Will'
};

Object.seal(a); // 密封物件

a.name = 'John'; // 可以變更屬性值！

delete a.name;   // 也無法刪除屬性 (不會報錯)

a.type = 'pp';   // 也無法新增屬性 (不會報錯)
```

### 結論

在 JavaScript 中使用 [Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object) 型別，相對來說比較隨心所欲一些，雖然容易上手，但隨著 JS 程式碼量越來越多，漸漸的就會開始產生許多問題。

因此，對常用物件進行適當的藍圖設計，不但可以藉由抽象化大幅降低程式碼的複雜度，若搭配 TypeScript 一起使用，對所有類別中的屬性、方法與參數，都能在**編譯時期**進行型別檢查，程式碼品質肯定會有所改善，開發效率也大幅提升！ 👍

### 相關連結

-   MDN
    -   [Object - JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object)
    -   [JavaScript data types and data structures - JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures)
    -   [Classes - JavaScript](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Classes)
-   Can I use
    -   [JavaScript built-in: Object: freeze](https://caniuse.com/#feat=mdn-javascript_builtins_object_freeze)
    -   [JavaScript built-in: Object: seal](https://caniuse.com/#feat=mdn-javascript_builtins_object_seal)
    -   [JavaScript built-in: Object: defineProperty](https://caniuse.com/#feat=mdn-javascript_builtins_object_defineproperty)
    -   [JavaScript built-in: Object: defineProperties](https://caniuse.com/#feat=mdn-javascript_builtins_object_defineproperties)
-   JAVASCRIPT.INFO
    -   [Class inheritance](https://javascript.info/class-inheritance)
