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
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...