深入javascript資料存取

welchang發表於2021-09-09
深入javascript資料存取

資料存取是電腦科學中最常見的操作,如何安排資料的儲存位置不僅關係到程式碼在執行過程中資料的檢索速度,更影響著整個程式的設計思維。這篇文章將對javascript語言中資料存取的相關知識進行深入的討論。透過對本文的閱讀和學習,你可以理解並掌握:

  1. js儲存資料的位置以及最佳化方式
  2. js作用域鏈的實質以及改變作用域鏈的方式
  3. js閉包的實質與閉包導致的記憶體洩露
  4. 為什麼ES5的嚴格模式會禁用with
  5. 原型鏈與資料儲存
  6. js中使用var宣告的變數宣告提升的本質
javascript資料儲存位置與效能最佳化

javascript中的四種資料資料儲存位置

javascript中有以下四種基本的資料儲存位置:

1 字面量

字面量只代表自身,不儲存在特定的位置,比如下面的匿名函式

$btn.click(function(){... ...});

2 本地變數

本地變數使用var宣告,從相對存取資料位置而言的區域性變數和字面量中存取速度的效能差異是微不足道的。

3 陣列成員

以數字作為索引

4 物件成員

以字串作為索引

儲存位置與效能最佳化

一般而言,從字面量和區域性變數獲取資料的速度要快於從物件或陣列的屬性中獲取資料的速度,但在效能方面很大程度上取決於瀏覽器本身。

顯然把資料儲存到區域性變數中會在存取資料方面帶來效能提升,但是將資料儲存在物件的屬性中卻更有利於程式碼的設計與架構。所以在選擇儲存方式時,需要綜合考慮其利弊。一般而言,編碼時,還是推薦使用物件導向的原則,把相關的資料資訊與操作封裝在一個物件中,但要避免物件的深層巢狀,比如下面這樣,因為每增加一層物件,就會增加一份代價:

var foo = {
    bar:{
        student: {
            name:'John Doe'
        }
    }
}

foo.bar.student.name // => John Doe

如果經常會使用到物件的某個屬性或者方法,那麼可以選擇把它快取到區域性變數中,以加快它的讀取速度,比如:

var isArray = Array.isArray,
     slice = Array.prototype.slice;

function foo() {
    var arr = slice.apply(arguments);
    console.log(isArray(arr));
}

foo(); // =>true

但注意上面介紹的方式在針對DOM方法時,不會按照我們想象的那樣工作:

var gid = document.getElementById;
console.log(gid('foo').innerText); // 報錯 Illegal invocation
深入作用域管理

作用域的管理,簡而言之:內層作用域可以訪問外層作用域的變數,而反之,內層作用域的變數對外層是不可見的。但其原理究竟如何呢?

一切的一切都要從Function構造器說起。在javascript中,萬物皆物件,函式也是物件,由Function建構函式產生。在函式初始化時,Function建構函式既會為函式新增一些可以由程式設計師操作的屬性,比如:prototype和name,還會為其新增一些程式設計師無法訪問,僅供javascript引擎訪問的屬性,其中有一個屬性叫做[[scope]],它就是傳說中的作用域。

[[scope]]屬性即指向該函式的作用域鏈,它規定了哪些屬性可以被物件訪問。以下面這個全域性函式為例:

var c = 'foo';
function add(a, b){
    return a+b
}

作用域鏈實質上是一個物件連結串列(可以假想成一個陣列),其第一個元素是一個包含了所有全域性範圍內定義的變數的物件,其中就包括:document、navigator、c等等。

在執行函式add時,比如下面的程式碼:

var d = add(1,2);

引擎會建立一個獨一無二的函式執行上下文(也稱執行環境),並把函式add的[[scope]]屬性複製一份作為執行環境自己的作用域鏈。之後,它會建立一個包含了該執行函式所有的區域性變數,引數以及this的活動物件,並把它推送至自己作用域鏈的最頂端。注意函式的作用域鏈和執行環境的作用域鏈是不同的

當在函式的邏輯中尋找變數時,我們的javascript引擎就會從上到下的遍歷函式執行上下文作用域鏈的元素,直至找到與查詢的變數名稱相同的屬性為止。實際上,這個搜尋過程會對效能造成影響。即:擁有該變數的物件元素在作用域鏈中越靠前,越容易找到,損耗越小。

這其實也說明使用var宣告的變數宣告提前的原因。因為在函式執行時,先建立包含函式所有區域性變數的活動物件,再去執行函式邏輯。

改變作用域鏈

一般而言,作用域鏈一旦確定就無法改變,但JS中提供了兩種方式可以用來改變作用域鏈,它們是:with和catch子語句。

禁止使用with

ES5的嚴格模式下明文規定禁止使用with,但只知道它會影響效能而不知為何的同學應該不在少數

看下面的程式碼:

