前端工程研究:關於 JavaScript 的物件藍圖建立方法 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

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

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

不用藍圖的物件建立方法

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

var a = {};

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

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

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

var a = {
  name: 'Will'
};

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

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

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

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

如果要放 Symbol 物件進去,也可以這樣寫:

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

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

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

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

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

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

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

這個函數有兩點不太相同:

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

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

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

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

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

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

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

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

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

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

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

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

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

如何實現物件的繼承

物件導向程式設計領域中有許多基本原則,例如封裝、繼承、多形等等。在 JavaScript 裡,繼承是一大重點!

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

  1. 使用 prototype 實現物件繼承

    如下範例是一個簡單的物件繼承關係,建構式函數名稱為 Lesson,但我們透過建構式函數提供的 prototype 屬性來建立物件實體(object instance)的上層物件(parent object):

    function Lesson(name) {
      this.name = name;
    }
    
    Lesson.prototype.sayHello = function() {
      return `Hello ${this.name}`;
    }
    
    var a = new Lesson('JavaScript 開發實戰:核心概念篇');
    a.sayHello();
    

    使用 prototype 實現物件繼承

    上面這段程式碼比較特別的地方有以下幾點:

    1. 這個建構式函數只需要撰寫初始化物件的程式碼
    2. 物件需要共用的部分全部移到 Lesson.prototype 原型物件中,而不是保存在物件實體
  2. 使用 class 實現物件繼承

    我直接拿上面這個 prorotype 的例子,直接改寫成 class 的版本:

    class Lesson {
      name;
      constructor(name) {
        this.name = name;
      }
      sayHello() {
        return `Hello ${this.name}`;
      }
    }
    
    var a = new Lesson('JavaScript 開發實戰:核心概念篇');
    a.sayHello();
    

    使用 class 實現物件繼承

    你可以從上述兩個範例的執行結果來看,物件的結構幾乎是完全相同的!而且使用 class 語法糖來定義物件繼承關係,會相對簡單許多! 👍

    這裡比較特別的地方在於,透過 class 所建立的物件,只有類別中的 Methods (方法) 才會放進物件的上層物件中(a.__proto__),也就是 Lesson.prototype 物件中。

  3. 建立一個沒有物件繼承的『純物件』

    一般來說,你建立的任何一個物件,無論有幾層物件繼承,其頂層物件皆為 Object.prototype,那如果我真的想建立一個完全沒有上層物件純物件,那該怎麼做呢?你可以參考以下程式寫法:

    var a = Object.create(null);
    

    建立一個沒有物件繼承的『純物件』

    我覺得這樣的物件感覺真的蠻酷的,非常的乾淨! 😃

如何實現物件的多層繼承

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

  1. 使用 prototype 實現多層物件繼承

    // 定義上層建構式
    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 實現多層物件繼承

    此時,變數 e 所指向的物件,其上層物件就是 Employee.prototype,而 Employee.prototype上層物件就是 Person.prototype,而 Person.prototype上層物件是誰呢?當你沒有特別定義的時候,那就是 Object.prototype 物件,這個物件幾乎是所有物件的最頂層物件!

    Object.prototype上層物件null

  2. 使用 class 實現多層物件繼承

    透過 JavaScript 的 class 所建立出來的物件,跟用傳統 prototype 建立出來的物件,其繼承關係可能跟你想像的有點不太一樣,尤其是拿 C#Java 的類別特性來相比,在觀念上的差異其實是不太相同的!

    我直接拿上面這個例子,直接改寫成 class 的版本:

    // 定義上層類別
    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 實現多層物件繼承

    從上圖示來看,你應該不難發現,如果你用 C#Java 的類別特性來想,程式碼肯定不會如你預期的來執行!

    1. 上層類別的「屬性」其實並非「上層物件」的屬性,而是在建構式執行的時候,都寫入到「物件實體」中!
    2. 雖然 EmployeePerson 是繼承關係,但並非「物件」的繼承,而僅是「類別」的繼承而已!

    如果我在 Person 類別加入一個 getAge 方法,你會發現 getAge 確實會繼承下來,但是最終的物件結構不太相同:

    // 定義上層類別
    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 實現多層物件繼承 - 上層類別加入方法

    從上圖可以看出,所有建構式寫入的屬性,都會出現在第一層物件實體中。但是上層類別的方法,會放在 e.__proto__.__proto__ 物件中。

    類別繼承所產生的物件階層關係

  3. 設定一般物件類別上層物件

    由於 class 其實只是 prototype語法糖,因此如果你想用 class 去繼承另一個建構式,也完全是可行的!範例如下:

    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() 來達成,例如:

    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

在 JavaScript 裡,還有一個進階的物件定義方法,就是去定義每個物件中屬性的特性(Attributes in property),你可以使用 Object.defineProperty() 去定義特定物件下某個屬性的特性,或透過 Object.defineProperties() 一次定義多個屬性的特性。

我們通常會先準備好一個物件,然後使用 Object.defineProperty 去設定特定幾個屬性的特性,如下程式範例:

// 先建立一個空物件
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-inObject.keys() 取得物件中所有屬性)

    value

    • 用來設定該屬性的預設值
    • 當你設定 value 的時候,就不能設定訪問描述器 (accessor descriptor),這兩者是衝突的!

    writable

    • 用來設定該屬性的『是否可被修改』
    • 當你設定 writable 的時候,就不能設定訪問描述器 (accessor descriptor),這兩者是衝突的!
  2. 存取描述器 (accessor descriptor):用來描述存取屬性的 get/set 自訂邏輯

    以下是 ES2015 的範例:

    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 自訂邏輯

    以下是 ES5 的範例:

    function Lesson(name) {
      this.name = name;
    }
    
    Object.defineProperty(Lesson.prototype, 'message', {
      get() { return 'Hello ' + this.name; },
      enumerable: false,  // 無法枚舉的屬性
      configurable: false // 無法被刪除的屬性
    });
    

凍結與密封物件結構

預設所有的 JavaScript 物件,所有屬性都是「可變的」(Mutable)。但你可以用 Object.freeze() 將一個物件凍結,讓該物件從此之後不能再新增移除任何屬性,甚至不能修改屬性值!換句話說,該物件會被轉變成一種「不可變的」狀態 (Immutable)!

var a = {
  name: 'Will'
};

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

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

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

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

被凍結的物件,基本上就再也無法變更,但你可以透過 Object.assign() 快速重建一個新的物件。

var a = {
  name: 'Will'
};

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

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

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

JavaScript 還有一個可以密封物件的 API 叫做 Object.seal(),它跟 Object.freeze() 不一樣的地方,就是「密封物件」是可以變更屬性值的!

var a = {
  name: 'Will'
};

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

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

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

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

結論

在 JavaScript 中使用 Object 型別,相對來說比較隨心所欲一些,雖然容易上手,但隨著 JS 程式碼量越來越多,漸漸的就會開始產生許多問題。

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

相關連結