JS的物件導向(理解物件,原型,原型鏈,繼承,類)

小學生發表於2022-04-01

JS有6種簡單資料型別(也稱為基本資料型別):Undefined、Null、Boolean、Number ,String和Symbol 。還有 1 種複雜資料型別——Object,Object 本質上是由一組無序的名值對組成的。JS不支援任何建立自定義型別的機制,而所有值最終都將是上述7種資料型別之一 。
Array,Function都是Object,都繼承了Object的原型物件。

理解物件

Object 是一種無序名值對的集合

方式一
const person = new Object();
person.name = '張三';
person.age = 16;
person.sayName = () => {
    console.log(this.name);
}


// 方式二
const person = {
    name: '張三',
    age: 16,
    sayName: ()=>{
        console.log(this.name);
    }

}

1.資料屬性

    Object.defineProperty(person, 'name', {
        writable: true, // 是否能被重寫
        configurable: true, // 能否被刪除
        enumerable: true, // 能否被for-in遍歷
        value: '張三',
    });

writable和configurable為false的情況下,修改和刪除不生效。而且在嚴格模式下會報錯。

2.訪問器屬性

    let person = {age: 16};
    Object.defineProperty(person, 'name', {
        get() {
            console.log('get..');
            return this.name_;
        },
        set(newV) {
            console.log('set。。。。', newV);
            this.name_ = newV;
        }
    });

    person.name = '李四';
    console.log(person.name);

此外還可以一次定義多個屬性

    const person = {};
    Object.defineProperties(person, {
        _name: {
            configurable: true,
            enumerable: true,
        },
        name: {
            get: function() {
                return this._name;
            },
            set: function(newV) {
                this._name = newV;
            }
        },
        age: {
            get: function() {
                return this.age;
            },
            set: function(newV) {
                this.age = newV;
            }
        }
    })

3.讀取屬性的特性

使用 Object.getOwnPropertyDescriptor()方法可以取得指定屬性的屬性描述符。

    const person = {age: 16, _name: 'zhang'};
    const descriptor = Object.getOwnPropertyDescriptor(person, 'age');
    console.log('...', descriptor.writable);  // true
    console.log('...', descriptor.enumerable);  // true
    console.log('...', descriptor.configurable);  // true
    console.log('...', descriptor.value);  // 16

建立物件

1.工廠模式

    function createPerson(name, age) {
        return {
            name,
            age,
            sayName: function () { // 注意這裡不能用箭頭函式,會影響this的指向
                console.log(this.name);
            }
        };
    }
    let person1 = createPerson("張三", 29);
    let person2 = createPerson("李四", 27);

    person1.sayName(); // 張三
    person2.sayName(); // 李四

缺點:無法解決物件標識問題(即新建立的物件是什麼型別)。

2.建構函式模式

注意事項

  • new建立函式
  • 函式名應該大寫。

      function Person(name, age) {
          this.name = name;
          this.age = age;
          this.sayName = ()=>{
              console.log(this.name);
          };
      }
      let person1 = new Person("張三", 29);
      let person2 = new Person("李四", 27);
    
      person1.sayName(); // 張三
      person2.sayName(); // 李四

    new執行的操作

  • 在記憶體中建立一個新物件。
  • 這個新物件內部的[[Prototype]]特性被賦值為建構函式的 prototype 屬性。
  • 建構函式內部的 this 被賦值為這個新物件(即 this 指向新物件)。
  • 執行建構函式內部的程式碼(給新物件新增屬性)。
  • 如果建構函式返回非空物件,則返回該物件;否則,返回剛建立的新物件。

建構函式模式缺點:建構函式的主要問題在於,其定義的方法會在每個例項上 都建立一遍。

console.log(person1.sayName == person2.sayName); // false

3.原型模式

    function Person() {}
    Person.prototype.name = "張三";
    Person.prototype.age = 29;
    Person.prototype.sayName = function() {
        console.log(this.name);
    };
    let person1 = new Person();
    person1.sayName(); // "張三"
    let person2 = new Person();
    person2.sayName(); // "張三"
    console.log(person1.sayName === person2.sayName); // true

物件原型,原型鏈

1.理解原型

無論何時,只要建立一個函式,就會按照特定的規則為這個函式建立一個 prototype 屬性(指向 原型物件)。預設情況下,所有原型物件自動獲得一個名為 constructor 的屬性,指回與之關聯的構 造函式。對前面的例子而言,Person.prototype.constructor 指向 Person。然後,因建構函式而異,可能會給原型物件新增其他屬性和方法。

    function Person() {}
    console.log(Person.prototype.constructor === Person); // true

