深入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底層原理與實現的思考與認知,但其難點在於偏理論性,不易實踐,也難於測試。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章