javascript
JavaScript 面向对象详解
首先,我肯定是需要你告訴我,什么是面向對象,面向對象有哪些特點,以及這些特點的解釋。
JavaScript 如何實現這些特點,比如封裝、繼承、多態。如果關于上述三點,你能夠解釋到有多少種實現方式、優缺點是什么。以及近幾年流行的解決方案是什么。這就是「加分」 ,比如對于繼承吧。類式繼承、構造函數繼承、組合繼承、原型繼承、寄生組合繼承等等,說出大概的實現思路和優缺點,再介紹下 extends 或者 mixin 的實現甚至你可以衍生到 JavaScript 的模塊化發展甚至到為什么現在 TS 如此流行。那么可以說到這一環節解答的就非常棒了。
回答完 JavaScript 的面向對象,是不是可以從此衍生下為什么需要面向對象。以及當先對于軟件設計的高內聚、低耦合的思考?來個對此題一個提綱挈領的總結?
綜上所述,其實不難看出,越是這種基礎且開放的題目,可以是一個陷阱,更可以是一個機會。因為一道題真的可以全方面的感受到應聘的基礎是否扎實。
JavaScript 面向對象詳解(一)
ES6 之前的 JavaScript 面向對象比較不好理解,涉及到很多知識和思想。
ES6 增加了 class 和 extends 來實現類的封裝和繼承,但是通過 babel 轉換成 ES5 之后還是之前的一套邏輯。
這里,我打算用四篇文章,來講解一下關于 ES5 中面向對象的知識體系,一起學習一下吧!
一. JavaScript 的對象
1.1. 傳統對象 vs JavaScript 對象
傳統的面向對象
- 面向對象語言的一個標志就是類
- 類是所有對象的統稱, 是更高意義上的一種抽象. 對象是類的實例.
- 通過類我們可以創建任意多個具體的對象.
- 在學習 C++/OC/Java/Python 等編程語言的時候, 都可以按照這種方式去創建類和對象.
JavaScript 的面向對象
- JavaScript 中沒有類的概念(ES6 之前),因此我們通常稱為基于對象,而不是面向對象.
- 雖然 JavaScript 中的基于對象也可以實現類似于類的封裝、繼承、甚至是多態。但是和傳統意義的面向對象還是稍微有一些差異(后面我們會講解它是如何實現的)
- ECMA 中定義對象是這樣: 無序屬性的集合, 屬性可以包含基本值, 對象或者函數.
- 也就是對象是一組沒有順序的值組成的集合而已.
- 對象的每個屬性或者方法都有一個名字, 而名字對應一個值. 有沒有覺得非常熟悉?
- 沒錯, 其實就是我們經常看到和使用的映射(或者有些語言稱為字典, 通常會使用哈希表來實現).
1.2. 簡單的方式創建對象
創建自定義對象最簡單的方式就是創建一個 Object 實例, 然后添加屬性和方法
// 1.創建person的對象 var person = new Object();// 2.給person對象賦值了一些動態的屬性和方法 person.name = 'LBJ輝'; person.age = 18; person.height = 1.88;person.sayHello = function () {alert('Hello, My name is ' + this.name); };// 3.調用方法, 查看結果 person.sayHello();代碼解析:
- 步驟一: 創建一個名為 person 的對象.
- 步驟二: 給對象動態的賦值了一些屬性包括一個方法
- 步驟三: 調用 sayHello()方法, 主要看一下 this.name 會獲取到誰呢? LBJ 輝
插播一個信息: 函數和方法的關系
-
很多人在學習編程的時候, 會分不清楚什么是函數, 什么又是方法. 或者在什么情景下稱為函數, 什么情景下稱為方法.
-
首先, 如果你看的是英文文檔, 會有明顯的區分: Function 被稱為函數, Method 被稱為方法.
-
但是英文中, 為什么需要有這兩個稱呼呢?
-
- 在早期的編程語言中, 只有函數(類似于數學中函數的稱呼)
- 后來有了面向對象語言, 面向對象語言中, 類中也可以定義函數. 但是人們為了區分在類中定義的函數, 通常稱類中的函數為方法.
- 還有一個非常重要的原因是, 通常方法中會攜帶一個調用者的當前對象(會將調用者作為參數一起傳遞進去), 也就是說 this(有些語言中是 self. 比如 OC/Swift/Python 等)
- 當然, 你從這個角度來說, JavaScript 中就沒有函數了, 因為函數中都有 this 這樣的參數. 但是通常來說, 我們還是會將封裝到類中的函數稱為方法, 而全局定義的函數稱為函數.
-
如果接觸過 Java 的同學可能會知道 Java 中只有方法的程序, 沒有函數的稱呼. 學習過 C 語言的同學可能知道, C 語言中只有函數的稱呼, 沒有方法的稱呼.
-
這就是因為 Java 中通常不定義全局函數, 但是在類中定義的. 而 C 語言不支持面向對象的編程.
OK, 我們繼續 JavaScript 面向對象之旅.
- 前面創建對象的方式, 被早期的 JavaScript 程序員經常使用
后來, 對象字面量稱為創建這種對象的首選方式
// 1.創建對象的字面量 var person = {name: 'Coderwhy',age: 18,height: 1.88,sayHello: function () {alert('My name is ' + this.name);}, };// 2.調用對象的方法 person.sayHello();1.3. JavaScript 中屬性的特性
JavaScript 中關于屬性有一個比較重要的概念: 屬性描述符
- 雖然我們開發中, 大多數情況不去可以的使用這些屬性描述符
- 但是某些情況下, 也確實會用到.
- 建議大家先了解一下這些屬性描述符, 以及它們的作用, 在以后用到時會非常有幫助.
JavaScript 中開始擁有了一種描述屬性特征的特性(即屬性描述符)。
- 根據特性的不同,可以把屬性分成兩種類型:數據屬性和訪問器屬性。
常見的屬性特性有哪些呢?
-
[[Configurable]] // true or false
-
- 表示能否通過 delete 刪除屬性從而重新定義屬性,能否修改屬性的特性,或者能否把屬性修改為訪問器屬性。像前面例子中那樣直接在對象上定義的屬性,它們的這個特性默認值為 true。
-
[[Writable]] // true or false
-
- 表示能否修改屬性的值。像前面例子中那樣直接在對象上定義的屬性,它們的這個特性默認值為 true。
-
[[Enumerable]] // true or false
-
- 表示能否通過 for-in 循環返回屬性。像前面例子中那樣直接在對象上定義的屬性,它們的這個特性默認值為 true。
-
[[Value]] // everty thing
-
- 包含這個屬性的數據值。讀取屬性值的時候,從這個位置讀;寫入屬性值的時候,把新值保存在這個位置。這個特性的默認值為 undefined。
-
[[set]] // function or undefined
-
- 在寫入屬性時調用的函數。默認值為 undefined。
-
[[get]] // function or undefined
-
- 在讀取屬性時調用的函數。默認值為 undefined。
這些屬性特性是什么東西呢?
-
從上面, 我們對這些特定的解釋, 你會發現, 每個特定都會有自己特定的用途.
-
比如 Configurable 當我們配置為 false 時, 就無法使用 delete 來刪除該屬性.
-
設置屬性特定
-
- obj: 將要被添加屬性或修改屬性的對象
- prop: 對象的屬性
- descriptor: 對象屬性的特性
- 要想修改屬性的特性,必須通過兩個 Object 方法,即 Object.defineProperty 和 Object.defineProperties
- 正如其字面意思,這兩個方法都是用來定義(修改)屬性的,前者一次只能定義一個屬性,后者則可以多個。
- defineProperty(obj, prop, descriptor)
案例練習:
var person = {}; Object.defineProperty(person, 'birth', {writable: false,value: 2000, });alert(person.birth); // 2000 person.birth = 1999; alert(person.birth); // 2000注意:在使用 defineProperty 方法定義新屬性時(非修改舊屬性),如果不指定,configurable, enumerable 和 writable 特性的默認值都是 false。
也就是上面的代碼等同于:
var person = {}; Object.defineProperty(person, 'birth', {configurable: false,enumerable: false,writable: false,value: 2000, });數據屬性:
- 數據屬性包含一個數值的位置,在這個位置可以讀取和寫入值。
- 數據屬性擁有 4 個特性: [[Configurable]]/[[Enumerable]]/[[Writable]]/[[Value]]
- 按照上面的方式, 我們定義的屬性就是數據屬性
訪問器屬性:
- 訪問器屬性不包含數據值,它們包含一對 getter 和 setter 函數。
- 訪問器屬性不能直接定義,需要使用后面提到的 Object.defineProperty 函數定義。
- 訪問器屬性也擁有 4 個特性: [[Configurable]]/[[Enumerable]]/[[Get]]/[[Set]]
定義一個訪問器屬性:
var person = {birth: 2000,age: 17, }; Object.defineProperty(person, 'year', {get: function () {return this.birth + this.age;},set: function (newValue) {this.age = newValue - this.birth;}, });person.year = 2088; alert(person.age); // 88 person.age = 30; alert(person.year); // 2030注意: getter 和 setter 都是可選的,在非嚴格模式下,只指定了 getter 卻進行了寫入操作,寫入的值會被忽略; 只指定了 setter 卻進行了讀取操作,讀取到的屬性值為 undefined。在嚴格模式下,則都會報錯。
二. JavaScript 創建對象
雖然 Object 構造函數或對象字面量可以用來創建單個對象
但是這些方式有個明顯的缺點: 使用同一個接口創建很多對象, 會產生大量的重復代碼.
我們會有一些列的方式來解決這個問題, 最終得到我們最佳理想的方式來創建對象.
2.1. 使用工廠模式
工廠模式是一種非常常見的設計模式, 這種模式抽象了創建具體對象的過程.
因為 JavaScript 中沒法創建類, 開發人員就發明了一種函數, 用函數來封裝以特定接口創建對象的細節.
工廠模式創建對象:
// 創建工廠函數 function createPerson(name, age, height) {var o = new Object();o.name = name;o.age = age;o.height = height;o.sayHello = function () {alert('Hello, My name is ' + this.name);};return o; }// 創建兩個對象 var person1 = createPerson('LBJ輝', 18, 1.88); var person2 = createPerson('LBJ', 38, 2.03); person1.sayHello(); // Hello, My name is LBJ輝 person2.sayHello(); // Hello, My name is LBJ代碼解析:
- 函數 createPerson()能夠根據接受的參數來構建一個包含所有必要信息的 Person 對象
- 可以無數次地調用這個函數,而每次它都會返回一個包含三個屬性一個方法的對象。
- 工廠模式雖然解決了創建多個相似對象的問題,但卻沒有解決對象識別的問題(即怎樣知道一個對象的類型)。
- 隨著 JavaScript 的發展,又一個新模式出現了。
2.2. 構造函數模式
JavaScript 中的構造函數可用來創建特定類型的對象。
- 像 Object 和 Array 這樣的原生構造函數,在運行時會自動出現在執行環境中。
- 此外,也可以創建自定義的構造函數,從而定義自定義對象類型的屬性和方法。
使用構造函數模式創建對象:
// 構造函數 function Person(name, age, height) {this.name = name;this.age = age;this.height = height;this.sayHello = function () {alert(this.name);}; }// 使用構造函數創建對象 var person1 = new Person('LBJ輝', 18, 1.88); var person2 = new Person('LBJ', 38, 2.03); person1.sayHello(); // LBJ輝 person2.sayHello(); // LBJ代碼解析:
-
在這個例子中,Person()函數取代了 createPerson()函數。
-
我們會發現這個函數有一些不太一樣的地方:
-
- 沒有顯式地創建對象;(比如創建一個 Object 對象)
- 直接將屬性和方法賦給了 this 對象;
- 沒有 return 語句
-
另外, 我們還注意到函數名 Person 使用的是大寫字母 P。
-
- 按照慣例,構造函數始終都應該以一個大寫字母開頭,而非構造函數則應該以一個小寫字母開頭;
- 這個做法借鑒自其他面向對象語言,主要是為了區別于 ECMAScript 中的其他函數;
- 因為構造函數本身也是函數,只不過可以用來創建對象而已;
-
還有, 我們在調用函數時, 不再只是簡單的函數+(), 而是使用了 new 關鍵字
-
這種方式調用構造函數實際上會經歷以下 4 個步驟:
-
- 創建一個新對象, 這個新的對象類型其實就是 Person 類型.
- 將構造函數的作用域賦給新對象(因此 this 就指向了這個新對象,也就是 this 綁定);
- 執行構造函數中的代碼(為這個新對象添加屬性和方法);
- 返回新對象, 但是是默認返回的, 不需要使用 return 語句;
在前面例子的最后,person1 和 person2 分別保存著 Person 的一個不同的實例。
- 這兩個對象都有一個 constructor(構造函數)屬性,該屬性指向 Person.
- 后面我們會詳細說道 constructor 到底從何而來, 所以你需要特別知道一下這里有這個屬性.
我們也可以通過 instanceof 來查看它的類型
- 注意: 我們會發現 person1 和 person2 既是 Person 類型, 也是 Object 類型.
- 這是因為默認所有的對象都繼承自 Object.(關于繼承, 后續詳細討論)
2.3. 關于構造函數
關于構造函數
- 我們知道, 構造函數也是一個函數, 只是使用的方式和別的函數不太一樣.(使用 new)
- 但是, 構造函數畢竟也是函數, 因此也可以像普通的函數一樣去使用.
- 而且, 其他任何的函數, 也可以通過 new 關鍵字來調用, 這個時候這個函數也可以被稱為構造函數.
把構造函數當做普通的函數去調用
// 當做構造函數使用 var person = new Person('LBJ輝', 18, 1.88); // person對象 person.sayHello();// 作為普通的函數調用 Person('LBJ', 38, 2.03); // window對象 window.sayHello();// 在另外一個對象的作用域調用 var o = new Object(); Person.call(o, 'Wade', 39, 1.93); // o對象 o.sayHello();構造函數來創建對象的缺陷:
- 構造函數模式雖然好用,但也并非沒有缺點。
- 使用構造函數的主要問題,就是每個方法都要在每個實例上重新創建一遍。
- 在前面的例子中,personl 和 person2 都有一個名為 sayName()的方法,但那兩個方法不是同一個 Function 的實例。
- JavaScript 中的函數也是對象,因此每定義一個函數,也就是實例化了一個對象
構造函數的換一種形式:
- 也就是上面的代碼類似于下面的寫法
有什么問題呢?
- 從這個角度上來看構造函數,更容易明白每個 Person 實例都包含一個不同的 Function 實例.
- 但是, 有必要創建多個 Function 實例嗎? 它們執行的代碼完全相同.
- 你也許會考慮, 它們需要區分不同的對象, 不過, 在調用函數時, 我們傳入的 this 就可以區分了. 沒有必要創建出多個 Function 的實例.
我們可以驗證一下這是兩個不同的函數:
alert(person1.sayHello === person2.sayHello); // false有沒有辦法讓它們是同一個函數呢? 使用全局函數即可
// 定義全局和函數 function sayHello() {alert(this.name); }// 構造函數 function Person(name, age, height) {this.name = name;this.age = age;this.height = height;this.sayHello = sayHello; }// 使用構造函數創建對象 var person1 = new Person('LBJ輝', 18, 1.88); var person2 = new Person('LBJ', 38, 2.03);alert(person1.sayHello === person2.sayHello); // true新的問題:
- 這樣做確實解決了兩個函數做同一件事的問題,可是新問題又來了: 在全局作用域中定義的函數我們的目的卻是只能被某個對象調用,這讓全局作用域有點名不副實。
- 而且我們進一步思考: 如果對象需要定義很多方法,那么就要定義很多個全局函數,于是我們這個自定義的引用類型就絲毫沒有封裝性可言了。
有沒有新的解決方案呢?使用原型模式.
JavaScript 面向對象詳解(二)
前面, 我們討論了很多種場景對象的方式: 從 Object 到字面量, 再到工廠模式, 再到構造函數.
最終我們發現, 構造函數是比較理想的一種方式, 但是它也存在問題.
為了最終解決這個問題, 我們需要學習一個新的知識: 原型(prototype).
一. 理解原型模式
1.1. 什么是原型呢?
你需要先知道一個事實:
- 我們創建的每個函數都有一個 prototype(原型)屬性
- 這個屬性是一個指針,指向一個對象
- 而這個對象的作用是存放這個類型創建的所有實例共享的屬性和方法。
- 指向的這個對象, 就是我們的所謂的原型對象.
原型對象的作用:
- 使用原型對象的好處是可以讓所有對象實例共享它所包含的屬性和方法。
- 換句話說,不必在構造函數中定義對象實例的信息,而是可以將這些信息直接添加到原型對象中。
我們來看看原型對象的使用:
// 創建對象的構造函數 function Person() {}// 通過原型對象來設置一些屬性和值 Person.prototype.name = 'LBJ輝'; Person.prototype.age = 18; Person.prototype.height = 1.88; Person.prototype.sayHello = function () {alert(this.name); };// 創建兩個對象, 并且調用方法 var person1 = new Person(); var person2 = new Person();person1.sayHello(); // LBJ輝 person2.sayHello(); // LBJ輝代碼解析:
- 在上面的代碼中, 我們沒有給實例對象單獨設置屬性和方法, 而是直接設置給了原型對象.
- 而原型對象的作用是可以讓所有的對象來共享這些屬性和方法.
- 因此, 我們調用 sayHello()方法時, 它們打印的結果是一樣的, 它們是共享的.
1.2. 深入原型對象
原型對象的創建:
- 無論什么時候,只要創建了一個新函數,就會根據一組特定的規則為該函數創建一個 prototype 屬性,這個屬性指向函數的原型對象。
原型上的 constructor 屬性:
- 默認情況下,所有原型對象都會自動獲得一個 constructor(構造函數)屬性,這個屬性包含一個指向 prototype 屬性所在函數的指針。
- 用我們上面的例子來說, Person.prototype.constructor 指向 Person。
- 也就是原型對象自身來說, 只有一個 constructor 屬性, 而其他屬性可以由我們添加或者從 Object 中繼承.
新的實例創建時, 原型對象在哪里呢?
- 當調用構造函數創建一個新實例后,該實例的內部將包含一個內部屬性,該屬性的指針, 指向構造函數的原型對象。
- 這個屬性是 proto
- 簡單說, 每個實例中, 其實也會有一個屬性, 該屬性是指向原型對象的.
我們通過一個圖來解釋上面的概念:
解析:
- 上面的圖解析了 Person 構造函數、Person 的原型屬性以及 Person 現有的兩個實例之間的關系
- Person.prototype 指向原型對象, 而 Person.prototype.constructor 又指回了 Person.
- 原型對象中除了包含 constructor 屬性之外,還包括后來添加的其他屬性。
- Person 的每個實例——personl 和 person2 都包含一個內部屬性* proto *,該屬性也指向原型對象;
對象搜索屬性和方法的過程:
-
每當代碼讀取某個對象的某個屬性時,都會執行一次搜索,也就是要找到給定名稱的屬性。
-
搜索首先從 對象實例本身 開始
- 如果在實例中找到了具有給定名字的屬性,則返回該屬性的值;
-
如果沒有找到,則繼續搜索指針指向的原型對象,在原型對象中查找具有給定名字的屬性
- 如果在原型對象中找到了這個屬性,則返回該屬性的值。
-
也就是說,在我們調用 personl.sayHello()的時候,會先后執行兩次搜索。
-
現在我們也能理解, 為什么所有的實例中都包含一個 constructor 屬性, 這是因為默認所有的原型對象中都包含了該屬性.
可以通過proto來修改原型的值(通常不會這樣修改, 知道即可)
- 你可以理解為什么 person1 修改了 name 后, person2 也會修改嗎?
- 通過上面的圖, 自己再來理解一下吧.
但是要注意下面的情況:
- 當我們給 person1.name 進行賦值時, 其實在給 person1 實例添加一個 name 屬性.
- 這個時候再次訪問時, 就不會訪問原型中的 name 屬性了.
通過 hasOwnProperty 判斷屬性屬于實例還是原型.
// 判斷屬性屬于誰 alert(person1.hasOwnProperty('name')); // true alert(person2.hasOwnProperty('name')); // false1.3. 簡潔的原型語法
簡潔語法概述:
- 如果按照前面的做法, 每添加一個原型屬性和方法, 都要敲一遍 Person.prototype.
- 為了減少不必要的輸入, 另外也為了更好的封裝性, 更常用的做法是用一個包含所有屬性和方法的對象字面量來重寫整個原型對象.
字面量重寫原型對象:
// 定義Person構造函數 function Person() {}// 重寫Person的原型屬性 Person.prototype = {name: 'LBJ輝',age: 18,height: 1.88,sayHello: function () {alert(this.name);}, };注意:
- 我們將 Person.prototype 賦值了一個新的對象字面量, 最終結果和原來是一樣的;
- 但是: constructor 屬性不再指向 Person 了;
- 前面我們說過, 每創建一個函數, 就會同時創建它的 prototype 對象, 這個對象也會自動獲取 constructor 屬性;
- 而我們這里相當于給 prototype 重新賦值了一個對象, 那么這個新對象的 constructor 屬性, 會指向 Object 構造函數, 而不是 Person 構造函數了;
如果在某些情況下, 我們確實需要用到 constructor 的值, 可以手動的給 constructor 賦值即可
// 定義Person構造函數 function Person() {}// 重寫Person的原型屬性 Person.prototype = {constructor: Person,name: 'LBJ輝',age: 18,height: 1.88,sayHello: function () {alert(this.name);}, };// 創建Person對象 var person = new Person();alert(person.constructor === Object); // false alert(person.constructor === Person); // truealert(person instanceof Person); // true上面的方式雖然可以, 但是也會造成 constructor 的[[Enumerable]]特性被設置了 true.
- 默認情況下, 原生的 constructor 屬性是不可枚舉的.
- 如果希望解決這個問題, 就可以使用我們前面介紹的 Object.defineProperty()函數了.
1.4. 修改原型屬性
考慮下面的代碼執行是否會有問題:
// 定義Person構造函數 function Person() {}// 創建Person的對象 var person = new Person();// 給Person的原型添加方法 Person.prototype.sayHello = function () {alert('Hello JavaScript'); };// 調用方法 person.sayHello();代碼解析:
- 我們發現代碼的執行沒有任何問題.
- 因為在創建 person 的時候, person 的proto也是指向的 Person.prototype.
- 所以, 當動態的修改了 Person.prototype 中的 sayHello 屬性時, person 中也可以獲取到該屬性
我們再來看下面的代碼會不會有問題:
// 定義Person構造函數 function Person() {}// 創建Person的對象 var person = new Person();// 給Person的原型添加方法 Person.prototype = {constructor: Person,sayHello: function () {alert('Hello JavaScript');}, }; // 調用方法 person.sayHello();代碼解析:
- 代碼是不能正常運行的. 因為 Person 的 prototype 指向了一個新的對象.
- 而最初我們創建的 person 依然指向原來的原型對象, 原來的原型對象沒有 sayHello()函數.
- 當然, 如果再次之后, 再創建的 Person 對象, 是可以調用 sayHello()的, 但是再次之前創建的, 沒有該方法.
1.5. 原型對象問題
原型對象也有一些缺點:
- 首先, 它不再有為構造函數傳遞參數的環節, 所有實例在默認情況下都將有相同的屬性值.
- 另外, 原型中所有的屬性是被很多實例共享的, 這種共享對于函數來說非常適合, 對于基本屬性通常情況下也不會有問題. (因為通過 person.name 直接修改時, 會在實例上重新創建該屬性名, 不會在原型上修改. 除非使用 person.proto.name 修改).
- 但是, 對于引用類型的實例, 就必然會存在問題.
考慮下面代碼的問題:
// 定義Person構造函數 function Person() {}// 設置Person原型 Person.prototype = {constructor: Person,name: 'LBJ輝',age: 18,height: 1.88,hobby: ['Basketball', 'Football'],sayHello: function () {alert('Hello JavaScript');}, };// 創建兩個person對象 var person1 = new Person(); var person2 = new Person();alert(person1.hobby); // Basketball,Football alert(person2.hobby); // Basketball,Footballperson1.hobby.push('tennis');alert(person1.hobby); // Basketball,Football,tennis alert(person2.hobby); // Basketball,Football,tennisOK, 我們會發現, 我們明明給 person1 添加了一個愛好, 但是 person2 也被添加到一個愛好.
- 因為它們是共享的同一個數組.
- 但是, 我們希望每個人有屬于自己的愛好, 而不是所有的 Person 愛好都相同.
二. 組合構造函數和原型模式
創建自定義類型的最常見方式,就是組合使用構造函數模式與原型模式。
構造函數模式用于定義實例屬性,而原型模式用于定義方法和共享的屬性。
結果,每個實例都會有自己的一份實例屬性的副本,但同時又共享著對方法的引用,最大限度地節省了內存。
另外,這種混成模式還支持向構造函數傳遞參數;可謂是集兩種模式之長。
組合構造函數和原型模式的代碼
// 創建Person構造函數 function Person(name, age, height) {this.name = name;this.age = age;this.height = height;this.hobby = ['Basketball', 'Football']; }// 重新Peron的原型對象 Person.prototype = {constructor: Person,sayHello: function () {alert('Hello JavaScript');}, };// 創建對象 var person1 = new Person('LBJ輝', 18, 1.88); var person2 = new Person('LBJ', 38, 2.03);// 測試是否共享了函數 alert(person1.sayHello == person2.sayHello); // true// 測試引用類型是否存在問題 person1.hobby.push('tennis'); alert(person1.hobby); alert(person2.hobby);如果理解了原型, 上面的代碼非常好理解.
- person1 和 person2 各有一份自己的屬性, 但是方法是共享的.
事實上, 還有一些其他的變種模式來實現基于對象的封裝. 但是這種方式是最常用的, 因此我們這里不再展開討論其他的模式. 后續需要我們再深入討論。
JavaScript 面向對象詳解(三)
繼承是面向對象中非常重要的特性.
ES5 中和類的實現一樣, 不能直接實現繼承. 實現繼承主要是依靠原型鏈來實現的。
一. 原型鏈
原型鏈是 ES5 中實現繼承的主要手段, 因此相對比較重要, 我們需要深入理解原型鏈.
1.1. 深入理解原型鏈
先來回顧一下構造函數、原型和實例的關系:
- 每個構造函數都有一個原型對象, 通過 prototype 指針指向該原型對象.
- 原型對象都包含一個指向構造函數的指針, 通過 constructor 指針, 指向構造函數
- 而實例都包含一個指向原型對象的內部指針, 該內部指針我們通常使用proto來描述.
思考如下情況:
- 我們知道, 可以通過 Person.prototype = {}的方式來重寫原型對象.
- 假如, 我們后面賦值的不是一個{}, 而是另外一個類型的實例, 結果會是怎么樣呢?
- 顯然,此時的原型對象將包含一個指向另一個原型的指針,相應地,另一個原型中也包含著一個指向另一個構造函數的指針。
- 假如另一個原型又是另一個類型的實例,那么上述關系依然成立,如此層層遞進,就構成了實例與原型的鏈條。這就是所謂原型鏈的基本概念。
有些抽象, 我們通過代碼來理解:
// 創建Person構造函數 function Person() {}// 設置Animal的原型 Person.prototype = {};我們將代碼修改成原型鏈的形式:
// 1.創建Animal的構造函數 function Animal() {this.animalProperty = 'Animal'; }// 2.給Animal的原型中添加一個方法 Animal.prototype.animalFunction = function () {alert(this.animalProperty); };// 3.創建Person的構造函數 function Person() {this.personProperty = 'Person'; }// 4.給Person的原型對象重新賦值 Person.prototype = new Animal();// 5.給Person添加屬于自己的方法 Person.prototype.personFunction = function () {alert(this.personProperty); };// 6.創建Person的實例 var person = new Person(); person.animalFunction(); //Animal person.personFunction(); //Person代碼解析:
-
代碼有一些復雜, 但是如果你希望學習好原型鏈, 必須耐心去看一看上面的代碼, 你會發現其實都是我們學習過的.
-
重點我們來看第 4 步代碼: 給 Person.prototype 賦值了一個 Animal 的實例. 也就是 Person 的原型變成了 Animal 的實例.
-
Animal 實例本身有一個proto可以指向 Animal 的原型.
-
那么, 我們來思考一個問題: 如果現在搜索一個屬性或者方法, 這個時候會按照什么順序搜索呢?
- 第一步, 在 person 實例中搜索, 搜索到直接返回或者調用函數. 如果沒有執行第二步.
- 第二步, 在 Person 的原型中搜索, Person 的原型是誰? Animal 的實例. 所以會在 Animal 的實例中搜索, 無論是屬性還是方法, 如果搜索到則直接返回或者執行. 如果沒有, 執行第三步.
- 第三步, 在 Animal 的原型中搜索, 搜索到返回或者執行, 如果沒有, 搜索結束. (當然其實還有 Object, 但是先不考慮)
畫圖解析可能更加清晰:
當代碼執行到第 3 步(上面代碼的序號)的時候, 如圖所示:
當代碼執行第 4 步(上面代碼的序號)時, 發生了如圖所示的變化
- 注意圖片中的紅色線, 原來指向的是誰, 現在指向的是誰.
代碼繼續執行
- Person.prototype.personFunction = function (){}
- 當執行第 5 步, 也就是給 Person 的原型賦值了一個函數時, 事實上在給 new Animal(Animal 的實例)賦值了一個新的方法.
代碼繼續執行, 我們創建了一個 Person 對象
- 創建 Person 對象, person 對象會有自己的屬性, personProperty.
- 另外, person 對象有一個prototype指向 Person 的原型.
- Person 的原型是誰呢? 就是我們之前的 new Animal(Animal 的一個實例), 所以會指向它.
原型鏈簡單總結:
- 通過實現原型鏈,本質上擴展了本章前面介紹的原型搜索機制。
- 當以讀取模式訪問一個實例屬性時,首先會在實例中搜索該屬性。如果沒有找到該屬性,則會繼續搜索實例的原型。在通過原型鏈實現繼承的情況下,搜索過程就得以沿著原型鏈繼續向上。
- 在找不到屬性或方法的情況下,搜索過程總是要一環一環地前行到原型鏈末端才會停下來。
1.2. 原型和實例的關系
如果我們希望確定原型和實例之間的關系, 有兩種方式:
- 第一種方式是使用 instanceof 操作符,只要用這個操作符來測試實例與原型鏈中出現過的構造函數,結果就會返回 true。
- 第二種方式是使用 isPrototypeOf()方法。同樣,只要是原型鏈中出現過的原型,都可以說是該原型鏈所派生的實例的原型,因此 isPrototypeOf()方法也會返回 true
instanceof 操作符
// instanceof alert(person instanceof Object); // true alert(person instanceof Animal); // true alert(person instanceof Person); // trueisPrototypeOf()函數
// isPrototypeOf函數 alert('isPrototypeOf函數函數'); alert(Object.prototype.isPrototypeOf(person)); // true alert(Animal.prototype.isPrototypeOf(person)); // true alert(Person.prototype.isPrototypeOf(person)); // true1.3. 添加新的方法
添加新的方法
- 在第 5 步操作中, 我們為子類型添加了一個新的方法. 但是這里有一個注意點.
- 無論是子類中添加新的方法, 還是對父類中方法進行重寫. 都一定要將添加方法的代碼, 放在替換原型語句之后.
- 否則, 我們添加的方法將會無效.
錯誤代碼引起的代碼:
// 1.定義Animal的構造函數 function Animal() {this.animalProperty = 'Animal'; }// 2.給Animal添加方法 Animal.prototype.animalFunction = function () {alert(this.animalProperty); };// 3.定義Person的構造函數 function Person() {this.personProperty = 'Person'; }// 4.給Person添加方法 Person.prototype.personFunction = function () {alert(this.personProperty); };// 5.給Person賦值新的原型對象 Person.prototype = new Animal();// 6.創建Person對象, 并且調用方法 var person = new Person(); person.personFunction(); // 不會有任何彈窗, 因為找不到該方法代碼解析:
- 執行上面的代碼不會出現任何的彈窗, 因為我們添加的方法是無效的, 被賦值的新的原型覆蓋了.
- 正確的辦法是將第 4 步和第 5 步操作換一下位置即可.
總結
- 其實這個問題沒什么好說的, 只要你理解了原型鏈(好好看看我上面畫的圖, 或者自己畫一下圖)
- 但是, 切記在看圖的過程中一樣掃過, 因為這會讓你錯過很多細節, 對原型鏈的理解就會出現問題.
1.4. 原型鏈的問題
原型鏈對于繼承來說:
- 原型鏈似乎對初學 JavaScript 原型的人來說, 已經算是比較高明的設計技巧了, 有些人理解起來都稍微有些麻煩.
- 但是, 這種設計還存在一些缺陷, 不是最理性的解決方案. (但是后續的解決方案也是依賴原型鏈, 無論如何都需要先理解它)
原型鏈存在的問題:
- 原型鏈存在最大的問題是關于引用類型的屬性.
- 通過上面的原型實現了繼承后, 子類的 person 對象繼承了(可以訪問)Animal 實例中的屬性(animalProperty).
- 但是如果這個屬性是一個引用類型(比如數組或者其他引用類型), 就會出現問題.
引用類型的問題代碼:
// 1.定義Animal的構造函數 function Animal() {this.colors = ['red', 'green']; }// 2.給Animal添加方法 Animal.prototype.animalFunction = function () {alert(this.colors); };// 3.定義Person的構造函數 function Person() {this.personProperty = 'Person'; }// 4.給Person賦值新的原型對象 Person.prototype = new Animal();// 5.給Person添加方法 Person.prototype.personFunction = function () {alert(this.personProperty); };// 6.創建Person對象, 并且調用方法 var person1 = new Person(); var person2 = new Person();alert(person1.colors); // red,green alert(person2.colors); // red,greenperson1.colors.push('blue');alert(person1.colors); // red,green,blue alert(person2.colors); // red,green,blue代碼解析:
- 我們查看第 6 步的操作
- 創建了兩個對象, 并且查看了它們的 colors 屬性
- 修改了 person1 中的 colors 屬性, 添加了一個新的顏色 blue
- 再次查看兩個對象的 colors 屬性, 會發現 person2 的 colors 屬性也發生了變化
- 兩個實例應該是相互獨立的, 這樣的變化如果我們不制止將會在代碼中引發一些列問題.
原型鏈的其他問題:
- 在創建子類型的實例時,不能向父類型的構造函數中傳遞參數。
- 實際上,應該說是沒有辦法在不影響所有對象實例的情況下,給父類型的構造函數傳遞參數。
- 從而可以修改父類型中屬性的值, 在創建構造函數的時候就確定一個值.
二. 經典繼承
為了解決原型鏈繼承中存在的問題, 開發人員提供了一種新的技術: constructor stealing(有很多名稱: 借用構造函數或經典繼承或偽造對象), steal 是偷竊的意思, 但是這里可以翻譯成借用.
2.1. 經典繼承的思想
經典繼承的做法非常簡單: 在子類型構造函數的內部調用父類型構造函數.
- 因為函數可以在任意的時刻被調用
- 因此通過 apply()和 call()方法也可以在新創建的對象上執行構造函數.
經典繼承代碼如下:
// 創建Animal的構造函數 function Animal() {this.colors = ['red', 'green']; }// 創建Person的構造函數 function Person() {// 繼承Animal的屬性Animal.call(this);// 給自己的屬性賦值this.name = 'LBJ輝'; }// 創建Person對象 var person1 = new Person(); var person2 = new Person();alert(person1.colors); // red,green alert(person2.colors); // red,green person1.colors.push('blue'); alert(person1.colors); // red,green,blue alert(person2.colors); // red,green代碼解析:
- 我們通過在 Person 構造函數中, 使用 call 函數, 將 this 傳遞進去.
- 這個時候, 當 Animal 中有相關屬性初始化時, 就會在 this 對象上進行初始化操作.
- 這樣就實現了類似于繼承 Animal 屬性的效果.
這個時候, 我們也可以傳遞參數, 修改上面的代碼:
// 創建Animal構造函數 function Animal(age) {this.age = age; }// 創建Person構造函數 function Person(name, age) {Animal.call(this, age);this.name = name; }// 創建Person對象 var person = new Person('LBJ輝', 18); alert(person.name); alert(person.age);2.2. 經典繼承的問題
經典繼承的問題:
- 對于經典繼承理解比較深入, 你已經能發現: 經典繼承只有屬性的繼承, 無法實現方法的繼承.
- 因為調用 call 函數, 將 this 傳遞進去, 只能將父構造函數中的屬性初始化到 this 中.
- 但是如果函數存在于父構造函數的原型對象中, this 中是不會有對應的方法的.
回顧原型鏈和經典繼承:
- 原型鏈存在的問題是引用類型問題和無法傳遞參數, 但是方法可以被繼承
- 經典繼承是引用類型沒有問題, 也可以傳遞參數, 但是方法無法被繼承.
- 怎么辦呢? 將兩者結合起來怎么樣?
三. 組合繼承
如果你認識清楚了上面兩種實現繼承的方式存在的問題, 就可以很好的理解組合繼承了.
組合繼承(combination inheritance, 有時候也稱為偽經典繼承), 就是將原型鏈和經典繼承組合在一起, 從而發揮各自的優點.
3.1. 組合繼承的思想
組合繼承:
- 組合繼承就是發揮原型鏈和經典繼承各自的優點來完成繼承的實現.
- 使用原型鏈實現對原型屬性和方法的繼承.
- 通過經典繼承實現對實例屬性的繼承, 以及可以在構造函數中傳遞參數.
組合繼承的代碼:
// 1.創建構造函數的階段 // 1.1.創建Animal的構造函數 function Animal(age) {this.age = age;this.colors = ['red', 'green']; }// 1.2.給Animal添加方法 Animal.prototype.animalFunction = function () {alert('Hello Animal'); };// 1.3.創建Person的構造函數 function Person(name, age) {Animal.call(this, age);this.name = name; }// 1.4.給Person的原型對象重新賦值 Person.prototype = new Animal(0);// 1.5.給Person添加方法 Person.prototype.personFunction = function () {alert('Hello Person'); };// 2.驗證和使用的代碼 // 2.1.創建Person對象 var person1 = new Person('LBJ輝', 18); var person2 = new Person('LBJ', 38);// 2.2.驗證屬性 alert(person1.name + '-' + person1.age); // LBJ輝,18 alert(person2.name + '-' + person2.age); // LBJ,38// 2.3.驗證方法的調用 person1.animalFunction(); // Hello Animal person1.personFunction(); // Hello Person// 2.4.驗證引用屬性的問題 person1.colors.push('blue'); alert(person1.colors); // red,green,blue alert(person2.colors); // red,green代碼解析:
- 根據前面學習的知識, 結合當前的代碼, 大家應該可以理解上述代碼的含義.
- 但是我還是建議大家一定要多手動自己來敲代碼, 來理解其中每一個步驟.
- 記住: 看懂, 聽懂不一定真的懂, 自己可以寫出來, 才是真的懂了.
3.2. 組合繼承的分析
組合繼承是 JavaScript 最常用的繼承模式之一.
- 如果你理解到這里, 點到為止, 那么組合來實現繼承只能說問題不大.
- 但是它依然不是很完美, 存在一些問題不大的問題.(不成問題的問題, 基本一詞基本可用, 但基本不用)
組合繼承存在什么問題呢?
- 組合繼承最大的問題就是無論在什么情況下, 都會調用兩次父類構造函數.
- 一次在創建子類原型的時候
- 另一次在子類構造函數內部(也就是每次創建子類實例的時候).
- 另外, 如果你仔細按照我的流程走了上面的每一個步驟, 你會發現: 所有的子類實例事實上會擁有兩份父類的屬性
- 一份在當前的實例自己里面(也就是 person 本身的), 另一份在子類對應的原型對象中(也就是 person.proto里面)
- 當然, 這兩份屬性我們無需擔心訪問出現問題, 因為默認一定是訪問實例本身這一部分的.
怎么解決呢?
- 看起來組合繼承也不是非常完美的解決方案, 雖然也可以應用.
- 有沒有終極的解決方案呢? 預知后事如何, 且聽下回分解.
JavaScript 面向對象詳解(四)
在上一篇中, 我們討論了 ES5 中, 實現繼承的一些方式.
在最后, 我們說了組合繼承是相對完美的解決方案, 但是它也存在一些問題.
這篇文章, 我們就通過某種新的模式, 給出一種目前使用最多, 也是我們最終的解決方案.
一. 原型式繼承
1.1. 原型式繼承的思想
原型式繼承的淵源
- 這種模式要從道格拉斯·克羅克福德(Douglas Crockford, 著名的前端大師, JSON 的創立者)在 2006 年寫的一篇文章說起: Prototypal Inheritance in JavaScript(在 JS 中使用原型式繼承)
- 在這篇文章中, 它介紹了一種繼承方法, 而且這種繼承方法不是通過構造函數來實現的.
- 為了理解這種方式, 我們先再次回顧一下 JavaScript 想實現繼承的目的: 重復利用另外一個對象的屬性和方法.
原型式繼承的核心函數:
// 封裝object()函數 function object(o) {function F() {}F.prototype = o;return new F(); }代碼解析:
- 在 object()函數內部, 先創建一個臨時的構造函數.
- 然后將傳遞的對象作為這個構造函數的原型
- 最后返回了這個臨時類型的一個新的實例.
- 事實上, object()對傳入的對象執行了一次淺復制.
1.2. 原型式繼承的使用
使用原型式繼承:
// 使用原生式繼承 var person = {name: 'LBJ輝',colors: ['red', 'green'], };// 通過person去創建另外一個對象 var person1 = object(person); person1.name = 'LBJ'; person1.colors.push('blue');alert(person1.name); // LBJ alert(person1.colors); // red,green,bluealert(person.name); // LBJ輝 alert(person.colors); // red,green,blue代碼解析:
- 這種方式和我們傳統意義上理解的繼承有些不同. 它做的事情是通過一個對象去創建另外一個對象.(利用 person 去創建 person1)
- 當然, person1 中繼承過來的屬性是放在了自己的原型對象中的.
- 也可以給 person1 自己再次添加 name 屬性, 這個時候 name 才是在實例本身中.
- 但是如果是修改或者添加引用類型的內容, 還是會引起連鎖反應.
- 可能暫時你看不到這些代碼的意義, 但是這些代碼是我們后續終極方案的前提思想, 所以先看看和練習一下這些代碼.
針對這種思想, ES5 中新增了 Object.create()方法來規范化了原型式繼承.
- 也就是上面的代碼可以修改成這樣.(只是將 object 函數修改成了 Object.create)
Object.create()還可以傳入第二個參數:
- 第二個參數用于每個屬性的自定義描述.
- 比如 person1 的 name 我們希望修改成"LBJ", 就可以這樣來做
1.3. 原型式繼承的問題
-
原型式繼承的的優點和缺點:
-
- 如果我們只是希望一個對象和另一個對象保持類似的情況下, 原型式繼承完全可以勝任, 這是它的優勢.
- 但是, 原型式繼承依然存在屬性共享的問題, 就像使用原型鏈一樣.
二. 寄生式繼承
2.1. 寄生式繼承的思想
寄生式(Parasitic)繼承
- 寄生式(Parasitic)繼承是與原型式繼承緊密相關的一種思想, 并且同樣由道格拉斯·克羅克福德(Douglas Crockford)提出和推廣的
- 寄生式繼承的思路是結合原型類繼承和工廠模式的一種方式.
- 即創建一個封裝繼承過程的函數, 該函數在內部以某種方式來增強對象, 最后再將這個對象返回.
寄生式函數多增加了一個核心函數:
// 封裝object函數 function object(o) {function F() {}F.prototype = o;return new F(); }// 封裝創建新對象的函數 function createAnother(original) {var clone = object(original);clone.sayHello = function () {alert('Hello JavaScript');};return clone; }2.2. 寄生式繼承的應用
我們來使用一下寄生式繼承
// person對象 var person = {name: 'LBJ輝',colors: ['red', 'green'], };// 新的對象 var person1 = createAnother(person); person1.sayHello();代碼解讀:
- 我們基于 person 對象, 創建了另外一個對象 person1.
- 在最新的 person1 對象中, 不僅會擁有 person 的屬性和方法, 而且還有自己定義的方法.
2.3. 寄生式繼承的問題
寄生式繼承存在的問題:
- 寄生式繼承和原型式繼承存在一樣的問題, 引用類型會共享. (因為是在原型式繼承基礎上的一種封裝)
- 另外寄生式繼承還存在函數無法復用的問題, 因為每次 createAnother 一個新的對象, 都需要重新定義新的函數.
三. 寄生組合式繼承
3.1. 寄生組合式繼承的思想
寄生組合式繼承
-
現在我們來回顧一下之前提出的比較理想的組合繼承
-
- 組合繼承是比較理想的繼承方式, 但是存在兩個問題:
- 問題一: 構造函數會被調用兩次: 一次在創建子類型原型對象的時候, 一次在創建子類型實例的時候.
- 問題二: 父類型中的屬性會有兩份: 一份在原型對象中, 一份在子類型實例中.
-
事實上, 我們現在可以利用寄生式繼承將這兩個問題給解決掉.
-
- 你需要先明確一點: 當我們在子類型的構造函數中調用父類型.call(this, 參數)這個函數的時候, 就會將父類型中的屬性和方法復制一份到了子類型中. 所以父類型本身里面的內容, 我們不再需要.
- 這個時候, 我們還需要獲取到一份父類型的原型對象中的屬性和方法.
- 能不能直接讓子類型的原型對象 = 父類型的原型對象呢?
- 不要這么做, 因為這么做意味著以后修改了子類型原型對象的某個引用類型的時候, 父類型原生對象的引用類型也會被修改.
- 我們使用前面的寄生式思想就可以了.
寄生組合式的核心代碼:
// 定義object函數 function object(o) {function F() {}F.prototype = o;return new F(); }// 定義寄生式核心函數 function inhreitPrototype(subType, superType) {var prototype = object(superType.prototype);prototype.constructor = subType;subType.prototype = prototype; }3.2. 寄生組合式繼承的應用
直接給出使用的代碼, 也是我們以后使用繼承的終極方式
// 定義Animal構造函數 function Animal(age) {this.age = age;this.colors = ['red', 'green']; }// 給Animal添加方法 Animal.prototype.animalFunction = function () {alert('Hello Animal'); };// 定義Person構造函數 function Person(name, age) {Animal.call(this, age);this.name = name; }// 使用寄生組合式核心函數 inhreitPrototype(Person, Animal);// 給Person添加方法 Person.prototype.personFunction = function () {alert('Hello Person'); };代碼的優點:
- 這種方式的高效體現在現在它只調用了一次 Animal 的構造函數.
- 并且也避免了在原型上面多出的多余屬性, 而且原型之間不會產生任何的干擾(子類型原型和父類型原型之間).
- 在 ES5 中, 普遍認為寄生組合式繼承是最理想的繼承范式.
總結
以上是生活随笔為你收集整理的JavaScript 面向对象详解的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 《Mysql》必知必会读书笔记
- 下一篇: SpringBoot 二维码生成base