原型1.jpg

在自定義建構函式時,原型物件預設只會獲得 constructor 屬性,其他的所有方法都繼承自 Object。每次呼叫建構函式建立一個新例項,這個例項的內部[[Prototype]]指標就會被賦值為構 造函式的原型物件。指令碼中沒有訪問這個[[Prototype]]特性的標準方式,但 Firefox、Safari 和 Chrome 會在每個物件上暴露__proto__屬性,通過這個屬性可以訪問物件的原型。在其他實現中,這個特性 完全被隱藏了。關鍵在於理解這一點:例項與建構函式原型之間有直接的聯絡,但例項與建構函式之間沒有。
原型2.jpg
注意:

  1. 建構函式、原型物件和例項是 3 個完全不同的物件

      console.log(person1 !== Person); // true
      console.log(person1 !== Person.prototype); // true
      console.log(Person.prototype !== Person);  // true
  2. 例項與建構函式沒有直接聯絡,與原型物件有直接聯絡

    // 例項通過__proto__連結到原型物件,它實際上指向隱藏特性[[Prototype]]。
    // 建構函式通過 prototype 屬性連結到原型物件
    console.log(person1.__proto__ === Person.prototype); // true
    conosle.log(person1.__proto__.constructor === Person); // true
  3. 同一個建構函式建立的兩個例項,共享同一個原型物件:

    console.log(person1.__proto__ === person2.__proto__); // true

原型相關方法

  • isPrototypeOf()
    可以使用 isPrototypeOf()方法確定兩個物件之間的這種關係。本質上,isPrototypeOf()會在傳入引數的[[Prototype]]指向呼叫它的物件時 返回 true,如下所示:

    console.log(Person.prototype.isPrototypeOf(person1)); // true 
    console.log(Person.prototype.isPrototypeOf(person2)); // true

    這裡通過原型物件呼叫 isPrototypeOf()方法檢查了 person1 和 person2。因為這兩個例子內 部都有連結指向 Person.prototype,所以結果都返回 true。

  • Object.getPrototypeOf()
    Object 型別有一個方法叫 Object.getPrototypeOf(),返回引數的內部特性 [[Prototype]]的值。

    console.log(Object.getPrototypeOf(person1) == Person.prototype); // true 
    console.log(Object.getPrototypeOf(person1).name); // "張三"
  • 重寫物件的繼承關係 方法一:Object.setPrototypeOf()

      let person = {name:'zhang'};
      let student = {grade: '一年級'};
    
      Object.setPrototypeOf(student, person);
    
      console.log(student.grade); // 一年級
      console.log(student.name); // zhang
      console.log(Object.getPrototypeOf(student) === person); // true

    Object.setPrototypeOf()可能會嚴重影響程式碼效能。所以不推薦使用

  • 重寫物件的繼承關係 方法二: Object.create()
    為避免使用 Object.setPrototypeOf()可能造成的效能下降,可以通過 Object.create()來創 建一個新物件,同時為其指定原型:

      let person = {name:'zhang'};
      let student = Object.create(person)
      student.grade = '一年級';
    
      console.log(student.grade); // 一年級
      console.log(person.name); // zhang
      console.log(Object.getPrototypeOf(student) === person); // true

2.原型鏈

