JavaScript 型別、原型與繼承學習筆記

L發表於2021-02-01


這篇筆記中有什麼:

✔️JavaScript的極簡介紹
✔️JavaScript中資料型別的簡單梳理
✔️JavaScript中的物件導向原理

這篇筆記中沒有什麼:

❌JavaScript的具體語法
❌JavaScript通過各種內建物件實現的其他特性


一、概覽

  • 解釋型,或者說即時編譯型( Just-In-Time Compiled )語言。
  • 多正規化動態語言,原生支援函數語言程式設計,通過原型鏈支援物件導向程式設計。
  • 其實是和Java是完全不同的東西。設計中有參考Java的資料結構和記憶體管理、C語言的基本語法,但理念上並不相似。
  • 最開始是專門為瀏覽器設計的一門指令碼語言,但現在也被用於很多其他環境,甚至可以在任意搭載了JavaScript引擎的裝置中執行。

二、資料型別

1. JavaScript中的資料型別

最新的標準中,定義了8種資料型別。其中包括:

  • 7種基本型別:Number、String、Boolean、BigInt、Null、Undefined以及ES2016新增的Symbol。
  • 1種複雜型別:Object。

2. 什麼是基本型別(Primitive Data Type)

2.1 概念

基本資料型別,有些版本也譯為原始資料型別。

什麼是基本型別?看一下MDN上給出的定義:

In JavaScript, a primitive (primitive value, primitive data type) is data that is not an object and has no methods.

基本型別是最底層的型別,不是物件,沒有方法。

所有基本資料型別的值都是不可改變的——可以為變數賦一個新值、覆蓋原來的值,但是無法直接修改值本身。

這一點對於number、boolean來說都很直觀,但是對於字串來說可能需要格外注意:同一塊記憶體中的一個字串是不可以部分修改的,一定是整體重新賦值。

  var a = "hello"; // 一個string型別的變數,值為“hello”
  console.log(a); // hello
  console.log(typeof a); // string
  a[0] = "H"; 
  console.log(a); // hello
  var c = a; // world
  c = c + " world"; // 這裡,並沒有改變本來的hello,而是開闢了新的記憶體空間,構造了新的基本值“hello world”
  console.log(c); // hello world

2.2 七個基本型別

  • 布林 boolean
    • 取值為truefalse
    • 0""NaNnullundefined也會被轉換為false
  • Null
    • Null型別只有一個值:null。表示未被宣告的值。
    • 注意:由於歷史原因,typeof null的結果是"object"
  • undefined
    • 未初始化的值(宣告瞭但是沒有賦值)。
	var a;
	console.log(typeof a); // undefined
	console.log(typeof a);  // "undefined"
  • 數字 number
    • 64位雙精度浮點數(並沒有整數和浮點數的區別)。
  • 大整數 bigint
    • 可以用任意精度表示整數。
    • 通過在整數末尾附加n或呼叫建構函式來建立。
    • 不可以與Number混合運算,會報型別錯誤。需要先進行轉換。
  • 字串 string
    • Unicode字元序列。
  • 符號 Symbol
    • 可以用來作為Object的key的值(預設私有)。
    • 通過Symbol()函式構造,每個從該函式返回的symbol值都是唯一的。
    • 可以使用可選的字串來描述symbol,僅僅相當於註釋,可用於除錯。
	var sym1 = Symbol("abc");
	var sym2 = Symbol("abc");
	console.log(sym1 == sym2); // false
	console.log(sym1 === sym2); // false

2.3 基本型別封裝物件

接觸了一些JavaScript的程式碼,又瞭解了它對型別的分類之後,可能會感到非常困惑:基本資料型別不是物件,沒有方法,那麼為什麼又經常會看到對字串、數字等“基本型別”的變數呼叫方法呢?

如下面的例子:

var str = "hello";

console.log(typeof str); // string
console.log(str.charAt(2)); // "l"

可以看到,str的型別確實是基本型別string,理論上來說並不是物件。但是我們實際上卻能夠通過點運算子呼叫一些為字串定義的方法。這是為什麼呢?

其實,執行str.charAt(2)的時候發生了很多事情,遠比我們所看到的一個“普通的呼叫”要複雜。

Java中有基本型別包裝類的概念。比如:Integer是對基本int型別進行了封裝的包裝類,提供一些額外的函式。

在JavaScript中,原理也是如此,只是在形式上進行了隱藏。JavaScript中,定義了原生物件String,作為基本型別string封裝物件。我們看到的charAt()方法,其實是String物件中的定義。當我們試圖訪問基本型別的屬性和方法時,JavaScript會自動為基本型別值封裝出一個封裝物件,之後從封裝物件中去訪問屬性、方法。而且,這個物件是臨時的,呼叫完屬性之後,包裝物件就會被丟棄。