var obj = {
    nickname:'Kyle',
    age: 21
};

 function foo() {
    var bar = 'bar';
    var nickname = 'Agent';
    with(obj){
        console.log(nickname); // Kyle
        console.log(age); // 21
        console.log(bar); // bar
    }
}

foo();

使用with語句的本質,是將with語句後面括號中的物件直接新增到函式執行上下文作用域鏈的頂部,這使得nickname、age在訪問時像是使用區域性變數一樣。但這會導致很嚴重的效能損耗,因為當我們試著去訪問真正的區域性變數,比如bar時,所有的區域性變數儲存在作用域鏈的第二個物件中了,這增加了訪問代價。

而且,上面在訪問nickname時,根據作用域鏈自頂向下搜尋的原則,obj的nickname屬性先被找到,立即返回結果,而區域性變數nickname則被obj的nickname屬性遮蔽了。

根據上述原因,ES5的嚴格模式中決定杜絕對with語句的使用。

catch子句

catch子句也能夠改變函式的作用域鏈。在try語句塊中出現錯誤時,執行過程會自動跳轉到catch子語句中,並把一個異常物件推到作用域的首位。

雖然使用try-catch時,會改變作用域鏈,增加訪問區域性變數時效能的消耗,但瑕不掩瑜,try-catch仍然是非常有用的語句。使用函式委託的方式能夠把catch子句對效能的損耗降低到最小:

try{
 // some error
}catch(err){
    handleError(err)
};

這樣做只執行了一條語句,並且沒有訪問區域性變數,所以作用域鏈的臨時改變就不會影響程式碼效能。

綜上所述:改變作用域鏈後,訪問區域性變數會對效能造成影響,因為包含區域性變數的活動物件不再位於作用域鏈的首位。

閉包的實質

閉包是javascript中最重要的特性之一,簡而言之,閉包指的是:能夠記住建立它的環境的函式。相信透過對上文的閱讀,你已經大概對閉包的實現有了一個基本的猜想。

我們透過下面這個簡單的例子來學習閉包的本質

function test(){
    var bar = 'hello';
    return function(){
        alert(bar);
    }
}

test()(); // 彈出hello

首先我們的test函式被初始化,其[[scope]]作用域鏈中只有一個物件,該物件包含了全域性範圍內定義的所有變數,比如document。我們給它起一個別名叫做global。當test函式被執行時,引擎會為其建立一個執行上下文,執行上下文會將函式本身的[[scope]]屬性完全複製過來,作為其作用域鏈,並且建立一個包含該函式內部所有區域性變數和引數的活動變數(我們為它起名叫active),然後將其推送到執行上下文作用域鏈的首位。

但故事並沒有結束,因為在這個函式的執行過程中,初始化了另一個匿名函式。在初始化這個匿名函式時,其作用域鏈的[[scope]]屬性當然會被建立它的環境中所定義的變數所組成的物件所填充,而建立它的上下文,也就是函式test中所定義的變數所組成的物件,正是先前在執行函式test時建立的活動物件active,這樣匿名函式作用域鏈的第一個元素指向物件active,第二個則元素執行建立test函式的環境,也就是global物件,因為global已經是全域性了,所以到此為止。但如果還有環境,那麼繼續向下排列。由於匿名函式的[[scope]]屬性包含了與執行環境作用域相同的物件引用,因此,函式test在執行完畢後,活動物件active不會隨著執行環境一同銷燬。這也就是閉包的底層原理了。

根據上述原理,下面的程式碼會導致記憶體洩露:

function test(){
    var bar = 'hello',
          foo = 'foo';
    return function(){
        alert(bar);
    }
}

test()(); // 彈出hello

上面的程式碼中,foo永遠也不會被使用到,但是它仍然始終存在於活動物件中,這樣就會導致記憶體洩露。

原型鏈與資料儲存

這個部分大部分參考我寫的另一篇文章《輕鬆理解javascript原型》,你可以在我的部落格上查詢到原文。

一切從函式開始

在javascript中,函式是物件,我們可以把函式儲存在一個變數中,也可以給函式新增屬性。JS中所有的函式都由一個叫做Function的構造器建立。當一個函式物件被建立時,Function構造器會"隱蔽地"給這個函式物件新增一個叫做prototype的屬性,其值是一個包含函式本身(constuctor)的物件:

this.prototype = {constructor : this}

其中,prototype就是“傳說中”的原型,而的this指的就是函式本身。javascript會“公平地”為每個函式建立原型物件。無論這個函式以後是否用作建構函式。

下面的程式碼是個很好的例子:

function sayHello () {

}

console.log(sayHello.prototype)  //=> { constuctor : sayHello(),  __proto__ : Object}

你會發現還有一個叫做__proto__的屬性,這又是什麼呢?先不要亂了陣腳,繼續向下看。

優秀的工匠——new

當函式“有志氣”成為一名建構函式的時候,prototype屬性開始真正發揮作用。new運算子是一名優秀的“工匠”,它可以使用物件模具——建構函式產生一個個的例項物件。