原型3.jpg

  /**
  * 正常的原型鏈都會終止於 Object 的原型物件 * Object 原型的原型是 null
  */
  console.log(Person.prototype.__proto__ === Object.prototype);  // true
  console.log(Person.prototype.__proto__.constructor === Object); // true
  console.log(Person.prototype.__proto__.__proto__ === null); // true
  console.log(Person.prototype.__proto__ === Person);
  • 用instanceof檢查原型鏈

    console.log(person1 instanceof Person);  // true
    console.log(person1 instanceof Object);  // true
    console.log(Person.prototype instanceof Object);  // true
  • 原型層級
    在通過物件訪問屬性時,會按照這個屬性的名稱開始搜尋。搜尋開始於物件例項本身。如果在這個 例項上發現了給定的名稱,則返回該名稱對應的值。如果沒有找到這個屬性,則搜尋會沿著指標進入原 型物件,然後在原型物件上找到屬性後,再返回對應的值。因此,在呼叫 person1.sayName()時,會 發生兩步搜尋。首先,JavaScript 引擎會問:“person1 例項有 sayName 屬性嗎?”答案是沒有。然後, 繼續搜尋並問:“person1 的原型有 sayName 屬性嗎?”答案是有。於是就返回了儲存在原型上的這 個函式。在呼叫 person2.sayName()時,會發生同樣的搜尋過程,而且也會返回相同的結果。這就是 原型用於在多個物件例項間共享屬性和方法的原理。
    雖然可以通過例項讀取原型物件上的值,但不可能通過例項重寫這些值。如果在例項上新增了一個 與原型物件中同名的屬性,那就會在例項上建立這個屬性,這個屬性會遮住原型物件上的屬性。下面看 一個例子:

      function Person() {}
      Person.prototype.name = "張三";
      Person.prototype.age = 29;
      Person.prototype.job = "碼農";
      Person.prototype.sayName = function() {
          console.log(this.name);
      };
      let person1 = new Person();
      let person2 = new Person();
      person1.name = "李四";
      console.log(person1.name); // "李四",來自例項
      console.log(person2.name); // "張三",來自原型
  • 使用hasOwnProperty()方法確定屬性在例項上,還是在原型物件上

      function Person() {}
      Person.prototype.name = "張三";
      Person.prototype.age = 29;
      Person.prototype.job = "碼農";
      Person.prototype.sayName = function() {
          console.log(this.name);
      };
      let person1 = new Person();
      let person2 = new Person();
      console.log(person1.hasOwnProperty("name")); // false
    
    
      person1.name = "李四";
      console.log(person1.name); // "李四",來自例項
      console.log(person1.hasOwnProperty("name")); // true
    
      console.log(person2.name); // "張三",來自原型
      console.log(person2.hasOwnProperty("name")); // false
    
    
      delete person1.name;
      console.log(person1.name); // "張三",來自原型
      console.log(person1.hasOwnProperty("name")); // false
  • in操作符
    in 操作符會在可 以通過物件訪問指定屬性時返回 true,無論該屬性是在例項上還是在原型上。

     function Person() {}
      Person.prototype.name = "張三";
      Person.prototype.age = 29;
      Person.prototype.job = "碼農";
      Person.prototype.sayName = function() {
          console.log(this.name);
      };
      let person1 = new Person();
      let person2 = new Person();
      console.log(person1.hasOwnProperty("name")); // false
    
    
      person1.name = "李四";
      console.log(person1.name); // "李四",來自例項
      console.log(person1.hasOwnProperty("name")); // true
      console.log("name" in person1); // true
    
      console.log(person2.name); // "張三",來自原型
      console.log(person2.hasOwnProperty("name")); // false
      console.log("name" in person1); // true
    
      delete person1.name;
      console.log(person1.name); // "張三",來自原型
      console.log(person1.hasOwnProperty("name")); // false
      console.log("name" in person1); // true

繼承

繼承是物件導向程式設計中討論最多的話題。很多物件導向語言都支援兩種繼承:介面繼承和實現繼承。前者只繼承方法簽名,後者繼承實際的方法。介面繼承在js中是不可能的,因為函式沒有簽名。實現繼承是js唯一支援的繼承方式,而這主要是通過原型鏈實現的。

1.原型鏈

    function Person(name) {
        this.name = name;
    }

    Person.prototype.sayName = function (){
        console.log(`我是${this.name}`);
    }

    function Student(grade) {
        this.grade = grade;
    }

    Student.prototype = new Person('張三');
    Student.prototype.sayGrade = function () {
        console.log(`我已經${this.grade}了`);
    }

    const stu1 = new Student('一年級');
    console.log(stu1.name); // 張三
    console.log(stu1.grade); // 一年級
    stu1.sayName(); // 我是張三
    stu1.sayGrade(); // 我已經一年級了

原型鏈的缺點
主要問題出現在原型中包含引用值的時候。前面在談到原型的問題時也提到過,原型中包含的引用值會在所有例項間共享,這也是為什麼屬性通常會 在建構函式中定義而不會定義在原型上的原因。在使用原型實現繼承時,原型實際上變成了另一個型別 的例項。這意味著原先的例項屬性搖身一變成為了原型屬性。

    function Person() {
        this.hobby = ['唱', '跳'];
    }

    function Student() {}

    Student.prototype = new Person();

    const stu1 = new Student();
    console.log(stu1.hobby); // ['唱', '跳']
    stu1.hobby.push('rapper');
    console.log(stu1.hobby); // ['唱', '跳', 'rapper']

    const stu2 = new Student();
    console.log(stu2.hobby); // ['唱', '跳', 'rapper']

