YDKJS:作用域與閉包

来源:https://www.cnblogs.com/yzg1/archive/2018/01/16/8297241.html
-Advertisement-
Play Games

<! TOC "作用域與閉包" "什麼是作用域" "編譯器" "理解作用域" "嵌套的作用域" "詞法作用域" "詞法分析時" "欺騙詞法作用域" "函數與塊作用域" "函數中的作用域" "隱藏標識符於普通作用域" "函數作為作用域" "塊作為作用域" "提升" "先有雞還是先有蛋?" "編譯器再次 ...


什麼是作用域

作用域是一組定義在何處儲存變數以及如何訪問變數的規則

編譯器

javascript 是編譯型語言。但是與傳統編譯型語言不同,它是邊編譯邊執行的。編譯型語言一般從源碼到執行會經歷三個步驟:

  • 分詞/詞法分析

將一連串字元串打斷成有意義的片段,成為 token(記號)。

  • 解析

將一個 token 流(數組)轉化為一個嵌套元素的樹,即抽象語法樹(AST)。

  • 代碼生成

將抽象語法樹轉化為可執行的代碼。其實是轉化成機器指令。

比如var a = 1的編譯過程:

  1. 分詞/詞法分析: var a = 1這段程式可能會被打斷成如下 token:vara=1,空格保留與否得看其是否具有意義。
  2. 解析:將第一步的 token 形成抽象樹:大致如下:
    變數聲明: { 標識符: a 賦值表達式: { 數字字面量: 1 } }
  3. 代碼生成: 轉化成機器命令:創建一個稱為 a 的變數,並分配記憶體,存入一個值為數字 1。

理解作用域

作用域就是通過標識符名稱查詢變數的一組規則。

代碼解析運行中的角色:

  • 引擎

負責代碼的編譯和程式的執行。

  • 編譯器

協助引擎,主要負責解析和代碼生成。

  • 作用域

協助引擎,收集並維護一張所有被聲明的標識符(變數)的列表,並對當前執行的代碼如何訪問這些變數強制實施一組嚴格的規則。

比如var a = 1的運行:

  1. 編譯器遇到var a,會首先讓作用域去查詢 a 是否已經存在,存在則忽略,不存在,則讓作用域創建它;
  2. 編譯器遇到a = 1,會編譯成引擎稍後需要運行的代碼;
  3. 引擎執行編譯後的代碼,會讓當前查看是否存在變數a可以訪問,存在則引用這個變數,不存在則查看其他其他。

上面過程中,引擎會對變數進行查詢,而查詢分為 RHS(right-hand Side)查詢 和 LHS(left-hand Side)查詢,它們根據變數出現在賦值操作的左手邊還是右手邊來判斷查詢方式。

  • RHS

變數在賦值的右手邊時採用這種方式查詢,查不到會拋出錯誤 referenceError

  • LHS

變數在賦值的左手邊時採用這種方式查詢,在非嚴格模式下,查不到會再頂層作用域創建這個變數

嵌套的作用域

實際工作中,通常會有多於一個的作用域需要考慮,會存在作用域嵌套在其他作用域中的情況。

嵌套作用域的規則:

從當前作用域開始查找,如果沒有,則向上走一級繼續查找,以此類推,直至到了最外層全局作用域,無論找到與否,都會停止。

詞法作用域

作用域的工作方式一般有倆種模型:詞法作用域和動態作用域。javascript 所採用的是詞法作用域。

詞法分析時

詞法作用域是在詞法分析時被定義的作用域。

上述定義的潛在含義即:詞法作用域是基於寫程式時變數和作用域的塊兒在何處被編寫所決定的。公認的最佳實踐是將詞法作用域看作是僅僅依靠詞法的。

查詢變數:

引擎查找標識符時會在當前作用域開始一直向最外層作用域查找,一旦匹配到第一個,作用域查詢便停止。

相同名稱的標識符可以在嵌套作用域的多個層中被指定,這成為“遮蔽”。

