JavaScript 型別、原型與繼承學習筆記
阿新 • • 發佈:2021-02-02
[TOC]
---
這篇筆記中有什麼:
✔️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來說都很直觀,但是對於字串來說可能需要格外注意:同一塊記憶體中的一個字串是不可以部分修改的,一定是整體重新賦值。
```javascript
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
- 取值為`true`和`false`。
- `0`、`""`、`NaN`、`null`、`undefined`也會被轉換為`false`。
- Null
- Null型別只有一個值:`null`。表示未被宣告的值。
- 注意:由於歷史原因,typeof null的結果是`"object"`。
- undefined
- 未初始化的值(聲明瞭但是沒有賦值)。
```javascript
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,僅僅相當於註釋,可用於除錯。
```javascript
var sym1 = Symbol("abc");
var sym2 = Symbol("abc");
console.log(sym1 == sym2); // false
console.log(sym1 === sym2); // false
```
#### 2.3 基本型別封裝物件
接觸了一些JavaScript的程式碼,又瞭解了它對型別的分類之後,可能會感到非常困惑:基本資料型別不是物件,沒有方法,那麼為什麼又經常會看到對字串、數字等“基本型別”的變數呼叫方法呢?
如下面的例子:
```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會自動為基本型別值封裝出一個封裝物件,之後從封裝物件中去訪問屬性、方法。而且,這個物件是**臨時**的,呼叫完屬性之後,包裝物件就會被丟棄。
這也就解釋了一件事:為什麼給基本型別新增屬性不會報錯,但是並不會有任何效果。因為,新增的屬性其實新增在了臨時物件上,而臨時物件很快就被銷燬了,並不會對原始值造成影響。
封裝物件有: `String`、`Number`、`Boolean` 和 `Symbol`。
我們也可以通過new去顯性地建立包裝物件(除了`Symbol`)。
```javascript
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 物件的建立
```javascript
var obj = new Object(); // 通過new操作符
var obj = {}; // 通過物件字面量(object literal)
```
#### 3.4 物件的訪問
有兩種方式來訪問物件的屬性,一種是通過點操作符,一種是通過中括號。
```javascript
var a = {};
a["age"] = 3; // 新增新的屬性
console.log(a.age); // 3
for(i in a){
console.log(i); // "age"
console.log(a[i]); // 3
}
```
對於物件的方法,如果加括號,是返回呼叫結果;如果不加括號,是返回方法本身,可以賦值給其他變數。
```javascript
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. 自定義物件
#### 如何建立類似物件
繼承一個物件可以通過原型,那麼如何可複用地產生物件呢?
可以使用函式來模擬我們想要的“類”。實現一個類似於構造器的函式,在這個函式中定義並返回我們想要的物件。這樣,每次呼叫這個函式的時候我們都可以產生一個同“類”的新物件。
```javascript
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`的是原始值,則會被忽略。
```javascript
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]]`屬性都由`Funtion`的`prototype`屬性賦值而來。
我們定義的函式物件,預設的`prototype`是一個空物件。我們可以通過改變這個空物件的屬性,動態地影響到所有以這個物件為原型的物件(也就是從這個函式生成的所有物件)。
於是上面的例子可以改寫為:
```javascript
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](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/A_re-introduction_to_JavaScript)
[MDN | Primitive](https://developer.mozilla.org/zh-CN/docs/Glossary/Primitive)
[原型繼承](https://zh.javascript.info/prototype-inheritance )
[MDN | 原型與渲染鏈](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Inheritance_and_the_prototype