2.盜用建構函式

為了解決原型包含引用值導致的繼承問題,一種叫作“盜用建構函式”(constructor stealing)的技 術在開發社群流行起來(這種技術有時也稱作“物件偽裝”或“經典繼承”)。基本思路很簡單:在子類 建構函式中呼叫父類建構函式。因為畢竟函式就是在特定上下文中執行程式碼的簡單物件,所以可以使用 apply()和 call()方法以新建立的物件為上下文執行建構函式。

     function Person() {
        this.hobby = ['唱', '跳'];
    }

    function Student() {
        Person.call(this);
    }

    const stu1 = new Student();
    console.log(stu1.hobby); // ['唱', '跳']
    stu1.hobby.push('rapper');
    console.log(stu1.hobby); // ['唱', '跳', 'rapper']

    const stu2 = new Student();
    console.log(stu2.hobby); // ['唱', '跳']

優點:相比於使用原型鏈,盜用建構函式的一個優點就是可以在子類建構函式中向父類建構函式傳參。

缺點:盜用建構函式的主要缺點,也是使用建構函式模式自定義型別的問題:必須在建構函式中定義方法,因此函式不能重用。此外,子類也不能訪問父類原型上定義的方法,因此所有型別只能使用建構函式模式。由於存在這些問題,盜用建構函式基本上也不能單獨使用。

3.組合繼承

組合繼承(有時候也叫偽經典繼承)綜合了原型鏈和盜用建構函式,將兩者的優點集中了起來。基本的思路是使用原型鏈繼承原型上的屬性和方法,而通過盜用建構函式繼承例項屬性。這樣既可以把方 法定義在原型上以實現重用,又可以讓每個例項都有自己的屬性。

    function Person(name, age) {
        this.name = name;
        this.age = age;
        this.hobby = ['唱', '跳'];
    }
    Person.prototype.sayName = function (){
        console.log(`我是${this.name}`);
    }

    function Student({name, age}) {
        Person.call(this, name, age);
    }

    Student.prototype = new Person();

    const stu1 = new Student({name:'張三', age: 16});
    console.log(stu1.hobby); // ['唱', '跳']
    stu1.hobby.push('rapper');
    console.log(stu1.hobby); // ['唱', '跳', 'rapper']
    stu1.sayName(); // 我是張三


    const stu2 = new Student({name:'李四', age: 24});
    console.log(stu2.hobby); // ['唱', '跳', 'rapper']
    stu2.sayName(); // 我是李四

組合繼承彌補了原型鏈和盜用建構函式的不足,是 JavaScript 中使用最多的繼承模式。而且組合繼 承也保留了 instanceof 操作符和 isPrototypeOf()方法識別合成物件的能力。

4.原型式繼承

原型式繼承適用於這種情況:你有一個物件,想在它的基礎上再建立一個新物件。你需要把這個物件先傳給 object(),然後再對返回的物件進行適當修改。

function object(o) {
        function F() {}
        F.prototype = o;
        return new F();
    }


    let person = {
        name: "張三",
        friends: ["唱", "跳"]
    };
    let anotherPerson = object(person);
    anotherPerson.name = "李四";
    anotherPerson.friends.push("rapper");
    let yetAnotherPerson = object(person);
    yetAnotherPerson.name = "王二麻子";
    yetAnotherPerson.friends.push("籃球");
    console.log(person.friends); // ['唱', '跳', 'rapper', '籃球']

js新增的Object.create()與這裡的 object()方法效果相同。
原型式繼承非常適合不需要單獨建立建構函式,但仍然需要在物件間共享資訊的場合。但要記住,屬性中包含的引用值始終會在相關物件間共享,跟使用原型模式是一樣的。

5.寄生式繼承

與原型式繼承比較接近的一種繼承方式是寄生式繼承(parasitic inheritance),也是 Crockford 首倡的 一種模式。寄生式繼承背後的思路類似於寄生建構函式和工廠模式:建立一個實現繼承的函式,以某種 方式增強物件,然後返回這個物件。基本的寄生繼承模式如下:

  function object(o) {
        function F() {}
        F.prototype = o;
        return new F();
    }

    function createObject(original) {
        let clone = object(original); // 通過呼叫函式建立一個新物件
        clone.sayName = function () { // 以某種方式增強這個物件
            console.log(this.name);
        };
        return clone; // 返回這個物件
    }

    let person = {
        name: "張三",
    };
    let anotherPerson = createObject(person);
    anotherPerson.sayName();  // 張三