不管函數是從哪裡被調用、如何調用,它的詞法作用域是由這個函數被聲明的位置唯一定義的。

欺騙詞法作用域

javascript 提供了在運行時修改詞法作用域的機制——with 和 eval,它們會欺騙詞法作用域。實際工作中,這種做法並不被推薦,應當儘量避免使用。

欺騙詞法作用域會導致更低下的性能。

引擎在編譯階段會對代碼做許多優化工作,比如靜態地分析代碼。但如果代碼存在 eval 和 with,導致詞法作用域的不固定行為,這一切的優化都有可能毫無意義,所以引擎就會簡單地不做任何優化。

  1. eval

eval函數接收一個字元串作為參數,併在運行時將該字元串的內容在當前位置運行。

function foo(str, a) {
    eval(str); // 作弊!
    console.log(a, b);
}

var b = 2;
foo("var b = 3", 1); //1,3

上面的代碼,var b = 3會再 eval 位置運行,從而在 foo 作用域內創建了變數b。當console.log(a,b)調用發生時,引擎會直接訪問 foo 作用域內的b,而不會再訪問外部的b變數。

註意:使用嚴格模式,在 eval 中作出的聲明不會實際上修改包圍他的作用域

  1. with

我們通常使用 with 來引用一個對象的多個屬性。

var obj = {
    a: 1,
    b: 2,
    c: 3
};

with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

console.log(obj); //{a: 3, b: 4, c: 5}

但是,with 會做的事,比這要多得多。

var o1 = { a: 3 };
var o2 = { b: 3 };

function foo(obj) {
    with (obj) {
        a = 2;
    }
}

foo(o1);
console.log(o1.a); //2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2  全局作用域泄漏

with 語句接受一個對象,並將這個對象視為一個完全隔離的詞法作用域

但是 with 塊內部的一個普通的var聲明並不會歸於這個with塊兒的作用域,而是歸於包含它的函數作用域。

所以,上面代碼執行foo(o2)時,在執行到 a = 2 時,引擎會進行 LHS查找,但是一直到最外層都沒有找到 a 變數,所以會在最外層創建這個變數,這裡就造成了作用域泄漏。

函數與塊作用域

javascript 中是不是只能通過函數創建新的作用域,有沒有其他方式/結構創建作用域?

函數中的作用域

javascript 擁有基於函數的作用域

函數作用域支持著這樣的想法:所有變數都屬於函數,而去貫穿整個函數都可以使用或重用(包括嵌套的作用域中)。

這樣以來,一個聲明出現在作用域何處是無關緊要的。

隱藏標識符於普通作用域

我們可以通過將變數和函數圍在一個函數的作用域中來“隱藏”它們。

為什麼需要“隱藏”變數和函數?

如果允許外圍的作用域訪問一個工作的私有細節,不僅沒必要,而且可能是危險的。所以軟體設計中有一個最低許可權原則原則:

最低許可權原則:也稱“最低授權”/“最少曝光”,在軟體設計中,比如一個模塊/對象的 API,你應當只暴露所需要的最低限度的東西,而隱藏其他一切。

將變數和函數隱藏可以避免多個同名但用處不同的標識符之間發生無意的衝突,從而導致值被意外的覆蓋。

實際可操作的方式:

  1. 全局命名空間

在引用多個庫時,如果他們沒有隱藏內部/私有函數和變數,那麼它們十分容易出現相互衝突。所以,這些庫通常會在全局作用域中使用一個特殊的名稱來創建一個單讀的變數聲明。它經常是一個對象,然後這個對象被用作這個庫一個命名空間,所有要暴露出來的功能都會作為屬性掛載在這個對象上。

比如,Jquery 的對象就是 jquery/$;

  1. 模塊管理

實現命名衝突的另一種方式是模塊管理。

函數作為作用域

