你不知道的JavaScript(上) - 閱讀筆記

wq93發表於2019-03-10

你不知道的JavaScript(上)


① 作用域和閉包

一. 作用域是什麼?

作用域是一套規則,用於在何處以及如何查詢變數(識別符號).如果查詢的目的是對變數進行賦值,那麼就行使用LHS查詢;如果目的是獲取變數的值,就會使用RHS查詢.賦值操作會導致LHS查詢. =操作符或呼叫函式時傳入引數的操作都會導致關聯作用域的賦值操作.

  • PS: 對變數賦值LHS,為變數取值RHS

JavaScript引擎首先會在程式碼執行前對其編譯,在這個過程中,像var a = 2這樣的宣告被分解成兩個獨立的步驟:

  1. 首先,var a在其作用域中宣告新變數.這會在最開始的階段,也就是程式碼執行前進行.
  2. 接下來,a=2會查詢(LHS查詢)變數a並對其進行賦值 LHSRHS查詢都會在當前執行作用域中開始,如果有需要(沒有找到所需的識別符號),就會向上級作用域繼續查詢目標識別符號,這樣每次上升一級,最後抵達全域性作用域,無論找到或沒找到都將停止.
  • PS: 把作用域鏈比喻成一棟建築

不成功的RHS引用會導致丟擲ReferenceError異常. 不成功的LHS引用會導致自動隱式地建立一個全域性變數(非嚴格模式下),該變數使用LHS引用的目標作為識別符號,或者丟擲ReferenceError(嚴格模式下)

對變數賦值LHS,為變數取值RHS

LHS與RHS

對變數賦值`LHS`,為變數取值`RHS`
複製程式碼

二. 詞法作用域

詞法作用域意味著作用域是由書寫程式碼時函式宣告的位置決定. 編譯的詞法分析階段基本能夠知道全部識別符號在那裡以及如何宣告的,從而能夠預測在執行過程中如何對它們進行查詢.

JavaScript中有兩個機制可以"欺騙"詞法作用域: eval(...)with. 前者可以對一段包含一個或多個宣告的"程式碼"字串進行演算,並藉此來修改已存在的詞法作用域. 後者本質上是通過一個物件的引用當作作用域來處理,將物件的屬性當作作用域中的識別符號來處理,從而建立一個新的詞法作用域.

這兩個機制的副作用是引擎無法在編譯時對作用域查詢進行優化,因為引擎只能謹慎的認為這樣的優化是無效的.使用這其中一種機制都將導致程式碼執行變慢.不要使用它們

三. 函式作用域和塊作用域

函式是JavaScript中最常見的作用域單元. 本質上,宣告在一個函式內部的變數或者函式會在所處的作用域中被"隱藏"起來,這是有意為之的良好軟體的設計原則

但函式不是唯一的作用域單元. 塊作用域指的是變數和函式不僅可以屬於所處的作用域也可以屬於某個程式碼塊.

  • 從ES3開始,try/catch結構在catch分句中具有塊作用域

在ES6中引入了let關鍵字,用來在任何程式碼塊中宣告變數,if(..){let a = 2}會宣告一個劫持if{...}塊的變數,並將變數新增到這個塊中.

有些人認為塊作用域不應該完全作為函式作用域的替代方案.兩種功能應該同時存在,開發者可以並且也應該根據需要選擇使何種作用域,創造可讀,可維護的優良程式碼

四. 變數提升

我們習慣將var a = 2;看作是一個宣告,而實際上JS引擎並不認為. 它將var aa=2當作兩個單獨的宣告,第一個是編譯階段的任務,而第二個則是執行階段的任務

這意味著無論作用域中的宣告出現在什麼地方,都將在程式碼本身被執行前首先進行處理,可以將這個過程形象地想象成所有的宣告(變數和函式)都會被"移動"到各自作用域的最頂端,這個過程稱為提升

宣告本身會被提升,而包括函式表示式的賦值在內的賦值操作並不會提升.

要注意避免重複宣告,特別是當普通的var宣告和函式宣告混合在一起的時候,否則會引起很多危險的問題!

PS: 函式宣告和變數宣告都會被提升. 但是一個值得注意的細節(這個細節可以出現在有多個'重複'宣告的程式碼中)是函式會首先被提升,然後才是變數.

foo() //1
var foo
function foo() {
    console.log('1')
}
foo = function() {
    console.log('2')
}

複製程式碼

五. 閉包

閉包無處不在,你只需要識別並擁抱它

  • 閉包的模型
function foo() {
    var a = 2
    function bar() {
        console.log(a)
    }
    return bar
}
var baz = foo()
baz() //2
複製程式碼

在這個例子中,它在自己定義的詞法作用域以外的地方執行

foo()執行後,通常會期待foo()的整個內部作用域都被銷燬,因為我們知道引擎有垃圾回收器用來釋放不再使用的記憶體空間. 由於看上去foo()的內容不會再被使用,所以我們會認為垃圾回收機制會將其回收

而閉包可以阻止這件事的發生. 拜bar()所宣告的位置所賜,它擁有涵蓋foo()內部作用域的閉包,使得該作用域一直存活,以供bar()在之後任何時間進行引用