當new運算子使用建構函式產生物件例項時,會“強制性地”在新物件中新增一個叫做__proto__的屬性作為”隱秘連線“,它的值就等於它的建構函式prototype屬性的值,換句話說,使這它與其建構函式的prototype屬性指向同一個物件。

顯然,每一個javascript物件都會擁有一個叫做__proto__的屬性,因為javascript中所有的物件都隱式或顯式地由建構函式new出,於是,也可以說在javscript中沒有真正意義上的空物件。

當然,我們的new運算子沒有忘記它的“老本行”:它會將建構函式中定義的例項屬性或方法(this.屬性)新增到新建立的物件中。

下面的程式碼或許能夠幫助你理解:

function Student (name) {
    this.name = name;
}

// 為構造器的prototype新增一個屬性
Student.prototype.age = 20;

var Tom = new Student("Tom");
console.log(Tom.name); // => Tom
console.log(Tom.__proto__.constructor); // =>function Student() {this.name = name}
console.log(Tom.__proto__.age); // =>20

簡而言之,原型prototype是javascript函式的一個屬性,當這個函式作為構造器產生例項時,new運算子會獲得函式的prototype屬性的值並將其賦給物件例項的__proto__屬性,並以此作為隱秘連線。因此,你在建構函式的prototype屬性中設定的值都會被該構造器的例項所擁有。

磐石——Object構造器

之所以還不說原型鏈,是因為我想先試著不把事情變得那麼複雜:還是以上面的Student偽類為例。Tom物件的__proto__屬性來自其構造器Student的prototype屬性,這個應該很好理解。但是,問題是Student的prototype也是一個物件,它有我們設定的age屬性,更有每個物件都擁有的__proto__屬性。那麼問題來了,Student的prototype物件是誰建立的呢,它的__proto__值從來自哪裡呢?

Object構造器是無名英雄——它建立所有以物件字面量表示的物件。Student的prototype物件正是由Object構造器建立的,它的__protot__值是在Object構造器的prototype屬性。

希望下面的例子能夠幫助你理解:

var obj = {};
console.log(obj.constructor); // =>function Object() {native code}
console.log('__proto__' in obj); // =>true

靈魂連線——原型鏈

好的,原型鏈在我們試圖從某個物件獲取某個屬性(或方法)時發揮作用。如果那個屬性剛好像下面這樣存在於這個物件之中,那無需多慮,直接返回即可。

var student = {name : 'Jack'}
student.name // =>Jack

但是,如果這個屬性不直接存在於這個物件中,那麼javascript會在這個物件的構造器的prototype屬性,也就是這個物件的__proto__屬性中進行查詢。

由於訪問__proto__並非官方ECMA標準的一部分,所以後面我們都說”其建構函式的prototype屬性”,而不說“這個物件的__proto__屬性“了。

好吧,如果找到,則直接返回,否則,繼續這個迴圈,因為prototype的值也是物件:繼續在 /該物件的構造器的prototype物件/ 的構造器的prototype屬性中尋找……。

所以你該知道,由於prototype屬性一定是一個物件,因此原型鏈或者說查詢中的最後一站是Object.prototype。如果查詢到這裡仍然沒有發現,則迴圈結束,返回undefined。

因為這種鏈查詢機制的存在,上面的程式碼得到了簡化,這也是Javascript中繼承的基石:

console.log(Tom.__proto__.age); // =>20
console.log(Tom.age); // =>20

好吧,我希望透過下面的例子帶你拉通走一遍:

var arr = [];
console.log(arr.foo); //=>undefined

首先,當JS得知要訪問arr的foo屬性時,他首先會在arr物件裡查詢foo屬性,但是結局令人失望。之後,它會去查詢arr的建構函式即Array的prototype屬性,看是否能在這裡查詢到什麼線索,結果也沒有。最後,它會去查詢Array的prototype物件的建構函式——Object的prototype屬性——仍然沒有找到,搜尋結束,返回undefined。

之所以舉一個原生的建構函式的例子是因為我一直害怕因為使用自定義的例子而給大家帶來一種只有自定義的建構函式才可以這樣的錯覺。你要知道,這篇文章所講述的道理適合一切的構造器。

好了,讓我們看一個自定義的構造器並在原型鏈上查詢到屬性的”好“例子:

Object.prototype.foo = "some foo";

function Student(name) {
    this.name = name;
}

// 為構造器的prototype新增一個屬性
Student.prototype.age = 20;

var Tom = new Student("Tom");
console.log(Tom.name); // => Tom
console.log(Tom.age); // =>20
console.log(Tom.foo); // =>some foo

這裡要說明的是,原型鏈在查詢時,會使用它查詢到的第一個值;一旦找到,立即返回,不會再往下進行尋找。

小結

對js資料存取的深入探究有利於加深我們對js底層原理與實現的思考與認知,但其難點在於偏理論性,不易實踐,也難於測試。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4830/viewspace-2798712/,如需轉載,請註明出處,否則將追究法律責任。

相關文章