聲明一個函數,可以拿來隱藏函數和變數,但這種方式同時也存在著問題:

  • 不得不聲明一個命名函數,這個函數的標識符名稱本身就污染了外圍作用域
  • 不得不通過名稱明確地調用這個函數

不需要名稱,又能自動執行的,js 恰好提供了這樣一種方式。

(function(){
    ...
})()

上面的代碼使用了匿名函數和立即調用函數表達式:

  1. 匿名函數

函數表達式可以匿名,函數聲明不能匿名。

匿名函數的缺點:

  • 在棧中沒有有用的名稱可以表示,調試困難;
  • 想要遞歸自己(arguments.callee)或者解綁事件處理器變得麻煩
  • 更不易代碼閱讀

最佳的方式總是命名你的函數表達式。

  1. 立即調用函數表達式

通過一個(),我們可以將函數作為表達式。末尾再加一個括弧可以執行這個函數表達式。這種模式被成為 IIFE(立即調用函數表達式;Immediately Invoked Function Expression)

塊作為作用域

大部門語言都支持塊級作用域,從而將信息隱藏到我們的代碼塊中,塊級作用域是一種擴展了最低許可權原則的工具。

但是,錶面上看來 javascript 沒有塊級作用域。

for (var i = 0; i < 10; i++) {
    console.log(i);
}
console.log(i); // 10 變數i被劃入了外圍作用域中
if (true) {
    var bar = 9;
    console.log(bar); //9
}
console.log(bar); //9 // 變數bar被劃入了外圍作用域中

但也有特殊情況:

  • with

它從對象中創建的作用域僅存在於這個 with 語句的生命周期中。

  • try/catch

ES3 明確指出 try/catch 中的 cathc 子語句中聲明的變數,是屬於 catch 塊的塊級作用域。

js try { var a = 1; } catch (e) { var c = 2; } console.log(a); //1 console.log(c); //undefined

  • let/const

let 將變數聲明依附在它所在的塊兒(通常是{...})作用域中。

  • 隱含使用現存得塊兒