bar()依然持有對該作用域的引用,而這個引用就叫作閉包

當然,無論使用何種方式對函式型別的值進行傳遞,當函式在別處被呼叫時都可以觀察到閉包

function foo () {
    var a = 2
    function baz() {
        console.log(a) //2
    }
    bar(baz)
}
function bar(fn) {
    fn() //這就是閉包
}

複製程式碼
var fn
function foo() {
    var a = 2
    function baz() {
        console.log(a)
    }
    fn = baz // 將baz分配給全域性變數
}
function bar() {
    fn() // 這就是閉包
}
foo()
bar() //2
複製程式碼

無論通過何種手段將內部函式傳遞到所在詞法作用域以外,它都會持有對原始定義作用域的引用,無論在何處執行這個函式都會使用閉包

閉包的場景

PS:定時器中的閉包

function wait(mes) {
    setTimeout(function timer(){
        console.log(mes) //這就是閉包
    },1000)
}
複製程式碼

解析: 將一個內部函式傳遞給setTimeout(...). timer具有涵蓋wait(...)作用域的閉包,因此還保有對變數message的引用.

  • 定時器,事件監聽,Ajax請求,垮視窗通訊,Web Workers或者任何其的非同步任務中,只要使用了回撥函式,實際上就是在使用閉包

迴圈和閉包

PS:典型例子

for(var i = 1;i<=5;i++) {
    setTimeout(function timer(){
        console.log(i) // 每秒一次的頻率輸出五次6
    },i*1000)
}
複製程式碼

解析:

  1. 6從哪兒來?

    迴圈終止條件是i不再<=5,條件首次成立時i的值是6

  2. 執行機制

    根據作用域的工作原理,實際情況是儘管迴圈中五個函式是在各個迭代中分別定義的,但是它們都被封閉在一個共享的全域性作用域中,因此實際上是同一個並僅有一個i

改進:

for(var i=1;i<=5;i++) {
    (function(j){
        setTimeout(function timer(){
            console.log(j)
        },j*1000)
    })(i)
}
複製程式碼

解析:

在迭代中使用IIFE會為每次迭代都生成一個新的作用域,使得延遲函式的回撥可以將新的作用域封閉在每個迭代內部,每個迭代中都會含有一個具有正確值的變數提供我們訪問

進一步改進:

for(var i=1;i<=5;i++){
    let j=i // 閉包的塊作用域
    setTimeout(function timer(){
    	console.log(j)
    },j*1000)
}
複製程式碼

終極模式:

for(let i=1;i<=5;i++){
    setTimeout(function timer(){
    	console.log(i)
    },i*1000)
}
複製程式碼

解析:

let宣告變數有一個特殊行為,指的是變數在迴圈過程中不止宣告一次,每次迭代都會宣告. 隨後的每個迭代都會使用上一個迭代結束的值來初始化這個變數

模組

function CoolModule(){
    var something = 'cool'
    var another = [1,2,3]
    function doSomething() {
        console.log(something)
    }
    
    function doAnother() {
        console.log(another.join('!'))
    }
    return {
        doSomething:doSomething,
        doAnother:doAnother
    }
}
複製程式碼

CoolModule()只是一個函式,必須要通過呼叫它來建立一個模組例項. 如果不執行外部函式,內部作用域和閉包都無法建立.

  1. 必須有外部的封閉函式,該函式必須至少呼叫一次(每次呼叫都會建立一個新的模組例項)
  2. 封閉函式必須返回至少一個內部函式,這樣內部函式才能在私有作用域中形成閉包,並且可以訪問或者修改私有的狀態

小結

閉包實際上是一個普通且明顯的事實,那就是我們在詞法作用域的環境下寫程式碼,而其中的函式也是值,我們可以開心的傳來傳去

當函式可以記住並訪問所在的詞法作用域,即使函式是在當前詞法作用域之外執行,這時就產生了閉包

模組有兩個主要的特徵: (1)為建立內部作用域而呼叫一個包裝函式 (2) 包裝函式的返回值必須至少包括一個對內部函式的引用,這樣就會建立涵蓋整個函式內部作用域的閉包.

六. 附錄

動態作用域

動態作用域並不關心函式和作用域是如何宣告以及在何處宣告的,只關心它們從何處呼叫. 換句話是,作用域鏈是基於呼叫棧,而不是程式碼中的作用域巢狀.

function foo(){
    console.log(a) // 2 (不是3)
}
function bar() {
    var a = 3
    foo()
}
var a = 2
bar()
複製程式碼

解析:

事實上JavaScript並不具有動態作用域. 它只有詞法作用域,簡單明瞭. 但是this的動態機制某種程度上很像動態作用域

主要區別

詞法作用域是在寫程式碼或者定義時確定的,而動態作用域是在執行時確定的.(this也是) 詞法作用域關注函式在何處宣告,而動態作用域關注函式從何處呼叫.

塊作用域的替代方案

foeExample:
{
  let a = 2
  console.log(2) // 2
}
console.log(a) // ReferenceError

// =====> ES6之前
try{
   throw 2
 }catch (a) {
   console.log(2)
 }
 console.log(a) // ReferenceError