這也就解釋了一件事:為什麼給基本型別新增屬性不會報錯,但是並不會有任何效果。因為,新增的屬性其實新增在了臨時物件上,而臨時物件很快就被銷燬了,並不會對原始值造成影響。

封裝物件有: StringNumberBooleanSymbol

我們也可以通過new去顯性地建立包裝物件(除了Symbol)。

var str = "hello";
var num = 23;
var bool = false;
var S = new String(str)
var N = new Number(num)
var B = new Boolean(bool);
console.log(typeof S); //object
console.log(typeof N); // object
console.log(typeof B); // object

一般來說,將這件事託付給JavaScript引擎去做更好一些,手動建立封裝物件可能會導致很多問題。

包裝物件作為一種技術上的實現細節,不需要過多關注。但是瞭解這個原理有助於我們更好地理解和使用基本資料型別。

3. 什麼是物件型別(Object)

3.1 四類特殊物件

  • 函式 Function
    • 每個JavaScript函式實際上都是一個Function物件
    • JavaScript中,函式是“一等公民”,也就是說,函式可以被賦值給變數,可以被作為引數,可以被作為返回值。(這個特性Lua中也有)
    • 因此,可以將函式理解為,一種附加了可被呼叫功能的普通物件。
  • 陣列 Array
    • 用於構造陣列的全域性物件。陣列是一種類列表的物件。Array的長度可變,元素型別任意,因此可能是非密集型的。陣列索引只能是整數,索引從0開始
    • 訪問元素時通過中括號
    • 日期 Date
    • 通過new操作符建立
  • 正則 RegExp
    • 用於將文字與一個模式進行匹配

3.2 物件是屬性的集合

物件是一種特殊的資料,可以看做是一組屬性的集合。屬性可以是資料,也可以是函式(此時稱為方法)。每個屬性有一個名稱和一個值,可以近似看成是一個鍵值對。名稱通常是字串,也可以是Symbol

3.3 物件的建立

var obj = new Object(); // 通過new操作符
var obj = {}; // 通過物件字面量(object literal)

3.4 物件的訪問

有兩種方式來訪問物件的屬性,一種是通過點操作符,一種是通過中括號。

var a = {};
a["age"] = 3; // 新增新的屬性
console.log(a.age); // 3
for(i in a){
  console.log(i); // "age"
  console.log(a[i]); // 3
}

對於物件的方法,如果加括號,是返回撥用結果;如果不加括號,是返回方法本身,可以賦值給其他變數。

var a = {name : "a"};
a.sayHello = function(){
  console.log(this.name + ":hello");
}
var b = {name : "b"};
b.saySomething = a.sayHello;
b.saySomething(); //"b:hello"

注:函式作為物件的方法被呼叫時,this值就是該物件。

3.5 引用型別

有些地方會用到引用型別這個概念來指代Object型別。要理解這個說法,就需要理解javascript中變數的訪問方式。

  • 基本資料型別的值是按值訪問的

  • 引用型別的值是按引用訪問的

按值訪問意味著值不可變、比較是值與值之間的比較、變數的識別符號和值都存放在棧記憶體中。賦值時,進行的是值的拷貝,賦值操作後,兩個變數互相不影響。

按引用訪問意味著值可變(Object的屬性可以動態的增刪改)、比較是引用的比較(兩個不同的空物件是不相等的)、引用型別的值儲存在堆記憶體中,棧記憶體裡儲存的是地址。賦值時,進行的是地址值的拷貝,複製操作後兩個變數指向同一個物件。通過其中一個變數修改物件屬性的話,通過另一個變數去訪問屬性,也是已經被改變過的。

3.6 和Lua中Table的比較

Object型別的概念和Lua中的table型別比較相似。變數儲存的都是引用,資料組織都是類鍵值對的形式。table中用原表(metatable)來實現物件導向的概念,Javascript中則是用原型(prototype)。
目前看到的相似點比較多,差異性有待進一步比較。

三、物件導向

1. 意義

程式設計時經常會有重用的需求。我們希望能夠大規模構建同種結構的物件,有時我們還希望能夠基於某個已有的物件構建新的物件,只重寫或新增部分新的屬性。這就需要“型別和繼承”的概念。

Javascript中並沒有class實現,除了基本型別之外只有Object這一種型別。但是我們可以通過原型繼承的方式實現物件導向的需求。

注:ECMAScript6中引入了一套新的關鍵字用來實現class。但是底層原理仍然是基於原型的。此處先不提。

2. 原型與繼承

Javascript中,每個物件都有一個特殊的隱藏屬性[[Prototype]],它要麼為null,要麼就是對另一個物件的引用。被引用的物件,稱為這個物件的原型物件。

原型物件也有一個自己的[[Prototype]],層層向上,直到一個物件的原型物件為null