js if (true) { let bar = 1; console.log(bar); //1 } console.log(bar); // ReferenceError

  • 創建明確塊兒

js if (true) { { // 明確的塊兒 let bar = 1; console.log(bar); //1 } } console.log(bar); // ReferenceError

const 也創建一個塊級作用域,但是它的值是固定的(常量)。

註意: let/const 聲明不進行變數提升。

塊級作用域的用處:

  1. 垃圾回收

可以處理閉包和釋放記憶體的垃圾回收。

js function process() { // do something } var bigData = {...}; // 大體量數據 process(bigData); var btn = document.getElementById('btn'); btn.addEventListener("click",function(e){ console.log('btn click'); })

點擊事件的回調函數根本不需要 bigData 這個大體量數據。理論上講,在執行完 process 函數後,這個消耗巨大記憶體的數據結構應該被作為垃圾而回收。然而因為 click 函數在整個函數作用域上擁有一個閉包,bigData 將會仍然保持一段事件。

塊級作用域可以解決這個問題:

js function process() { // do something } { let bigData = {...}; // 大體量數據 process(bigData); } var btn = document.getElementById('btn'); btn.addEventListener("click",function(e){ console.log('btn click'); })

  1. 迴圈

對每一次迴圈的迭代重新綁定。

js for (let i = 0; i < 10; i++) { console.log(i); } console.log(i); // ReferenceError

也可以這樣:

js { let j; for (j = 0; i < 10; i++) { let i = j; // 每次迭代重新綁定 console.log(i); } }

提升

函數作用域還是塊級作用域的行為都依賴於一個相同的規則: 在一個作用域中聲明的任何變數都附著在這個作用域上。

但是出現一個作用域內各種位置的聲明如何依附作用域?

先有雞還是先有蛋?

我們傾向於認為代碼是自上而下地被解釋執行的。這大致上是對的,但也有一部分並非如此。

a = 2;
var a;
console.log(a); // 2

如果代碼自上而下的解釋運行,預期應該輸出 undefined ,因為 var aa = 2 之後,應該重新定義了變數 a。顯然,結果並不是如此。

console.log(a); // undefined
var a = 2;

從上面的例子上,你也許會猜測這裡會輸出 2,或者認為這裡會導致一個 ReferenceError 被拋出。不幸的是,結果卻是 undefined。

代碼究竟如何執行,是先有聲明還是賦值?

編譯器再次襲來

我們知道,引擎在 javascript 執行代碼之前會先對代碼進行編譯,編譯的其中一個工作就是找到所有的聲明,並將它關聯在合適的作用域上。

所以,在我們的代碼被執行前,所有的聲明,包括變數和函數,都會被首先處理。

對於var a = 2,我們認為是一個語句,但 javascript 實際上認為這是倆個語句:var aa = 2。第一句(聲明)會在編譯階段處理,第二句(賦值)會在執行階段處理。

知道了這些,我想對於上一節的疑惑也就迎刃而解了:先有聲明,後有賦值

註意:提升是以作用域為單位的

函數聲明會被提升,但是表達式不會。

foo(); // 1
goo(); // TypeError

function foo() {
    console.log(1);
}

var goo = function() {
    console.log(2);
};

變數 goo 被提升了,但表達式沒有,所以調用 goo 時,goo 的值為 undefined。所以會報 TypeError。

函數優先

函數聲明和變數都會提升。但是函數享有更高的優先順序。

console.log(typeof foo); // function

var foo = 2;

function foo() {
    console.log(1);
}

從上面代碼可以看出,結果輸出 function 而不是 undefined 。說明函數聲明優先於變數。

重覆聲明,後面的會覆蓋前面的。

作用域閉包

必須要對作用域有健全和堅實的理解才能理解閉包。

啟蒙

在 javascript 中閉包無處不在,你只是必須認出它並接納它。它是依賴於詞法作用域編寫代碼而產生的結果。

事實真相

閉包就是函數能夠記住並訪問它的詞法作用域,即使當這個函數在他的詞法作用域之外執行時

function foo() {
    var a = 2;
    function bar() {
        console.log(2);
    }
    bar();
}

這種形式算閉包嗎?技術上算,它實現了閉包,函數 bar 在函數 foo 的作用域上有一個閉包,即 bar 閉住了 foo 的作用域。但是在上面代碼中並不是可以嚴格地觀察到。

function foo() {
    var a = 2;
    function bar() {
        console.log(2);
    }
    return bar;
}
var baz = foo();
baz(); //2  這樣使用才算真正意義上的閉包

bar 對於 foo 內的作用域擁有此法作用域訪問權,當我們調用 foo 之後返回 bar 的引用。按理來說,foo 執行過後,我們一般會期望 foo 的整個內部作用域消失,因為垃圾回收機制會自動回收不再使用的記憶體。但 bar 擁有一個詞法作用域的閉包,覆蓋著 foo 的內部作用域,閉包為了能使 bar 在以後的任意時刻可以引用這個作用域而保持的它的存在。

所以,bar 在詞法作用域之外依然擁有對那個作用域的引用,這個引用稱為閉包。

閉包使一個函數可以繼續訪問它在編寫時被定義的詞法作用域。

var a = 2;

function bar() {
    console.log(a);
}

function foo(fn) {
    fn(); // 發現閉包!
}

foo(bar);

上面的代碼,函數作為參數被傳遞,實際上這也是一種觀察/使用閉包的例子。

無論我們使用什麼方法將一個函數傳送到它的詞法作用域之外,它都將維護一個指向它被聲明時的作用域的引用。

迴圈 + 閉包

for (var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i); // 5
    }, i * 1000);
}

這段代碼的預期是每隔一秒分別列印數字:1,2,3,4,5。但是我們執行後發現結果一共輸出了 5 次 6。

為什麼達不到預期的效果?

定時器的回調函數會在迴圈完成之後執行(詳見事件迴圈機制)。而 for 不是塊級作用域,所以每次執行 timer 函數的時候,它們的閉包都在全局作用域上。而此時全局作用域環境中的變數 i 的值為 6。

我們的代碼缺少了什麼?

因為每一個 timer 函數執行的時候都是使用全局作用域,所以訪問的變數必然是一致的,所以想要達到預期的結果,我們必須為每一個 timer 函數創建一個私有作用域,併在這個私有作用域記憶體在一個可供回調函數訪問的變數。現在我們來改寫一下:

for (var i = 1; i <= 5; i++) {
    (function() {
        let j = i;
        setTimeout(function() {
            console.log(j); // 1,2,3,4,5
        }, i * 1000);
    })();
}

我們使用 IIFE 為每次迭代創建新的作用域,並且保存每次迭代需要的值。

其實這裡主要用到的原理是使用塊級作用域,所以,理論上還有其他方式可以實現,比如:with,try/catch,let/const,大家都可以嘗試下哦。

模塊

模塊也利用了閉包的力量。

function coolModule() {
    var something = "cool";
    function doSomething() {
        console.log(something);
    }

    return {
        doSomething: doSomething
    };
}

var foo = coolModule()
foo.doSomething() // cool

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 前面的話 iOS版Safari為了向開發人員傳達一些特殊信息,新增了一些專有事件。因為iOS設備既沒有滑鼠也沒有鍵盤,所以在為移動Safari開發交互性網頁時,常規的滑鼠和鍵盤事件根本不夠用。隨著Android 中的WebKit的加入,很多這樣的專有事件變成了事實標準,導致W3C開始制定Touch ...
  • jquery之遍歷與事件 一.遍歷: 二.事件的綁定 事件: ...
  • 上次我們聊了《Html5前端如何實現文字陰影》,其實在開發中現在對於陰影效果的使用已經越來越廣泛了,那麼今天我們就來說一說用同樣的手法實現邊框陰影。 一.邊框陰影box-shadow 邊框陰影參數:參數1 x-shadow:設置對象的陰影水平偏移值,單位可以是px、em或百分比等,允許負值。參數2 ...
  • 21、數組 定義數組 * 字面量方式 var 數組名稱 = [ value,value,... ] * 構造函數方式 var 數組名稱 = new Array(value,value,...); var 數組名稱 = new Array(length) 創建對象方式創建數組分析圖 附:var num ...
  • 閱讀本書主要目的: 自從學會CSS以來,雖然熟練掌握了其使用方法和技巧,但對其底層的原理和實現並不清晰,閱讀本書想進一步系統化的學習和深入研究其本質,對這門前端基礎語言從熟練使用到真正理解。 第1章 CSS和文檔 1.1 WEB的衰落(為了表現增加很多標記元素如font等,這些阻礙了頁面的結構化) ...
  • 眾所周知,文本溢出顯示省略號用CSS就可以: 單行文本: 多行文本: 如果想中間顯示省略號呢?? 現在需求是,一段文本很長,但最後有一個關鍵詞很重要,而且改關鍵詞有括弧括起來的,需要顯示出來,所以如果文本過長,不單隻做省略號處理,還要把括弧裡面的內容顯示出來。 上面的代碼意思是:如果文本長度大於13 ...
  • 三種密碼強度的正則表達式: 較弱:全是數字或全是字母 6-16個字元:/^[0-9]{6,16}$|^[a-zA-Z]{6,16}$/; 中級:數字、26個英文字母 6-16個字元: /^[A-Za-z0-9]{6,16}$/; 較高:由數字、26個英文字母或者下劃線組成的字元串 6-16個字元: ...
  • 1、概述 簡單值(基本類型)通過值複製的方式來賦值/傳遞。 複合值(對象)通過引用複製的方式來賦值/傳遞。 結合記憶體示意圖,理解會更深刻。 簡單類型的值在常量池只有一份,變數a和變數b都是常量池中2的一個副本。 變數c和變數d都是指向堆中的一個數組對象。 ...
一周排行
    -Advertisement-
    Play Games
  • GoF之工廠模式 @目錄GoF之工廠模式每博一文案1. 簡單說明“23種設計模式”1.2 介紹工廠模式的三種形態1.3 簡單工廠模式(靜態工廠模式)1.3.1 簡單工廠模式的優缺點:1.4 工廠方法模式1.4.1 工廠方法模式的優缺點:1.5 抽象工廠模式1.6 抽象工廠模式的優缺點:2. 總結:3 ...
  • 新改進提供的Taurus Rpc 功能,可以簡化微服務間的調用,同時可以不用再手動輸出模塊名稱,或調用路徑,包括負載均衡,這一切,由框架實現並提供了。新的Taurus Rpc 功能,將使得服務間的調用,更加輕鬆、簡約、高效。 ...
  • 本章將和大家分享ES的數據同步方案和ES集群相關知識。廢話不多說,下麵我們直接進入主題。 一、ES數據同步 1、數據同步問題 Elasticsearch中的酒店數據來自於mysql資料庫,因此mysql數據發生改變時,Elasticsearch也必須跟著改變,這個就是Elasticsearch與my ...
  • 引言 在我們之前的文章中介紹過使用Bogus生成模擬測試數據,今天來講解一下功能更加強大自動生成測試數據的工具的庫"AutoFixture"。 什麼是AutoFixture? AutoFixture 是一個針對 .NET 的開源庫,旨在最大程度地減少單元測試中的“安排(Arrange)”階段,以提高 ...
  • 經過前面幾個部分學習,相信學過的同學已經能夠掌握 .NET Emit 這種中間語言,並能使得它來編寫一些應用,以提高程式的性能。隨著 IL 指令篇的結束,本系列也已經接近尾聲,在這接近結束的最後,會提供幾個可供直接使用的示例,以供大伙分析或使用在項目中。 ...
  • 當從不同來源導入Excel數據時,可能存在重覆的記錄。為了確保數據的準確性,通常需要刪除這些重覆的行。手動查找並刪除可能會非常耗費時間,而通過編程腳本則可以實現在短時間內處理大量數據。本文將提供一個使用C# 快速查找並刪除Excel重覆項的免費解決方案。 以下是實現步驟: 1. 首先安裝免費.NET ...
  • C++ 異常處理 C++ 異常處理機制允許程式在運行時處理錯誤或意外情況。它提供了捕獲和處理錯誤的一種結構化方式,使程式更加健壯和可靠。 異常處理的基本概念: 異常: 程式在運行時發生的錯誤或意外情況。 拋出異常: 使用 throw 關鍵字將異常傳遞給調用堆棧。 捕獲異常: 使用 try-catch ...
  • 優秀且經驗豐富的Java開發人員的特征之一是對API的廣泛瞭解,包括JDK和第三方庫。 我花了很多時間來學習API,尤其是在閱讀了Effective Java 3rd Edition之後 ,Joshua Bloch建議在Java 3rd Edition中使用現有的API進行開發,而不是為常見的東西編 ...
  • 框架 · 使用laravel框架,原因:tp的框架路由和orm沒有laravel好用 · 使用強制路由,方便介面多時,分多版本,分文件夾等操作 介面 · 介面開發註意欄位類型,欄位是int,查詢成功失敗都要返回int(對接java等強類型語言方便) · 查詢介面用GET、其他用POST 代碼 · 所 ...
  • 正文 下午找企業的人去鎮上做貸後。 車上聽同事跟那個司機對罵,火星子都快出來了。司機跟那同事更熟一些,連我在內一共就三個人,同事那一手指桑罵槐給我都聽愣了。司機也是老社會人了,馬上聽出來了,為那個無辜的企業經辦人辯護,實際上是為自己辯護。 “這個事情你不能怪企業。”“但他們總不能讓銀行的人全權負責, ...