複製程式碼
try/catch效能

問: 為什麼不直接使用IIFE來建立作用域?

答:

首先,try/catch的效能的確糟糕,但技術層面上沒有合理的理由來說明try/catch必須這麼慢,或者會一直這麼慢下去. 自從TC39支援在ES6的轉換器中使用try/catch後,Traceur團隊已經要求chrome對try/catch的效能進行改進,他們顯然有很充分的動機來做這件事情

其次: IIFE和try/catch並不是等價的,因為如果將一段程式碼中的任意一部分拿出來用函式進行包裹,會改變這段程式碼的含義,其中this,return,break和continue都會發生變化. IIFE並不是一個普通的解決方案,它只適應在某些情況下進行手動操作

匿名函式沒有name識別符號,會導致?
  1. 呼叫棧更難追蹤
  2. 自我引用更難
  3. 程式碼更難理解

this的詞法

箭頭函式的this

箭頭函式在涉及this繫結的行為和普通函式的行為完全不一致. 它放棄了所以普通this繫結的規則,取而代之的是用當前的詞法作用域覆蓋了this的值(箭頭函式不止於少寫程式碼)

② this和物件原型

一. 關於this

它的作用域

this在任何情況下都不指向函式的詞法作用域. 在JavaScript內部,作用域確實和物件類似,可見的識別符號都是它的屬性. 但是作用域"物件"無法通過JavaScript程式碼訪問,它存在JavaScript引擎內部

每當你想要把this和詞法作用域的查詢混合使用時,一定要提醒自己,這是無法實現的

this是什麼?

this是在執行時進行繫結的,並不是在編寫時繫結的,它的上下文取決於函式呼叫時的各種條件. this的繫結和函式的宣告的位置沒有任何關係,只取決於函式的呼叫方法

this實際上是在函式被呼叫時發生的繫結,它指向什麼完全取決於函式在哪裡被呼叫

二. this的全面解析

呼叫位置

呼叫位置就是函式在程式碼中被呼叫的位置

呼叫棧就是為了到達當前執行位置所呼叫的所以函式

this繫結規則

1. 預設繫結

獨立函式呼叫

function foo() {
    console.log(this.a)
}
var a = 2
foo() // 2
複製程式碼

解析: foo()是直接使用不帶任何修飾的函式引用進行呼叫的,因此只能使用預設繫結,無法應用其他規則

如果使用嚴格模式(strict mode),則不能將全域性物件用於預設繫結,因此this會繫結到undefined

function foo() {
    "use strict"
    console.log(this.a)
}
var a =  2
foo() // TypeError: this is undefined
複製程式碼

這裡有一個微妙但非常重要的細節,雖然this的繫結規則完全取決於呼叫位置,但是隻有foo()執行在非strict mode

下時,預設繫結才能繫結到全域性物件;在嚴格模式下呼叫foo()則不影響預設繫結.

function foo() {
    console.log(this.a)
}
var a = 2
(function(){
    "use strict"
    foo() //2
})()
複製程式碼
2.隱式繫結

forexample:

function foo () {
    console.log(this.a)
} 
var obj = {
    a: 2,
    foo:foo
}
obj.foo() //2
複製程式碼

當函式引用有上下文物件時,隱式繫結規則會把函式呼叫中的this繫結到這個上下文物件

例外:

物件屬性引用鏈中只有上一層或者說最後一層在呼叫位置中起作用.

function foo() {
    console.log(this.a)
}
var obj2 = {
    a:42,
    foo:foo
}
var obj1 = {
    a:2,
    obj2:obj2
}
obj1.obj2.foo() // 42
複製程式碼
隱式丟失

一個最常見的this繫結的問題就是被隱式繫結的函式丟失繫結物件,也就是說它會應用預設繫結,從而把this繫結到全域性物件或者undefined上,取決於是否是嚴格模式

function foo() {
    console.log(this.a)
}
var obj = {
    a:2,
    foo:foo
}
var bar = obj.foo // 函式別名
var a = 'oops,global'
bar() // oops,global
複製程式碼

解析:

雖然bar是obj.foo的一個引用,但是實際上,它引用的是foo函式本身,因此此時的bar()其實是一個不帶任何修飾的函式呼叫,使用的預設繫結

function foo() {
    console.log(this.a)
}
function doFoo(fn){
    // fn其實引用的是foo
    fn(); // <--呼叫位置
}
var obj = {
    a:2
    foo:foo
}
var a = 'oops,global'
doFoo(obj.foo) // oops,global

複製程式碼

解析:

引數傳遞其實就是一種隱式賦值,因此我們傳入函式也會被隱式賦值

  • 回撥函式丟失this繫結是非常常見的,接下來學習如何通過固定this來修復這個問題
3.顯式繫結

JavaScript提供的絕大多數函式以及我們自己建立的所有函式都可以使用call(...)apply(...)方法

它們的第一個引數是一個物件,是給this準備的,接著在呼叫函式時將其繫結到this. 因為你可以直接指定this的繫結物件,因此我們稱之為顯示繫結.

3.1 硬繫結

