1. 程式人生 > >林大媽的JavaScript進階知識(一):物件與記憶體

林大媽的JavaScript進階知識(一):物件與記憶體

JavaScript中的基本資料型別

在JS中,有6種基本資料型別:

  1. string
  2. number
  3. boolean
  4. null
  5. undefined
  6. Symbol(ES6)

除去這六種基本資料型別以外,其他的所有變數資料型別都是Object。基本型別的操作在JS底層中是這樣實現的:

// 1. 申請一塊記憶體,儲存foo變數的內容為1
let foo = 1
// 2. 定義foo為1時,foo的資料型別是number
typeof foo // "number"
// 3. 我們知道,const的意思是constant(常量,無法改變的)
const bar = foo
// 4. 修改值時,新申請了一塊記憶體儲存foo的內容為2
foo = 2
// 4. 則會發現,foo已經是2了,bar仍然是1
console.log(foo) // 2
console.log(bar) // 1
由此可見,我們定義的變數實際上都是指標。基本資料型別的修改實際上是新申請一塊記憶體地址,將這個指標指向新的記憶體地址。使用const定義變數,實際上相當於定義了一個指標常量,指向固定的地址不能被修改。

JavaScript中的物件

定義和修改物件

我們來試著從變數定義的執行結果看出它在底層的執行方式:

// 1. 定義一個物件
const obj = {
    foo: 1
}
// 2. 定義一個新變數與其相等
const anotherObj = obj
// 3. 修改這個物件
obj.foo = 2
// 4. 發現兩個物件都修改了
console.log(obj)
console.log(anotherObj)
由此可見,JS中物件的賦值是一種淺拷貝。

熟悉了物件的本質以後,我們要逐步瞭解物件有哪些特性。

物件的屬性與方法

實際上,在學習一般高階語言的時候,應該先介紹類的屬性與方法(共性),才介紹例項化類產生的物件如何使用(特性)。但由於JS是以原型、物件為主的語言,類只能在ES6中以語法糖的形式存活,我們只能先從物件入手,反推類的性質。

物件其實就是一些屬性和一些方法的集合。而物件的屬性和方法要深究,其實也是非常複雜的問題(光看內建物件Object以及Object.prototype上有多少方法處理物件的屬性就知道不簡單):

屬性

描述符

每個屬性上有描述符號。所謂的描述符號,是一些鍵值對,它們描述了對於這個屬性是否能操作、是否能列舉等等的所有特性。

描述符號只能是資料描述符和存取描述符兩個裡面的一個(在一般的宣告中,屬性預設含有的是資料描述符)。

首先,這兩種描述符公有的兩個屬性是:configurable(這個屬性的描述符是否能被修改、以及這個屬性是否能被delete運算子刪除)和enumerable(是否能被列舉)。

然後是資料描述符,顧名思義,它定義了value(值)和writable(值是否能被賦值語句修改)。

最後是存取描述符,同樣的顧名思義,它定義了這個屬性的get(讀取時執行的函式)和set(修改時執行的函式)。

定義和修改屬性

我們可以通過Object.defineProperty來具體地配置一個屬性的描述符:

const foo = {}
// 1. 資料描述符
Object.defineProperty(foo, 'bar', {
    // 1.1. 固定了是資料描述符不能被修改
    configurable: false,
    // 1.2. 設定該屬性可以列舉
    enumerable: true,
    // 1.3. 值為3
    value: 3,
    // 1.4. 無論怎麼賦值更新foo.bar,它的值仍然是3
    writable: false
})
// 2. 存取描述符
let baz = 3
Object.defineProperty(foo, 'baz', {
    // 2.1. 固定了是存取描述符不能被修改
    configurable: false,
    // 2.2. 設定該屬性可以列舉
    enumerable: true,
    // 2.3. 使用foo.baz讀取時,會順帶輸出這句話
    get: function () {
        console.log('The getter is called.')
        return baz
    },
    // 2.4. 使用賦值語句為foo.baz賦值時,會順帶輸出這句話
    set: function (value) {
        console.log('The setter is called.')
        baz = value
    }
})
由此可見,定義屬性時可以根據自己的需求修改預設的描述符。

瞭解到這裡,我們不難聯想到,著名的前端框架Vue實現資料的雙向繫結,實際上就是利用了這個存取描述符。我們在編寫Vue程式碼時,定義Vue物件中data屬性的值。Vue在編譯過程中,首先收集了這個值的所有依賴(也就是它在我們程式碼中出現的各種地方),然後利用Object.defineProperty,把屬性的描述符改成存取描述,並在setter中修改所有的依賴,通知檢視更新。這樣就有了我們覺得非常神奇的資料雙向繫結。

遍歷屬性

對屬性的常用操作除了定義與修改,還有遍歷。最常用的遍歷方法是:

// 兩種方法,都只能遍歷enumerable的屬性
const obj = {
    foo: 1,
    bar: 2,
    baz: 3
}
// 1. Object.keys
let objAttrs = Object.keys(obj)
// 2. for...in...
let objAttrs = []
for (let key in obj) {
    objAttrs.push(key)
}
// 以上兩種遍歷的方法數量和順序均一致
// 如果不希望遍歷原型上的屬性,還可以使用Object.hasOwnProperty進行過濾
遍歷時需要考慮到屬性是否能被列舉以及原型上的屬性是否需要被遍歷到。

方法

函式呼叫

函式有總共四種呼叫模式,這四種呼叫模式其實都是圍繞著this指向的不同而定的(下面的全域性在瀏覽器環境中表示window,在node環境中表示global):

  1. 普通函式呼叫 —— this指向全域性
  2. 方法呼叫 —— this指向方法所定義的物件
  3. 構造器呼叫 ——(使用new關鍵字時)this指向當前函式物件(函式本身就是物件)
  4. (call、apply和bind)呼叫 —— this指向(call、apply和bind)函式的第一個引數
方法是什麼

方法就是定義在類或者物件上,用來處理物件有關資料的函式。簡而言之,方法就是函式的子集。方法特別於其他函式的點在於,它的this是指向當前物件的。

從方法到this

由此可見,我們通過不同的方式呼叫函式,最終為的還是根據自己的需求定義this的指向。我們試著來區分幾個例子,從而最終總結出JS中this的指向情況:

一般情況
// 定義一個物件,裡面有一個輸出物件自身的方法
const obj = {
  foo: function () {
    return this
  }
}
// 直接執行obj.foo方法,正常得到obj物件
console.log(obj.foo())
// 用一個外部變數接收obj.foo方法
const fakeFoo = obj.foo
// 執行這個接收回來的方法,獲得this為全域性物件
console.log(fakeFoo())
上述例子說明,一般情況下,this指向的是函式被呼叫時所在的上下文環境。
(所謂函式呼叫時的上下文環境,實際上也等同於JS中的詞法作用域(lexical scope),即函式作用域)
內部函式

下面再來看看內部函式的this指向:

// 定義一個物件,裡面有一個方法,方法裡面有一個返回this的內部函式
// 以此測試內部函式中this指向
const obj = {
    foo: function () {
        return function () {
            return this
        }
    }
}
// 執行這個內部的函式,發現this指向的是全域性物件
console.log(obj.foo()())
上述例子說明,內部函式中,this沒有指向當前物件,而是指向的是全域性。
箭頭函式

當然,ES6中箭頭函式的出現修復了這些問題,內部函式的this也能正確指向當前物件了:

// 僅僅把上述物件的內部函式換為箭頭函式
const obj = {
    foo: function () {
        return () => {
            return this
        }
    }
}
// 正確得到this為當前物件
console.log(obj.foo()())
上述例子說明,箭頭函式把this繫結回了詞法作用域。

但是,由於JS的詞法作用域為函式作用域,以下的寫法又會發生錯誤:

const obj = {
    foo: () => {
        return this
    }
}
// 得到的this為全域性物件
console.log(obj.foo())
上述例子說明了,由於JS詞法作用域為函式作用域,箭頭函式沒有外部函式包著,因此是全域性作用域。

但是,箭頭函式強制將this繫結到函式執行的上下文環境。這導致了bind、call與apply的失效。

// 定義一個物件,裡面有一個方法返回當前物件的foo屬性
// 並將這個方法應用到foo為2的新物件上
const obj = {
  foo: 1,
  bar: function () {
    const foo = this.foo
    const baz = function () {
        return foo
    }
    return baz.call({ foo: 2 })
  }
}
// 得到新物件的值為2
console.log(obj.bar())
正常情況下,call方法正常地將這個方法應用到另一個物件上。
// 僅將內部返回foo的函式改為箭頭函式
const obj = {
  foo: 1,
  bar: function () {
    const foo = this.foo
    const baz = () => {
        return foo
    }
    return baz.call({ foo: 2 })
  }
}
// 得到的還是舊的1,說明call方法並沒有成功將this繫結到新物件上
console.log(obj.bar())
而箭頭函式的this則被緊鎖在了舊物件上。

總結:

  • JS的6種基本資料型別:string、number、boolean、null、undefined、Symbol
  • JS中,除了6種基本資料型別以外,其他變數都是物件,我們通過操作指標對這些物件進行處理
  • 物件的屬性有兩種描述符的其中一種:資料描述符(預設)和存取描述符
  • 物件的方法中,this預設指向這個物件,而方法的內部函式this預設指向全域性

拓展:

  • 通過Object.defineProperty可以定義和修改某個屬性的描述符
  • 普通函式中的this預設指向詞法作用域,使用new定義物件、call、apply、bind等內建方法,可以修改this的指向
  • 箭頭函式將this鎖在了詞法作用域,沒辦法使用call、apply、bind進行修改