前言
HTML萬物皆標籤。
CSS萬物皆盒。
JavaScript萬物皆對象。
對象
JavaScript對象的本質是數據和功能的集合,語法上表現為鍵值對的集合。
鍵
對象的鍵可以理解為變量名。
值
對象的值的類型可以是任意數據類型。
鍵值對
鍵和值之間用:相連。
多組鍵值對之間用,分割。
let profile = {
name: '吳彥祖',
age: 48,
charmingThenMe: false,
works: ['特警新人類', '新警察故事', '門徒', '除暴'],
bio: function () {
alert('你很能打嗎?你會打有個屁用啊。')
},
hi() {
this.bio()
alert('出來混要有勢力,要有背景,你哪個道上的?')
}
}
按照值是否為函數這一標準,進一步將鍵值對分為屬性(property)和方法(method)。
對象為數據和功能的集合,數據對應屬性,功能對應方法。
以profile為例,前四個為屬性,後倆為方法。
hi() {
...
}
// 等價於
hi: function() {
...
}
// 上面的寫法是方法的短語法。
訪問
有兩種方式訪問對象的鍵值對,分別為點式訪問和括號式訪問。
// 點式訪問
profile.name // '吳彥祖'
// 括號式訪問
profile['age'] // 48
當你發現在一些特殊場景下使用點式訪問無法實現時,記得嘗試括號式訪問。
這些特殊場景大多出現於鍵產生於運行時。
比如:
當你在遍歷中需要從實參中獲取鍵。
或者你需要同時定義對象的鍵和值。
構造函數
實際開發中,若按照上面的方式使用對象,意味着每需要一個profile都需要手動寫出一個擁有相同鍵的對象,這會帶來災難性的後果:
- 巨量的重複代碼
- 一旦需要更新屬性或方法,則必須遍歷每一個對象
我們需要抽象。
具體來説,我們需要一個函數,可以自動創建具有相同鍵的對象,而不是每次使用時,手動重寫一遍鍵。
// 鍵只需要在定義createProfile()時寫一次
function createProfile(name, age, charmingThenMe, works, bio, hi) {
let o = {}
o.name = name
o.age = age
o.charmingThenMe = charmingThenMe
o.works = works
o.bio = function () {
alert(bio)
}
o.hi = function () {
o.bio()
alert(hi)
}
return o
}
// 後續生成對象時,只需要寫值,鍵會自動填充
let edisonChen = createProfile(
'陳冠希',
42,
false,
['無間道', '頭文字D', '神槍手'],
'在嗎拓海',
'微信轉賬三百塊'
)
edisonChen.name
edisonChen.hi()
抽象實現。
但createProfile()似乎有點冗餘,我們分析一下createProfile()內部都幹了些什麼:
let o = {}創建一個空對象o.foo = bar為對象添加屬性和方法return o返回新創建的對象
這不就是new的作用嗎!
當使用new調用一個函數(此處稱之為f())時,具體會有以下過程:
- 創建一個空對象
o - 將
o的原型指向f()的prototype屬性 - 將
this綁定到o,並執行f() - 返回
o
這意味着,如果我們使用new來調用生成對象的函數,我們只需要關注核心的業務邏輯即可,諸如生成空對象、綁定this、返回對象這種雜活兒直接委託出去。
function Profile(name, age, charmingThenMe, works, bio, hi) {
this.name = name
this.age = age
this.charmingThenMe = charmingThenMe
this.works = works
this.bio = function () {
alert(bio)
}
this.hi = function () {
this.bio()
alert(hi)
}
}
Profile函數體內只有新對象所需的數據和方法,我們真正關注的也只是這一部分。
至於函數名為什麼從createProfile變成了Profile,這完全是依照慣例的約定俗成:
使用對象名並以大寫開頭作為該對象構造函數的名稱。
let j = new Profile('周杰倫', 43, false, ['夜曲', '最偉大的作品'], '喔唷', '不錯哦')
j.works
j.hi()
這就是JavaScript的構造函數。
原型
JavaScript中的每個對象都有一個叫做原型的內建屬性。
原型也是一個對象,原型也有原型,逐級溯源,形成原型鏈。
當一個對象的原型為null時,原型鏈結束。
一個對象,不僅能訪問自己獨有的屬性和方法,還可以訪問整個原型鏈上所有對象的屬性和方法。
這解釋了,為什麼你只是聲明瞭一個字符串,就可以調用一批字符串的內建方法。
// 在控制枱中執行以下代碼:
let o = {
name: 'a',
hi() {
console.log(`hello world`);
}
}
o;
點開控制枱返回的對象,你會發現,除了剛剛自定義的屬性name和方法hi(),還有一個長得很奇怪的 [[Prototype]],點開它你會發現另一個奇怪的鍵————__proto__ 。
這就是對象o的原型,它不僅長得奇怪,甚至連名字都沒有。
是的,ECMAScript認為對象原型“不配擁有姓名”,儘管你可以通過o.__proto__訪問到它,但o.__proto__是不受標準認可的屬性,它只是各大瀏覽器內部的實現,並且已經被官方廢棄。
獲取原型
不要通過__proto__屬性去獲取對象的原型。
使用Object.getPrototypeOf()獲取。
let n = 123
do {
n = Object.getPrototypeOf(n)
console.log(n)
} while (n)
// Number
// Object
// null
設置原型
JavaScript中一般使用Object.create()或者constructors構造函數設置原型。
Object.create()
使用實參作為原型生成一個新對象。
let a = {
hi() {
console.log('hello world')
}
}
let b = Object.create(a)
b.hi() // hello world
constructor
JavaScript中,所有函數都有一個叫prototype的屬性,當使用new關鍵字來調用一個構造函數來生成新對象時,構造函數的這個prototype屬性被設置為新生成對象的原型。
這個機制能夠保證:只要指定了構造函數的prototype屬性,所有由構造函數生成的新對象的原型都能保持一致。
// 聲明並初始化一個fruit對象,作為原型對象供構造函數使用
let fruit = {
hi() {
console.log(`吃個${this.name}${this.name}`)
}
}
// 聲明Fruit(構造)函數
function Fruit(name) {
this.name = name
}
// 設置構造函數的prototype屬性
Fruit.prototype.hi = fruit.hi
// 或者
// Fruit.prototype = fruit
let p = new Fruit('桃') // 生成新對象p
p.hi() // '吃個桃桃'
console.log(Fruit.prototype === Object.getPrototypeOf(p)) // true
// 均為 fruit
- 使用字面量方式創建
fruit對象,對象中定義了hi方法 - 聲明構造函數
Fruit,通過this將name屬性和值添加到執行時產生的新對象上 - 將
fruit的hi方法添加到構造函數函數的prototype上(實踐中原型往往具有多個屬性,此時使用直接賦值一次性添加) - 使用
new關鍵字生成新對象p - 調用
p的hi方法(p本身並沒有hi方法,而是繼承自fruits) - 驗證構造函數的
prototype與新對象p的原型的一致性
自有屬性
可以看到,上面的示例中,由構造函數Peach生成的對象p具有兩個鍵:
- 一個是屬性
name,定義在構造函數中 - 一個是方法
hi,定義在原型中
那些直接定義在對象上,而非通過繼承獲得的屬性,屬於自有屬性。
通過Object.hasOwn()判斷屬性是否為自有屬性:
console.log(Object.hasOwn(p, 'name')) // ture
console.log(Object.hasOwn(p, 'hi')) // false
console.log(Object.hasOwn(fruits, 'hi')) // true
嚴格來説,自有屬性應該被稱之為自有鍵,如果你一定要使用屬性和方法來區分鍵的話。
但屬性在很多語境下是不區分狹義的屬性和方法的,後者在標準中也未被定義。
原型小結
回顧上面的fruits示例,思考下面這個問題:
為什麼要將方法定義在原型中,而將屬性定義構造函數中呢?
因為這種行為與數據分離的機制恰好契合了類和實例。
對象間因具有相同的行為而被抽象為類,行為(方法)被 類(原型)定義。
對象間因數據的差異而成為一個又一個的實例,數據(屬性)被構造函數(返回實例)定義。
原型是JavaScript強大而靈活的特性之一,它使得代碼複用和對象組合成為可能。
類
JavaScript提供了一種更加開發者友好的方式來實現類和實例 —— class。
以fruits為例:
class Fruits {
name
constructor(name) {
this.name = name
}
hi() {
console.log(`吃個${this.name}${this.name}`)
}
}
let p = new Fruits('🍑')
p.hi() // 吃個🍑🍑
可以看出,class通過封裝:
- 聲明並初始化原型對象
- 聲明構造函數
- 初始化構造函數的
prototype屬性
等步驟,將基於原型鏈生成對象的語法,相較於純構造函數而言,進一步簡化。
省略屬性
你甚至可以省略屬性的聲明。
class Fruits {
constructor(name) {
this.name = name
}
hi() {
console.log(`吃個${this.name}${this.name}`)
}
}
let p = new Fruits('🍑')
p.hi() // 吃個🍑🍑
⚠️注意:實踐中不要省略,因為這會降低代碼的可讀性。
屬性的默認值
屬性在初始化的時候,可以指定默認值。
class Fruits {
name
constructor(name) {
this.name = name || '🍉'
}
hi() {
console.log(`吃個${this.name}${this.name}`)
}
}
let w = new Fruits()
w.hi() // 吃個🍉🍉
省略構造函數
如果沒有初始化的需求,則可以省略構造函數。
class Fruits {
hi() {
console.log(`吃個屁`)
}
}
let p = new Fruits()
p.hi() // 吃個屁
繼承
原型有原型鏈,類有繼承。
以汽車舉例,先定義汽車父類:
class Vehicle {
brand // 所有的車都有品牌
constructor(b) {
this.brand = b
}
// 所有的品牌都有標語
slogan() {
console.log(`This is ${this.brand}`)
}
}
通過繼承汽車,定義電動汽車:
class EV extends Vehicle{
batteryType // 電池類型
remaining // 剩餘電量
constructor(b, bt, r) {
super(b)
this.batteryType = bt
this.remaining = r
}
charge() {
let t = 0
switch (this.batteryType) {
case '三元鋰':
t = (1-this.remaining) / 2
break
case '磷酸鐵鋰':
t = 1- this.remaining
break
default:
t = Math.random()
}
console.log(
`尊貴的${this.brand}車主:` +
`您的${this.batteryType || ''}電池` +
`只需${Math.ceil(t*60)}分鐘即可充滿!`
)
}
}
使用extends從父類繼承屬性和方法,使用super()調用父類的方法。
let t = new EV('Tesla', '三元鋰', 0.3)
t.charge() // 尊貴的Tesla車主:您的三元鋰電池只需21分鐘即可充滿!
let w = new EV('WuLing', '磷酸鐵鋰', 0.5)
w.charge() // 尊貴的WuLing車主:您的磷酸鐵鋰電池只需30分鐘即可充滿!
通過繼承,子類可以使用父類的屬性和方法。
也可以定義子類自己的屬性和方法。
甚至,父類中已有的屬性和方法,也支持在子類中重新定義。
結語
雖然class看起來是個新東西,但本質上它還是原型鏈,或者説,它是原型鏈的語法糖。
JavaScript本質上不是傳統意義上面向對象的編程語言,JavaScript是基於原型的編程語言。