硬繫結是一種非常常見的模式,ES5提供了內建的方法Function.prototype.bind(...)

bind(...)會返回一個硬編碼的新函式,它會把你指定的引數設定為this的上下文並呼叫原始函式

4.new繫結

包括內建函式和自定義函式在內的所有函式都可以用new來呼叫,這種函式呼叫被稱為建構函式呼叫. 實際上並不存在所謂的"建構函式",只有對於函式的構造呼叫

使用new來呼叫函式,或者說發生建構函式呼叫時,會自動執行下面的操作

  1. 建立一個全新的物件
  2. 這個物件會被執行[[Prototype]]連結
  3. 這個新物件會繫結到函式呼叫的this
  4. 如果函式沒有返回其他物件,那麼new表示式中的函式呼叫會自動返回這個新物件

判斷this(重要!!!)

按照下面的順序進行判斷(記住特例):

  1. 函式是否在new中呼叫(new 繫結)?如果是的話this繫結的是新建立的物件 var bar = new foo()
  2. 函式是否通過call或apply(顯示繫結)或者硬繫結呼叫? 如果是的話, this繫結的是指定的物件 var bar = foo.call(obj2)
  3. 函式是否在某個上下文物件中呼叫(隱式繫結)?如果是的話,this繫結的是那個上下文物件 var bar = obj.foo()
  4. 如果都不是的話,使用的是預設繫結. 如果在嚴格模式下,就繫結到undefined,否則繫結的全域性物件 var bar = foo()

不過...凡是都有例外,接下來我們介紹例外吧

this的繫結例外

  1. 被忽略的this

如果我們把nullundefined作為this的繫結物件傳入call apply 或者bind,這些值在呼叫時會被忽略,實際應用的是預設繫結

function foo() {
    console.log(this.a)
}
var a = 2
foo.call(null) // 2
複製程式碼

柯里化傳入更安全的this

在JavaScript中建立一個空物件最簡單的方法是Object.create(null),它不會建立Object.prototype這個委託,所以比{}更空

function foo(a,b) {
    console.log("a" + a + ", b :" + b)
}
// DMZ空物件
var Ø = Object.create(null)
// 把陣列展開成引數
foo.apply(Ø,[2,3]) //a:2,b:3
//使用bind(...)進行柯里化
var bar = foo.bind(Ø,2)
bar(3) //a:2,b:3
複製程式碼
  1. 間接引用
var a = 2
var o = {a:3,foo:foo}
var p = {a:4}
o.foo() // 3
(p.foo = o.foo)() // 2
複製程式碼

賦值表示式p.foo = o.foo的返回值是目標函式的引用,因此呼叫位置是foo()而不是p.foo()0.foo(),故這裡會應用預設繫結

箭頭函式當作物件的屬性的值

Element.prototype.hide = () => { this.style.display = 'none' }
複製程式碼

會報錯,檢視babel解析後的程式碼,發現this沒有繫結上:

Element.prototype.hide = function() {
    undefined.style.display = 'none'    
}
複製程式碼

​ 箭頭函式的 this 是靜態的,也就是說,只需要看箭頭函式在什麼函式作用域下宣告的,那麼這個 this 就會繫結到這個函式的上下文中。即“穿透”箭頭函式。

例子裡的箭頭函式並沒有在哪個函式裡宣告,所以 this 會 fallback 到全域性/undefined

"穿透"到最近的詞法作用域(注意物件的{}不算外層作用域),如果外層沒有被函式包裹,那麼就是window

例如:

 let a = {
    foo() {
      console.log(this)
    },
    foo1: () => {
      console.log(this)
    },
    foo2: function foo2() {
      console.log(this)
    }
  } 
  a.foo() // a
  a.foo1() // window
  a.foo2() // a

複製程式碼

小結

如果要判定一個執行函式的this繫結,就需要找到這個函式的直接呼叫位置. 找到之後可以順序應用下面這四條規則來判斷this的繫結函式

  1. 由new呼叫? 繫結到新建立的物件 (new繫結)
  2. 由call或apply(或者bind)呼叫? 繫結到指定的物件 (顯示繫結)
  3. 由上下文呼叫? 繫結到那個上下文物件 (隱式繫結)
  4. 預設繫結: 在嚴格模式下繫結到undefined,否則會繫結到全域性 (預設繫結)

注意: 有些呼叫可能在無意中使用預設繫結規則. 如果想'更安全'地忽略this繫結,你可以使用一個DMZ物件,

比如 var Ø = Object.create(null),以保護全域性物件

ES6中的箭頭函式並不會使用四條標準的繫結規則,而是根據當前的詞法作用域來決定this,具體說,箭頭函式會繼承外層函式呼叫的this繫結(無論this繫結到什麼). 這其實和ES6之前的var self = this的機制一樣

三. 物件

基本型別

  1. string
  2. number
  3. boolean
  4. null
  5. undefined
  6. object

注意:

null有時會被當作一種物件型別,但是這其實只是語言本身的一個bug,即對null執行typeof null時返回字串"object". 實際上,null本身是基本型別