可以很容易地推斷出,這是一個鏈狀,或者說樹狀的關係。null是沒有原型的,是所有原型鏈的終點。

如前文所說,JavaScript中的Object是屬性的集合。原型屬性將多個Obeject串連成鏈。當試圖訪問一個物件的屬性時,會首先在該物件中搜尋,如果沒有找到,那麼會沿著原型鏈一路搜尋上去,直到在某個原型上找到了該屬性或者到達了原型鏈的末尾。Javascript就是通過這種形式,實現了繼承

從原理來看,可以很自然地明白,原型鏈前端的屬性會遮蔽掉後端的同名屬性。

函式在JavaScript中是一等公民,函式的繼承與和其他屬性的繼承沒有區別。

需要注意的是,在呼叫一個方法obj.method()時,即使方法是從obj的原型中獲取的,this始終引用obj。方法始終與當前物件一起使用。

3. 自定義物件

如何建立類似物件

繼承一個物件可以通過原型,那麼如何可複用地產生物件呢?

可以使用函式來模擬我們想要的“類”。實現一個類似於構造器的函式,在這個函式中定義並返回我們想要的物件。這樣,每次呼叫這個函式的時候我們都可以產生一個同“類”的新物件。

function makePerson(name, age){
    return {
        name: name,
        age: age,
        getIntro:function(){
            return "Name:" + this.name + " Age:" + this.age;
        };
    };
}
var xiaoming = makePerson("Xiaoming", 10);
console.log(xiaoming.name, xiaoming.age); // "Xiaoming" 10
console.log(xiaoming.getIntro()); // "Name:Xiaoming Age:10"

關鍵字this,使用在函式中時指代的總是當前物件——也就是呼叫了這個函式的物件。

構造器和new

我們可以使用this和關鍵字new來對這個構造器進行進一步的封裝。

關鍵字new可以建立一個嶄新的空物件,使用這個新物件的this來呼叫函式,並將這個this作為函式返回值。我們可以在函式中對this進行屬性和方法的設定。

這樣,我們的函式就是一個可以配合new來使用的真正的構造器了。

通常構造器沒有return語句。如果有return語句且返回的是一個物件,則會用這個物件替代this返回。如果是return的是原始值,則會被忽略。

function makePerson(name, age){
    this.name = name;
    this.age = age;
    this.getIntro = function(){
        return "Name:" + this.name + " Age:" + this.age;
    };
}
var xiaoming = new makePerson("Xiaoming", 10);
console.log(xiaoming.name, xiaoming.age); // "Xiaoming" 10
console.log(xiaoming.getIntro()); // "Name:Xiaoming Age:10"

構造器的prototype屬性

上面的實現可以炮製我們想要的自定義物件,但是它和C++中的class比還有一個很大的缺點:每個物件中都包含了重複的函式物件。但是如果我們把這個函式放在外面實現,又會增加不必要的全域性函式。

JavaScript提供了一個強大的特性。每個函式物件都有一個prototype屬性,指向某一個物件。通過new建立出來的新物件,會將構造器的prototype屬性賦值給自己的[[Prototype]]屬性。也就是說,每一個通過new 構造器函式生成出來的物件,它的[[Prototype]]都指向構造器函式當前的prototype所指向的物件。

注意,函式的prototype屬性和前文所說的隱藏的[[Prototype]]屬性並不是一回事。

函式物件的prototype是一個名為“prototype”的普通屬性,指向的並不是這個函式物件的原型。函式物件的原型儲存在函式物件的[[Prototype]]中。

事實上,每個函式物件都可以看成是通過new Function()構造出來的,也就是說,每個函式物件的[[Prototype]]屬性都由Funtionprototype屬性賦值而來。

我們定義的函式物件,預設的prototype是一個空物件。我們可以通過改變這個空物件的屬性,動態地影響到所有以這個物件為原型的物件(也就是從這個函式生成的所有物件)。

於是上面的例子可以改寫為:

function makePerson(name, age){
    this.name = name;
    this.age = age;
}
var xiaoming = new makePerson("Xiaoming", 10);
makePerson.prototype.getIntro = function(){
    return "Name:" + this.name + " Age:" + this.age;
};
console.log(xiaoming.name, xiaoming.age); // "Xiaoming" 10
console.log(xiaoming.getIntro()); // "Name:Xiaoming Age:10"

這裡是先構造了物件xiaoming,再為它的原型增加了新的方法。可以看到,xiaoming可以通過原型鏈呼叫到新定義的原型方法。

需要注意的是,如果直接令函式的prototype為新的物件,將不能影響到之前生成的繼承者們——因為它們的[[Prototype]]中儲存的是原來的prototype所指向的物件的引用。

四、參考

MDN | 重新介紹JavaScript
MDN | Primitive
原型繼承
MDN | 原型與渲染鏈

相關文章