寄生式繼承同樣適合主要關注物件,而不在乎型別和建構函式的場景。object()函式不是寄生式 繼承所必需的,任何返回新物件的函式都可以在這裡使用。
缺點:通過寄生式繼承給物件新增函式會導致函式難以重用,與建構函式模式類似。

6.寄生式組合繼承

組合繼承其實也存在效率問題。最主要的效率問題就是父類建構函式始終會被呼叫兩次:一次在是 建立子類原型時呼叫,另一次是在子類建構函式中呼叫。本質上,子類原型最終是要包含超類物件的所 有例項屬性,子類建構函式只要在執行時重寫自己的原型就行了。
寄生式組合繼承通過盜用建構函式繼承屬性,但使用混合式原型鏈繼承方法。基本思路是不通過呼叫父類建構函式給子類原型賦值,而是取得父類原型的一個副本。說到底就是使用寄生式繼承來繼承父 類原型,然後將返回的新物件賦值給子類原型。

 function object(o) {
        function F() {}
        F.prototype = o;
        return new F();
    }

    function inheritPrototype(child, parent) {
        let prototype = object(parent.prototype); // 建立物件
        prototype.constructor = child; // 增強物件
        child.prototype = prototype; // 賦值物件
    }


    function Person(name, age) {
        this.name = name;
        this.age = age;
        this.hobby = ['唱', '跳'];
    }
    Person.prototype.sayName = function (){
        console.log(`我是${this.name}`);
    }

    function Student({name, age}) {
        Person.call(this, name, age);
    }

    inheritPrototype(Student, Person);


    Student.prototype.sayAge = function (){
        console.log(`我今年${this.age}`);
    }


    const stu1 = new Student({name:'張三', age: 16});
    console.log(stu1.hobby); // ['唱', '跳']
    stu1.hobby.push('rapper');
    console.log(stu1.hobby); // ['唱', '跳', 'rapper']
    stu1.sayName(); // 我是張三
    stu1.sayAge(); // 我今年16


    const stu2 = new Student({name:'李四', age: 24});
    console.log(stu2.hobby); // ['唱', '跳', 'rapper']
    stu2.sayName(); // 我是李四
    stu2.sayAge(); // 我今年24
   

這個 inheritPrototype()函式實現了寄生式組合繼承的核心邏輯。這個函式接收兩個引數:子 類建構函式和父類建構函式。在這個函式內部,第一步是建立父類原型的一個副本。然後,給返回的 prototype 物件設定 constructor 屬性,解決由於重寫原型導致預設 constructor 丟失的問題。最後將新建立的物件賦值給子型別的原型。
這裡只呼叫了一次 Person 建構函式,避免了 Student.prototype 上不必要也用不到的屬性,因此可以說這個例子的效率更高。而且,原型鏈仍然保持不變,因此 instanceof 操作符和 isPrototypeOf()方法正常有效。寄生式組合繼承可以算是引用型別繼承的最佳模式。

class是es6新增的定義,其實可以理解成一種語法糖,本質上就是一個函式。

class Person {}

console.log(Person); // class Person {}
console.log(typeof Person); // function

類的構成
類可以包含建構函式方法、例項方法、獲取函式、設定函式和靜態類方法,但這些都不是必需的。

    class Person {
        constructor(name) {
            console.log('constructor');
            this.name = name;
        }

        sayAge(){
            console.log('age....')
        }

        static sayHi() {
            console.log('sayHi');
        }
    }
  • 類的繼承

      class Person {
          constructor({name, age}) {
              this.name = name;
              this.age = age;
          }
    
          sayName() {
              console.log(`我是${this.name}`)
          }
      }
    
    
      class Student extends Person{
          constructor(props) {
              super(props);
              this.grade = props.grade;
          }
      }
    
      const stu1 = new Student({name: '張三', age: 14, grade:'一年級'});
    
      console.log(stu1.name); // 張三
      console.log(stu1.grade); // 一年級
      stu1.sayName(); // 我是張三

    雖然類繼承使用的是新語法extends,但背後依舊使用的是原型鏈。

相關文章