黑科技: 原理是這樣的,不同的物件在底層都表示為二進位制,在JavaScript中二進位制前三位都為0的話會被判斷為object型別,null的二進位制表示是全0,自然前三位也是0,所以typerof時會返回"object"

內建物件

  1. String
  2. Number
  3. Boolean
  4. Object
  5. Function
  6. Array
  7. Date
  8. RegExp
  9. Error

必要時語言會自動把字串字面量轉換成一個String物件

在物件中,屬性名永遠都是字串。如果使用string以外的其他值作為屬性名,它首先會被轉換為一個字串。

複製物件(待解決)

淺拷貝
深拷貝(JSON.stringify(obj)
屬性描述符
1. writable

是否可以修改屬性的值

2.configurable

屬性是否可配置,把configurable修改成false是單向操作,無法撤銷

除了無法修改,configurable:false還會禁止這個屬性

3.Enumerable

屬性是否會出現在物件的屬性列舉中

4.不變性

所有的方法建立的都是淺不變性,它們只會影響目標物件和它的直接屬性.如果目標物件引用了其他物件,其他物件的內容不受影響,仍然是可變的

4.1 物件常量

結合writable:falseconfigurable:flase就可以建立一個真正的常量屬性(不可修改,不可重新定義或刪除)

var object1 = {}
Object.defineProperty(object1,"FAVORITE_NUMBER",{
    value:42,
    writable:false,
    configurable:false
})
複製程式碼

4.2 禁止擴充套件

如果想禁止一個物件新增新屬性並且保留已有屬性,可以使用Object.preventExtensions()

var myObj = { a : 2 }
Object.preventExtensions(myObj)
myObj.a = 3
myObj.a = undefined
複製程式碼

在非嚴格模式下,建立屬性a會靜默失敗. 在嚴格模式下,將會丟擲TypeError錯誤

4.3 密封

Object.seal(...)會建立一個密封的物件,這個方法實際上會在一個現有物件上呼叫Object.preventExtensions(...)並將所有現有的屬性標記為configurable:flase

所以,密封后的物件不能新增屬性,不能重新配置屬性或者刪除現有屬性(只能修改屬性的值)

4.4 凍結

Object.freeze(...)會建立一個凍結物件,這個方法實際上會在一個現有物件上呼叫Object.seal(...)並把所有"資料訪問"屬性標記為writable:fasle,這樣就無法修改它們的值

這個方法是可以應用在物件上的級別最高的不可變性

5.[[Get]]

在語言規範中,obj.a在obj上實際上是實現了[[Get]]操作. 物件預設的內建[[Get]]操作首先在物件上查詢是否有名稱相同的屬性,如果找到就會返回這個屬性的值

如果無論如何都沒有找到名稱相同的屬性,那麼[[Get]]操作會返回值undefined

var myObj = {
    a: 2
}
myObj.b // undefined
複製程式碼

這種方法和訪問變數是不一樣的. 如果你引用了一個當前詞法作用域中不存在的變數,並不會像物件屬性一樣返回undefined,而是會丟擲一個ReferenceError異常

故,僅通過返回值,你無法判斷一個屬性是存在並且持有一個undefined值,還是變數不存在,所以[[Get]]無法返回某個特定值而返回預設的undefined

6.[[Put]]

[[Put]]被觸發時,實際行為分成兩種:

  1. 已經存在這個屬性
    1. 屬性是否是訪問描述符? 如果是並且存在setter就呼叫setter
    2. 屬性的資料描述符中writable是否是false? 如果是,在非嚴格模式下靜默失敗,在嚴格模式下丟擲TypeError異常
    3. 如果都不是,將該值設定為屬性值
  1. 不存在這個屬性
7.Getter函式和Setter函式

在ES5中可以使用getter和setter部分改寫預設操作,但是隻能應用在單個屬性上,無法應用在整個屬性上.

getter和setter都會覆蓋單個屬性預設的[[Getter]]和[[Setter]]操作

8.判斷屬性的存在性

當我們通過屬性名訪問某個值時可能返回undefined,這個值可能是物件屬性中儲存的undefined,也有可能是屬性不存在返回的undefined,那麼我們怎麼區分呢?

Forexample:

var obj = {
    a:2
}
('a' in obj) // true
('b' in obj) // false
obj.hasOwnProperty('a') //true
obj.hasOwnProperty('b') // fasle
複製程式碼

in 操作符會檢查屬性是否在物件及其[[Prototype]]原型鏈中,hasOwnProperty(...)只會檢查屬性是否在物件中,不會去檢查[[Prototype]]

看起來in操作符可以檢查容器是否有某個值,但是它實際上檢查的是某個屬性名是否存在.

PS:

4 in [1,2,4] // false
// 該陣列包含的屬性名是0 1 2 並沒有我們要找的4
複製程式碼
9.遍歷

最好只在物件上應用for...in迴圈中

小結

JavaScript中的物件有字面形式(var a= {...})和構造形式(var a = new Array(...))

"萬物皆物件"的概念是錯誤的. 物件是6個或者7個(null)基礎型別之一. 物件有包括function在內的子型別,不同子型別具有不同的行為,比如內部標籤[object Array]表示是物件的子型別陣列

物件就是鍵值對的集合. 可以通過.propName或者['propName']語法來獲取屬性值. 訪問屬性時,引擎實際上會呼叫內部的預設[[Get]]操作(在設定屬性值時是[[Put]]),[[Get]]操作檢查物件本身是否包含這個屬性,如果沒找到的話還會查詢[[Prototype]]

屬性的特性可以通過屬性描述符來控制,比如writableconfigurable. 還可以使用Object.preventExtensions(...),Object.seal(...)Object.freeze(...)來設定物件的不可變性級別,其中Object.freeze(...)是應用在物件上不可變性的最高階別.

屬性不一定包含值-它們可能是具備getter/setter的"訪問描述符". 此外屬性可以是可列舉或不可列舉的,這決定了它們是否會出現在for...in迴圈中

可以使用for...of遍歷資料結構(陣列,物件等等)中的值,for...of會尋找內建或者定義的@@iterator物件並呼叫它的next()方法來遍歷資料值

四. 混合物件"類"

建構函式

類例項是由一個特殊的類方法構造的,這個方法名通常和類名相同,被稱為建構函式. 這個方法的任務就是初始化例項需要的所有資訊

類的繼承

多型(super關鍵字)

在傳統的物件導向的語言中super還有一個功能,就是從子類的建構函式中通過super可以直接呼叫父類的建構函式.通常來說這沒什麼問題,因為對於真正的類來說,建構函式是屬於類的.

然而,在JavaScript中恰好相反-實際上類是屬於建構函式的. 由於JavaScript中父類和子類的關係只存在於兩者建構函式對應的.prototype物件中,因此它們的建構函式之間並不存在直接關係,從而無法簡單地實現兩者的相對引用.

小結

類是一種設計模式. 許多語言提供了對於物件導向類軟體設計的原生語法. JavaScript也有類似的語法,但是和其他語言中的類完全不一樣

類意味著複製

傳統的類例項化時,它的行為會被複制到例項中. 類被繼承時,行為也會複製到子類中

多型(在繼承鏈的不同層次名稱相同但是功能不同的函式)看起來似乎是從子類引用父類,但是本質上引用的其實是複製的結果

JavaScript不會(像類那樣)自動建立物件的副本, 只能複製引用,無法複製被引用的物件或者函式本身

混入模式(利用for...in遍歷判斷物件不存在的屬性,不存在則新增)可以用來模擬類的複製行為,但是通常會產生醜陋並且脆弱的語法,比如顯式偽多型(Object.methidName.call(this,....)),這會讓程式碼更難懂並且難以維護.

顯示混入實際上無法完全模擬類的複製行為,因為物件(和函式,函式也是物件)只能複製引用,無法複製被引用的物件或者函式本身.

總地來說,在JavaScript中模擬類是得不償失的,雖然能解決當前的問題,但是可能會埋下更多的隱患.

五. 原型

1.[[Prototype]]

JavaScript中的物件有一個特殊的[[prototype]]內建屬性,其實就是對於其他物件的引用. 幾乎所有的物件在建立時[[Prototype]]屬性都會被賦予一個非空的值

var anotherObj = {
    a:2
}
// 建立一個關聯到 antherObj 的物件
var myObj = Object.create(anotherObj)
複製程式碼
1.1 Object.prototype

所有普通的[[prototype]]鏈最終都會指向內建的Object.prototype

1.2屬性設定和遮蔽

在[第三部分物件中]提到過,給一個物件設定屬性並不僅僅是新增一個新屬性或者修改已有的屬性值

myObj.foo = 'bar'
複製程式碼
  • 如果myObj物件中包含名為foo的普通資料訪問屬性,這條賦值語句只會修改已有的屬性值
  • 如果foo不是直接存在於myObj中,[[Prototype]]鏈就會被遍歷,類似[[Get]]操作. 如果原型鏈找不到foo,foo就會被直接新增到myObj
  • 如果foo存在原型鏈上層,賦值語句myObj.foo = 'bar'的行為就會有些不同,下面是詳細的介紹
  • 如果屬性名foo既出現在myObj中也出現在myObj[[Prototype]]鏈上層,那麼就會發生遮蔽. myObj中包含的foo屬性會遮蔽原型鏈上層的所有foo屬性,因為myObj.foo總是會選擇原型鏈中最底層的foo屬性

分析如果foo不直接存在myObj中而是存在原型鏈上層時,myObj.foo = 'bar'會出現三種情況 >>>

  • 如果在[[Prototype]]鏈上層存在名為foo的普通資料訪問屬性並且沒有被標記只讀,那會直接在myObj中新增一個名為foo的新屬性,它是遮蔽屬性
  • 如果在[[Prototype]]鏈上層存在foo,但是它被標記為只讀,那麼無法修改已有屬性或者在myObj上建立遮蔽屬性. 如果執行在嚴格模式下,程式碼會丟擲一個錯誤. 否則, 這條賦值語句會被忽略. 總之,不會發生遮蔽
  • 如果在[[Prototype]]鏈上層存在foo屬性並且它是一個setter,那就一定會呼叫這個setter. foo不會被新增到myObj,也不會重新定義foo這個setter

只讀屬性會阻止[[Prototype]]鏈下層隱式建立(遮蔽)屬. 這看起來有點奇怪,myObj物件會因為有一個只讀foo就不能包含foo屬性. 更奇怪的是,這個限制只存在於=賦值中,使用object.defineProperty(...)並不會受到影響

2."類"

2.1 類函式

通過呼叫new Foo()建立的每個物件將最終被[[Prototype]]連結到這個Foo.prototype物件

function Foo() {
    ...
}
var a = new Foo()
Object.getPrototypeOf(a) === Foo.prototype // true
複製程式碼

在物件導向的語言中,類可以被複制多次,就像模具製作東西一樣.

但是在JavaScript中,並沒有類似的複製機制. 我們不能建立一個類的多個例項,只能建立多個物件,它們的[[Prototype]]關聯的是同一個物件. 但是在預設情況下並不會進行復制,因此這些物件之間並不完全失去聯絡,它們是互相關聯的.

關於名稱

"原型繼承"嚴重影響了大家對JavaScript機制真實原理的理解

繼承意味著複製操作,JavaScript並不會複製物件屬性. 相反,JavaScript會在兩個物件之間建立一個關聯,這樣一個物件就可以通過委託訪問到另一個物件的屬性和函式

2.2 "建構函式"
function Foo() {
    // ...
}
Foo.prototype.constructor === Foo // true
var a = new Foo()
a.constructor === Foo // true
複製程式碼

Foo.prototype預設有一個公有並且不可列舉的屬性.constructor,這個屬性引用的是物件關聯的函式.可以看到通過"建構函式"呼叫new Foo(...)建立的物件也有一個.constructor屬性,指向"建立這個物件的函式"

建構函式還是呼叫?

new會劫持所有普通函式並構造物件的形式來呼叫它

function NothingSpecial() {
    console.log("Don't mind me")
}
var a = new NothingSpecial()
// Don't mind me
a // {}
複製程式碼

JavaScript中對於"建構函式"最準確的解釋是,所有帶new的函式呼叫

函式不是建構函式,但是當且使用new時,函式呼叫會變成"建構函式呼叫"

建構函式返回值的問題
  1. 沒有返回值的情況像其他傳統語言一樣,返回例項化的物件
function Person(){

    this.name="monster1935";
    this.age='24';
    this.sex="male";

}
console.log(Person());  //undefined
console.log(new Person());//Person {name: "monster1935", age: "24", sex: "male"}
複製程式碼
  1. 如果存在返回值則檢查其返回值是否為引用型別,如果為非引用型別,如(string,number,boolean,null,undefined),上述幾種型別的情況與沒有返回值的情況相同,實際返回例項化的物件
function Person(){

    this.name="monster1935";
    this.age='24';
    this.sex="male";

    return "monster1935";

}
console.log(Person());  //monster1935
console.log(new Person());//Person {name: "monster1935", age: "24", sex: "male"}
複製程式碼
  1. 如果存在返回值是引用型別,則實際返回該引用型別
function Person(){

    this.name="monster1935";
    this.age='24';
    this.sex="male";

    return {
        name:'Object',
        age:'12',
        sex:'female'
    }

}
console.log(Person());  //Object {name: "Object", age: "12", sex: "female"}
console.log(new Person());//Object {name: "Object", age: "12", sex: "female"}
複製程式碼

3.(原型)繼承

function Foo(name) {
    this.name = name
}
Foo.prototype.myName = function() {
    return this.name
}
function Bar(name,label) {
    Foo.call(this,name)
    this.label = label
}
// 建立一個新的Bar.prototype物件並關聯到Foo.prototype
Bar.prototype = Object.create(Foo.prototype)
//注意 現在沒有Bar.prototype.constructor了
Bar.prototype.myLabel = function() {
    return this.label
}
var a = new Bar("a","obj.a")
a.myName() // "a"
a.myLabel() // "obj.a"
複製程式碼

注意: 下面這兩種方式是常見的錯誤做法,實際上它們都存在一些問題

Bar.prototype = Foo.prototype // 直接引用的是Foo.prototype物件,賦值語句會互相修改
// 基本滿足要求,但是可能會產生一些副作用
Bar.prototype = new Foo()
複製程式碼

因此,要建立一個合適的物件,我們必須使用Object.create(...)而不是使用具有副作用的Foo(...)

這樣唯一的缺點就是建立一個新物件然後把舊物件拋棄掉,不能直接修改已有的預設物件

兩種關聯的方式
// ES6之前需要拋棄預設的Bar.prototype
Bar.prototype = Object.create(Foo.prototype)

// ES6開始可以直接修改現有的Bar.prototype
Object.setPrototypeOf(Bar.prototype,Foo.prototype)
複製程式碼
檢查"類"關係
  • instanceOf

    a instanceOf Foo, 左邊是一個普通的物件,右邊是一個函式. 回答的問題是:"在a的整條[[Prototype]]鏈中是否有Foo.prototype指向的物件?"

  • isPrototypeOf

    Foo.isPrototypeOf(a), 回答的問題是:"在a的整條[[Prototype]]鏈中是否出現過Foo.prototype"

獲取物件的原型鏈

Object.getPrototypeOf(a)

瀏覽器也支援一種非標準的方法訪問內部的[[Prototype]]屬性

a._proto_ (是可設定屬性)

4.物件關聯
原型鏈的概念

如果在物件上沒有找到需要的屬性或者方法引用,引擎就會繼續在[[Prototype]]關聯的物件上進行查詢. 如果後者中也沒有找到需要的引用就會繼續查詢它的[[Prototype]],以此類推,這一系列的連結稱為"原型鏈"

Object.create(...)

Object.create(...)會建立一個物件並把它關聯到我們指定的物件

Object.create(null)會建立一個擁有空[[Prototype]]連結的物件,這個物件無法進行委託. 由於這個物件沒有原型鏈,所以instanceOf操作符無法進行判斷

小結

如果要訪問物件中並不存在的一個屬性,[[Get]]操作就會查詢物件內部[[Prototype]]關聯的物件. 這個關聯關係實際上定義一條"原型鏈",在查詢屬性時就會對它進行遍歷

所有普通物件都有內建的Object.prototype,指向原型鏈的頂端,如果在原型鏈中找不到指定的屬性就會停止. toString(),valuOf()和其他一些通用的功能都存在於Object.prototype物件上,因此語言中所有的物件都可以使用他沒

關聯兩個物件最常用的方法是使用new關鍵字進行函式呼叫,在呼叫的4個步驟中會建立一個關聯其他物件的新物件

  1. 建立一個全新的物件
  2. 這個物件會被執行[[Prototype]]連結
  3. 這個新物件會繫結到函式呼叫的this
  4. 如果函式沒有返回其他物件,那麼new表示式中的函式呼叫會自動返回這個新物件

使用new呼叫函式時會把新物件的.prototype屬性關聯到"其他物件". 帶new的函式呼叫通常被稱為"建構函式呼叫",儘管它們實際上和傳統面向類語言中的建構函式不一樣

雖然這些JavaScript機制和傳統物件導向的"類初始化"和"類繼承"很相似,但是JavaScript中的機制有一個核心區別,那就是不會進行復制,物件之間是通過內部的[[Prototype]]鏈關聯的

出於各種原因,以"繼承"結尾的術語和其它物件導向的術語都無法幫助你理解JavaScript的真實機制,相比之下,"委託"是一個更合適的術語,因為物件之間的關係不是複製而是委託

六.行為委託

JavaScript中原型鏈這個機制的本質就行物件之間的關聯關係

Js中函式之所以可以訪問call(...),apply(...),bind(...)是因為函式本身是物件

1.委託理念

行為委託認為物件之間是兄弟關係,互相委託,而不是父類和子類關係.JavaScript的[[Prototype]]機制本質上就是行為委託節制. 也就是說,我們可以選擇在Js中努力實現類機制,也可以擁抱更自然的[[Prototype]]委託機制

2.類與物件

ES6的class語法糖

見下章

七. ES6中的class

傳統面向類的語言中父類和子類,子類和例項之間其實是複製操作,但是在[[Prototype]]中沒有複製,相反,它們之間只有委託關聯

1.class

class Widget{
    constructor(width,height) {
        this,width = width || 50
        this.height = height || 50
        this.$elem = null
    }
    render($where) {
        if(this.$elem) {
            this.$elem.css({
            	width:this.width + 'px',
            	height:this.height+'px'
            }).appendTo($where)
        }
    }
}
class Button extends Widget {
  constructor(width, height, label) {
    super(width, height)
    this.label = label || 'Default'
    this.$elem = $("<button>").text(this.label)
  }

  render($where) {
    super.render($where)
    this.$elem.click(this.onClick.bind(this))
  }

  onClick(evt) {
    console.log("Button" + this.label + 'clicked!')
  }
}
複製程式碼

除了語法更好看之外,ES6還解決了什麼問題?

  1. 不再引用雜亂的.prototype了
  2. Button宣告直接"繼承"了Widget,不再需要通過Object.create(...)來替換.prototype物件,也不需要設定._proto_或者Object.setPrototypeOf(...)
  3. 可以通過super(...)來實現相對多型,這樣任何方法都可以引用原型鏈上層的同名方法. 建構函式不屬於類,所以無法相互引用---super()可以完美解決建構函式的 問題
  4. class字面語法不能宣告屬性.看起來這是一種限制,但是它會排除掉許多不好的情況,如果沒有這種限制的話,原型鏈末端的"例項"可能會意外地獲取其他地方的屬性
  5. 可以通過extends很自然地擴充套件物件型別,甚至是內建的物件型別,比如Array或RegExp

2.class陷阱

class基本上只是現有[[Prototype]]機制(委託)的一種語法糖

也就是說class並不會像傳統面向類的語言一樣在宣告時靜態複製所有行為.如果修改或者替換了父"類"中的一個方法,那麼子"類"和所有例項都會受到影響,因為它們在定義時並沒有進行復制,只是使用基於[[Prototype]]的實時委託

class語法無法定義類成員屬性(只能定義方法)


原文地址: 傳送門

Github: 歡迎Startwq93


相關文章